Private/PSPkinit/CertEnroll.ps1

<#
    CertEnroll.ps1: certificate request/enrollment support for PSPkinit, built on
    the native Windows CertEnroll (X509Enrollment.*) and CertificateAuthority.*
    COM object models - the same underlying API certreq.exe itself uses. Chosen
    over shelling out to certreq.exe so that request/submission results come back
    as structured data (disposition codes, exceptions) instead of scraped text,
    and so the whole module keeps working on Windows PowerShell 5.1 (the modern
    System.Security.Cryptography.X509Certificates.CertificateRequest/
    SubjectAlternativeNameBuilder .NET classes are .NET 5+ only and have no
    Framework equivalent - COM interop has no such version gate).
 
    Key enum values used below (from the CERTENROLLLib / CertCli type libraries):
      AlternativeNameType: RFC822_NAME=2, DNS_NAME=3, USER_PRINCIPAL_NAME=11
      X509CertificateEnrollmentContext: ContextUser=1, ContextMachine=2
      X509KeySpec: XCN_AT_KEYEXCHANGE=1
      X509PrivateKeyExportFlags: XCN_NCRYPT_ALLOW_EXPORT_FLAG=1
      EncodingType: XCN_CRYPT_STRING_BASE64HEADER=0, XCN_CRYPT_STRING_BASE64=1
      InstallResponseRestrictionFlags: AllowNone=0, AllowUntrustedCertificate=2
      ICertRequest2 disposition: CR_DISP_ISSUED=3 (others are treated as failure)
 
    Getting the CA to actually honor a client-supplied SAN has two independent
    paths:
      1. Embedded CSR extension (what New-PkinitCertificateSigningRequest
         builds via CX509ExtensionAlternativeNames) - honored automatically
         when the template allows enrollee-supplied subject/SAN
         (msPKI-Certificate-Name-Flag bit CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT,
         "Supply in the request" on the template's Subject Name tab). No
         CA-wide registry flag needed for this path.
      2. Request attribute string (SAN:upn=..., built by
         ConvertTo-PkinitSanAttributeString and sent as a fallback by
         Submit-PkinitCertificateSigningRequest) - only honored if the CA also
         has the EDITF_ATTRIBUTESUBJECTALTNAME2 edit flag set:
             certutil -setreg policy\EditFlags +EDITF_ATTRIBUTESUBJECTALTNAME2
             Restart-Service CertSvc
         This path exists for templates that build the subject from AD and
         don't allow enrollee-supplied SANs.
#>


function New-PkinitComObject {
    <#
        .SYNOPSIS
        Thin seam around New-Object -ComObject, so every CertEnroll/CertificateAuthority
        COM object creation in this module can be intercepted by Pester mocks.
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)] [string] $ProgId
    )

    return New-Object -ComObject $ProgId
}

function ConvertTo-PkinitAlternativeNameTypeCode {
    <#
        .SYNOPSIS
        Maps a friendly SAN type name to its CERTENROLLLib AlternativeNameType code.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)] [ValidateSet('UserPrincipalName', 'Dns', 'Email')] [string] $SanType
    )

    switch ($SanType) {
        'UserPrincipalName' { return 11 } # XCN_CERT_ALT_NAME_USER_PRINCIPAL_NAME (otherName, OID 1.3.6.1.4.1.311.20.2.3)
        'Dns' { return 3 }              # XCN_CERT_ALT_NAME_DNS_NAME
        'Email' { return 2 }            # XCN_CERT_ALT_NAME_RFC822_NAME
    }
}

function ConvertTo-PkinitSanAttributeString {
    <#
        .SYNOPSIS
        Builds the "SAN:keyword=value" ICertRequest2::Submit request-attribute
        string, sent as a fallback alongside the SAN already embedded in the
        CSR itself. This attribute-based path is only honored by CA policy
        modules that have EDITF_ATTRIBUTESUBJECTALTNAME2 set; templates that
        allow enrollee-supplied SANs pick up the embedded CSR extension
        directly and don't need this at all.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [ValidateSet('UserPrincipalName', 'Dns', 'Email')] [string] $SanType,
        [Parameter(Mandatory)] [string] $San
    )

    $keyword = switch ($SanType) {
        'UserPrincipalName' { 'upn' }
        'Dns' { 'dns' }
        'Email' { 'email' }
    }

    return "SAN:$keyword=$San"
}

function ConvertTo-PkinitSidSecurityExtensionValue {
    <#
        .SYNOPSIS
        Builds the raw DER value of the SID security extension
        (szOID_NTDS_CA_SECURITY_EXT, OID 1.3.6.1.4.1.311.25.2) that Windows CAs
        normally add automatically, so it can be embedded directly on a CSR
        instead - useful when the CA/template has this extension disabled
        (msPKI-Enrollment-Flag NO_SECURITY_EXTENSION) and won't add it
        automatically, but doesn't strip a client-supplied one either.
 
        .DESCRIPTION
        Structurally this reuses the GeneralNames/otherName template from RFC
        5280 as a single-entry "SEQUENCE OF GeneralName", with the otherName's
        type-id set to 1.3.6.1.4.1.311.25.2.1 and its value an OCTET STRING
        containing the SID's string form (e.g. "S-1-5-21-...") - matching
        exactly what a real Windows CA embeds as this extension's value
        (verified byte-for-byte in tests against a captured CA-issued
        extension, and structurally confirmed against GhostPack/Certify's
        CertSidExtension.EncodeSidExtension implementation).
 
        This value is NOT itself the "2.5.29.17" Subject Alternative Name
        extension - it's the *value* of a separate, standalone extension
        identified by OID 1.3.6.1.4.1.311.25.2, which merely reuses the SAN
        ASN.1 template internally.
 
        .PARAMETER Sid
        The security identifier, in its string form (e.g. 'S-1-5-21-...-1105').
 
        .OUTPUTS
        System.Byte[] - the raw DER extension value. The caller is responsible
        for wrapping this in an actual X.509 Extension (OID
        1.3.6.1.4.1.311.25.2) - see New-PkinitSidSecurityExtension.
 
        .NOTES
        SECURITY: only ever supply the SID of an identity you are authorized
        to test as. Being able to freely set this value, if a CA/template
        also permits it, is the same primitive used in real-world certificate
        SID-spoofing techniques (see e.g. GhostPack/Certify's --sid option,
        which is compiled out by default via #if !DISARMED).
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Sid
    )

    $typeIdOid = ConvertTo-Asn1Oid -Dotted '1.3.6.1.4.1.311.25.2.1'
    $sidOctetString = ConvertTo-Asn1OctetString -Bytes ([System.Text.Encoding]::ASCII.GetBytes($Sid))
    $valueWrapper = ConvertTo-Asn1ContextExplicit -TagNumber 0 -InnerTlv $sidOctetString

    # otherName ::= [0] IMPLICIT SEQUENCE { type-id, value } as a GeneralName choice -
    # IMPLICIT means the context tag REPLACES the universal SEQUENCE tag entirely, so
    # the TLV is built directly here rather than wrapping an already-tagged SEQUENCE.
    $otherName = New-Asn1Tlv -Tag 0xA0 -Content ([byte[]]$typeIdOid + [byte[]]$valueWrapper)

    return ConvertTo-Asn1Sequence -Children @($otherName)
}

function New-PkinitSidSecurityExtension {
    <#
        .SYNOPSIS
        Builds a CX509Extension COM object for the SID security extension
        (1.3.6.1.4.1.311.25.2), ready to add to a
        CX509CertificateRequestPkcs10's X509Extensions collection.
 
        .PARAMETER Sid
        The security identifier, in its string form (e.g. 'S-1-5-21-...-1105').
        See the SECURITY note on ConvertTo-PkinitSidSecurityExtensionValue.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Sid
    )

    $extensionValueBase64 = [Convert]::ToBase64String((ConvertTo-PkinitSidSecurityExtensionValue -Sid $Sid))

    $oid = New-PkinitComObject -ProgId 'X509Enrollment.CObjectId'
    $oid.InitializeFromValue('1.3.6.1.4.1.311.25.2')

    $extension = New-PkinitComObject -ProgId 'X509Enrollment.CX509Extension'
    $extension.Initialize($oid, 1, $extensionValueBase64) # EncodingType XCN_CRYPT_STRING_BASE64

    return $extension
}

function New-PkinitCertificateSigningRequest {
    <#
        .SYNOPSIS
        Generates a private key and a PKCS#10 CSR (with a SAN extension embedded
        for good measure) via the CertEnroll COM object model.
 
        .PARAMETER Sid
        Optional. If supplied, embeds the SID security extension
        (1.3.6.1.4.1.311.25.2) directly on the CSR - see
        New-PkinitSidSecurityExtension for what this is for and its security
        implications. Only meaningful for CAs/templates that don't already add
        this extension automatically and don't strip a client-supplied one.
 
        .OUTPUTS
        PSCustomObject with Enrollment (the CX509Enrollment COM object - keep it
        around, it's needed later to install the CA's response against the same
        private key) and Base64Csr (string, ready to submit).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Subject,
        [Parameter(Mandatory)] [string] $San,
        [Parameter(Mandatory)] [ValidateSet('UserPrincipalName', 'Dns', 'Email')] [string] $SanType,
        [Parameter()] [string] $Sid,
        [Parameter()] [int] $KeyLength = 2048,
        [Parameter()] [string] $ProviderName = 'Microsoft Software Key Storage Provider',
        [Parameter()] [switch] $MachineContext
    )

    $enrollmentContext = if ($MachineContext) { 2 } else { 1 } # ContextMachine=2, ContextUser=1

    $privateKey = New-PkinitComObject -ProgId 'X509Enrollment.CX509PrivateKey'
    $privateKey.ProviderName = $ProviderName
    $privateKey.Length = $KeyLength
    $privateKey.KeySpec = 1 # XCN_AT_KEYEXCHANGE
    $privateKey.MachineContext = [bool]$MachineContext
    $privateKey.ExportPolicy = 1 # XCN_NCRYPT_ALLOW_EXPORT_FLAG
    $privateKey.Create()

    $distinguishedName = New-PkinitComObject -ProgId 'X509Enrollment.CX500DistinguishedName'
    $distinguishedName.Encode($Subject, 0) # X500NameFlags XCN_CERT_NAME_STR_NONE

    $alternativeName = New-PkinitComObject -ProgId 'X509Enrollment.CAlternativeName'
    $alternativeName.InitializeFromString((ConvertTo-PkinitAlternativeNameTypeCode -SanType $SanType), $San)

    $alternativeNames = New-PkinitComObject -ProgId 'X509Enrollment.CAlternativeNames'
    $alternativeNames.Add($alternativeName)

    $sanExtension = New-PkinitComObject -ProgId 'X509Enrollment.CX509ExtensionAlternativeNames'
    $sanExtension.InitializeEncode($alternativeNames)

    $pkcs10Request = New-PkinitComObject -ProgId 'X509Enrollment.CX509CertificateRequestPkcs10'
    $pkcs10Request.InitializeFromPrivateKey($enrollmentContext, $privateKey, '')
    $pkcs10Request.Subject = $distinguishedName
    $pkcs10Request.X509Extensions.Add($sanExtension)
    if ($Sid) {
        $pkcs10Request.X509Extensions.Add((New-PkinitSidSecurityExtension -Sid $Sid))
    }
    $pkcs10Request.Encode()

    $enrollment = New-PkinitComObject -ProgId 'X509Enrollment.CX509Enrollment'
    $enrollment.InitializeFromRequest($pkcs10Request)
    $base64Csr = $enrollment.CreateRequest(1) # EncodingType XCN_CRYPT_STRING_BASE64

    return [PSCustomObject]@{
        Enrollment = $enrollment
        Base64Csr  = $base64Csr
    }
}

function Submit-PkinitCertificateSigningRequest {
    <#
        .SYNOPSIS
        Submits a Base64 PKCS#10 CSR to a CA via the CertificateAuthority.Request
        (ICertRequest2) COM object, passing the certificate template and SAN as
        request attributes.
 
        .OUTPUTS
        System.String - the issued certificate, Base64-encoded (no PEM header).
        Throws if the CA does not issue the certificate.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [string] $Base64Csr,
        [Parameter(Mandatory)] [string] $Template,
        [Parameter(Mandatory)] [string] $San,
        [Parameter(Mandatory)] [ValidateSet('UserPrincipalName', 'Dns', 'Email')] [string] $SanType,
        [Parameter(Mandatory)] [string] $CertificateAuthorityConfig
    )

    $attributes = @(
        "CertificateTemplate:$Template"
        (ConvertTo-PkinitSanAttributeString -SanType $SanType -San $San)
    ) -join "`n"

    $certRequest = New-PkinitComObject -ProgId 'CertificateAuthority.Request'
    # 0xFF (CR_IN_ENCODEANY) lets the CA auto-detect the request encoding.
    $disposition = $certRequest.Submit(0xFF, $Base64Csr, $attributes, $CertificateAuthorityConfig)

    if ($disposition -ne 3) {
        # CR_DISP_ISSUED
        $message = $certRequest.GetDispositionMessage()
        throw "Certificate request was not issued (disposition $disposition): $message"
    }

    return $certRequest.GetCertificate(1) # CR_OUT_BASE64 (no header)
}

function Install-PkinitCertificateResponse {
    <#
        .SYNOPSIS
        Installs an issued certificate against the private key generated for it,
        via the same CX509Enrollment object used to create the request.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Enrollment,
        [Parameter(Mandatory)] [string] $Base64Certificate
    )

    # InstallResponseRestrictionFlags: AllowUntrustedCertificate (2) - this is a
    # test/lab-oriented tool, so don't require the CA's chain to already be
    # trusted by the local machine.
    $Enrollment.InstallResponse(2, $Base64Certificate, 1, '')
}

function Get-PkinitCertificateFromStore {
    <#
        .SYNOPSIS
        Finds a certificate (with its now-associated private key) in the
        certificate store by thumbprint, after Install-PkinitCertificateResponse.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Thumbprint,
        [Parameter()] [switch] $MachineContext
    )

    $storePath = if ($MachineContext) { 'Cert:\LocalMachine\My' } else { 'Cert:\CurrentUser\My' }
    return Get-ChildItem -Path $storePath | Where-Object { $_.Thumbprint -eq $Thumbprint }
}