1 /** 2 * Copyright (c) 2016, 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 android.net.wifi.hotspot2; 18 19 import android.net.wifi.hotspot2.omadm.PpsMoParser; 20 import android.text.TextUtils; 21 import android.util.Base64; 22 import android.util.Log; 23 import android.util.Pair; 24 25 import java.io.ByteArrayInputStream; 26 import java.io.IOException; 27 import java.io.InputStreamReader; 28 import java.io.LineNumberReader; 29 import java.nio.charset.StandardCharsets; 30 import java.security.GeneralSecurityException; 31 import java.security.KeyStore; 32 import java.security.PrivateKey; 33 import java.security.cert.Certificate; 34 import java.security.cert.CertificateException; 35 import java.security.cert.CertificateFactory; 36 import java.security.cert.X509Certificate; 37 import java.util.ArrayList; 38 import java.util.HashMap; 39 import java.util.List; 40 import java.util.Map; 41 42 /** 43 * Utility class for building PasspointConfiguration from an installation file. 44 */ 45 public final class ConfigParser { 46 private static final String TAG = "ConfigParser"; 47 48 // Header names. 49 private static final String CONTENT_TYPE = "Content-Type"; 50 private static final String CONTENT_TRANSFER_ENCODING = "Content-Transfer-Encoding"; 51 52 // MIME types. 53 private static final String TYPE_MULTIPART_MIXED = "multipart/mixed"; 54 private static final String TYPE_WIFI_CONFIG = "application/x-wifi-config"; 55 private static final String TYPE_PASSPOINT_PROFILE = "application/x-passpoint-profile"; 56 private static final String TYPE_CA_CERT = "application/x-x509-ca-cert"; 57 private static final String TYPE_PKCS12 = "application/x-pkcs12"; 58 59 private static final String ENCODING_BASE64 = "base64"; 60 private static final String BOUNDARY = "boundary="; 61 62 /** 63 * Class represent a MIME (Multipurpose Internet Mail Extension) part. 64 */ 65 private static class MimePart { 66 /** 67 * Content type of the part. 68 */ 69 public String type = null; 70 71 /** 72 * Decoded data. 73 */ 74 public byte[] data = null; 75 76 /** 77 * Flag indicating if this is the last part (ending with --{boundary}--). 78 */ 79 public boolean isLast = false; 80 } 81 82 /** 83 * Class represent the MIME (Multipurpose Internet Mail Extension) header. 84 */ 85 private static class MimeHeader { 86 /** 87 * Content type. 88 */ 89 public String contentType = null; 90 91 /** 92 * Boundary string (optional), only applies for the outter MIME header. 93 */ 94 public String boundary = null; 95 96 /** 97 * Encoding type. 98 */ 99 public String encodingType = null; 100 } 101 102 /** 103 * @hide 104 */ ConfigParser()105 public ConfigParser() {} 106 107 /** 108 * Parse the Hotspot 2.0 Release 1 configuration data into a {@link PasspointConfiguration} 109 * object. The configuration data is a base64 encoded MIME multipart data. Below is 110 * the format of the decoded message: 111 * 112 * Content-Type: multipart/mixed; boundary={boundary} 113 * Content-Transfer-Encoding: base64 114 * [Skip uninterested headers] 115 * 116 * --{boundary} 117 * Content-Type: application/x-passpoint-profile 118 * Content-Transfer-Encoding: base64 119 * 120 * [base64 encoded Passpoint profile data] 121 * --{boundary} 122 * Content-Type: application/x-x509-ca-cert 123 * Content-Transfer-Encoding: base64 124 * 125 * [base64 encoded X509 CA certificate data] 126 * --{boundary} 127 * Content-Type: application/x-pkcs12 128 * Content-Transfer-Encoding: base64 129 * 130 * [base64 encoded PKCS#12 ASN.1 structure containing client certificate chain] 131 * --{boundary} 132 * 133 * @param mimeType MIME type of the encoded data. 134 * @param data A base64 encoded MIME multipart message containing the Passpoint profile 135 * (required), CA (Certificate Authority) certificate (optional), and client 136 * certificate chain (optional). 137 * @return {@link PasspointConfiguration} 138 */ parsePasspointConfig(String mimeType, byte[] data)139 public static PasspointConfiguration parsePasspointConfig(String mimeType, byte[] data) { 140 // Verify MIME type. 141 if (!TextUtils.equals(mimeType, TYPE_WIFI_CONFIG)) { 142 Log.e(TAG, "Unexpected MIME type: " + mimeType); 143 return null; 144 } 145 146 try { 147 // Decode the data. 148 byte[] decodedData = Base64.decode(new String(data, StandardCharsets.ISO_8859_1), 149 Base64.DEFAULT); 150 Map<String, byte[]> mimeParts = parseMimeMultipartMessage(new LineNumberReader( 151 new InputStreamReader(new ByteArrayInputStream(decodedData), 152 StandardCharsets.ISO_8859_1))); 153 return createPasspointConfig(mimeParts); 154 } catch (IOException | IllegalArgumentException e) { 155 Log.e(TAG, "Failed to parse installation file: " + e.getMessage()); 156 return null; 157 } 158 } 159 160 /** 161 * Create a {@link PasspointConfiguration} object from list of MIME (Multipurpose Internet 162 * Mail Extension) parts. 163 * 164 * @param mimeParts Map of content type and content data. 165 * @return {@link PasspointConfiguration} 166 * @throws IOException 167 */ createPasspointConfig(Map<String, byte[]> mimeParts)168 private static PasspointConfiguration createPasspointConfig(Map<String, byte[]> mimeParts) 169 throws IOException { 170 byte[] profileData = mimeParts.get(TYPE_PASSPOINT_PROFILE); 171 if (profileData == null) { 172 throw new IOException("Missing Passpoint Profile"); 173 } 174 175 PasspointConfiguration config = PpsMoParser.parseMoText(new String(profileData)); 176 if (config == null) { 177 throw new IOException("Failed to parse Passpoint profile"); 178 } 179 180 // Credential is needed for storing the certificates and private client key. 181 if (config.getCredential() == null) { 182 throw new IOException("Passpoint profile missing credential"); 183 } 184 185 // Don't allow the installer to make changes to the update identifier. This is an 186 // indicator of an R2 (or newer) network. 187 if (config.getUpdateIdentifier() != Integer.MIN_VALUE) { 188 config.setUpdateIdentifier(Integer.MIN_VALUE); 189 } 190 191 // Parse CA (Certificate Authority) certificate. 192 byte[] caCertData = mimeParts.get(TYPE_CA_CERT); 193 if (caCertData != null) { 194 try { 195 config.getCredential().setCaCertificate(parseCACert(caCertData)); 196 } catch (CertificateException e) { 197 throw new IOException("Failed to parse CA Certificate"); 198 } 199 } 200 201 // Parse PKCS12 data for client private key and certificate chain. 202 byte[] pkcs12Data = mimeParts.get(TYPE_PKCS12); 203 if (pkcs12Data != null) { 204 try { 205 Pair<PrivateKey, List<X509Certificate>> clientKey = parsePkcs12(pkcs12Data); 206 config.getCredential().setClientPrivateKey(clientKey.first); 207 config.getCredential().setClientCertificateChain( 208 clientKey.second.toArray(new X509Certificate[clientKey.second.size()])); 209 } catch(GeneralSecurityException | IOException e) { 210 throw new IOException("Failed to parse PKCS12 string: " + e.getMessage()); 211 } 212 } 213 return config; 214 } 215 216 /** 217 * Parse a MIME (Multipurpose Internet Mail Extension) multipart message from the given 218 * input stream. 219 * 220 * @param in The input stream for reading the message data 221 * @return A map of a content type and content data pair 222 * @throws IOException 223 */ parseMimeMultipartMessage(LineNumberReader in)224 private static Map<String, byte[]> parseMimeMultipartMessage(LineNumberReader in) 225 throws IOException { 226 // Parse the outer MIME header. 227 MimeHeader header = parseHeaders(in); 228 if (!TextUtils.equals(header.contentType, TYPE_MULTIPART_MIXED)) { 229 throw new IOException("Invalid content type: " + header.contentType); 230 } 231 if (TextUtils.isEmpty(header.boundary)) { 232 throw new IOException("Missing boundary string"); 233 } 234 if (!TextUtils.equals(header.encodingType, ENCODING_BASE64)) { 235 throw new IOException("Unexpected encoding: " + header.encodingType); 236 } 237 238 // Read pass the first boundary string. 239 for (;;) { 240 String line = in.readLine(); 241 if (line == null) { 242 throw new IOException("Unexpected EOF before first boundary @ " + 243 in.getLineNumber()); 244 } 245 if (line.equals("--" + header.boundary)) { 246 break; 247 } 248 } 249 250 // Parse each MIME part. 251 Map<String, byte[]> mimeParts = new HashMap<>(); 252 boolean isLast = false; 253 do { 254 MimePart mimePart = parseMimePart(in, header.boundary); 255 mimeParts.put(mimePart.type, mimePart.data); 256 isLast = mimePart.isLast; 257 } while(!isLast); 258 return mimeParts; 259 } 260 261 /** 262 * Parse a MIME (Multipurpose Internet Mail Extension) part. We expect the data to 263 * be encoded in base64. 264 * 265 * @param in Input stream to read the data from 266 * @param boundary Boundary string indicate the end of the part 267 * @return {@link MimePart} 268 * @throws IOException 269 */ parseMimePart(LineNumberReader in, String boundary)270 private static MimePart parseMimePart(LineNumberReader in, String boundary) 271 throws IOException { 272 MimeHeader header = parseHeaders(in); 273 // Expect encoding type to be base64. 274 if (!TextUtils.equals(header.encodingType, ENCODING_BASE64)) { 275 throw new IOException("Unexpected encoding type: " + header.encodingType); 276 } 277 278 // Check for a valid content type. 279 if (!TextUtils.equals(header.contentType, TYPE_PASSPOINT_PROFILE) && 280 !TextUtils.equals(header.contentType, TYPE_CA_CERT) && 281 !TextUtils.equals(header.contentType, TYPE_PKCS12)) { 282 throw new IOException("Unexpected content type: " + header.contentType); 283 } 284 285 StringBuilder text = new StringBuilder(); 286 boolean isLast = false; 287 String partBoundary = "--" + boundary; 288 String endBoundary = partBoundary + "--"; 289 for (;;) { 290 String line = in.readLine(); 291 if (line == null) { 292 throw new IOException("Unexpected EOF file in body @ " + in.getLineNumber()); 293 } 294 // Check for boundary line. 295 if (line.startsWith(partBoundary)) { 296 if (line.equals(endBoundary)) { 297 isLast = true; 298 } 299 break; 300 } 301 text.append(line); 302 } 303 304 MimePart part = new MimePart(); 305 part.type = header.contentType; 306 part.data = Base64.decode(text.toString(), Base64.DEFAULT); 307 part.isLast = isLast; 308 return part; 309 } 310 311 /** 312 * Parse a MIME (Multipurpose Internet Mail Extension) header from the input stream. 313 * @param in Input stream to read from. 314 * @return {@link MimeHeader} 315 * @throws IOException 316 */ parseHeaders(LineNumberReader in)317 private static MimeHeader parseHeaders(LineNumberReader in) 318 throws IOException { 319 MimeHeader header = new MimeHeader(); 320 321 // Read the header from the input stream. 322 Map<String, String> headers = readHeaders(in); 323 324 // Parse each header. 325 for (Map.Entry<String, String> entry : headers.entrySet()) { 326 switch (entry.getKey()) { 327 case CONTENT_TYPE: 328 Pair<String, String> value = parseContentType(entry.getValue()); 329 header.contentType = value.first; 330 header.boundary = value.second; 331 break; 332 case CONTENT_TRANSFER_ENCODING: 333 header.encodingType = entry.getValue(); 334 break; 335 default: 336 Log.d(TAG, "Ignore header: " + entry.getKey()); 337 break; 338 } 339 } 340 return header; 341 } 342 343 /** 344 * Parse the Content-Type header value. The value will contain the content type string and 345 * an optional boundary string separated by a ";". Below are examples of valid Content-Type 346 * header value: 347 * multipart/mixed; boundary={boundary} 348 * application/x-passpoint-profile 349 * 350 * @param contentType The Content-Type value string 351 * @return A pair of content type and boundary string 352 * @throws IOException 353 */ parseContentType(String contentType)354 private static Pair<String, String> parseContentType(String contentType) throws IOException { 355 String[] attributes = contentType.split(";"); 356 String type = null; 357 String boundary = null; 358 359 if (attributes.length < 1) { 360 throw new IOException("Invalid Content-Type: " + contentType); 361 } 362 363 // The type is always the first attribute. 364 type = attributes[0].trim(); 365 // Look for boundary string from the rest of the attributes. 366 for (int i = 1; i < attributes.length; i++) { 367 String attribute = attributes[i].trim(); 368 if (!attribute.startsWith(BOUNDARY)) { 369 Log.d(TAG, "Ignore Content-Type attribute: " + attributes[i]); 370 continue; 371 } 372 boundary = attribute.substring(BOUNDARY.length()); 373 // Remove the leading and trailing quote if present. 374 if (boundary.length() > 1 && boundary.startsWith("\"") && boundary.endsWith("\"")) { 375 boundary = boundary.substring(1, boundary.length()-1); 376 } 377 } 378 379 return new Pair<String, String>(type, boundary); 380 } 381 382 /** 383 * Read the headers from the given input stream. The header section is terminated by 384 * an empty line. 385 * 386 * @param in The input stream to read from 387 * @return Map of key-value pairs. 388 * @throws IOException 389 */ readHeaders(LineNumberReader in)390 private static Map<String, String> readHeaders(LineNumberReader in) 391 throws IOException { 392 Map<String, String> headers = new HashMap<>(); 393 String line; 394 String name = null; 395 StringBuilder value = null; 396 for (;;) { 397 line = in.readLine(); 398 if (line == null) { 399 throw new IOException("Missing line @ " + in.getLineNumber()); 400 } 401 402 // End of headers section. 403 if (line.length() == 0 || line.trim().length() == 0) { 404 // Save the previous header line. 405 if (name != null) { 406 headers.put(name, value.toString()); 407 } 408 break; 409 } 410 411 int nameEnd = line.indexOf(':'); 412 if (nameEnd < 0) { 413 if (value != null) { 414 // Continuation line for the header value. 415 value.append(' ').append(line.trim()); 416 } else { 417 throw new IOException("Bad header line: '" + line + "' @ " + 418 in.getLineNumber()); 419 } 420 } else { 421 // New header line detected, make sure it doesn't start with a whitespace. 422 if (Character.isWhitespace(line.charAt(0))) { 423 throw new IOException("Illegal blank prefix in header line '" + line + 424 "' @ " + in.getLineNumber()); 425 } 426 427 if (name != null) { 428 // Save the previous header line. 429 headers.put(name, value.toString()); 430 } 431 432 // Setup the current header line. 433 name = line.substring(0, nameEnd).trim(); 434 value = new StringBuilder(); 435 value.append(line.substring(nameEnd+1).trim()); 436 } 437 } 438 return headers; 439 } 440 441 /** 442 * Parse a CA (Certificate Authority) certificate data and convert it to a 443 * X509Certificate object. 444 * 445 * @param octets Certificate data 446 * @return X509Certificate 447 * @throws CertificateException 448 */ parseCACert(byte[] octets)449 private static X509Certificate parseCACert(byte[] octets) throws CertificateException { 450 CertificateFactory factory = CertificateFactory.getInstance("X.509"); 451 return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(octets)); 452 } 453 parsePkcs12(byte[] octets)454 private static Pair<PrivateKey, List<X509Certificate>> parsePkcs12(byte[] octets) 455 throws GeneralSecurityException, IOException { 456 KeyStore ks = KeyStore.getInstance("PKCS12"); 457 ByteArrayInputStream in = new ByteArrayInputStream(octets); 458 ks.load(in, new char[0]); 459 in.close(); 460 461 // Only expects one set of key and certificate chain. 462 if (ks.size() != 1) { 463 throw new IOException("Unexpected key size: " + ks.size()); 464 } 465 466 String alias = ks.aliases().nextElement(); 467 if (alias == null) { 468 throw new IOException("No alias found"); 469 } 470 471 PrivateKey clientKey = (PrivateKey) ks.getKey(alias, null); 472 List<X509Certificate> clientCertificateChain = null; 473 Certificate[] chain = ks.getCertificateChain(alias); 474 if (chain != null) { 475 clientCertificateChain = new ArrayList<>(); 476 for (Certificate certificate : chain) { 477 if (!(certificate instanceof X509Certificate)) { 478 throw new IOException("Unexpceted certificate type: " + 479 certificate.getClass()); 480 } 481 clientCertificateChain.add((X509Certificate) certificate); 482 } 483 } 484 return new Pair<PrivateKey, List<X509Certificate>>(clientKey, clientCertificateChain); 485 } 486 } 487