Payload/Import-HDTOsCertificate.ps1

<#
    .SYNOPSIS
        Imports the staged certificates on the deployed machine's first boot.

    .DESCRIPTION
        WHAT SetupComplete.cmd RUNS, staged onto the applied volume by the
        InstallCertificate step - never run on the build host, and never in
        WinPE. Windows runs SetupComplete.cmd after Setup and BEFORE THE FIRST
        LOGON, which puts this ahead of autologon and therefore ahead of
        Start-HDTResume needing the network.

        IT IS THE SAME TWO STORES AS THE BOOT IMAGE'S IMPORT: certificate
        authorities into LocalMachine\Root, the machine's own certificate into
        LocalMachine\My with its private key persisted to the machine key set.

        IT DOES NOT LOAD THE HDT MODULE. This runs on a machine that has not
        started the engine yet and may have no module staged at all, so the one
        thing it needs from HDT - undoing the manifest's obfuscation - is done
        here with the same key derivation, written out rather than imported.
        THE KEY IS A CONSTANT AND OBFUSCATION IS NOT CLAIMED AS SECURITY: it
        stops the password sitting in plain text in a file every user of this
        machine can read, and nothing else.

        A FAILURE IS LOUD AND NOT FATAL. Nothing here stops the first boot: a
        machine that refused to finish because a certificate would not import is
        a machine nobody can log on to. It goes to
        %WINDIR%\Setup\Files\HDT\certificate-import.log, which is where the
        files it was reading already are.

    .PARAMETER ManifestPath
        The document naming what to import, written by the step beside this
        script.

    .EXAMPLE
        powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Windows\Setup\Files\HDT\Import-HDTOsCertificate.ps1
#>

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $ManifestPath = (Join-Path -Path $PSScriptRoot -ChildPath 'certificate.json')
)

Set-StrictMode -Version Latest

# NOT 'Stop'. This script's contract is that it never stops the first boot.
$ErrorActionPreference = 'Continue'
$InformationPreference = 'Continue'

$logPath = Join-Path -Path $PSScriptRoot -ChildPath 'certificate-import.log'

$say = {
    param([string] $Message)

    $text = '{0} {1}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Message

    Write-Information -MessageData $text
    try {
        [System.IO.File]::AppendAllText($logPath, $text + [System.Environment]::NewLine)
    } catch {
        Write-Information -MessageData (' (the log could not be written: {0})' -f $_.Exception.Message)
    }
}

if (-not (Test-Path -LiteralPath $ManifestPath)) {
    & $say ('there is nothing to import: {0} is not there.' -f $ManifestPath)
    return
}

try {
    $manifest = ConvertFrom-Json -InputObject ([System.IO.File]::ReadAllText($ManifestPath))
} catch {
    & $say ('{0} could not be read: {1}' -f $ManifestPath, $_.Exception.Message)
    return
}

# THE SAME DERIVATION AS Get-HDTShareSecretKey, written out because no module is
# loaded here. If that sentence ever changes there, it changes here too - and the
# contract test that compares them is what says so.
$unprotect = {
    param([string] $Protected)

    if ([string]::IsNullOrWhiteSpace($Protected)) { return '' }

    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $key = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes(
                'Hephaestus Deployment Toolkit share credential obfuscation key v1'))
    } finally {
        $sha.Dispose()
    }

    $raw = [System.Convert]::FromBase64String($Protected)

    $aes = [System.Security.Cryptography.Aes]::Create()
    try {
        $aes.Key = $key
        $aes.IV = $raw[0..15]

        $decryptor = $aes.CreateDecryptor()
        try {
            $plain = $decryptor.TransformFinalBlock($raw, 16, $raw.Length - 16)
            return [System.Text.Encoding]::UTF8.GetString($plain)
        } finally {
            $decryptor.Dispose()
        }
    } finally {
        $aes.Dispose()
    }
}

# -- the certificate authorities ---------------------------------------------

foreach ($current in @($manifest.root)) {
    try {
        $certificate = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Certificate2 `
            -ArgumentList ([string] $current)

        $store = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Store `
            -ArgumentList 'Root', 'LocalMachine'

        $store.Open('ReadWrite')
        try {
            $store.Add($certificate)
        } finally {
            $store.Close()
        }

        & $say ('trusting {0} ({1})' -f $certificate.Subject, $certificate.Thumbprint)
    } catch {
        & $say ('{0} could not be trusted: {1}' -f $current, $_.Exception.Message)
    }
}

# -- the machine's own certificate --------------------------------------------

$client = ''
if ($null -ne $manifest.PSObject.Properties['client']) { $client = [string] $manifest.client }

if (-not [string]::IsNullOrWhiteSpace($client)) {
    try {
        # MachineKeySet BECAUSE NOBODY IS LOGGED ON, and PersistKeySet because a
        # key that vanished with this process would leave a certificate the
        # supplicant cannot use.
        $flag = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::MachineKeySet -bor
        [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet

        $password = & $unprotect ([string] $manifest.protected)

        $certificate = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Certificate2 `
            -ArgumentList $client, $password, $flag

        $store = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Store `
            -ArgumentList 'My', 'LocalMachine'

        $store.Open('ReadWrite')
        try {
            $store.Add($certificate)
        } finally {
            $store.Close()
        }

        & $say ('this machine will authenticate as {0} ({1})' -f $certificate.Subject, $certificate.Thumbprint)

        # THE CERTIFICATE ALONE AUTHENTICATES NOTHING. Wired AutoConfig is the
        # supplicant, and Windows ships it stopped and set to Manual - so a
        # machine holding a perfectly good certificate still never speaks EAP.
        # Set to Automatic and started here, where the certificate it needs has
        # just arrived.
        try {
            Set-Service -Name 'dot3svc' -StartupType Automatic
            Start-Service -Name 'dot3svc'

            & $say 'wired AutoConfig (dot3svc) set to Automatic and started.'
        } catch {
            & $say ('dot3svc could not be started: {0}. The certificate is installed; 802.1X will not run until the service does.' -f
                $_.Exception.Message)
        }
    } catch {
        & $say ('{0} could not be imported: {1}. If the file is right, the password stored with Set-HDTBootImageCertificatePassword is not the one it was exported with.' -f
            $client, $_.Exception.Message)
    }
}