Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 44e817fd authored by Treehugger Robot's avatar Treehugger Robot Committed by Gerrit Code Review
Browse files

Merge "wifi: hotspot2: add support for parsing Release 1 installation file"

parents 97d9fe0a 057714d1
Loading
Loading
Loading
Loading
+473 −0
Original line number Diff line number Diff line
/**
 * 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.
 *
 * @hide
 */
public final class ConfigBuilder {
    private static final String TAG = "ConfigBuilder";

    // 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;
    }


    /**
     * 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
     *
     * --{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 buildPasspointConfig(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.credential == 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.credential.caCertificate = 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.credential.clientPrivateKey = clientKey.first;
                config.credential.clientCertificateChain =
                        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:
                    throw new IOException("Unexpected header: " + entry.getKey());
            }
        }
        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.toString().split(";");
        String type = null;
        String boundary = null;

        if (attributes.length < 1 || attributes.length > 2) {
            throw new IOException("Invalid Content-Type: " + contentType);
        }

        type = attributes[0].trim();
        if (attributes.length == 2) {
            boundary = attributes[1].trim();
            if (!boundary.startsWith(BOUNDARY)) {
                throw new IOException("Invalid Content-Type: " + contentType);
            }
            boundary = boundary.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);
    }
}
 No newline at end of file
+85 −0
Original line number Diff line number Diff line
Q29udGVudC1UeXBlOiBtdWx0aXBhcnQvbWl4ZWQ7IGJvdW5kYXJ5PXtib3VuZGFyeX0KQ29udGVu
dC1UcmFuc2Zlci1FbmNvZGluZzogYmFzZTY0CgotLXtib3VuZGFyeX0KQ29udGVudC1UeXBlOiBh
cHBsaWNhdGlvbi94LXBhc3Nwb2ludC1wcm9maWxlCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6
IGJhc2U2NAoKUEUxbmJYUlVjbVZsSUhodGJHNXpQU0p6ZVc1amJXdzZaRzFrWkdZeExqSWlQZ29n
SUR4V1pYSkVWRVErTVM0eVBDOVdaWEpFVkVRKwpDaUFnUEU1dlpHVStDaUFnSUNBOFRtOWtaVTVo
YldVK1VHVnlVSEp2ZG1sa1pYSlRkV0p6WTNKcGNIUnBiMjQ4TDA1dlpHVk9ZVzFsClBnb2dJQ0Fn
UEZKVVVISnZjR1Z5ZEdsbGN6NEtJQ0FnSUNBZ1BGUjVjR1UrQ2lBZ0lDQWdJQ0FnUEVSRVJrNWhi
V1UrZFhKdU9uZG0KWVRwdGJ6cG9iM1J6Y0c5ME1tUnZkREF0Y0dWeWNISnZkbWxrWlhKemRXSnpZ
M0pwY0hScGIyNDZNUzR3UEM5RVJFWk9ZVzFsUGdvZwpJQ0FnSUNBOEwxUjVjR1UrQ2lBZ0lDQThM
MUpVVUhKdmNHVnlkR2xsY3o0S0lDQWdJRHhPYjJSbFBnb2dJQ0FnSUNBOFRtOWtaVTVoCmJXVSth
VEF3TVR3dlRtOWtaVTVoYldVK0NpQWdJQ0FnSUR4T2IyUmxQZ29nSUNBZ0lDQWdJRHhPYjJSbFRt
RnRaVDVJYjIxbFUxQTgKTDA1dlpHVk9ZVzFsUGdvZ0lDQWdJQ0FnSUR4T2IyUmxQZ29nSUNBZ0lD
QWdJQ0FnUEU1dlpHVk9ZVzFsUGtaeWFXVnVaR3g1VG1GdApaVHd2VG05a1pVNWhiV1UrQ2lBZ0lD
QWdJQ0FnSUNBOFZtRnNkV1UrUTJWdWRIVnllU0JJYjNWelpUd3ZWbUZzZFdVK0NpQWdJQ0FnCklD
QWdQQzlPYjJSbFBnb2dJQ0FnSUNBZ0lEeE9iMlJsUGdvZ0lDQWdJQ0FnSUNBZ1BFNXZaR1ZPWVcx
bFBrWlJSRTQ4TDA1dlpHVk8KWVcxbFBnb2dJQ0FnSUNBZ0lDQWdQRlpoYkhWbFBtMXBOaTVqYnk1
MWF6d3ZWbUZzZFdVK0NpQWdJQ0FnSUNBZ1BDOU9iMlJsUGdvZwpJQ0FnSUNBZ0lEeE9iMlJsUGdv
Z0lDQWdJQ0FnSUNBZ1BFNXZaR1ZPWVcxbFBsSnZZVzFwYm1kRGIyNXpiM0owYVhWdFQwazhMMDV2
ClpHVk9ZVzFsUGdvZ0lDQWdJQ0FnSUNBZ1BGWmhiSFZsUGpFeE1qSXpNeXcwTkRVMU5qWThMMVpo
YkhWbFBnb2dJQ0FnSUNBZ0lEd3YKVG05a1pUNEtJQ0FnSUNBZ1BDOU9iMlJsUGdvZ0lDQWdJQ0E4
VG05a1pUNEtJQ0FnSUNBZ0lDQThUbTlrWlU1aGJXVStRM0psWkdWdQpkR2xoYkR3dlRtOWtaVTVo
YldVK0NpQWdJQ0FnSUNBZ1BFNXZaR1UrQ2lBZ0lDQWdJQ0FnSUNBOFRtOWtaVTVoYldVK1VtVmhi
RzA4CkwwNXZaR1ZPWVcxbFBnb2dJQ0FnSUNBZ0lDQWdQRlpoYkhWbFBuTm9ZV3RsYmk1emRHbHlj
bVZrTG1OdmJUd3ZWbUZzZFdVK0NpQWcKSUNBZ0lDQWdQQzlPYjJSbFBnb2dJQ0FnSUNBZ0lEeE9i
MlJsUGdvZ0lDQWdJQ0FnSUNBZ1BFNXZaR1ZPWVcxbFBsVnpaWEp1WVcxbApVR0Z6YzNkdmNtUThM
MDV2WkdWT1lXMWxQZ29nSUNBZ0lDQWdJQ0FnUEU1dlpHVStDaUFnSUNBZ0lDQWdJQ0FnSUR4T2Iy
UmxUbUZ0ClpUNVZjMlZ5Ym1GdFpUd3ZUbTlrWlU1aGJXVStDaUFnSUNBZ0lDQWdJQ0FnSUR4V1lX
eDFaVDVxWVcxbGN6d3ZWbUZzZFdVK0NpQWcKSUNBZ0lDQWdJQ0E4TDA1dlpHVStDaUFnSUNBZ0lD
QWdJQ0E4VG05a1pUNEtJQ0FnSUNBZ0lDQWdJQ0FnUEU1dlpHVk9ZVzFsUGxCaApjM04zYjNKa1BD
OU9iMlJsVG1GdFpUNEtJQ0FnSUNBZ0lDQWdJQ0FnUEZaaGJIVmxQbGx0T1hWYVJFRjNUbmM5UFR3
dlZtRnNkV1UrCkNpQWdJQ0FnSUNBZ0lDQThMMDV2WkdVK0NpQWdJQ0FnSUNBZ0lDQThUbTlrWlQ0
S0lDQWdJQ0FnSUNBZ0lDQWdQRTV2WkdWT1lXMWwKUGtWQlVFMWxkR2h2WkR3dlRtOWtaVTVoYldV
K0NpQWdJQ0FnSUNBZ0lDQWdJRHhPYjJSbFBnb2dJQ0FnSUNBZ0lDQWdJQ0FnSUR4TwpiMlJsVG1G
dFpUNUZRVkJVZVhCbFBDOU9iMlJsVG1GdFpUNEtJQ0FnSUNBZ0lDQWdJQ0FnSUNBOFZtRnNkV1Ur
TWpFOEwxWmhiSFZsClBnb2dJQ0FnSUNBZ0lDQWdJQ0E4TDA1dlpHVStDaUFnSUNBZ0lDQWdJQ0Fn
SUR4T2IyUmxQZ29nSUNBZ0lDQWdJQ0FnSUNBZ0lEeE8KYjJSbFRtRnRaVDVKYm01bGNrMWxkR2h2
WkR3dlRtOWtaVTVoYldVK0NpQWdJQ0FnSUNBZ0lDQWdJQ0FnUEZaaGJIVmxQazFUTFVOSQpRVkF0
VmpJOEwxWmhiSFZsUGdvZ0lDQWdJQ0FnSUNBZ0lDQThMMDV2WkdVK0NpQWdJQ0FnSUNBZ0lDQThM
MDV2WkdVK0NpQWdJQ0FnCklDQWdQQzlPYjJSbFBnb2dJQ0FnSUNBZ0lEeE9iMlJsUGdvZ0lDQWdJ
Q0FnSUNBZ1BFNXZaR1ZPWVcxbFBrUnBaMmwwWVd4RFpYSjAKYVdacFkyRjBaVHd2VG05a1pVNWhi
V1UrQ2lBZ0lDQWdJQ0FnSUNBOFRtOWtaVDRLSUNBZ0lDQWdJQ0FnSUNBZ1BFNXZaR1ZPWVcxbApQ
a05sY25ScFptbGpZWFJsVkhsd1pUd3ZUbTlrWlU1aGJXVStDaUFnSUNBZ0lDQWdJQ0FnSUR4V1lX
eDFaVDU0TlRBNWRqTThMMVpoCmJIVmxQZ29nSUNBZ0lDQWdJQ0FnUEM5T2IyUmxQZ29nSUNBZ0lD
QWdJQ0FnUEU1dlpHVStDaUFnSUNBZ0lDQWdJQ0FnSUR4T2IyUmwKVG1GdFpUNURaWEowVTBoQk1q
VTJSbWx1WjJWeVVISnBiblE4TDA1dlpHVk9ZVzFsUGdvZ0lDQWdJQ0FnSUNBZ0lDQThWbUZzZFdV
KwpNV1l4WmpGbU1XWXhaakZtTVdZeFpqRm1NV1l4WmpGbU1XWXhaakZtTVdZeFpqRm1NV1l4WmpG
bU1XWXhaakZtTVdZeFpqRm1NV1l4ClpqRm1NV1l4Wmp3dlZtRnNkV1UrQ2lBZ0lDQWdJQ0FnSUNB
OEwwNXZaR1UrQ2lBZ0lDQWdJQ0FnUEM5T2IyUmxQZ29nSUNBZ0lDQWcKSUR4T2IyUmxQZ29nSUNB
Z0lDQWdJQ0FnUEU1dlpHVk9ZVzFsUGxOSlRUd3ZUbTlrWlU1aGJXVStDaUFnSUNBZ0lDQWdJQ0E4
VG05awpaVDRLSUNBZ0lDQWdJQ0FnSUNBZ1BFNXZaR1ZPWVcxbFBrbE5VMGs4TDA1dlpHVk9ZVzFs
UGdvZ0lDQWdJQ0FnSUNBZ0lDQThWbUZzCmRXVSthVzF6YVR3dlZtRnNkV1UrQ2lBZ0lDQWdJQ0Fn
SUNBOEwwNXZaR1UrQ2lBZ0lDQWdJQ0FnSUNBOFRtOWtaVDRLSUNBZ0lDQWcKSUNBZ0lDQWdQRTV2
WkdWT1lXMWxQa1ZCVUZSNWNHVThMMDV2WkdWT1lXMWxQZ29nSUNBZ0lDQWdJQ0FnSUNBOFZtRnNk
V1UrTWpROApMMVpoYkhWbFBnb2dJQ0FnSUNBZ0lDQWdQQzlPYjJSbFBnb2dJQ0FnSUNBZ0lEd3ZU
bTlrWlQ0S0lDQWdJQ0FnUEM5T2IyUmxQZ29nCklDQWdQQzlPYjJSbFBnb2dJRHd2VG05a1pUNEtQ
QzlOWjIxMFZISmxaVDRLCgotLXtib3VuZGFyeX0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94
LXg1MDktY2EtY2VydApDb250ZW50LVRyYW5zZmVyLUVuY29kaW5nOiBiYXNlNjQKCkxTMHRMUzFD
UlVkSlRpQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENrMUpTVVJMUkVORFFXaERaMEYzU1VKQlowbEtR
VWxNYkVaa2QzcE0KVm5WeVRVRXdSME5UY1VkVFNXSXpSRkZGUWtOM1ZVRk5Ra2w0UlVSQlQwSm5U
bFlLUWtGTlZFSXdWa0pWUTBKRVVWUkZkMGhvWTA1TgpWRmwzVFZSRmVVMVVSVEZOUkVVeFYyaGpU
azFxV1hkTlZFRTFUVlJGTVUxRVJURlhha0ZUVFZKQmR3cEVaMWxFVmxGUlJFVjNaRVpSClZrRm5V
VEJGZUUxSlNVSkpha0ZPUW1kcmNXaHJhVWM1ZHpCQ1FWRkZSa0ZCVDBOQlVUaEJUVWxKUWtOblMw
TkJVVVZCQ25wdVFWQlYKZWpJMlRYTmhaVFIzY3pRelkzcFNOREV2U2pKUmRISlRTVnBWUzIxV1ZY
TldkVzFFWWxsSWNsQk9kbFJZUzFOTldFRmpaWGRQVWtSUgpXVmdLVW5GMlNIWndiamhEYzJOQ01T
dHZSMWhhZGtoM2VHbzBlbFl3VjB0dlN6SjZaVmhyWVhVemRtTjViRE5JU1V0MWNFcG1jVEpVClJV
RkRaV1pXYW1vd2RBcEtWeXRZTXpWUVIxZHdPUzlJTlhwSlZVNVdUbFpxVXpkVmJYTTRORWwyUzJo
U1FqZzFNVEpRUWpsVmVVaGgKWjFoWlZsZzFSMWR3UVdOV2NIbG1jbXhTQ2taSk9WRmthR2dyVUdK
ck1IVjVhM1JrWW1ZdlEyUm1aMGhQYjJWaWNsUjBkMUpzYWswdwpiMFIwV0NzeVEzWTJhakIzUWtz
M2FFUTRjRkIyWmpFcmRYa0tSM3BqZW1sblFWVXZORXQzTjJWYWNYbGtaamxDS3pWU2RYQlNLMGxh
CmFYQllOREY0UldsSmNrdFNkM0ZwTlRFM1YxZDZXR05xWVVjeVkwNWlaalExTVFwNGNFZzFVRzVX
TTJreGRIRXdOR3BOUjFGVmVrWjMKU1VSQlVVRkNielJIUVUxSU5IZElVVmxFVmxJd1QwSkNXVVZH
U1hkWU5IWnpPRUpwUW1OVFkyOWtDalZ1YjFwSVVrMDRSVFFyYVUxRgpTVWRCTVZWa1NYZFJOMDFF
YlVGR1NYZFlOSFp6T0VKcFFtTlRZMjlrTlc1dldraFNUVGhGTkN0cGIxSmhhMFpFUVZNS1RWSkJk
MFJuCldVUldVVkZFUlhka1JsRldRV2RSTUVWNFoyZHJRV2QxVlZZelJFMTBWelp6ZDBSQldVUldV
akJVUWtGVmQwRjNSVUl2ZWtGTVFtZE8KVmdwSVVUaEZRa0ZOUTBGUldYZEVVVmxLUzI5YVNXaDJZ
MDVCVVVWTVFsRkJSR2RuUlVKQlJtWlJjVTlVUVRkU2RqZExLMngxVVRkdwpibUZ6TkVKWmQwaEZD
amxIUlZBdmRXOW9kalpMVDNrd1ZFZFJSbUp5VWxScVJtOU1WazVDT1VKYU1YbHRUVVJhTUM5VVNY
ZEpWV00zCmQyazNZVGgwTlcxRmNWbElNVFV6ZDFjS1lWZHZiMmxUYW5sTVRHaDFTVFJ6VG5KT1Ew
OTBhWE5rUW5FeWNqSk5SbGgwTm1nd2JVRlIKV1U5UWRqaFNPRXMzTDJablUzaEhSbkY2YUhsT2JX
MVdUQW94Y1VKS2JHUjRNelJUY0hkelZFRk1VVlpRWWpSb1IzZEtlbHBtY2pGUQpZM0JGVVhnMmVF
MXVWR3c0ZUVWWFdrVXpUWE01T1hWaFZYaGlVWEZKZDFKMUNreG5RVTlyVGtOdFdUSnRPRGxXYUhw
aFNFb3hkVlk0Ck5VRmtUUzkwUkN0WmMyMXNibTVxZERsTVVrTmxhbUpDYVhCcVNVZHFUMWh5WnpG
S1VDdHNlRllLYlhWTk5IWklLMUF2Yld4dGVITlEKVUhvd1pEWTFZaXRGUjIxS1duQnZUR3RQTDNS
a1RrNTJRMWw2YWtwd1ZFVlhjRVZ6VHpaT1RXaExXVzg5Q2kwdExTMHRSVTVFSUVORgpVbFJKUmts
RFFWUkZMUzB0TFMwSwotLXtib3VuZGFyeX0tLQo=
+73 −0

File added.

Preview size limit exceeded, changes collapsed.

+85 −0

File added.

Preview size limit exceeded, changes collapsed.

+85 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading