1 /* 2 * Copyright (C) 2017 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.server.wifi.hotspot2; 18 19 import android.text.TextUtils; 20 import android.util.Log; 21 import android.util.Pair; 22 23 import java.io.BufferedReader; 24 import java.io.FileReader; 25 import java.io.IOException; 26 import java.nio.charset.StandardCharsets; 27 import java.util.ArrayList; 28 import java.util.HashMap; 29 import java.util.List; 30 import java.util.Map; 31 32 /** 33 * Utility class for parsing legacy (N and older) Passpoint configuration file content 34 * (/data/misc/wifi/PerProviderSubscription.conf). In N and older, only Release 1 is supported. 35 * 36 * This class only retrieve the relevant Release 1 configuration fields that are not backed 37 * elsewhere. Below are relevant fields: 38 * - FQDN (used for linking with configuration data stored elsewhere) 39 * - Friendly Name 40 * - Roaming Consortium 41 * - Realm 42 * - IMSI (for SIM credential) 43 * 44 * Below is an example content of a Passpoint configuration file: 45 * 46 * tree 3:1.2(urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0) 47 * 8:MgmtTree+ 48 * 17:PerProviderSubscription+ 49 * 4:r1i1+ 50 * 6:HomeSP+ 51 * c:FriendlyName=d:Test Provider 52 * 4:FQDN=8:test.net 53 * 13:RoamingConsortiumOI=9:1234,5678 54 * . 55 * a:Credential+ 56 * 10:UsernamePassword+ 57 * 8:Username=4:user 58 * 8:Password=4:pass 59 * 60 * 9:EAPMethod+ 61 * 7:EAPType=2:21 62 * b:InnerMethod=3:PAP 63 * . 64 * . 65 * 5:Realm=a:boingo.com 66 * . 67 * . 68 * . 69 * . 70 * 71 * Each string is prefixed with a "|StringBytesInHex|:". 72 * '+' indicates start of a new internal node. 73 * '.' indicates end of the current internal node. 74 * '=' indicates "value" of a leaf node. 75 * 76 */ 77 public class LegacyPasspointConfigParser { 78 private static final String TAG = "LegacyPasspointConfigParser"; 79 80 private static final String TAG_MANAGEMENT_TREE = "MgmtTree"; 81 private static final String TAG_PER_PROVIDER_SUBSCRIPTION = "PerProviderSubscription"; 82 private static final String TAG_HOMESP = "HomeSP"; 83 private static final String TAG_FQDN = "FQDN"; 84 private static final String TAG_FRIENDLY_NAME = "FriendlyName"; 85 private static final String TAG_ROAMING_CONSORTIUM_OI = "RoamingConsortiumOI"; 86 private static final String TAG_CREDENTIAL = "Credential"; 87 private static final String TAG_REALM = "Realm"; 88 private static final String TAG_SIM = "SIM"; 89 private static final String TAG_IMSI = "IMSI"; 90 91 private static final String LONG_ARRAY_SEPARATOR = ","; 92 private static final String END_OF_INTERNAL_NODE_INDICATOR = "."; 93 private static final char START_OF_INTERNAL_NODE_INDICATOR = '+'; 94 private static final char STRING_PREFIX_INDICATOR = ':'; 95 private static final char STRING_VALUE_INDICATOR = '='; 96 97 /** 98 * An abstraction for a node within a tree. A node can be an internal node (contained 99 * children nodes) or a leaf node (contained a String value). 100 */ 101 private abstract static class Node { 102 private final String mName; 103 Node(String name) { 104 mName = name; 105 } 106 107 /** 108 * @return the name of the node 109 */ 110 public String getName() { 111 return mName; 112 } 113 114 /** 115 * Applies for internal node only. 116 * 117 * @return the list of children nodes. 118 */ 119 public abstract List<Node> getChildren(); 120 121 /** 122 * Applies for leaf node only. 123 * 124 * @return the string value of the node 125 */ 126 public abstract String getValue(); 127 } 128 129 /** 130 * Class representing an internal node of a tree. It contained a list of child nodes. 131 */ 132 private static class InternalNode extends Node { 133 private final List<Node> mChildren; 134 InternalNode(String name, List<Node> children) { 135 super(name); 136 mChildren = children; 137 } 138 139 @Override 140 public List<Node> getChildren() { 141 return mChildren; 142 } 143 144 @Override 145 public String getValue() { 146 return null; 147 } 148 } 149 150 /** 151 * Class representing a leaf node of a tree. It contained a String type value. 152 */ 153 private static class LeafNode extends Node { 154 private final String mValue; 155 LeafNode(String name, String value) { 156 super(name); 157 mValue = value; 158 } 159 160 @Override 161 public List<Node> getChildren() { 162 return null; 163 } 164 165 @Override 166 public String getValue() { 167 return mValue; 168 } 169 } 170 171 public LegacyPasspointConfigParser() {} 172 173 /** 174 * Parse the legacy Passpoint configuration file content, only retrieve the relevant 175 * configurations that are not saved elsewhere. 176 * 177 * For both N and M, only Release 1 is supported. Most of the configurations are saved 178 * elsewhere as part of the {@link android.net.wifi.WifiConfiguration} data. 179 * The configurations needed from the legacy Passpoint configuration file are: 180 * 181 * - FQDN - needed to be able to link to the associated {@link WifiConfiguration} data 182 * - Friendly Name 183 * - Roaming Consortium OIs 184 * - Realm 185 * - IMSI (for SIM credential) 186 * 187 * Make this function non-static so that it can be mocked during unit test. 188 * 189 * @param fileName The file name of the configuration file 190 * @return Map of FQDN to {@link LegacyPasspointConfig} 191 * @throws IOException 192 */ 193 public Map<String, LegacyPasspointConfig> parseConfig(String fileName) 194 throws IOException { 195 Map<String, LegacyPasspointConfig> configs = new HashMap<>(); 196 BufferedReader in = new BufferedReader(new FileReader(fileName)); 197 in.readLine(); // Ignore the first line which contained the header. 198 199 // Convert the configuration data to a management tree represented by a root {@link Node}. 200 Node root = buildNode(in); 201 202 if (root == null || root.getChildren() == null) { 203 Log.d(TAG, "Empty configuration data"); 204 return configs; 205 } 206 207 // Verify root node name. 208 if (!TextUtils.equals(TAG_MANAGEMENT_TREE, root.getName())) { 209 throw new IOException("Unexpected root node: " + root.getName()); 210 } 211 212 // Process and retrieve the configuration from each PPS (PerProviderSubscription) node. 213 List<Node> ppsNodes = root.getChildren(); 214 for (Node ppsNode : ppsNodes) { 215 LegacyPasspointConfig config = processPpsNode(ppsNode); 216 configs.put(config.mFqdn, config); 217 } 218 return configs; 219 } 220 221 /** 222 * Build a {@link Node} from the current line in the buffer. A node can be an internal 223 * node (ends with '+') or a leaf node. 224 * 225 * @param in Input buffer to read data from 226 * @return {@link Node} representing the current line 227 * @throws IOException 228 */ 229 private static Node buildNode(BufferedReader in) throws IOException { 230 // Read until non-empty line. 231 String currentLine = null; 232 while ((currentLine = in.readLine()) != null) { 233 if (!currentLine.isEmpty()) { 234 break; 235 } 236 } 237 238 // Return null if EOF is reached. 239 if (currentLine == null) { 240 return null; 241 } 242 243 // Remove the leading and the trailing whitespaces. 244 currentLine = currentLine.trim(); 245 246 // Check for the internal node terminator. 247 if (TextUtils.equals(END_OF_INTERNAL_NODE_INDICATOR, currentLine)) { 248 return null; 249 } 250 251 // Parse the name-value of the current line. The value will be null if the current line 252 // is not a leaf node (e.g. line ends with a '+'). 253 // Each line is encoded in UTF-8. 254 Pair<String, String> nameValuePair = 255 parseLine(currentLine.getBytes(StandardCharsets.UTF_8)); 256 if (nameValuePair.second != null) { 257 return new LeafNode(nameValuePair.first, nameValuePair.second); 258 } 259 260 // Parse the children contained under this internal node. 261 List<Node> children = new ArrayList<>(); 262 Node child = null; 263 while ((child = buildNode(in)) != null) { 264 children.add(child); 265 } 266 return new InternalNode(nameValuePair.first, children); 267 } 268 269 /** 270 * Process a PPS (PerProviderSubscription) node to retrieve Passpoint configuration data. 271 * 272 * @param ppsNode The PPS node to process 273 * @return {@link LegacyPasspointConfig} 274 * @throws IOException 275 */ 276 private static LegacyPasspointConfig processPpsNode(Node ppsNode) throws IOException { 277 if (ppsNode.getChildren() == null || ppsNode.getChildren().size() != 1) { 278 throw new IOException("PerProviderSubscription node should contain " 279 + "one instance node"); 280 } 281 282 if (!TextUtils.equals(TAG_PER_PROVIDER_SUBSCRIPTION, ppsNode.getName())) { 283 throw new IOException("Unexpected name for PPS node: " + ppsNode.getName()); 284 } 285 286 // Retrieve the PPS instance node. 287 Node instanceNode = ppsNode.getChildren().get(0); 288 if (instanceNode.getChildren() == null) { 289 throw new IOException("PPS instance node doesn't contained any children"); 290 } 291 292 // Process and retrieve the relevant configurations under the PPS instance node. 293 LegacyPasspointConfig config = new LegacyPasspointConfig(); 294 for (Node node : instanceNode.getChildren()) { 295 switch (node.getName()) { 296 case TAG_HOMESP: 297 processHomeSPNode(node, config); 298 break; 299 case TAG_CREDENTIAL: 300 processCredentialNode(node, config); 301 break; 302 default: 303 Log.d(TAG, "Ignore uninterested field under PPS instance: " + node.getName()); 304 break; 305 } 306 } 307 if (config.mFqdn == null) { 308 throw new IOException("PPS instance missing FQDN"); 309 } 310 return config; 311 } 312 313 /** 314 * Process a HomeSP node to retrieve configuration data into the given |config|. 315 * 316 * @param homeSpNode The HomeSP node to process 317 * @param config The config object to fill in the data 318 * @throws IOException 319 */ 320 private static void processHomeSPNode(Node homeSpNode, LegacyPasspointConfig config) 321 throws IOException { 322 if (homeSpNode.getChildren() == null) { 323 throw new IOException("HomeSP node should contain at least one child node"); 324 } 325 326 for (Node node : homeSpNode.getChildren()) { 327 switch (node.getName()) { 328 case TAG_FQDN: 329 config.mFqdn = getValue(node); 330 break; 331 case TAG_FRIENDLY_NAME: 332 config.mFriendlyName = getValue(node); 333 break; 334 case TAG_ROAMING_CONSORTIUM_OI: 335 config.mRoamingConsortiumOis = parseLongArray(getValue(node)); 336 break; 337 default: 338 Log.d(TAG, "Ignore uninterested field under HomeSP: " + node.getName()); 339 break; 340 } 341 } 342 } 343 344 /** 345 * Process a Credential node to retrieve configuration data into the given |config|. 346 * 347 * @param credentialNode The Credential node to process 348 * @param config The config object to fill in the data 349 * @throws IOException 350 */ 351 private static void processCredentialNode(Node credentialNode, 352 LegacyPasspointConfig config) 353 throws IOException { 354 if (credentialNode.getChildren() == null) { 355 throw new IOException("Credential node should contain at least one child node"); 356 } 357 358 for (Node node : credentialNode.getChildren()) { 359 switch (node.getName()) { 360 case TAG_REALM: 361 config.mRealm = getValue(node); 362 break; 363 case TAG_SIM: 364 processSimNode(node, config); 365 break; 366 default: 367 Log.d(TAG, "Ignore uninterested field under Credential: " + node.getName()); 368 break; 369 } 370 } 371 } 372 373 /** 374 * Process a SIM node to retrieve configuration data into the given |config|. 375 * 376 * @param simNode The SIM node to process 377 * @param config The config object to fill in the data 378 * @throws IOException 379 */ 380 private static void processSimNode(Node simNode, LegacyPasspointConfig config) 381 throws IOException { 382 if (simNode.getChildren() == null) { 383 throw new IOException("SIM node should contain at least one child node"); 384 } 385 386 for (Node node : simNode.getChildren()) { 387 switch (node.getName()) { 388 case TAG_IMSI: 389 config.mImsi = getValue(node); 390 break; 391 default: 392 Log.d(TAG, "Ignore uninterested field under SIM: " + node.getName()); 393 break; 394 } 395 } 396 } 397 398 /** 399 * Parse the given line in the legacy Passpoint configuration file. 400 * A line can be in the following formats: 401 * 2:ab+ // internal node 402 * 2:ab=2:bc // leaf node 403 * . // end of internal node 404 * 405 * @param line The line to parse 406 * @return name-value pair, a value of null indicates internal node 407 * @throws IOException 408 */ 409 private static Pair<String, String> parseLine(byte[] lineBytes) throws IOException { 410 Pair<String, Integer> nameIndexPair = parseString(lineBytes, 0); 411 int currentIndex = nameIndexPair.second; 412 try { 413 if (lineBytes[currentIndex] == START_OF_INTERNAL_NODE_INDICATOR) { 414 return Pair.create(nameIndexPair.first, null); 415 } 416 417 if (lineBytes[currentIndex] != STRING_VALUE_INDICATOR) { 418 throw new IOException("Invalid line - missing both node and value indicator: " 419 + new String(lineBytes, StandardCharsets.UTF_8)); 420 } 421 } catch (IndexOutOfBoundsException e) { 422 throw new IOException("Invalid line - " + e.getMessage() + ": " 423 + new String(lineBytes, StandardCharsets.UTF_8)); 424 } 425 Pair<String, Integer> valueIndexPair = parseString(lineBytes, currentIndex + 1); 426 return Pair.create(nameIndexPair.first, valueIndexPair.first); 427 } 428 429 /** 430 * Parse a string value in the given line from the given start index. 431 * A string value is in the following format: 432 * |HexByteLength|:|String| 433 * 434 * The length value indicates the number of UTF-8 bytes in hex for the given string. 435 * 436 * For example: 3:abc 437 * 438 * @param lineBytes The UTF-8 bytes of the line to parse 439 * @param startIndex The start index from the given line to parse from 440 * @return Pair of a string value and an index pointed to character after the string value 441 * @throws IOException 442 */ 443 private static Pair<String, Integer> parseString(byte[] lineBytes, int startIndex) 444 throws IOException { 445 // Locate the index that separate length and the string value. 446 int prefixIndex = -1; 447 for (int i = startIndex; i < lineBytes.length; i++) { 448 if (lineBytes[i] == STRING_PREFIX_INDICATOR) { 449 prefixIndex = i; 450 break; 451 } 452 } 453 if (prefixIndex == -1) { 454 throw new IOException("Invalid line - missing string prefix: " 455 + new String(lineBytes, StandardCharsets.UTF_8)); 456 } 457 458 try { 459 String lengthStr = new String(lineBytes, startIndex, prefixIndex - startIndex, 460 StandardCharsets.UTF_8); 461 int length = Integer.parseInt(lengthStr, 16); 462 int strStartIndex = prefixIndex + 1; 463 // The length might account for bytes for the whitespaces, since the whitespaces are 464 // already trimmed, ignore them. 465 if ((strStartIndex + length) > lineBytes.length) { 466 length = lineBytes.length - strStartIndex; 467 } 468 return Pair.create( 469 new String(lineBytes, strStartIndex, length, StandardCharsets.UTF_8), 470 strStartIndex + length); 471 } catch (NumberFormatException | IndexOutOfBoundsException e) { 472 throw new IOException("Invalid line - " + e.getMessage() + ": " 473 + new String(lineBytes, StandardCharsets.UTF_8)); 474 } 475 } 476 477 /** 478 * Parse a long array from the given string. 479 * 480 * @param str The string to parse 481 * @return long[] 482 * @throws IOException 483 */ 484 private static long[] parseLongArray(String str) 485 throws IOException { 486 String[] strArray = str.split(LONG_ARRAY_SEPARATOR); 487 long[] longArray = new long[strArray.length]; 488 for (int i = 0; i < longArray.length; i++) { 489 try { 490 longArray[i] = Long.parseLong(strArray[i], 16); 491 } catch (NumberFormatException e) { 492 throw new IOException("Invalid long integer value: " + strArray[i]); 493 } 494 } 495 return longArray; 496 } 497 498 /** 499 * Get the String value of the given node. An IOException will be thrown if the given 500 * node doesn't contain a String value (internal node). 501 * 502 * @param node The node to get the value from 503 * @return String 504 * @throws IOException 505 */ 506 private static String getValue(Node node) throws IOException { 507 if (node.getValue() == null) { 508 throw new IOException("Attempt to retreive value from non-leaf node: " 509 + node.getName()); 510 } 511 return node.getValue(); 512 } 513 } 514