• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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