/** * Copyright (c) 2016, The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.net.wifi.hotspot2; import android.net.wifi.hotspot2.omadm.PpsMoParser; import android.text.TextUtils; import android.util.Base64; import android.util.Log; import android.util.Pair; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.LineNumberReader; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Utility class for building PasspointConfiguration from an installation file. */ public final class ConfigParser { private static final String TAG = "ConfigParser"; // Header names. private static final String CONTENT_TYPE = "Content-Type"; private static final String CONTENT_TRANSFER_ENCODING = "Content-Transfer-Encoding"; // MIME types. private static final String TYPE_MULTIPART_MIXED = "multipart/mixed"; private static final String TYPE_WIFI_CONFIG = "application/x-wifi-config"; private static final String TYPE_PASSPOINT_PROFILE = "application/x-passpoint-profile"; private static final String TYPE_CA_CERT = "application/x-x509-ca-cert"; private static final String TYPE_PKCS12 = "application/x-pkcs12"; private static final String ENCODING_BASE64 = "base64"; private static final String BOUNDARY = "boundary="; /** * Class represent a MIME (Multipurpose Internet Mail Extension) part. */ private static class MimePart { /** * Content type of the part. */ public String type = null; /** * Decoded data. */ public byte[] data = null; /** * Flag indicating if this is the last part (ending with --{boundary}--). */ public boolean isLast = false; } /** * Class represent the MIME (Multipurpose Internet Mail Extension) header. */ private static class MimeHeader { /** * Content type. */ public String contentType = null; /** * Boundary string (optional), only applies for the outter MIME header. */ public String boundary = null; /** * Encoding type. */ public String encodingType = null; } /** * @hide */ public ConfigParser() {} /** * Parse the Hotspot 2.0 Release 1 configuration data into a {@link PasspointConfiguration} * object. The configuration data is a base64 encoded MIME multipart data. Below is * the format of the decoded message: * * Content-Type: multipart/mixed; boundary={boundary} * Content-Transfer-Encoding: base64 * [Skip uninterested headers] * * --{boundary} * Content-Type: application/x-passpoint-profile * Content-Transfer-Encoding: base64 * * [base64 encoded Passpoint profile data] * --{boundary} * Content-Type: application/x-x509-ca-cert * Content-Transfer-Encoding: base64 * * [base64 encoded X509 CA certificate data] * --{boundary} * Content-Type: application/x-pkcs12 * Content-Transfer-Encoding: base64 * * [base64 encoded PKCS#12 ASN.1 structure containing client certificate chain] * --{boundary} * * @param mimeType MIME type of the encoded data. * @param data A base64 encoded MIME multipart message containing the Passpoint profile * (required), CA (Certificate Authority) certificate (optional), and client * certificate chain (optional). * @return {@link PasspointConfiguration} */ public static PasspointConfiguration parsePasspointConfig(String mimeType, byte[] data) { // Verify MIME type. if (!TextUtils.equals(mimeType, TYPE_WIFI_CONFIG)) { Log.e(TAG, "Unexpected MIME type: " + mimeType); return null; } try { // Decode the data. byte[] decodedData = Base64.decode(new String(data, StandardCharsets.ISO_8859_1), Base64.DEFAULT); Map<String, byte[]> mimeParts = parseMimeMultipartMessage(new LineNumberReader( new InputStreamReader(new ByteArrayInputStream(decodedData), StandardCharsets.ISO_8859_1))); return createPasspointConfig(mimeParts); } catch (IOException | IllegalArgumentException e) { Log.e(TAG, "Failed to parse installation file: " + e.getMessage()); return null; } } /** * Create a {@link PasspointConfiguration} object from list of MIME (Multipurpose Internet * Mail Extension) parts. * * @param mimeParts Map of content type and content data. * @return {@link PasspointConfiguration} * @throws IOException */ private static PasspointConfiguration createPasspointConfig(Map<String, byte[]> mimeParts) throws IOException { byte[] profileData = mimeParts.get(TYPE_PASSPOINT_PROFILE); if (profileData == null) { throw new IOException("Missing Passpoint Profile"); } PasspointConfiguration config = PpsMoParser.parseMoText(new String(profileData)); if (config == null) { throw new IOException("Failed to parse Passpoint profile"); } // Credential is needed for storing the certificates and private client key. if (config.getCredential() == null) { throw new IOException("Passpoint profile missing credential"); } // Parse CA (Certificate Authority) certificate. byte[] caCertData = mimeParts.get(TYPE_CA_CERT); if (caCertData != null) { try { config.getCredential().setCaCertificate(parseCACert(caCertData)); } catch (CertificateException e) { throw new IOException("Failed to parse CA Certificate"); } } // Parse PKCS12 data for client private key and certificate chain. byte[] pkcs12Data = mimeParts.get(TYPE_PKCS12); if (pkcs12Data != null) { try { Pair<PrivateKey, List<X509Certificate>> clientKey = parsePkcs12(pkcs12Data); config.getCredential().setClientPrivateKey(clientKey.first); config.getCredential().setClientCertificateChain( clientKey.second.toArray(new X509Certificate[clientKey.second.size()])); } catch(GeneralSecurityException | IOException e) { throw new IOException("Failed to parse PCKS12 string"); } } return config; } /** * Parse a MIME (Multipurpose Internet Mail Extension) multipart message from the given * input stream. * * @param in The input stream for reading the message data * @return A map of a content type and content data pair * @throws IOException */ private static Map<String, byte[]> parseMimeMultipartMessage(LineNumberReader in) throws IOException { // Parse the outer MIME header. MimeHeader header = parseHeaders(in); if (!TextUtils.equals(header.contentType, TYPE_MULTIPART_MIXED)) { throw new IOException("Invalid content type: " + header.contentType); } if (TextUtils.isEmpty(header.boundary)) { throw new IOException("Missing boundary string"); } if (!TextUtils.equals(header.encodingType, ENCODING_BASE64)) { throw new IOException("Unexpected encoding: " + header.encodingType); } // Read pass the first boundary string. for (;;) { String line = in.readLine(); if (line == null) { throw new IOException("Unexpected EOF before first boundary @ " + in.getLineNumber()); } if (line.equals("--" + header.boundary)) { break; } } // Parse each MIME part. Map<String, byte[]> mimeParts = new HashMap<>(); boolean isLast = false; do { MimePart mimePart = parseMimePart(in, header.boundary); mimeParts.put(mimePart.type, mimePart.data); isLast = mimePart.isLast; } while(!isLast); return mimeParts; } /** * Parse a MIME (Multipurpose Internet Mail Extension) part. We expect the data to * be encoded in base64. * * @param in Input stream to read the data from * @param boundary Boundary string indicate the end of the part * @return {@link MimePart} * @throws IOException */ private static MimePart parseMimePart(LineNumberReader in, String boundary) throws IOException { MimeHeader header = parseHeaders(in); // Expect encoding type to be base64. if (!TextUtils.equals(header.encodingType, ENCODING_BASE64)) { throw new IOException("Unexpected encoding type: " + header.encodingType); } // Check for a valid content type. if (!TextUtils.equals(header.contentType, TYPE_PASSPOINT_PROFILE) && !TextUtils.equals(header.contentType, TYPE_CA_CERT) && !TextUtils.equals(header.contentType, TYPE_PKCS12)) { throw new IOException("Unexpected content type: " + header.contentType); } StringBuilder text = new StringBuilder(); boolean isLast = false; String partBoundary = "--" + boundary; String endBoundary = partBoundary + "--"; for (;;) { String line = in.readLine(); if (line == null) { throw new IOException("Unexpected EOF file in body @ " + in.getLineNumber()); } // Check for boundary line. if (line.startsWith(partBoundary)) { if (line.equals(endBoundary)) { isLast = true; } break; } text.append(line); } MimePart part = new MimePart(); part.type = header.contentType; part.data = Base64.decode(text.toString(), Base64.DEFAULT); part.isLast = isLast; return part; } /** * Parse a MIME (Multipurpose Internet Mail Extension) header from the input stream. * @param in Input stream to read from. * @return {@link MimeHeader} * @throws IOException */ private static MimeHeader parseHeaders(LineNumberReader in) throws IOException { MimeHeader header = new MimeHeader(); // Read the header from the input stream. Map<String, String> headers = readHeaders(in); // Parse each header. for (Map.Entry<String, String> entry : headers.entrySet()) { switch (entry.getKey()) { case CONTENT_TYPE: Pair<String, String> value = parseContentType(entry.getValue()); header.contentType = value.first; header.boundary = value.second; break; case CONTENT_TRANSFER_ENCODING: header.encodingType = entry.getValue(); break; default: Log.d(TAG, "Ignore header: " + entry.getKey()); break; } } return header; } /** * Parse the Content-Type header value. The value will contain the content type string and * an optional boundary string separated by a ";". Below are examples of valid Content-Type * header value: * multipart/mixed; boundary={boundary} * application/x-passpoint-profile * * @param contentType The Content-Type value string * @return A pair of content type and boundary string * @throws IOException */ private static Pair<String, String> parseContentType(String contentType) throws IOException { String[] attributes = contentType.split(";"); String type = null; String boundary = null; if (attributes.length < 1) { throw new IOException("Invalid Content-Type: " + contentType); } // The type is always the first attribute. type = attributes[0].trim(); // Look for boundary string from the rest of the attributes. for (int i = 1; i < attributes.length; i++) { String attribute = attributes[i].trim(); if (!attribute.startsWith(BOUNDARY)) { Log.d(TAG, "Ignore Content-Type attribute: " + attributes[i]); continue; } boundary = attribute.substring(BOUNDARY.length()); // Remove the leading and trailing quote if present. if (boundary.length() > 1 && boundary.startsWith("\"") && boundary.endsWith("\"")) { boundary = boundary.substring(1, boundary.length()-1); } } return new Pair<String, String>(type, boundary); } /** * Read the headers from the given input stream. The header section is terminated by * an empty line. * * @param in The input stream to read from * @return Map of key-value pairs. * @throws IOException */ private static Map<String, String> readHeaders(LineNumberReader in) throws IOException { Map<String, String> headers = new HashMap<>(); String line; String name = null; StringBuilder value = null; for (;;) { line = in.readLine(); if (line == null) { throw new IOException("Missing line @ " + in.getLineNumber()); } // End of headers section. if (line.length() == 0 || line.trim().length() == 0) { // Save the previous header line. if (name != null) { headers.put(name, value.toString()); } break; } int nameEnd = line.indexOf(':'); if (nameEnd < 0) { if (value != null) { // Continuation line for the header value. value.append(' ').append(line.trim()); } else { throw new IOException("Bad header line: '" + line + "' @ " + in.getLineNumber()); } } else { // New header line detected, make sure it doesn't start with a whitespace. if (Character.isWhitespace(line.charAt(0))) { throw new IOException("Illegal blank prefix in header line '" + line + "' @ " + in.getLineNumber()); } if (name != null) { // Save the previous header line. headers.put(name, value.toString()); } // Setup the current header line. name = line.substring(0, nameEnd).trim(); value = new StringBuilder(); value.append(line.substring(nameEnd+1).trim()); } } return headers; } /** * Parse a CA (Certificate Authority) certificate data and convert it to a * X509Certificate object. * * @param octets Certificate data * @return X509Certificate * @throws CertificateException */ private static X509Certificate parseCACert(byte[] octets) throws CertificateException { CertificateFactory factory = CertificateFactory.getInstance("X.509"); return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(octets)); } private static Pair<PrivateKey, List<X509Certificate>> parsePkcs12(byte[] octets) throws GeneralSecurityException, IOException { KeyStore ks = KeyStore.getInstance("PKCS12"); ByteArrayInputStream in = new ByteArrayInputStream(octets); ks.load(in, new char[0]); in.close(); // Only expects one set of key and certificate chain. if (ks.size() != 1) { throw new IOException("Unexpected key size: " + ks.size()); } String alias = ks.aliases().nextElement(); if (alias == null) { throw new IOException("No alias found"); } PrivateKey clientKey = (PrivateKey) ks.getKey(alias, null); List<X509Certificate> clientCertificateChain = null; Certificate[] chain = ks.getCertificateChain(alias); if (chain != null) { clientCertificateChain = new ArrayList<>(); for (Certificate certificate : chain) { if (!(certificate instanceof X509Certificate)) { throw new IOException("Unexpceted certificate type: " + certificate.getClass()); } clientCertificateChain.add((X509Certificate) certificate); } } return new Pair<PrivateKey, List<X509Certificate>>(clientKey, clientCertificateChain); } }