Home | History | Annotate | Download | only in hotspot2
      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      */
    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