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