Skip to content

PKU2U internals: certificate authentication between Windows peers

PKU2U (Public Key Cryptography Based User-to-User) is the Windows security package that authenticates two machines with certificates when they share no domain. It came with HomeGroup in Windows 7; Microsoft Entra joined devices use it for Remote Desktop and file sharing. Little of it is documented. This page explains how pku2u.dll works on Windows 11 24H2 (build 26100), and gives two PowerShell scripts, one for the server side and one for the client side, that perform a PKU2U logon without Microsoft Entra ID, with an identity provider that Windows ships disabled.

Internal interfaces. certpoleng.dll and the LSA identity-provider interface are not documented and can change with an update. Run the scripts on test machines only: they add a CA that can log on local accounts.
What is documented, and what is not

The public material is thin:

  • The protocol: the IETF draft draft-zhu-pku2u (Larry Zhu, expired). It describes PKINIT without a KDC: the acceptor plays the KDC and answers an AS-REQ itself, inside a GSS-API token.
  • The Windows SDK, in NTSecAPI.h: PKU2U_PACKAGE_NAME (L"pku2u"), PKU2U_CREDUI_CONTEXT / PKU2U_CERT_BLOB (certificates handed over by CredUI), and an LsaLogonUser submit type Pku2uCertificateS4ULogon = 14 with PKU2U_CERTIFICATE_S4U_LOGON. Elsewhere: RPC_C_AUTHN_PKU2U = 31 (rpcdce.h), STATUS_PKU2U_CERT_FAILURE / SEC_E_PKU2U_CERT_FAILURE.
  • One IT-pro page, Introducing Online Identity Integration, about the policy Network security: Allow PKU2U authentication requests to this computer to use online IDs.
  • The Identity Sharing SDK (IdentityProvider.h, IdentityStore.h: IIdentityProvider, IIdentityStore…), the Windows 7 COM model of identity providers. It is related, since both read the same registry, but it is not the interface PKU2U uses.

None of it says where the client's certificate comes from, how the server picks its own, which issuers are trusted, or how the remote user becomes a Windows token.

The pieces inside LSASS
  • pku2u.dll: the security package. It is built from the Kerberos sources (Kerb* functions, the same ASN.1 and crypto) and ships with public symbols.
  • NegoEx only. PKU2U is never called directly. AcquireCredentialsHandle("pku2u") fails with SEC_E_SECPKG_NOT_FOUND, because the package's SpAcquireCredentialsHandle expects the SECPKG_CREDENTIAL that NegoEx builds. Its NegoEx auth scheme is {0D53335C-F9EA-4D0D-B2EC-4AE3786EC308}, and an SSPI client reaches it through Negotiate with Kerberos and NTLM excluded (package list "!kerberos,!ntlm" in a SEC_WINNT_AUTH_IDENTITY_EX), or through plain Negotiate when the two machines share no domain. See also the NegoEx development notes.
  • The identity-provider extension: at initialization pku2u calls LsaGetInterface(1003, &table). LsaGetInterface is exported by lsass.exe itself, next to LsaRegisterInterface and LsaRegisterExtension, not by lsasrv.dll. If the interface is missing, every credential is refused with SEC_E_NO_CREDENTIALS. The table builds the PAC and checks the client before an AP-REQ (details on the LsaGetInterface page).
  • certpoleng.dll (Certificate Policy Engine, delay-loaded through ext-ms-win-security-certpoleng-l1-1-0). It holds the trust decisions: IntPstMapCertificateToProvider, IntPstMapUserCredsToProvider, IntPstGetCertificate, IntPstGetTrustAnchors, IntPstValidate and IntPstGetNameIdentifierForCertificate.

certpoleng reasons in terms of identity providers, read from HKLM\SOFTWARE\Microsoft\IdentityStore\Providers. Every PKU2U certificate, on either side, has to belong to one of them.

Identity providers and their trust anchors

Each provider names a TrustedRootContainer: a LocalMachine certificate store. A certificate belongs to a provider when its issuer is found in that store. certpoleng calls CertFindCertificateInStore(CERT_FIND_ISSUER_OF) on each provider in turn, then builds the chain with a chain engine whose roots are that store. The providers on a stock Windows 11:

Provider GUIDNameTrustedRootContainerLoaded by certpoleng
{B16898C6-A148-4967-9171-64D755DA8520}AzureADAAD Token Issueralways
{D7F9888F-E3FC-49B0-9EA6-A85B5F392A4F}MicrosoftAccountWindows Live ID Token Issueronly with Enabled = 1
{6D0F37E4-4FAC-4E44-9C07-6B8343FE4953}. (local)Local Cert Issuer (store absent by default)only with Enabled = 1
{E6284137-3900-4B85-97D1-51C9FF2ACEF0}. (containers)Container Cert Issueronly with Enabled = 1
{6217F659-465F-411F-9E2B-4C51169259F6}HomeGroup (hard-coded)—never, turned off by the feature flag DisableHomeGroupIdentityProvider
  • Loading rule (CProviderEntry::ReadInfoFromRegistry): a provider other than AzureAD is skipped unless a DWORD Enabled ≠ 0 exists, either in Providers\{guid} or in HKLM\SOFTWARE\Microsoft\IdentityStore\LoadParameters\{guid}. The Providers keys are not writable even by SYSTEM; the LoadParameters key is writable by SYSTEM. A DWORD PKU2UDisabled at the same places removes a provider from PKU2U only.
  • Which provider an outgoing caller gets depends on its account type (LsaLookupUserAccountType): Azure AD account → AzureAD, local account connected to a Microsoft account → MicrosoftAccount, plain local account → the local provider {6D0F37E4}. A domain account gets AzureAD only on an Azure AD joined device. HKLM\SOFTWARE\Microsoft\Ole OutgoingContainerAuthenticationService / IncomingContainerAuthenticationService = 9 switches to the container provider.
  • The cache is filled lazily on the first call that needs it. On a freshly booted machine, the very first certificate mapping can fail before the cache is refreshed and succeed on the next attempt.
Client side: the certificate comes from Credential Manager

When NegoEx asks pku2u for its metadata (SpQueryMetaData), the package fills the context from Credential Manager (Pku2uPopulateContextWithCredmanCreds → Pku2uCheckCredMgrCreds). It reads the caller's credentials through the LSA function CrediReadDomainCredentials with this target information:

CREDENTIAL_TARGET_INFORMATIONW ti = {
    .TargetName   = <the SPN given to InitializeSecurityContext>,   // e.g. HOST/peer
    .PackageName  = L"pku2u",
    .Flags        = CRED_TI_SERVER_FORMAT_UNKNOWN,
    .CredTypeCount = 2,
    .CredTypes    = { CRED_TYPE_DOMAIN_PASSWORD, CRED_TYPE_DOMAIN_CERTIFICATE },
};
// not found, or rejected (an SPN with "/" gives STATUS_INVALID_PARAMETER):
// retried with SspiGetTargetHostName(SPN) as TargetName  ->  "peer"
  • A certificate credential is a CRED_TYPE_DOMAIN_CERTIFICATE whose user name is CredMarshalCredential(CertCredential, SHA-1 of the certificate), stored under the host name of the target. The certificate must then pass IntPstMapCertificateToProvider. Otherwise it is ignored (0xC0070490, not found).
  • A password credential goes through IntPstMapUserCredsToProvider, which maps an online user name to a provider that issues a certificate on the user's behalf. With a plain local account it fails with 0x80070520 (ERROR_NO_SUCH_LOGON_SESSION).
  • With no usable credential, SpQueryMetaData returns SEC_E_NO_CREDENTIALS. NegoEx then drops PKU2U, and the caller sees SEC_E_NO_CREDENTIALS from InitializeSecurityContext.
  • The client also sends the trust anchors of its provider (IntPstGetTrustAnchors) in its metadata, so the acceptor knows which issuers the client accepts.
Server side: the acceptor's certificate

The acceptor needs a certificate too, because it signs the AS-REP like a PKINIT KDC. On receiving the client's metadata (SpExchangeMetaData), pku2u calls IntPstGetCertificate, and certpoleng takes the incoming path of IntGetCertificateForCaller:

  • The caller must be a machine identity: account type 0 (SYSTEM, a service). An acceptor running as a local user fails with SEC_E_NO_CREDENTIALS before any certificate is looked at. File-sharing and RDP servers already run as SYSTEM.
  • The certificate is chosen by the host name of the target the client asked for (_IntGetHostNameFromTargetName, with an IP-address check) among the providers able to accept incoming logons.
  • For the local provider: a LocalMachine\My certificate issued by the provider's CA, CN and DNS name = the target host, EKU server and client authentication, with its private key.
On the wire: one complete exchange

A successful authentication: client User, target HOST/pku2upeer. Every token is SPNEGO around NegoEx (NEGOEXTS messages, one conversation ID). The PKU2U payloads are GSS-API tokens whose mechanism OID is 1.3.6.1.5.2.7, followed by a two-byte TOK_ID, as in the draft.

#DirectionNegoEx messagesPKU2U content
1client → server
309 bytes
INITIATOR_NEGO, INITIATOR_META_DATAmetadata: trusted certifiers + target
2server → client
243 bytes
ACCEPTOR_NEGO, ACCEPTOR_META_DATAmetadata: the acceptor's trusted certifiers
3client → server
2157 bytes
AP_REQUESTTOK_ID 05 00: KRB_AS_REQ with PA-PK-AS-REQ
4server → client
2762 bytes
CHALLENGETOK_ID 06 00: KRB_AS_REP with PA-PK-AS-REP and a ticket
5client → server
1389 bytes
AP_REQUEST, VERIFYTOK_ID 01 00: KRB_AP_REQ (mutual), NegoEx checksum type 16
6server → client
328 bytes
CHALLENGE, VERIFYTOK_ID 02 00: KRB_AP_REP
Metadata (PKU2UMetaData)
SEQUENCE {
  [0] trustedCertifiers   SEQUENCE OF { [0] subjectName = Name of the trusted issuer (CN=pku2u-test-ca) }
  [1] target (client only) {
        [0] realm  GeneralString "WELLKNOWN:PKU2U"
        [1] sname  PrincipalName { name-type 2 (NT-SRV-INST), "HOST", "pku2upeer" } } }
AS-REQ (token 3)
pvno 5, msg-type 10
padata: type 16 PA-PK-AS-REQ = CMS SignedData (digest SHA-1, RSA), signer = the client certificate
        eContentType id-pkinit-authData, AuthPack:
          pkAuthenticator { ctime, nonce 0, paChecksum (SHA-1, 20 bytes) }
          clientPublicValue: dhpublicnumber, 1024-bit Diffie-Hellman
          clientDHNonce (32 bytes)
req-body:
  kdc-options  0x40810010
  cname        name-type -128 (KRB_NT_MS_PRINCIPAL)  ".\pku2u-test-ca\User"
  realm        "WELLKNOWN:PKU2U"
  sname        HOST/pku2upeer (name-type 2)
  till/rtime   9999-09-14
  etype        18, 17, 23, 3, 1, 24, -135
  addresses    NetBIOS name of the client

The client name is built as provider name (. for the local provider) \ issuer CN \ certificate identity.

AS-REP (token 4)
pvno 5, msg-type 11
padata: type 17 PA-PK-AS-REP, dhInfo:
          dhSignedData = CMS SignedData (id-pkinit-DHKeyData), signer = the acceptor certificate (CN=pku2upeer)
          serverDHNonce (32 bytes)
crealm "WELLKNOWN:PKU2U", cname -128 "User"     <- the local account the acceptor will log on
ticket: realm WELLKNOWN:PKU2U, sname HOST/pku2upeer, enc-part aes256-cts-hmac-sha1-96 (18)
enc-part: aes256-cts-hmac-sha1-96, key derived from the DH exchange

The acceptor is its own KDC. Pku2uHandlePeer2PeerAsRequest checks the client's CMS signature and chain (IntPstValidate) and agrees a DH key. Then Pku2uCreatePac builds a PAC (Pku2uComputeDomainRelativeSid, Pku2uComputeUserInfo), and Pku2uBuildTicketAS signs the PAC and encrypts the ticket for itself. The AP-REQ / AP-REP that follow are plain Kerberos.

From certificate to Windows token: a local S4U logon

When the AP-REQ is accepted, Pku2uCreateTokenFromTicket calls Pku2uMakeTokenInformationV2. For the local provider, that function hands the account name taken from the client certificate to lsasrv!LsaIGetTokenInformationForLocalUser, which runs LsapDoLocalS4ULogon:

LsaIGetTokenInformationForLocalUser(L"pku2u@example.com", ...)  -> STATUS_LOGON_FAILURE (no such local account)
LsaIGetTokenInformationForLocalUser(L"User", ...)               -> token for the local account User, ASC returns SEC_E_OK
  • The certificate identity (the UPN in the subject alternative name and the CN, User here) must name an existing local account. Otherwise the last AcceptSecurityContext fails with SEC_E_LOGON_DENIED after a fully successful cryptographic exchange.
  • No password and no private key of that account is involved. Whoever can obtain a certificate from a CA in the provider's store can open a network logon as any local user. On Entra joined devices the CA is Entra ID's issuer (AAD Token Issuer); with the local provider, whatever is in Local Cert Issuer.
  • A provider of your own can answer instead, with its own users and SIDs and no local account: see a third-party identity provider for PKU2U.
Reproduce it: a PKU2U logon without Entra ID (PowerShell)

Two scripts, in Pku2uLab.zip and listed below: Pku2uServer.ps1 for the accepting side and Pku2uClient.ps1 for the user's side. Here both run on one Windows 11 machine in a workgroup, in two PowerShell windows.

Prerequisites
Server (accepting side)Client (user's side)
WindowsWindows 11 24H2 (build 26100) in a workgroup: no domain, no Entra ID
Who runs the scripta local administrator, elevated PowerShellthe user who logs on, normal (not elevated) PowerShell. Only the machine settings need an elevated window
Process that authenticatesa process running as SYSTEM (account type 0). PKU2U refuses to accept in a user process: -Step Listen starts the SSPI server as a SYSTEM scheduled task; RDP and file sharing already run as SYSTEMa process of the user. The account must be a plain local account: Microsoft accounts and Entra accounts are mapped to other providers
Trust (machine, once)the lab CA in LocalMachine\Local Cert Issuer, and LoadParameters\{6D0F37E4-4FAC-4E44-9C07-6B8343FE4953}\Enabled = 1 (written as SYSTEM), then a restart
CertificateLocalMachine\My, issued by the lab CA, CN and DNS name = the target name the client usesCurrentUser\My, issued by the lab CA, and in Credential Manager under the target's host name
Accounta local account named after the client certificate (CN=User → User)none, the certificate carries the identity
Server side: elevated window
.\Pku2uServer.ps1 -Step Ca -ServerName pku2u-peer -UserName User
.\Pku2uServer.ps1 -Step Machine
Restart-Computer
.\Pku2uServer.ps1 -Step Listen
  • Ca: creates CN=PKU2U Lab CA, a server certificate for pku2u-peer in LocalMachine\My (replacing any earlier one from the same CA), and a client certificate for the local account User, exported to pku2u-lab\user.pfx with the CA certificate.
  • Machine: adds the CA to LocalMachine\Local Cert Issuer and sets Enabled = 1 through a one-shot task running as SYSTEM, the only account allowed to write that key. LSASS loads the providers at startup, hence the restart.
  • Listen: starts AcceptSecurityContext as SYSTEM in a scheduled task and prints what it does. The tokens go through files in pku2u-lab\x, which the non-elevated client may write.
Client side: normal window
.\Pku2uClient.ps1 -Step User -ServerName pku2u-peer
.\Pku2uClient.ps1 -Step Test -ServerName pku2u-peer
  • User: imports the client certificate into CurrentUser\My and saves it in Credential Manager under pku2u-peer (CRED_TYPE_DOMAIN_CERTIFICATE, the user name being CredMarshalCredential(CertCredential, SHA-1)).
  • Test: InitializeSecurityContext to HOST/pku2u-peer on plain Negotiate with the session's own credentials. Without a domain, Negotiate settles on PKU2U by itself, as for mstsc. Both scripts call SSPI through System.Net.NTAuthentication, the internal wrapper of .NET Framework (System.dll), by reflection. The only native call left is CredWrite: neither PowerShell nor cmdkey can save a certificate credential. It starts with one throwaway attempt: after a restart, the first mapping of a client certificate fails while certpoleng loads its providers.

On one machine the machine settings are shared, so the client has nothing to run elevated. The target is an alias, not the computer name: when the target names the machine itself, PKU2U takes another path and the client gets SEC_E_NO_CREDENTIALS. Expected output:

AcceptSecurityContext      #1  0x00090312    242 bytes
AcceptSecurityContext      #2  0x00090312   2764 bytes
AcceptSecurityContext      #3  0x00000000    328 bytes
authenticated user User, package pku2u
InitializeSecurityContext  #1  0x00090312    321 bytes
InitializeSecurityContext  #2  0x00090312   2154 bytes
InitializeSecurityContext  #3  0x00090312   1390 bytes
InitializeSecurityContext  #4  0x00000000      0 bytes

The same scripts set up Remote Desktop over PKU2U between two machines: see Remote Desktop over PKU2U.

Pku2uServer.ps1
<#
.SYNOPSIS
    PKU2U without Microsoft Entra ID, server side: a lab CA, the server's PKU2U certificate,
    the certificate of a local user, the built-in local identity provider, Remote Desktop.

.DESCRIPTION
    Elevated PowerShell (local administrator), in this order:
        .\Pku2uServer.ps1 -Step Ca -ServerName SRV -UserName User   # fills .\pku2u-lab for the client
        .\Pku2uServer.ps1 -Step Machine
        .\Pku2uServer.ps1 -Step Rdp                                 # Remote Desktop only
        Restart-Computer

    -ServerName is the name the client connects to (the computer name, or an alias in the
    client's hosts file). -UserName is a local account of this machine: a client presenting the
    user certificate is logged on as that account.

    PKU2U accepts a logon only in a process running as SYSTEM, with a certificate of
    LocalMachine\My whose name is the target the client asked for: LSASS for Remote Desktop,
    a SYSTEM scheduled task for the SSPI test:
        .\Pku2uServer.ps1 -Step Listen      # waits for Pku2uClient.ps1 -Step Test, one machine
#>
param(
    [Parameter(Mandatory)][ValidateSet('Ca', 'Machine', 'Rdp', 'Listen', 'Accept')]
    [string]$Step,
    [string]$ServerName = $env:COMPUTERNAME,
    [string]$UserName = $env:USERNAME,
    [string]$Folder,
    [string]$PfxPassword = 'pku2u'
)
$ErrorActionPreference = 'Stop'
if (-not $Folder) { $Folder = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'pku2u-lab' }

# The local identity provider of certpoleng.dll and the store it trusts
$LocalProvider = '{6D0F37E4-4FAC-4E44-9C07-6B8343FE4953}'
$TrustStore = 'Local Cert Issuer'

function Write-Step($text) { Write-Host "`n== $text" -ForegroundColor Cyan }
function Show($object, [string[]]$properties) { ($object | Format-List $properties | Out-String).Trim() }

# SSPI through System.Net.NTAuthentication, the internal wrapper of System.dll (.NET Framework):
# Negotiate with the session's own credentials; between machines that share no domain it
# settles on PKU2U through NegoEx
$NTAuthentication = [Net.ServicePoint].Assembly.GetType('System.Net.NTAuthentication')
$NonPublic = [Reflection.BindingFlags]'NonPublic,Instance'
function New-SspiContext([switch]$Server, [string]$Target) {
    $flags = [Enum]::ToObject([Net.ServicePoint].Assembly.GetType('System.Net.ContextFlags'), 0x800)   # Connection
    $ctor = $NTAuthentication.GetConstructor($NonPublic, $null, [Type[]]@([bool], [string], [Net.NetworkCredential], [string],
        $flags.GetType(), [Security.Authentication.ExtendedProtection.ChannelBinding]), $null)
    $ctor.Invoke(@([bool]$Server, 'Negotiate', [Net.CredentialCache]::DefaultNetworkCredentials, $Target, $flags, $null))
}
# one InitializeSecurityContext / AcceptSecurityContext: status, output token
function Step-SspiContext($ctx, [byte[]]$in) {
    $status = [Net.ServicePoint].Assembly.GetType('System.Net.SecurityStatus')
    $method = $NTAuthentication.GetMethod('GetOutgoingBlob', $NonPublic, $null, [Type[]]@([byte[]], [bool], $status.MakeByRefType()), $null)
    $arguments = @($in, $false, $null)
    $out = $method.Invoke($ctx, $arguments)
    if (-not $out) { $out = [byte[]]@() }
    [int]$arguments[2], $out
}
function Get-SspiProperty($ctx, $name) { $NTAuthentication.GetProperty($name, $NonPublic).GetValue($ctx) }

switch ($Step) {

    'Ca' {
        New-Item -ItemType Directory -Force $Folder | Out-Null
        Write-Step "Lab CA"
        $ca = Get-ChildItem Cert:\LocalMachine\My | Where-Object Subject -eq 'CN=PKU2U Lab CA' | Select-Object -First 1
        if (-not $ca) {
            $ca = New-SelfSignedCertificate -Subject 'CN=PKU2U Lab CA' -CertStoreLocation Cert:\LocalMachine\My `
                -KeyUsage CertSign, CRLSign, DigitalSignature -NotAfter (Get-Date).AddYears(5) `
                -TextExtension @('2.5.29.19={critical}{text}ca=1&pathlength=0')
        }
        Export-Certificate -Cert $ca -FilePath (Join-Path $Folder 'pku2u-lab-ca.cer') | Out-Null
        Show $ca Subject, Thumbprint

        # One server certificate per CA: with a second one in LocalMachine\My, PKU2U failed and mstsc fell back to NTLM
        Write-Step "Server certificate for $ServerName (LocalMachine\My)"
        Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Issuer -eq $ca.Subject -and $_.Thumbprint -ne $ca.Thumbprint } |
            ForEach-Object { Remove-Item "Cert:\LocalMachine\My\$($_.Thumbprint)" -DeleteKey }
        $server = New-SelfSignedCertificate -Subject "CN=$ServerName" -DnsName $ServerName -Signer $ca `
            -CertStoreLocation Cert:\LocalMachine\My -KeyAlgorithm RSA -KeyLength 2048 `
            -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.1,1.3.6.1.5.5.7.3.2')
        Show $server Subject, Issuer, Thumbprint

        Write-Step "User certificate for $UserName (exported to user.pfx)"
        $user = New-SelfSignedCertificate -Subject "CN=$UserName" -Signer $ca -CertStoreLocation Cert:\LocalMachine\My `
            -KeyAlgorithm RSA -KeyLength 2048 -KeyExportPolicy Exportable `
            -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.2', "2.5.29.17={text}upn=$UserName")
        Export-PfxCertificate -Cert $user -FilePath (Join-Path $Folder 'user.pfx') `
            -Password (ConvertTo-SecureString $PfxPassword -AsPlainText -Force) | Out-Null
        Remove-Item "Cert:\LocalMachine\My\$($user.Thumbprint)" -DeleteKey
        Show $user Subject, Issuer, Thumbprint
        Write-Host ""
        Get-ChildItem $Folder | Select-Object Name, Length | Format-Table -AutoSize | Out-String | ForEach-Object Trim
    }

    'Machine' {
        Write-Step "Trust the lab CA in LocalMachine\$TrustStore"
        $cer = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (Join-Path $Folder 'pku2u-lab-ca.cer')
        $store = New-Object System.Security.Cryptography.X509Certificates.X509Store($TrustStore, 'LocalMachine')
        $store.Open('ReadWrite'); $store.Add($cer); $store.Close()
        Show (Get-ChildItem "Cert:\LocalMachine\$TrustStore") Subject, Thumbprint

        Write-Step "Enable the local identity provider $LocalProvider"
        $key = "HKLM\SOFTWARE\Microsoft\IdentityStore\LoadParameters\$LocalProvider"
        # The key is writable by SYSTEM only: a one-shot scheduled task does it
        $task = 'Pku2uLabEnableProvider'
        $action = New-ScheduledTaskAction -Execute 'reg.exe' -Argument "add `"$key`" /v Enabled /t REG_DWORD /d 1 /f"
        Register-ScheduledTask -TaskName $task -Action $action -User 'NT AUTHORITY\SYSTEM' -Force | Out-Null
        Start-ScheduledTask -TaskName $task
        while ((Get-ScheduledTask -TaskName $task).State -eq 'Running') { Start-Sleep -Milliseconds 200 }
        Unregister-ScheduledTask -TaskName $task -Confirm:$false
        "Enabled = " + (Get-ItemProperty "Registry::$key").Enabled
        Write-Host "`nRestart the machine: LSASS reads the identity providers at startup." -ForegroundColor Yellow
    }

    'Rdp' {
        Write-Step "Remote Desktop with Network Level Authentication"
        $ts = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server'
        Set-ItemProperty $ts fDenyTSConnections 0
        Set-ItemProperty "$ts\WinStations\RDP-Tcp" UserAuthentication 1
        Enable-NetFirewallRule -DisplayGroup 'Remote Desktop'
        Show (Get-ItemProperty "$ts\WinStations\RDP-Tcp") UserAuthentication, SecurityLayer
        Write-Host "`nRestart the machine: the RDP listener starts with the system." -ForegroundColor Yellow
    }

    'Listen' {
        Write-Step "SSPI server over PKU2U as SYSTEM: waiting for Pku2uClient.ps1 -Step Test"
        # Tokens are exchanged as files in $Folder\x, writable by the client's non-elevated window
        $x = Join-Path $Folder 'x'
        Remove-Item -Recurse -Force $x -ErrorAction SilentlyContinue; New-Item -ItemType Directory $x | Out-Null
        icacls $x /grant '*S-1-5-32-545:(OI)(CI)M' | Out-Null
        $task = 'Pku2uLabAccept'
        $action = New-ScheduledTaskAction -Execute 'powershell.exe' `
            -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -Step Accept -Folder `"$Folder`""
        Register-ScheduledTask -TaskName $task -Action $action -User 'NT AUTHORITY\SYSTEM' -Force | Out-Null
        Start-ScheduledTask -TaskName $task
        try {
            $log = Join-Path $x 'server.log'; $shown = 0
            do {
                Start-Sleep -Milliseconds 200
                $running = (Get-ScheduledTask -TaskName $task).State -eq 'Running'
                $lines = @(if (Test-Path $log) { Get-Content $log })
                if ($lines.Count -gt $shown) { $lines[$shown..($lines.Count - 1)]; $shown = $lines.Count }
            } while ($running)
        } finally {
            Unregister-ScheduledTask -TaskName $task -Confirm:$false
        }
    }

    'Accept' {
        # Internal: the accepting side started by -Step Listen, run as SYSTEM
        $x = Join-Path $Folder 'x'
        $ctx = New-SspiContext -Server
        for ($i = 1; $i -le 6; $i++) {
            $f = Join-Path $x "c2s$i.bin"
            # five minutes for the first token, the time to start the client
            for ($t = 0; -not (Test-Path $f) -and $t -lt $(if ($i -eq 1) { 3000 } else { 600 }); $t++) { Start-Sleep -Milliseconds 100 }
            if (-not (Test-Path $f)) { break }
            Start-Sleep -Milliseconds 100
            $s, $out = Step-SspiContext $ctx ([IO.File]::ReadAllBytes($f))
            "AcceptSecurityContext      #$i  0x{0:X8}  {1,5} bytes" -f $s, $out.Length | Out-File -Append (Join-Path $x 'server.log')
            [IO.File]::WriteAllBytes((Join-Path $x "s2c$i.tmp"), $out); Move-Item -Force (Join-Path $x "s2c$i.tmp") (Join-Path $x "s2c$i.bin")
            if ($s -eq 0) {
                "authenticated user {0}, package {1}" -f (Get-SspiProperty $ctx AssociatedName), (Get-SspiProperty $ctx ProtocolName) |
                    Out-File -Append (Join-Path $x 'server.log')
            }
            if ($s -le 0) { break }
        }
    }
}
Pku2uClient.ps1
<#
.SYNOPSIS
    PKU2U without Microsoft Entra ID, client side: trust the lab CA, the built-in local identity
    provider, the user certificate in Credential Manager, mstsc, and an SSPI test.

.DESCRIPTION
    Next to this script, the pku2u-lab folder made by Pku2uServer.ps1 -Step Ca on the server.

    Machine settings, elevated PowerShell (local administrator):
        .\Pku2uClient.ps1 -Step Machine
        .\Pku2uClient.ps1 -Step Rdp -ServerName SRV -ServerAddress <IP of SRV>   # Remote Desktop only
        Restart-Computer

    The user's side, PowerShell NOT elevated, in the account that connects (CurrentUser\My and
    Credential Manager are per user):
        .\Pku2uClient.ps1 -Step User -ServerName SRV
        .\Pku2uClient.ps1 -Step Mstsc -ServerName SRV

    For mstsc the account must exist on the server too, with the same password: CredSSP
    delegates the session's own credentials once PKU2U has authenticated both machines.

    One machine as client and server (the target is an alias, since PKU2U treats the
    machine's own name apart):
        elevated:      .\Pku2uServer.ps1 -Step Ca -ServerName pku2u-peer -UserName User
                       .\Pku2uServer.ps1 -Step Machine
                       Restart-Computer
                       .\Pku2uServer.ps1 -Step Listen
        not elevated:  .\Pku2uClient.ps1 -Step User -ServerName pku2u-peer
                       .\Pku2uClient.ps1 -Step Test -ServerName pku2u-peer
#>
param(
    [Parameter(Mandatory)][ValidateSet('Machine', 'User', 'Rdp', 'Mstsc', 'Test')]
    [string]$Step,
    [string]$ServerName,
    [string]$ServerAddress,
    [string]$Folder,
    [string]$PfxPassword = 'pku2u'
)
$ErrorActionPreference = 'Stop'
if (-not $Folder) { $Folder = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'pku2u-lab' }
if ($Step -ne 'Machine' -and -not $ServerName) { throw "-ServerName is required for -Step $Step" }

# The local identity provider of certpoleng.dll and the store it trusts
$LocalProvider = '{6D0F37E4-4FAC-4E44-9C07-6B8343FE4953}'
$TrustStore = 'Local Cert Issuer'

# Credential Manager has no PowerShell cmdlet: CredWrite of a CRED_TYPE_DOMAIN_CERTIFICATE whose
# user name is the marshalled certificate reference
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class Pku2uCredman {
  [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
  struct CREDENTIAL {
    public int Flags, Type; public string TargetName, Comment; public long LastWritten;
    public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist, AttributeCount; public IntPtr Attributes;
    public string TargetAlias, UserName; }
  [StructLayout(LayoutKind.Sequential)]
  struct CERT_CREDENTIAL_INFO { public int cbSize; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 20)] public byte[] rgbHashOfCert; }
  [DllImport("advapi32", CharSet = CharSet.Unicode, SetLastError = true)] static extern bool CredWriteW(ref CREDENTIAL c, int flags);
  [DllImport("advapi32", CharSet = CharSet.Unicode, SetLastError = true)] static extern bool CredMarshalCredentialW(int type, ref CERT_CREDENTIAL_INFO c, out IntPtr s);
  public static string SaveCertificate(string target, byte[] sha1) {
    var info = new CERT_CREDENTIAL_INFO { cbSize = Marshal.SizeOf(typeof(CERT_CREDENTIAL_INFO)), rgbHashOfCert = sha1 };
    IntPtr p;
    if (!CredMarshalCredentialW(1, ref info, out p)) throw new System.ComponentModel.Win32Exception();
    var c = new CREDENTIAL { Type = 3, TargetName = target, UserName = Marshal.PtrToStringUni(p), Persist = 2 };
    if (!CredWriteW(ref c, 0)) throw new System.ComponentModel.Win32Exception();
    return c.UserName;
  }
}
"@

function Write-Step($text) { Write-Host "`n== $text" -ForegroundColor Cyan }
function Show($object, [string[]]$properties) { ($object | Format-List $properties | Out-String).Trim() }

# SSPI through System.Net.NTAuthentication, the internal wrapper of System.dll (.NET Framework):
# Negotiate with the session's own credentials; between machines that share no domain it
# settles on PKU2U through NegoEx
$NTAuthentication = [Net.ServicePoint].Assembly.GetType('System.Net.NTAuthentication')
$NonPublic = [Reflection.BindingFlags]'NonPublic,Instance'
function New-SspiContext([switch]$Server, [string]$Target) {
    $flags = [Enum]::ToObject([Net.ServicePoint].Assembly.GetType('System.Net.ContextFlags'), 0x800)   # Connection
    $ctor = $NTAuthentication.GetConstructor($NonPublic, $null, [Type[]]@([bool], [string], [Net.NetworkCredential], [string],
        $flags.GetType(), [Security.Authentication.ExtendedProtection.ChannelBinding]), $null)
    $ctor.Invoke(@([bool]$Server, 'Negotiate', [Net.CredentialCache]::DefaultNetworkCredentials, $Target, $flags, $null))
}
# one InitializeSecurityContext / AcceptSecurityContext: status, output token
function Step-SspiContext($ctx, [byte[]]$in) {
    $status = [Net.ServicePoint].Assembly.GetType('System.Net.SecurityStatus')
    $method = $NTAuthentication.GetMethod('GetOutgoingBlob', $NonPublic, $null, [Type[]]@([byte[]], [bool], $status.MakeByRefType()), $null)
    $arguments = @($in, $false, $null)
    $out = $method.Invoke($ctx, $arguments)
    if (-not $out) { $out = [byte[]]@() }
    [int]$arguments[2], $out
}

# After a restart the provider list is loaded on first use, and that first mapping of a
# client certificate fails: one throw-away attempt makes the next logon work.
function Initialize-Pku2u($target) {
    try { Step-SspiContext (New-SspiContext -Target $target) $null | Out-Null } catch { }
}

switch ($Step) {

    'Machine' {
        Write-Step "Trust the lab CA in LocalMachine\$TrustStore"
        $cer = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (Join-Path $Folder 'pku2u-lab-ca.cer')
        $store = New-Object System.Security.Cryptography.X509Certificates.X509Store($TrustStore, 'LocalMachine')
        $store.Open('ReadWrite'); $store.Add($cer); $store.Close()
        Show (Get-ChildItem "Cert:\LocalMachine\$TrustStore") Subject, Thumbprint

        Write-Step "Enable the local identity provider $LocalProvider"
        $key = "HKLM\SOFTWARE\Microsoft\IdentityStore\LoadParameters\$LocalProvider"
        # The key is writable by SYSTEM only: a one-shot scheduled task does it
        $task = 'Pku2uLabEnableProvider'
        $action = New-ScheduledTaskAction -Execute 'reg.exe' -Argument "add `"$key`" /v Enabled /t REG_DWORD /d 1 /f"
        Register-ScheduledTask -TaskName $task -Action $action -User 'NT AUTHORITY\SYSTEM' -Force | Out-Null
        Start-ScheduledTask -TaskName $task
        while ((Get-ScheduledTask -TaskName $task).State -eq 'Running') { Start-Sleep -Milliseconds 200 }
        Unregister-ScheduledTask -TaskName $task -Confirm:$false
        "Enabled = " + (Get-ItemProperty "Registry::$key").Enabled
        Write-Host "`nRestart the machine: LSASS reads the identity providers at startup." -ForegroundColor Yellow
    }

    'User' {
        Write-Step "User certificate into CurrentUser\My ($env:USERNAME)"
        $cert = Import-PfxCertificate -FilePath (Join-Path $Folder 'user.pfx') -CertStoreLocation Cert:\CurrentUser\My `
            -Password (ConvertTo-SecureString $PfxPassword -AsPlainText -Force)
        Show $cert Subject, Issuer, Thumbprint

        Write-Step "Credential Manager: domain certificate saved under $ServerName"
        $marshalled = [Pku2uCredman]::SaveCertificate($ServerName, $cert.GetCertHash())
        "TargetName = $ServerName, UserName = $marshalled"
        # A credential saved for TERMSRV/<server> would be handed to PKU2U instead of this one
        cmdkey /delete:TERMSRV/$ServerName 2>$null | Out-Null
        Initialize-Pku2u "HOST/$ServerName"
    }

    'Rdp' {
        Write-Step "Let mstsc hand over the session's own credentials (TERMSRV/*)"
        # Allow delegating default credentials, + the NTLM-only variant: no Kerberos between workgroup machines
        $root = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CredentialsDelegation'
        $policies = @{ AllowDefaultCredentials = 'ConcatenateDefaults_AllowDefault'
                       AllowDefCredentialsWhenNTLMOnly = 'ConcatenateDefaults_AllowDefNTLMOnly' }
        foreach ($name in $policies.Keys) {
            New-Item -Force "$root\$name" | Out-Null
            Set-ItemProperty "$root\$name" 1 'TERMSRV/*'
            Set-ItemProperty $root $name 1
            Set-ItemProperty $root $policies[$name] 1
        }
        Show (Get-ItemProperty $root) AllowDefaultCredentials, AllowDefCredentialsWhenNTLMOnly
        if ($ServerAddress) {
            Write-Step "hosts: $ServerName -> $ServerAddress"
            Add-Content "$env:SystemRoot\System32\drivers\etc\hosts" "`r`n$ServerAddress $ServerName"
        }
        Write-Host "`nRestart the machine: CredSSP reads the delegation policy at startup." -ForegroundColor Yellow
    }

    'Mstsc' {
        Write-Step "mstsc /v:$ServerName"
        Initialize-Pku2u "TERMSRV/$ServerName"
        Start-Process mstsc "/v:$ServerName"
    }

    'Test' {
        Write-Step "SSPI logon over PKU2U as $env:USERNAME, target HOST/$ServerName"
        # Tokens are exchanged as files with Pku2uServer.ps1 -Step Listen, which made $Folder\x
        $x = Join-Path $Folder 'x'
        if (-not (Test-Path $x)) { throw "Start .\Pku2uServer.ps1 -Step Listen first, in an elevated window" }
        Initialize-Pku2u "HOST/$ServerName"
        $ctx = New-SspiContext -Target "HOST/$ServerName"
        $in = $null
        for ($i = 1; $i -le 6; $i++) {
            $s, $out = Step-SspiContext $ctx $in
            "InitializeSecurityContext  #$i  0x{0:X8}  {1,5} bytes" -f $s, $out.Length
            if ($s -lt 0 -or $out.Length -eq 0) { break }
            [IO.File]::WriteAllBytes((Join-Path $x "c2s$i.tmp"), $out); Move-Item -Force (Join-Path $x "c2s$i.tmp") (Join-Path $x "c2s$i.bin")
            $f = Join-Path $x "s2c$i.bin"
            for ($t = 0; -not (Test-Path $f) -and $t -lt 600; $t++) { Start-Sleep -Milliseconds 100 }
            Start-Sleep -Milliseconds 100
            $in = [IO.File]::ReadAllBytes($f)
            if ($in.Length -eq 0) { break }
        }
    }
}
Registry values read by the PKU2U stack

Only Enabled is needed for the local provider. The other values are listed for reference; their meaning is deduced from their names.

LocationValueEffect
IdentityStore\LoadParameters\{guid}Enabledloads a provider other than AzureAD into certpoleng
IdentityStore\Providers\{guid} or LoadParameters\{guid}PKU2UDisabledkeeps a provider out of PKU2U
HKLM\SYSTEM\CurrentControlSet\Control\Lsa\pku2uAllowOnlineIDthe Allow PKU2U … online IDs policy, read by certpoleng on the acceptor side
HKLM\SOFTWARE\Microsoft\OleOutgoing/IncomingContainerAuthenticationService9 = use the container provider (Windows containers)
SYSTEM\CurrentControlSet\Control\Lsa\pku2u\Parameters, SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\pku2u\ParametersMaxTokenSize, SkewTime, DHDomainParameters, MODPDHModulusSize, MinMODPDHModulusSize, PreferWellKnownMODPDHDomainParameters, LeafOnly, SupportInitiatorFirst, SupportLocalHost, AlwaysPrompt, DisableLocalKdc, LocalKdcServerOptIn, DisableIAKerbpackage parameters, watched for changes (Pku2uWatchPolicy)
Working on certificate or peer-to-peer authentication on Windows? Contact us.