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 17package android.net.wifi.hotspot2; 18 19import android.net.wifi.hotspot2.omadm.PpsMoParser; 20import android.text.TextUtils; 21import android.util.Base64; 22import android.util.Log; 23import android.util.Pair; 24 25import java.io.ByteArrayInputStream; 26import java.io.IOException; 27import java.io.InputStreamReader; 28import java.io.LineNumberReader; 29import java.nio.charset.StandardCharsets; 30import java.security.GeneralSecurityException; 31import java.security.KeyStore; 32import java.security.PrivateKey; 33import java.security.cert.Certificate; 34import java.security.cert.CertificateException; 35import java.security.cert.CertificateFactory; 36import java.security.cert.X509Certificate; 37import java.util.ArrayList; 38import java.util.HashMap; 39import java.util.List; 40import java.util.Map; 41 42/** 43 * Utility class for building PasspointConfiguration from an installation file. 44 */ 45public 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 */ 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 */ 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 */ 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 // Parse CA (Certificate Authority) certificate. 186 byte[] caCertData = mimeParts.get(TYPE_CA_CERT); 187 if (caCertData != null) { 188 try { 189 config.getCredential().setCaCertificate(parseCACert(caCertData)); 190 } catch (CertificateException e) { 191 throw new IOException("Failed to parse CA Certificate"); 192 } 193 } 194 195 // Parse PKCS12 data for client private key and certificate chain. 196 byte[] pkcs12Data = mimeParts.get(TYPE_PKCS12); 197 if (pkcs12Data != null) { 198 try { 199 Pair<PrivateKey, List<X509Certificate>> clientKey = parsePkcs12(pkcs12Data); 200 config.getCredential().setClientPrivateKey(clientKey.first); 201 config.getCredential().setClientCertificateChain( 202 clientKey.second.toArray(new X509Certificate[clientKey.second.size()])); 203 } catch(GeneralSecurityException | IOException e) { 204 throw new IOException("Failed to parse PCKS12 string"); 205 } 206 } 207 return config; 208 } 209 210 /** 211 * Parse a MIME (Multipurpose Internet Mail Extension) multipart message from the given 212 * input stream. 213 * 214 * @param in The input stream for reading the message data 215 * @return A map of a content type and content data pair 216 * @throws IOException 217 */ 218 private static Map<String, byte[]> parseMimeMultipartMessage(LineNumberReader in) 219 throws IOException { 220 // Parse the outer MIME header. 221 MimeHeader header = parseHeaders(in); 222 if (!TextUtils.equals(header.contentType, TYPE_MULTIPART_MIXED)) { 223 throw new IOException("Invalid content type: " + header.contentType); 224 } 225 if (TextUtils.isEmpty(header.boundary)) { 226 throw new IOException("Missing boundary string"); 227 } 228 if (!TextUtils.equals(header.encodingType, ENCODING_BASE64)) { 229 throw new IOException("Unexpected encoding: " + header.encodingType); 230 } 231 232 // Read pass the first boundary string. 233 for (;;) { 234 String line = in.readLine(); 235 if (line == null) { 236 throw new IOException("Unexpected EOF before first boundary @ " + 237 in.getLineNumber()); 238 } 239 if (line.equals("--" + header.boundary)) { 240 break; 241 } 242 } 243 244 // Parse each MIME part. 245 Map<String, byte[]> mimeParts = new HashMap<>(); 246 boolean isLast = false; 247 do { 248 MimePart mimePart = parseMimePart(in, header.boundary); 249 mimeParts.put(mimePart.type, mimePart.data); 250 isLast = mimePart.isLast; 251 } while(!isLast); 252 return mimeParts; 253 } 254 255 /** 256 * Parse a MIME (Multipurpose Internet Mail Extension) part. We expect the data to 257 * be encoded in base64. 258 * 259 * @param in Input stream to read the data from 260 * @param boundary Boundary string indicate the end of the part 261 * @return {@link MimePart} 262 * @throws IOException 263 */ 264 private static MimePart parseMimePart(LineNumberReader in, String boundary) 265 throws IOException { 266 MimeHeader header = parseHeaders(in); 267 // Expect encoding type to be base64. 268 if (!TextUtils.equals(header.encodingType, ENCODING_BASE64)) { 269 throw new IOException("Unexpected encoding type: " + header.encodingType); 270 } 271 272 // Check for a valid content type. 273 if (!TextUtils.equals(header.contentType, TYPE_PASSPOINT_PROFILE) && 274 !TextUtils.equals(header.contentType, TYPE_CA_CERT) && 275 !TextUtils.equals(header.contentType, TYPE_PKCS12)) { 276 throw new IOException("Unexpected content type: " + header.contentType); 277 } 278 279 StringBuilder text = new StringBuilder(); 280 boolean isLast = false; 281 String partBoundary = "--" + boundary; 282 String endBoundary = partBoundary + "--"; 283 for (;;) { 284 String line = in.readLine(); 285 if (line == null) { 286 throw new IOException("Unexpected EOF file in body @ " + in.getLineNumber()); 287 } 288 // Check for boundary line. 289 if (line.startsWith(partBoundary)) { 290 if (line.equals(endBoundary)) { 291 isLast = true; 292 } 293 break; 294 } 295 text.append(line); 296 } 297 298 MimePart part = new MimePart(); 299 part.type = header.contentType; 300 part.data = Base64.decode(text.toString(), Base64.DEFAULT); 301 part.isLast = isLast; 302 return part; 303 } 304 305 /** 306 * Parse a MIME (Multipurpose Internet Mail Extension) header from the input stream. 307 * @param in Input stream to read from. 308 * @return {@link MimeHeader} 309 * @throws IOException 310 */ 311 private static MimeHeader parseHeaders(LineNumberReader in) 312 throws IOException { 313 MimeHeader header = new MimeHeader(); 314 315 // Read the header from the input stream. 316 Map<String, String> headers = readHeaders(in); 317 318 // Parse each header. 319 for (Map.Entry<String, String> entry : headers.entrySet()) { 320 switch (entry.getKey()) { 321 case CONTENT_TYPE: 322 Pair<String, String> value = parseContentType(entry.getValue()); 323 header.contentType = value.first; 324 header.boundary = value.second; 325 break; 326 case CONTENT_TRANSFER_ENCODING: 327 header.encodingType = entry.getValue(); 328 break; 329 default: 330 Log.d(TAG, "Ignore header: " + entry.getKey()); 331 break; 332 } 333 } 334 return header; 335 } 336 337 /** 338 * Parse the Content-Type header value. The value will contain the content type string and 339 * an optional boundary string separated by a ";". Below are examples of valid Content-Type 340 * header value: 341 * multipart/mixed; boundary={boundary} 342 * application/x-passpoint-profile 343 * 344 * @param contentType The Content-Type value string 345 * @return A pair of content type and boundary string 346 * @throws IOException 347 */ 348 private static Pair<String, String> parseContentType(String contentType) throws IOException { 349 String[] attributes = contentType.split(";"); 350 String type = null; 351 String boundary = null; 352 353 if (attributes.length < 1) { 354 throw new IOException("Invalid Content-Type: " + contentType); 355 } 356 357 // The type is always the first attribute. 358 type = attributes[0].trim(); 359 // Look for boundary string from the rest of the attributes. 360 for (int i = 1; i < attributes.length; i++) { 361 String attribute = attributes[i].trim(); 362 if (!attribute.startsWith(BOUNDARY)) { 363 Log.d(TAG, "Ignore Content-Type attribute: " + attributes[i]); 364 continue; 365 } 366 boundary = attribute.substring(BOUNDARY.length()); 367 // Remove the leading and trailing quote if present. 368 if (boundary.length() > 1 && boundary.startsWith("\"") && boundary.endsWith("\"")) { 369 boundary = boundary.substring(1, boundary.length()-1); 370 } 371 } 372 373 return new Pair<String, String>(type, boundary); 374 } 375 376 /** 377 * Read the headers from the given input stream. The header section is terminated by 378 * an empty line. 379 * 380 * @param in The input stream to read from 381 * @return Map of key-value pairs. 382 * @throws IOException 383 */ 384 private static Map<String, String> readHeaders(LineNumberReader in) 385 throws IOException { 386 Map<String, String> headers = new HashMap<>(); 387 String line; 388 String name = null; 389 StringBuilder value = null; 390 for (;;) { 391 line = in.readLine(); 392 if (line == null) { 393 throw new IOException("Missing line @ " + in.getLineNumber()); 394 } 395 396 // End of headers section. 397 if (line.length() == 0 || line.trim().length() == 0) { 398 // Save the previous header line. 399 if (name != null) { 400 headers.put(name, value.toString()); 401 } 402 break; 403 } 404 405 int nameEnd = line.indexOf(':'); 406 if (nameEnd < 0) { 407 if (value != null) { 408 // Continuation line for the header value. 409 value.append(' ').append(line.trim()); 410 } else { 411 throw new IOException("Bad header line: '" + line + "' @ " + 412 in.getLineNumber()); 413 } 414 } else { 415 // New header line detected, make sure it doesn't start with a whitespace. 416 if (Character.isWhitespace(line.charAt(0))) { 417 throw new IOException("Illegal blank prefix in header line '" + line + 418 "' @ " + in.getLineNumber()); 419 } 420 421 if (name != null) { 422 // Save the previous header line. 423 headers.put(name, value.toString()); 424 } 425 426 // Setup the current header line. 427 name = line.substring(0, nameEnd).trim(); 428 value = new StringBuilder(); 429 value.append(line.substring(nameEnd+1).trim()); 430 } 431 } 432 return headers; 433 } 434 435 /** 436 * Parse a CA (Certificate Authority) certificate data and convert it to a 437 * X509Certificate object. 438 * 439 * @param octets Certificate data 440 * @return X509Certificate 441 * @throws CertificateException 442 */ 443 private static X509Certificate parseCACert(byte[] octets) throws CertificateException { 444 CertificateFactory factory = CertificateFactory.getInstance("X.509"); 445 return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(octets)); 446 } 447 448 private static Pair<PrivateKey, List<X509Certificate>> parsePkcs12(byte[] octets) 449 throws GeneralSecurityException, IOException { 450 KeyStore ks = KeyStore.getInstance("PKCS12"); 451 ByteArrayInputStream in = new ByteArrayInputStream(octets); 452 ks.load(in, new char[0]); 453 in.close(); 454 455 // Only expects one set of key and certificate chain. 456 if (ks.size() != 1) { 457 throw new IOException("Unexpected key size: " + ks.size()); 458 } 459 460 String alias = ks.aliases().nextElement(); 461 if (alias == null) { 462 throw new IOException("No alias found"); 463 } 464 465 PrivateKey clientKey = (PrivateKey) ks.getKey(alias, null); 466 List<X509Certificate> clientCertificateChain = null; 467 Certificate[] chain = ks.getCertificateChain(alias); 468 if (chain != null) { 469 clientCertificateChain = new ArrayList<>(); 470 for (Certificate certificate : chain) { 471 if (!(certificate instanceof X509Certificate)) { 472 throw new IOException("Unexpceted certificate type: " + 473 certificate.getClass()); 474 } 475 clientCertificateChain.add((X509Certificate) certificate); 476 } 477 } 478 return new Pair<PrivateKey, List<X509Certificate>>(clientKey, clientCertificateChain); 479 } 480} 481