content: /**
   * 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);
      }
  }
  