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.
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.
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 anLsaLogonUsersubmit typePku2uCertificateS4ULogon = 14withPKU2U_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.
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 withSEC_E_SECPKG_NOT_FOUND, because the package'sSpAcquireCredentialsHandleexpects theSECPKG_CREDENTIALthat NegoEx builds. Its NegoEx auth scheme is{0D53335C-F9EA-4D0D-B2EC-4AE3786EC308}, and an SSPI client reaches it throughNegotiatewith Kerberos and NTLM excluded (package list"!kerberos,!ntlm"in aSEC_WINNT_AUTH_IDENTITY_EX), or through plainNegotiatewhen the two machines share no domain. See also the NegoEx development notes. - The identity-provider extension: at initialization pku2u calls
LsaGetInterface(1003, &table).LsaGetInterfaceis exported bylsass.exeitself, next toLsaRegisterInterfaceandLsaRegisterExtension, not bylsasrv.dll. If the interface is missing, every credential is refused withSEC_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 throughext-ms-win-security-certpoleng-l1-1-0). It holds the trust decisions:IntPstMapCertificateToProvider,IntPstMapUserCredsToProvider,IntPstGetCertificate,IntPstGetTrustAnchors,IntPstValidateandIntPstGetNameIdentifierForCertificate.
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.
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 GUID | Name | TrustedRootContainer | Loaded by certpoleng |
|---|---|---|---|
{B16898C6-A148-4967-9171-64D755DA8520} | AzureAD | AAD Token Issuer | always |
{D7F9888F-E3FC-49B0-9EA6-A85B5F392A4F} | MicrosoftAccount | Windows Live ID Token Issuer | only 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 Issuer | only 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 DWORDEnabled≠ 0 exists, either inProviders\{guid}or inHKLM\SOFTWARE\Microsoft\IdentityStore\LoadParameters\{guid}. TheProviderskeys are not writable even by SYSTEM; theLoadParameterskey is writable by SYSTEM. A DWORDPKU2UDisabledat 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\OleOutgoingContainerAuthenticationService/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.
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_CERTIFICATEwhose user name isCredMarshalCredential(CertCredential, SHA-1 of the certificate), stored under the host name of the target. The certificate must then passIntPstMapCertificateToProvider. 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 with0x80070520(ERROR_NO_SUCH_LOGON_SESSION). - With no usable credential,
SpQueryMetaDatareturnsSEC_E_NO_CREDENTIALS. NegoEx then drops PKU2U, and the caller seesSEC_E_NO_CREDENTIALSfromInitializeSecurityContext. - The client also sends the trust anchors of its provider (
IntPstGetTrustAnchors) in its metadata, so the acceptor knows which issuers the client accepts.
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_CREDENTIALSbefore 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\Mycertificate issued by the provider's CA, CN and DNS name = the target host, EKU server and client authentication, with its private key.
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.
| # | Direction | NegoEx messages | PKU2U content |
|---|---|---|---|
| 1 | client → server 309 bytes | INITIATOR_NEGO, INITIATOR_META_DATA | metadata: trusted certifiers + target |
| 2 | server → client 243 bytes | ACCEPTOR_NEGO, ACCEPTOR_META_DATA | metadata: the acceptor's trusted certifiers |
| 3 | client → server 2157 bytes | AP_REQUEST | TOK_ID 05 00: KRB_AS_REQ with PA-PK-AS-REQ |
| 4 | server → client 2762 bytes | CHALLENGE | TOK_ID 06 00: KRB_AS_REP with PA-PK-AS-REP and a ticket |
| 5 | client → server 1389 bytes | AP_REQUEST, VERIFY | TOK_ID 01 00: KRB_AP_REQ (mutual), NegoEx checksum type 16 |
| 6 | server → client 328 bytes | CHALLENGE, VERIFY | TOK_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.
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,
Userhere) must name an existing local account. Otherwise the lastAcceptSecurityContextfails withSEC_E_LOGON_DENIEDafter 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 inLocal 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.
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) | |
|---|---|---|
| Windows | Windows 11 24H2 (build 26100) in a workgroup: no domain, no Entra ID | |
| Who runs the script | a local administrator, elevated PowerShell | the user who logs on, normal (not elevated) PowerShell. Only the machine settings need an elevated window |
| Process that authenticates | a 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 SYSTEM | a 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 | |
| Certificate | LocalMachine\My, issued by the lab CA, CN and DNS name = the target name the client uses | CurrentUser\My, issued by the lab CA, and in Credential Manager under the target's host name |
| Account | a 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 forpku2u-peerinLocalMachine\My(replacing any earlier one from the same CA), and a client certificate for the local accountUser, exported topku2u-lab\user.pfxwith the CA certificate. - Machine: adds the CA to
LocalMachine\Local Cert Issuerand setsEnabled= 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
AcceptSecurityContextas SYSTEM in a scheduled task and prints what it does. The tokens go through files inpku2u-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\Myand saves it in Credential Manager underpku2u-peer(CRED_TYPE_DOMAIN_CERTIFICATE, the user name beingCredMarshalCredential(CertCredential, SHA-1)). - Test:
InitializeSecurityContexttoHOST/pku2u-peeron plainNegotiatewith the session's own credentials. Without a domain, Negotiate settles on PKU2U by itself, as for mstsc. Both scripts call SSPI throughSystem.Net.NTAuthentication, the internal wrapper of .NET Framework (System.dll), by reflection. The only native call left isCredWrite: neither PowerShell norcmdkeycan 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 }
}
}
}
Only Enabled is needed for the local provider. The other values are listed for reference; their meaning is deduced from their names.
| Location | Value | Effect |
|---|---|---|
IdentityStore\LoadParameters\{guid} | Enabled | loads a provider other than AzureAD into certpoleng |
IdentityStore\Providers\{guid} or LoadParameters\{guid} | PKU2UDisabled | keeps a provider out of PKU2U |
HKLM\SYSTEM\CurrentControlSet\Control\Lsa\pku2u | AllowOnlineID | the Allow PKU2U … online IDs policy, read by certpoleng on the acceptor side |
HKLM\SOFTWARE\Microsoft\Ole | Outgoing/IncomingContainerAuthenticationService | 9 = use the container provider (Windows containers) |
SYSTEM\CurrentControlSet\Control\Lsa\pku2u\Parameters, SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\pku2u\Parameters | MaxTokenSize, SkewTime, DHDomainParameters, MODPDHModulusSize, MinMODPDHModulusSize, PreferWellKnownMODPDHDomainParameters, LeafOnly, SupportInitiatorFirst, SupportLocalHost, AlwaysPrompt, DisableLocalKdc, LocalKdcServerOptIn, DisableIAKerb | package parameters, watched for changes (Pku2uWatchPolicy) |