Public/Complete-NSPSslRequest.ps1

function Complete-NSPSslRequest {
    <#
    .SYNOPSIS
        Turns the issuer's certificate files into deliverables: certificate, intermediates, full-chain
        PEM, and a password-protected PFX.
    .DESCRIPTION
        Reads the certificate files the issuer sent - by default the newest ZIP in the request's
        Inputs folder (IIS format: <serial>.crt plus *_intermediates.p7b), or any .crt/.cer/.pem/.p7b
        /.ca-bundle files or ZIPs given with -InputPath. The request's certificate is the one whose
        public key matches the request's private key, so extra files in the ZIP don't matter, and a
        ZIP for the wrong request is refused.

        The intermediates are put in order from the certificate up. A cross-signed intermediate that
        carries a root's name, such as Starfield TLS Root CA - R1 issued by Starfield Root
        Certificate Authority - G2, is an intermediate and stays in.

        The PFX also carries the root, so an offline machine can be given it: the issuer's copy if
        it sent one, else this machine's trusted copy (issuers usually leave it out - GoDaddy's IIS
        bundle stops at Root CA - R1). Import-NSPSslPfx and Install-NSPSslCertificate only add a
        root to Trusted Root when asked. The PEM files leave it out, because a server sending its
        root is wasted bytes and flagged by scanners; -IncludeRoot puts it in them too.

        Writes to the request's Deliverables folder:

            <domain>.crt the certificate (PEM)
            <domain>_intermediates.crt the intermediates, in order (PEM)
            <domain>_FullChain.pem certificate + intermediates (for FortiGate, nginx, Apache)
            <domain>.pfx certificate + intermediates + root + private key (Windows/IIS)
            <domain>_Install\ a copy of the PFX, Install-<domain>.ps1 and .cmd

        Copy the _Install folder to the server and double-click the .cmd: it elevates, installs the
        NSP modules from the PowerShell Gallery, and runs Install-NSPSslCertificate (import, IIS
        bindings, old certificate). -NoInstaller skips it.

        The PFX password is not saved anywhere; send it separately from the PFX.

        Replaces 2_CombineCerts.sh from NSP-SSLCertificates. Requests started there (Rebase\<domain>
        with Deliverables_<domain>) work too: pass -Path <the Rebase folder>.
    .PARAMETER Domain
        The request to finish, as given to New-NSPSslRequest.
    .PARAMETER Path
        The folder that holds request folders (see New-NSPSslRequest).
    .PARAMETER Folder
        A request folder, instead of -Domain and -Path. Get-NSPSslRequest output pipes here.
    .PARAMETER InputPath
        ZIPs, certificate files or folders to read instead of the newest ZIP in Inputs.
    .PARAMETER PfxPassword
        The password for the PFX. Prompted for when not given.
    .PARAMETER FriendlyName
        The PFX certificate's friendly name. Default: <domain>_<year issued>.
    .PARAMETER IncludeRoot
        Also put the root, when the issuer sent one, in the intermediates and full-chain PEM files.
        (The PFX always carries it.)
    .PARAMETER NoInstaller
        Don't write the <domain>_Install folder.
    .EXAMPLE
        Complete-NSPSslRequest -Domain vpn.contoso.com
    .EXAMPLE
        Complete-NSPSslRequest -Domain vpn.contoso.com -InputPath C:\Downloads\vpn.contoso.com.zip -PfxPassword (Read-Host -AsSecureString 'PFX password')
    .EXAMPLE
        Get-NSPSslRequest -Status CertificateReady | Complete-NSPSslRequest
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Domain')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, Position = 0, ParameterSetName = 'Domain')][string]$Domain,
        [Parameter(ParameterSetName = 'Domain')][string]$Path,
        [Parameter(Mandatory, ParameterSetName = 'Folder', ValueFromPipelineByPropertyName)][string]$Folder,
        [string[]]$InputPath,
        [Parameter(Mandatory)][securestring]$PfxPassword,
        [string]$FriendlyName,
        [switch]$IncludeRoot,
        [switch]$NoInstaller
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'Domain') {
            $Folder = Join-Path (Get-NSPSslWorkRoot -Path $Path) (ConvertTo-NSPSslFolderName -Domain $Domain.Trim())
        }
        $layout = Get-NSPSslRequestLayout -Folder $Folder
        if (-not (Test-Path -LiteralPath $layout.KeyPath)) { throw "No private key at $($layout.KeyPath). Start the request with New-NSPSslRequest." }

        if (-not $InputPath) {
            $zips = @(Get-ChildItem -LiteralPath $layout.Inputs -Filter '*.zip' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
            if (-not $zips) { throw "No ZIP in $($layout.Inputs). Download the certificate from the issuer in IIS format and put the ZIP there, or pass -InputPath." }
            if ($zips.Count -gt 1) { Write-Warning "$($zips.Count) ZIPs in $($layout.Inputs); using the newest, $($zips[0].Name)." }
            $InputPath = @($zips[0].FullName)
        }

        $extract = Join-Path ([IO.Path]::GetTempPath()) ('NSP-SSL-' + [guid]::NewGuid().ToString('N'))
        $candidates = [Collections.Generic.List[System.Security.Cryptography.X509Certificates.X509Certificate2]]::new()
        $seen = @{}
        $rsa = $null
        try {
            $files = foreach ($item in $InputPath) {
                if (-not (Test-Path -LiteralPath $item)) { throw "Not found: $item" }
                $entry = Get-Item -LiteralPath $item
                if ($entry.PSIsContainer) {
                    Get-ChildItem -LiteralPath $entry.FullName -File -Recurse
                } elseif ($entry.Extension -eq '.zip') {
                    $target = Join-Path $extract ([guid]::NewGuid().ToString('N'))
                    Expand-Archive -LiteralPath $entry.FullName -DestinationPath $target
                    Get-ChildItem -LiteralPath $target -File -Recurse
                } else {
                    $entry
                }
            }
            foreach ($file in @($files | Where-Object { $_.Extension -in '.crt', '.cer', '.pem', '.der', '.p7b', '.p7c', '.ca-bundle' })) {
                try {
                    foreach ($certificate in Read-NSPSslCertificateFile -Path $file.FullName) {
                        if (-not $seen.ContainsKey($certificate.Thumbprint)) { $seen[$certificate.Thumbprint] = $true; $candidates.Add($certificate) }
                    }
                } catch {
                    Write-Warning "Skipped $($file.Name): not a certificate file ($($_.Exception.Message))."
                }
            }
            if (-not $candidates.Count) { throw "No certificates found in $($InputPath -join ', ')." }

            $keyParameters = ConvertFrom-NSPSslPrivateKeyPem -Text ([IO.File]::ReadAllText($layout.KeyPath))
            $modulus = [Convert]::ToBase64String($keyParameters.Modulus)
            $leaf = $candidates | Where-Object {
                $publicKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($_)
                $publicKey -and [Convert]::ToBase64String($publicKey.ExportParameters($false).Modulus) -eq $modulus
            } | Sort-Object NotAfter -Descending | Select-Object -First 1
            if (-not $leaf) {
                throw "None of the $($candidates.Count) certificates in $($InputPath -join ', ') belong to the private key for $($layout.Domain). Check that the files are for this request and were issued from its CSR."
            }

            $chain = @(Get-NSPSslCertificateChain -Certificate $leaf -Candidates $candidates.ToArray())
            $issuers = @($chain | Where-Object { $IncludeRoot -or -not (Test-NSPSslSelfSigned -Certificate $_) })
            if (-not $chain -or -not (Test-NSPSslSelfSigned -Certificate $chain[-1])) {
                $top = if ($chain) { $chain[-1] } else { $leaf }
                if (-not $chain) { Write-Warning "The issuer sent no intermediates for $($leaf.Subject); clients may not be able to build its chain." }
                Write-Verbose "Chain ends at $($top.Subject), issued by $($top.Issuer), which the issuer didn't send."
            }
            # The PFX carries the root: the issuer's, else this machine's trusted copy.
            $pfxRoot = if ($chain -and (Test-NSPSslSelfSigned -Certificate $chain[-1])) { $chain[-1] } else { Find-NSPSslTrustedRoot -Certificate $(if ($chain) { $chain[-1] } else { $leaf }) }
            if (-not $pfxRoot) { Write-Warning "The root for $($leaf.Subject) isn't trusted on this machine, so the PFX goes without it. Machines that can reach Windows Update fetch it themselves." }

            $now = Get-Date
            if ($leaf.NotAfter -lt $now) { Write-Warning "The certificate expired on $($leaf.NotAfter)." }
            if ($leaf.NotBefore -gt $now) { Write-Warning "The certificate isn't valid until $($leaf.NotBefore)." }
            $issuedNames = @(Get-NSPSslDnsName -Certificate $leaf)
            $missing = @($layout.Names | Where-Object { $_ -notin $issuedNames })
            if ($missing) { Write-Warning "Requested but not on the certificate: $($missing -join ', ')." }

            if (-not $PSCmdlet.ShouldProcess($layout.Deliverables, "Write the certificate, intermediates, full chain and PFX for $($leaf.Subject)")) { return }

            $certificatePem = ConvertTo-NSPSslPem -Label 'CERTIFICATE' -Bytes $leaf.RawData
            $issuersPem = ($issuers | ForEach-Object { ConvertTo-NSPSslPem -Label 'CERTIFICATE' -Bytes $_.RawData }) -join ''
            [IO.File]::WriteAllText($layout.CertificatePath, $certificatePem)
            [IO.File]::WriteAllText($layout.IntermediatesPath, $issuersPem)
            [IO.File]::WriteAllText($layout.FullChainPath, $certificatePem + $issuersPem)

            $rsa = New-NSPSslRsa -Parameters $keyParameters
            $withKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($leaf, $rsa)
            if (-not $FriendlyName) { $FriendlyName = '{0}_{1}' -f (ConvertTo-NSPSslFolderName -Domain $layout.Domain), $leaf.NotBefore.Year }
            $withKey.FriendlyName = $FriendlyName
            $bundle = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection
            [void]$bundle.Add($withKey)
            foreach ($issuer in @($chain | Where-Object { -not (Test-NSPSslSelfSigned -Certificate $_) })) { [void]$bundle.Add($issuer) }
            if ($pfxRoot) { [void]$bundle.Add($pfxRoot) }
            $pfx = $bundle.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, (ConvertFrom-NSPSslSecureString -SecureString $PfxPassword))
            [IO.File]::WriteAllBytes($layout.PfxPath, $pfx)

            $installerPath = $null
            if (-not $NoInstaller) {
                try {
                    $installerPath = Write-NSPSslInstaller -Layout $layout
                } catch {
                    Write-Warning "The deliverables are written, but the install folder isn't: $($_.Exception.Message)"
                }
            }

            [pscustomobject]@{
                Domain            = $layout.Domain
                Subject           = $leaf.Subject
                Names             = $issuedNames
                Thumbprint        = $leaf.Thumbprint
                NotAfter          = $leaf.NotAfter
                Chain             = @($issuers | ForEach-Object { $_.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) })
                PfxRoot           = $(if ($pfxRoot) { $pfxRoot.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) } else { $null })
                FriendlyName      = $FriendlyName
                CertificatePath   = $layout.CertificatePath
                IntermediatesPath = $layout.IntermediatesPath
                FullChainPath     = $layout.FullChainPath
                PfxPath           = $layout.PfxPath
                InstallerPath     = $installerPath
            }
        } finally {
            if ($rsa) { $rsa.Dispose() }
            Remove-Item -LiteralPath $extract -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}