Private/Invoke-FIDO2MakeCredential.ps1

function Invoke-FIDO2MakeCredential {
    <#
    .SYNOPSIS
    Performs a FIDO2 MakeCredential ceremony via the Windows WebAuthn API.

    .DESCRIPTION
    Wraps the webauthn.dll WebAuthNAuthenticatorMakeCredential P/Invoke call.
    Given the createCredentialOptions returned by the CyberArk
    "Start FIDO2 registration" endpoint, prompts the user to interact with their
    FIDO2 authenticator and returns the resulting attestation as base64url-encoded
    values ready to submit to the "Register FIDO2 device" endpoint.

    .PARAMETER Options
    The createCredentialOptions object returned by the
    /api/fido2/registrationOptions endpoint.

    .NOTES
    Requires Windows 10 1903+ (ships webauthn.dll).
    #>

    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CreateCredentialOptions', Justification = 'Parameter does not hold a password or credential')]
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object]$Options
    )

    begin {

        if ((Test-IsCoreCLR) -and -not $IsWindows) {
            throw 'FIDO2 device registration is only supported on Windows platforms'
        }

        #Compile P/Invoke wrapper for Windows webauthn.dll on first use.
        #A separate type is used for registration so that this helper is self-contained.
        if (-not ('psPAS.WebAuthnReg.Native' -as [type])) {

            Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.Runtime.InteropServices;

namespace psPAS.WebAuthnReg {

    [StructLayout(LayoutKind.Sequential)]
    public struct WEBAUTHN_RP_ENTITY_INFORMATION {
        public uint dwVersion;
        [MarshalAs(UnmanagedType.LPWStr)] public string pwszId;
        [MarshalAs(UnmanagedType.LPWStr)] public string pwszName;
        [MarshalAs(UnmanagedType.LPWStr)] public string pwszIcon;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct WEBAUTHN_USER_ENTITY_INFORMATION {
        public uint dwVersion;
        public uint cbId;
        public IntPtr pbId;
        [MarshalAs(UnmanagedType.LPWStr)] public string pwszName;
        [MarshalAs(UnmanagedType.LPWStr)] public string pwszIcon;
        [MarshalAs(UnmanagedType.LPWStr)] public string pwszDisplayName;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct WEBAUTHN_COSE_CREDENTIAL_PARAMETER {
        public uint dwVersion;
        [MarshalAs(UnmanagedType.LPWStr)] public string pwszCredentialType;
        public int lAlg;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct WEBAUTHN_COSE_CREDENTIAL_PARAMETERS {
        public uint cCredentialParameters;
        public IntPtr pCredentialParameters;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct WEBAUTHN_CLIENT_DATA {
        public uint dwVersion;
        public uint cbClientDataJSON;
        public IntPtr pbClientDataJSON;
        [MarshalAs(UnmanagedType.LPWStr)] public string pwszHashAlgId;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct WEBAUTHN_CREDENTIAL {
        public uint dwVersion;
        public uint cbId;
        public IntPtr pbId;
        [MarshalAs(UnmanagedType.LPWStr)] public string pwszCredentialType;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct WEBAUTHN_CREDENTIALS {
        public uint cCredentials;
        public IntPtr pCredentials;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct WEBAUTHN_EXTENSIONS {
        public uint cExtensions;
        public IntPtr pExtensions;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS {
        public uint dwVersion;
        public uint dwTimeoutMilliseconds;
        public WEBAUTHN_CREDENTIALS CredentialList;
        public WEBAUTHN_EXTENSIONS Extensions;
        public uint dwAuthenticatorAttachment;
        [MarshalAs(UnmanagedType.Bool)] public bool bRequireResidentKey;
        public uint dwUserVerificationRequirement;
        public uint dwAttestationConveyancePreference;
        public uint dwFlags;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct WEBAUTHN_CREDENTIAL_ATTESTATION {
        public uint dwVersion;
        [MarshalAs(UnmanagedType.LPWStr)] public string pwszFormatType;
        public uint cbAuthenticatorData;
        public IntPtr pbAuthenticatorData;
        public uint cbAttestation;
        public IntPtr pbAttestation;
        public uint dwAttestationDecodeType;
        public IntPtr pvAttestationDecode;
        public uint cbAttestationObject;
        public IntPtr pbAttestationObject;
        public uint cbCredentialId;
        public IntPtr pbCredentialId;
    }

    public static class Native {

        [DllImport("webauthn.dll", CharSet = CharSet.Unicode)]
        public static extern int WebAuthNAuthenticatorMakeCredential(
            IntPtr hWnd,
            ref WEBAUTHN_RP_ENTITY_INFORMATION pRpInformation,
            ref WEBAUTHN_USER_ENTITY_INFORMATION pUserInformation,
            ref WEBAUTHN_COSE_CREDENTIAL_PARAMETERS pPubKeyCredParams,
            ref WEBAUTHN_CLIENT_DATA pWebAuthNClientData,
            ref WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS pWebAuthNMakeCredentialOptions,
            out IntPtr ppWebAuthNCredentialAttestation);

        [DllImport("webauthn.dll")]
        public static extern void WebAuthNFreeCredentialAttestation(IntPtr pWebAuthNCredentialAttestation);

        [DllImport("webauthn.dll", CharSet = CharSet.Unicode)]
        public static extern IntPtr WebAuthNGetErrorName(int hr);

        [DllImport("user32.dll")]
        public static extern IntPtr GetForegroundWindow();
    }
}
'@


        }

        #Base64Url encode a byte array
        $toB64Url = { param([byte[]]$Bytes) [Convert]::ToBase64String($Bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=') }

    }

    process {

        #Build clientDataJSON for the create ceremony
        $clientDataJson = '{"type":"webauthn.create","challenge":"' + $Options.challenge +
        '","origin":"https://' + $Options.rp.id + '","crossOrigin":false}'
        $clientDataBytes = [System.Text.Encoding]::UTF8.GetBytes($clientDataJson)

        #User handle is supplied as a base64url string in the registration options
        $userIdBytes = ConvertFrom-Base64UrlString -InputString $Options.user.id

        $allocations = New-Object System.Collections.Generic.List[IntPtr]
        $pAttestation = [IntPtr]::Zero

        try {

            #Marshal clientData
            $pClientDataJson = [Runtime.InteropServices.Marshal]::AllocHGlobal($clientDataBytes.Length)
            $allocations.Add($pClientDataJson)
            [Runtime.InteropServices.Marshal]::Copy($clientDataBytes, 0, $pClientDataJson, $clientDataBytes.Length)

            $clientData = New-Object psPAS.WebAuthnReg.WEBAUTHN_CLIENT_DATA
            $clientData.dwVersion = 1
            $clientData.cbClientDataJSON = $clientDataBytes.Length
            $clientData.pbClientDataJSON = $pClientDataJson
            $clientData.pwszHashAlgId = 'SHA-256'

            #Marshal user id
            $pUserId = [Runtime.InteropServices.Marshal]::AllocHGlobal($userIdBytes.Length)
            $allocations.Add($pUserId)
            [Runtime.InteropServices.Marshal]::Copy($userIdBytes, 0, $pUserId, $userIdBytes.Length)

            $rp = New-Object psPAS.WebAuthnReg.WEBAUTHN_RP_ENTITY_INFORMATION
            $rp.dwVersion = 1
            $rp.pwszId = $Options.rp.id
            $rp.pwszName = $Options.rp.name

            $user = New-Object psPAS.WebAuthnReg.WEBAUTHN_USER_ENTITY_INFORMATION
            $user.dwVersion = 1
            $user.cbId = $userIdBytes.Length
            $user.pbId = $pUserId
            $user.pwszName = $Options.user.name
            $user.pwszDisplayName = $Options.user.displayName

            #Marshal pubKeyCredParams array
            $paramStructs = @()
            foreach ($p in $Options.pubKeyCredParams) {
                $cp = New-Object psPAS.WebAuthnReg.WEBAUTHN_COSE_CREDENTIAL_PARAMETER
                $cp.dwVersion = 1
                $cp.pwszCredentialType = $p.type
                $cp.lAlg = [int]$p.alg
                $paramStructs += $cp
            }

            $pParamArray = [IntPtr]::Zero
            if ($paramStructs.Count -gt 0) {
                $paramSize = [Runtime.InteropServices.Marshal]::SizeOf([type][psPAS.WebAuthnReg.WEBAUTHN_COSE_CREDENTIAL_PARAMETER])
                $pParamArray = [Runtime.InteropServices.Marshal]::AllocHGlobal($paramSize * $paramStructs.Count)
                $allocations.Add($pParamArray)
                for ($i = 0; $i -lt $paramStructs.Count; $i++) {
                    $target = [IntPtr]::new($pParamArray.ToInt64() + ($i * $paramSize))
                    [Runtime.InteropServices.Marshal]::StructureToPtr($paramStructs[$i], $target, $false)
                }
            }

            $pubKeyCredParams = New-Object psPAS.WebAuthnReg.WEBAUTHN_COSE_CREDENTIAL_PARAMETERS
            $pubKeyCredParams.cCredentialParameters = $paramStructs.Count
            $pubKeyCredParams.pCredentialParameters = $pParamArray

            #Marshal excludeCredentials
            $excludeStructs = @()
            if ($Options.excludeCredentials) {
                foreach ($cred in $Options.excludeCredentials) {
                    $credIdBytes = ConvertFrom-Base64UrlString -InputString $cred.id
                    $pCredId = [Runtime.InteropServices.Marshal]::AllocHGlobal($credIdBytes.Length)
                    $allocations.Add($pCredId)
                    [Runtime.InteropServices.Marshal]::Copy($credIdBytes, 0, $pCredId, $credIdBytes.Length)

                    $wCred = New-Object psPAS.WebAuthnReg.WEBAUTHN_CREDENTIAL
                    $wCred.dwVersion = 1
                    $wCred.cbId = $credIdBytes.Length
                    $wCred.pbId = $pCredId
                    $wCred.pwszCredentialType = 'public-key'
                    $excludeStructs += $wCred
                }
            }

            $pExcludeArray = [IntPtr]::Zero
            if ($excludeStructs.Count -gt 0) {
                $credSize = [Runtime.InteropServices.Marshal]::SizeOf([type][psPAS.WebAuthnReg.WEBAUTHN_CREDENTIAL])
                $pExcludeArray = [Runtime.InteropServices.Marshal]::AllocHGlobal($credSize * $excludeStructs.Count)
                $allocations.Add($pExcludeArray)
                for ($i = 0; $i -lt $excludeStructs.Count; $i++) {
                    $target = [IntPtr]::new($pExcludeArray.ToInt64() + ($i * $credSize))
                    [Runtime.InteropServices.Marshal]::StructureToPtr($excludeStructs[$i], $target, $false)
                }
            }

            $excludeList = New-Object psPAS.WebAuthnReg.WEBAUTHN_CREDENTIALS
            $excludeList.cCredentials = $excludeStructs.Count
            $excludeList.pCredentials = $pExcludeArray

            #Map authenticatorSelection / attestation values
            $uvRequirement = 0
            switch ($Options.authenticatorSelection.userVerification) {
                'required'    { $uvRequirement = 1 }
                'preferred'   { $uvRequirement = 2 }
                'discouraged' { $uvRequirement = 3 }
            }

            $attachment = 0
            switch ($Options.authenticatorSelection.authenticatorAttachment) {
                'platform'       { $attachment = 1 }
                'cross-platform' { $attachment = 2 }
            }

            $attestationPref = 0
            switch ($Options.attestation) {
                'none'       { $attestationPref = 1 }
                'indirect'   { $attestationPref = 2 }
                'direct'     { $attestationPref = 3 }
                'enterprise' { $attestationPref = 4 }
            }

            $options = New-Object psPAS.WebAuthnReg.WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS
            #dwVersion=1: struct layout matches the fields declared above (up to dwFlags).
            #Higher versions (2+) require additional fields (pCancellationId, pExcludeCredentialList, ...);
            #using a higher version with a smaller struct causes webauthn.dll to read past the
            #end of the struct and access-violate.
            $options.dwVersion = 1
            $options.dwTimeoutMilliseconds = if ($Options.timeout) { [uint32]$Options.timeout } else { 60000 }
            #At V1, CredentialList is the (deprecated) exclude credentials list
            $options.CredentialList = $excludeList
            $options.dwAuthenticatorAttachment = $attachment
            $options.bRequireResidentKey = [bool]$Options.authenticatorSelection.requireResidentKey
            $options.dwUserVerificationRequirement = $uvRequirement
            $options.dwAttestationConveyancePreference = $attestationPref

            #Invoke Windows WebAuthn API
            $hr = [psPAS.WebAuthnReg.Native]::WebAuthNAuthenticatorMakeCredential(
                [psPAS.WebAuthnReg.Native]::GetForegroundWindow(),
                [ref]$rp,
                [ref]$user,
                [ref]$pubKeyCredParams,
                [ref]$clientData,
                [ref]$options,
                [ref]$pAttestation
            )

            if ($hr -ne 0) {
                $errorNamePtr = [psPAS.WebAuthnReg.Native]::WebAuthNGetErrorName($hr)
                $errorName = if ($errorNamePtr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::PtrToStringUni($errorNamePtr) } else { "Unknown error (0x{0:X})" -f $hr }
                throw "WebAuthNAuthenticatorMakeCredential failed: $errorName"
            }

            $attestation = [Runtime.InteropServices.Marshal]::PtrToStructure(
                $pAttestation, [type][psPAS.WebAuthnReg.WEBAUTHN_CREDENTIAL_ATTESTATION]
            )

            $credentialId = New-Object byte[] $attestation.cbCredentialId
            [Runtime.InteropServices.Marshal]::Copy($attestation.pbCredentialId, $credentialId, 0, $attestation.cbCredentialId)

            $attestationObject = New-Object byte[] $attestation.cbAttestationObject
            [Runtime.InteropServices.Marshal]::Copy($attestation.pbAttestationObject, $attestationObject, 0, $attestation.cbAttestationObject)

        } finally {

            if ($pAttestation -ne [IntPtr]::Zero) {
                [psPAS.WebAuthnReg.Native]::WebAuthNFreeCredentialAttestation($pAttestation)
            }
            foreach ($ptr in $allocations) {
                [Runtime.InteropServices.Marshal]::FreeHGlobal($ptr)
            }

        }

        #Return base64url-encoded values for the registration submission
        @{
            CredentialId      = & $toB64Url $credentialId
            AttestationObject = & $toB64Url $attestationObject
            ClientDataJson    = & $toB64Url $clientDataBytes
        }

    }

}