devices/chromebook/DeviceChromebook.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?php

/*
 * *****************************************************************************
 * Contributions to this work were made on behalf of the GÉANT project, a 
 * project that has received funding from the European Union’s Framework 
 * Programme 7 under Grant Agreements No. 238875 (GN3) and No. 605243 (GN3plus),
 * Horizon 2020 research and innovation programme under Grant Agreements No. 
 * 691567 (GN4-1) and No. 731122 (GN4-2).
 * On behalf of the aforementioned projects, GEANT Association is the sole owner
 * of the copyright in all material which was developed by a member of the GÉANT
 * project. GÉANT Vereniging (Association) is registered with the Chamber of 
 * Commerce in Amsterdam with registration number 40535155 and operates in the 
 * UK as a branch of GÉANT Vereniging.
 * 
 * Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. 
 * UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK
 *
 * License: see the web/copyright.inc.php file in the file structure or
 *          <base_url>/copyright.php after deploying the software
 */

/**
 * This file contains the DeviceChromebook class, for generating installers
 * on ChromeOS.
 *
 * We follow the specification at: https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/onc/docs/onc_spec.md
 *  
 * @package ModuleWriting
 */

namespace devices\chromebook;

use Exception;

/**
 * This is the main implementation class of the ChromeOS module
 *
 * @package ModuleWriting
 */
class DeviceChromebook extends \core\DeviceConfig
{

    /**
     * Number of iterations for the PBKDF2 function. 
     * 20000 is the minimum as per ChromeOS ONC spec
     * 500000 is the maximum as per Chromium source code
     * https://cs.chromium.org/chromium/src/chromeos/network/onc/onc_utils.cc?sq=package:chromium&dr=CSs&rcl=1482394814&l=110
     */
    const PBKDF2_ITERATIONS = 20000;

    /**
     * Constructs a Device object.
     *
     * @final not to be redefined
     */
    final public function __construct()
    {
        parent::__construct();
        $this->setSupportedEapMethods([\core\common\EAP::EAPTYPE_PEAP_MSCHAP2, \core\common\EAP::EAPTYPE_TTLS_PAP, \core\common\EAP::EAPTYPE_TTLS_MSCHAP2, \core\common\EAP::EAPTYPE_TLS, \core\common\EAP::EAPTYPE_SILVERBULLET]);
        $this->specialities['media:openroaming'] = _("This device does not support provisioning of OpenRoaming.");
        $this->specialities['media:consortium_OI'] = _("This device does not support provisioning of Passpoint networks.");
    }

    /**
     * encrypts the entire configuration. Only used in SB to protect the client
     * credential
     * 
     * @param string $clearJson the cleartext JSON string to encrypt
     * @param string $password  the import PIN we told the user
     * @return string
     */
    private function encryptConfig($clearJson, $password)
    {
        $salt = \core\common\Entity::randomString(12);
        $encryptionKey = hash_pbkdf2("sha1", $password, $salt, DeviceChromebook::PBKDF2_ITERATIONS, 32, TRUE); // the spec is not clear about the algo. Source code in Chromium makes clear it's SHA1.
        $strong = FALSE; // should become TRUE if strong crypto is available like it should.
        $initVector = openssl_random_pseudo_bytes(16, $strong);
        if ($strong === FALSE) {
            $this->loggerInstance->debug(1, "WARNING: OpenSSL reports that a random value was generated with a weak cryptographic algorithm (DeviceChromebook::writeInstaller()). You should investigate the reason for this!");
        }
        $cryptoJson = openssl_encrypt($clearJson, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $initVector);
        $hmac = hash_hmac("sha1", $cryptoJson, $encryptionKey, TRUE);

        $this->loggerInstance->debug(4, "Clear = $clearJson\nSalt = $salt\nPW = " . $password . "\nb(IV) = " . base64_encode($initVector) . "\nb(Cipher) = " . base64_encode($cryptoJson) . "\nb(HMAC) = " . base64_encode($hmac));

        // now, generate the container that holds all the crypto data
        $finalArray = [
            "Cipher" => "AES256",
            "Ciphertext" => base64_encode($cryptoJson),
            "HMAC" => base64_encode($hmac), // again by reading source code! And why?
            "HMACMethod" => "SHA1",
            "Salt" => base64_encode($salt), // this is B64 encoded, but had to read Chromium source code to find out! Not in the spec!
            "Stretch" => "PBKDF2",
            "Iterations" => DeviceChromebook::PBKDF2_ITERATIONS,
            "IV" => base64_encode($initVector),
            "Type" => "EncryptedConfiguration",
        ];
        return json_encode($finalArray);
    }

    /**
     * Creates a WiFi block (SSID based only, no support for Passpoint)
     * @param string $ssid       the SSID to configure
     * @param array  $eapdetails the EAP sub-block as derived from EapBlock()
     * @return array
     */
    private function wifiBlock($ssid, $eapdetails)
    {
        return [
            "GUID" => \core\common\Entity::uuid('', $ssid),
            "Name" => "$ssid",
            "Remove" => false,
            "Type" => "WiFi",
            "WiFi" => [
                "AutoConnect" => true,
                "EAP" => $eapdetails,
                "HiddenSSID" => false,
                "SSID" => $ssid,
                "Security" => "WPA-EAP",
            ],
            "ProxySettings" => $this->proxySettings(),
        ];
    }

    /**
     * Creates the ProxySettings block
     * 
     * @return array
     */
    protected function proxySettings()
    {
        if (isset($this->attributes['media:force_proxy'])) {
            // find the port delimiter. In case of IPv6, there are multiple ':' 
            // characters, so we have to find the LAST one
            $serverAndPort = explode(':', strrev($this->attributes['media:force_proxy'][0]), 2);
            // characters are still reversed, invert on use!
            return ["Type" => "Manual",
                "Manual" => [
                    "SecureHTTPProxy" => [
                        "Host" => strrev($serverAndPort[1]),
                        "Port" => strrev($serverAndPort[0])
                    ]
                ]
            ];
        }
        return ["Type" => "WPAD"];
    }

    /**
     * Creates a configuration block for wired Ethernet
     * 
     * @param array $eapdetails the EAP configuration as created with eapBlock()
     * @return array
     */
    private function wiredBlock($eapdetails)
    {
        return [
            "GUID" => \core\common\Entity::uuid('', "wired-dot1x-ethernet") . "}",
            "Name" => "eduroam configuration (wired network)",
            "Remove" => false,
            "Type" => "Ethernet",
            "Ethernet" => [
                "Authentication" => "8021X",
                "EAP" => $eapdetails,
            ],
            "ProxySettings" => ["Type" => "WPAD"],
        ];
    }

    /**
     * Creates the EAP configuration sub-block
     * 
     * @param array $caRefs list of strings with CA references
     * @return array
     */
    private function eapBlock($caRefs)
    {
        $selectedEap = $this->selectedEap;
        $outerId = $this->determineOuterIdString();
        $eapPrettyprint = \core\common\EAP::eapDisplayName($selectedEap);
        // ONC has its own enums, and guess what, they don't always match
        if ($eapPrettyprint["INNER"] == "MSCHAPV2") {
            $eapPrettyprint["INNER"] = "MSCHAPv2";
        }
        if ($eapPrettyprint["OUTER"] == "TTLS") {
            $eapPrettyprint["OUTER"] = "EAP-TTLS";
        }
        if ($eapPrettyprint["OUTER"] == "TLS") {
            $eapPrettyprint["OUTER"] = "EAP-TLS";
        }

        // define EAP properties

        $eaparray = [];

        // if silverbullet, we deliver the client cert inline

        if ($selectedEap == \core\common\EAP::EAPTYPE_SILVERBULLET) {
            $eaparray['ClientCertRef'] = "[" . $this->clientCert['GUID'] . "]";
            $eaparray['ClientCertType'] = "Ref";
        }

        $eaparray["Outer"] = $eapPrettyprint["OUTER"];
        if ($eapPrettyprint["INNER"] == "MSCHAPv2" || $eapPrettyprint["INNER"] == "PAP") {
            $eaparray["Inner"] = $eapPrettyprint["INNER"];
        }
        $eaparray["SaveCredentials"] = true;
        $eaparray["ServerCARefs"] = $caRefs; // maybe takes just one CA?
        $eaparray["UseSystemCAs"] = false;
        // enumerate the server names to check against with SubjectAlternativeNameMatch
        foreach ($this->attributes['eap:server_name'] as $oneName) {
            $eaparray["SubjectAlternativeNameMatch"][] = ["Type" => "DNS", "Value" => $oneName];
        }

        if ($outerId !== NULL) {
            $eaparray["AnonymousIdentity"] = $outerId;
        }
        if ($selectedEap == \core\common\EAP::EAPTYPE_SILVERBULLET) {
            $eaparray["Identity"] = $this->clientCert["certObject"]->username;
        }
        return $eaparray;
    }

    /**
     * prepare a ONC file
     *
     * @return string installer path name
     * @throws Exception
     */
    public function writeInstaller()
    {
        $this->loggerInstance->debug(4, "Chromebook Installer start\n");
        $caRefs = [];
        // we don't do per-user encrypted containers
        $jsonArray = ["Type" => "UnencryptedConfiguration"];

        foreach ($this->attributes['internal:CAs'][0] as $ca) {
            $caRefs[] = "{" . $ca['uuid'] . "}";
        }
        // define CA certificates
        foreach ($this->attributes['internal:CAs'][0] as $ca) {
            // strip -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----
            $this->loggerInstance->debug(3, $ca['pem']);
            $caSanitized1 = substr($ca['pem'], 27, strlen($ca['pem']) - 27 - 25 - 1);
            if ($caSanitized1 === FALSE) {
                throw new Exception("Error cropping PEM data at its BEGIN marker.");
            }
            $this->loggerInstance->debug(4, $caSanitized1 . "\n");
            // remove \n
            $caSanitized = str_replace("\n", "", $caSanitized1);
            $jsonArray["Certificates"][] = ["GUID" => "{" . $ca['uuid'] . "}", "Remove" => false, "Type" => "Authority", "X509" => $caSanitized];
            $this->loggerInstance->debug(3, $caSanitized . "\n");
        }
        // if we are doing silverbullet, include the unencrypted(!) P12 as a client certificate
        if ($this->selectedEap == \core\common\EAP::EAPTYPE_SILVERBULLET) {
            $jsonArray["Certificates"][] = ["GUID" => "[" . $this->clientCert['GUID'] . "]", "PKCS12" => base64_encode($this->clientCert['certdataclear']), "Remove" => false, "Type" => "Client"];
        }
        $eaparray = $this->eapBlock($caRefs);
        // define Wi-Fi networks
        foreach ($this->attributes['internal:networks'] as $netDefinition) {
            foreach ($netDefinition['ssid'] as $ssid) {
                $jsonArray["NetworkConfigurations"][] = $this->wifiBlock($ssid, $eaparray);
            }
        }
        // are we also configuring wired?
        if (isset($this->attributes['media:wired'])) {
            $jsonArray["NetworkConfigurations"][] = $this->wiredBlock($eaparray);
        }

        $clearJson = json_encode($jsonArray, JSON_PRETTY_PRINT);
        $finalJson = $clearJson;
        // if we are doing silverbullet we should also encrypt the entire structure(!) with the import password and embed it into a EncryptedConfiguration
        if ($this->selectedEap == \core\common\EAP::EAPTYPE_SILVERBULLET) {
            $finalJson = $this->encryptConfig($clearJson, $this->clientCert['importPassword']);
        }

        file_put_contents('installer_profile', $finalJson);

        $fileName = $this->installerBasename . '.onc';

        if (!$this->sign) {
            rename("installer_profile", $fileName);
            return $fileName;
        }

        // still here? We are signing. That actually can't be - ONC does not
        // have the notion of signing
        // but if they ever change their mind, we are prepared

        $outputFromSigning = system($this->sign . " installer_profile '$fileName' > /dev/null");
        if ($outputFromSigning === FALSE) {
            $this->loggerInstance->debug(2, "Signing the ONC installer $fileName FAILED!\n");
        }

        return $fileName;
    }

    /**
     * prepare module description and usage information
     * 
     * @return string HTML text to be displayed in the information window
     */
    public function writeDeviceInfo()
    {
        \core\common\Entity::intoThePotatoes();
        $out = "<p>";
        $out .= _("The installer is a file with the extension '.onc'. Please download it, open Chrome, and navigate to the URL <a href='chrome://network'>chrome://network</a>. Then, use the 'Import ONC file' button. The import is silent; the new network definitions will be added to the preferred networks.");
        \core\common\Entity::outOfThePotatoes();
        return $out;
    }
}