diff --git a/.azdo/ci.yml b/.azdo/ci.yml
index 50778f9f7f19..689f3767d9b6 100644
--- a/.azdo/ci.yml
+++ b/.azdo/ci.yml
@@ -145,6 +145,10 @@ stages:
Get-ChildItem -Path $installedOpenSSHDir -Recurse
displayName: Capture installed OpenSSH directory
+ - pwsh: |
+ Write-Host "##vso[task.setvariable variable=testFilesDrivePath;]$env:SystemDrive"
+ displayName: Set variable
+
- pwsh: |
# Run OpenSSH tests
Import-Module -Name "$(Build.SourcesDirectory)/contrib/win32/openssh/AzDOBuildTools" -Force
@@ -152,8 +156,13 @@ stages:
displayName: Run core tests
- pwsh: |
- Write-Host "##vso[task.setvariable variable=testFilesDrivePath;]$env:SystemDrive"
- displayName: Set variable
+ Import-Module "$(Build.SourcesDirectory)/.github/tools/PKCS11TestHelpers.psm1" -Force
+ $null = Invoke-Pkcs11Command "$env:SystemDrive/OpenSSH/ssh-add.exe" @('-D')
+ & "$(Build.SourcesDirectory)/.github/tools/Invoke-PKCS11CertificateTests.ps1" `
+ -OpenSSHBinPath "$env:SystemDrive/OpenSSH" -Architecture x64 `
+ -ResultsDirectory "$env:SystemDrive/OpenSSHTests" -CleanupMode None
+ displayName: Run required RSA and ECDSA PKCS11 certificate tests
+ timeoutInMinutes: 20
- task: PublishTestResults@2
inputs:
@@ -168,6 +177,9 @@ stages:
# Copy test results to results directory
$ResultsDirectory = "$(Build.SourcesDirectory)/Win32OpenSSHTestResults"
Copy-OpenSSHTestResults -ResultsPath $ResultsDirectory
+ Get-ChildItem "$env:SystemDrive/OpenSSHTests/PKCS11-*.xml", `
+ "$env:SystemDrive/OpenSSHTests/PKCS11-*-summary.json" -File -ErrorAction SilentlyContinue |
+ Copy-Item -Destination $ResultsDirectory
#
# Upload test results artifact
if (Test-Path -Path $ResultsDirectory)
diff --git a/.github/tools/Invoke-OpenSSHTests.ps1 b/.github/tools/Invoke-OpenSSHTests.ps1
index c07375600594..20448f13c44e 100644
--- a/.github/tools/Invoke-OpenSSHTests.ps1
+++ b/.github/tools/Invoke-OpenSSHTests.ps1
@@ -136,7 +136,7 @@ if (-not $PSBoundParameters.ContainsKey('Architecture') -or [string]::IsNullOrEm
# ──────────────────────────────────────────────────────────────────────────────
$scriptRoot = Split-Path -Parent $PSCommandPath
$repoRoot = Split-Path -Parent (Split-Path -Parent $scriptRoot)
-$binPath = Join-Path $repoRoot "bin\$Architecture\$Configuration"
+$binPath = Join-Path $repoRoot "bin\$(if ($Architecture -eq 'x86') { 'Win32' } else { $Architecture })\$Configuration"
$helperModule = Join-Path $repoRoot "contrib\win32\openssh\OpenSSHTestHelper.psm1"
$bashIterator = Join-Path $repoRoot "contrib\win32\openssh\bash_tests_iterator.ps1"
$regressPath = Join-Path $repoRoot "regress"
@@ -293,6 +293,19 @@ try {
$e2eOutput = Invoke-OpenSSHE2ETest *>&1 | Tee-Object -Variable e2eCapture
$e2eText = $e2eCapture | Out-String
$result.E2ETestOutput = $e2eText
+ if ($Architecture -in @('x64', 'x86')) {
+ # Setup/core Pester may leave the harness's SSO identity in the agent.
+ Import-Module (Join-Path $scriptRoot 'PKCS11TestHelpers.psm1') -Force
+ $null = Invoke-Pkcs11Command (Join-Path $binPath 'ssh-add.exe') @('-D')
+ & (Join-Path $scriptRoot 'Invoke-PKCS11CertificateTests.ps1') `
+ -OpenSSHBinPath $binPath -Architecture $Architecture `
+ -ResultsDirectory $Global:OpenSSHTestInfo['TestDataPath'] -CleanupMode Local
+ }
+ else {
+ $warning = "PKCS11 SoftHSM certificate coverage is unavailable for $Architecture; core E2E tests still run."
+ $result.Warnings += $warning
+ Write-Warning $warning
+ }
if ($e2eText -match 'Failed\s*:\s*[1-9]|Tests failed') {
$result.E2ETestsPassed = $false
diff --git a/.github/tools/Invoke-PKCS11CertificateTests.ps1 b/.github/tools/Invoke-PKCS11CertificateTests.ps1
new file mode 100644
index 000000000000..876148b86e89
--- /dev/null
+++ b/.github/tools/Invoke-PKCS11CertificateTests.ps1
@@ -0,0 +1,333 @@
+#Requires -Version 7.2
+[CmdletBinding(DefaultParameterSetName = 'Run')]
+param(
+ [Parameter(Mandatory, ParameterSetName = 'Run')][string]$OpenSSHBinPath,
+ [Parameter(ParameterSetName = 'Run')][ValidateSet('x64', 'x86')][string]$Architecture = 'x64',
+ [Parameter(ParameterSetName = 'Run')][string]$ResultsDirectory = "$env:TEMP/OpenSSH-PKCS11-Results",
+ [Parameter(ParameterSetName = 'Run')][ValidateSet('SoftHSM', 'Hardware')][string]$Mode = 'SoftHSM',
+ [Parameter(ParameterSetName = 'Run')][ValidateSet('Local', 'None')][string]$CleanupMode = 'Local',
+ [Parameter(ParameterSetName = 'Run')][string]$CacheDirectory = "$env:LOCALAPPDATA/OpenSSH-TestCache",
+ [Parameter(ParameterSetName = 'Run')][switch]$Child,
+ [Parameter(ParameterSetName = 'Run')][string]$TestDirectory,
+ [Parameter(Mandatory, ParameterSetName = 'Cleanup')][switch]$CleanupOnly,
+ [Parameter(ParameterSetName = 'Cleanup')][string]$StatePath
+)
+$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot 'PKCS11TestHelpers.psm1') -Force
+$repoRoot = Split-Path (Split-Path $PSScriptRoot)
+$stateDirectory = Join-Path $env:ProgramData 'OpenSSH-PKCS11-Tests'
+$fixtureDirectory = Get-Pkcs11FixtureDirectory $Architecture
+$serviceRegistryPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\ssh-agent'
+$serviceRunMarkerName = 'OpenSSHPkcs11TestRunId'
+$environmentNames = @('SOFTHSM2_CONF', 'OPENSSH_TEST_PKCS11_PROVIDER',
+ 'OPENSSH_TEST_PKCS11_PIN', 'OPENSSH_TEST_PKCS11_PUBLIC_KEYS',
+ 'OPENSSH_TEST_PKCS11_LABELS', 'OPENSSH_TEST_PKCS11_SOFTWARE_KEY',
+ 'SSH_ASKPASS', 'SSH_ASKPASS_REQUIRE', 'ASKPASS_PASSWORD', 'DISPLAY')
+
+function Save-Pkcs11State($State, $Path) {
+ $temporary = "$Path.tmp"
+ [IO.File]::WriteAllText($temporary, ($State | ConvertTo-Json -Depth 5))
+ Move-Item -LiteralPath $temporary -Destination $Path -Force
+}
+
+function Remove-Pkcs11Run($JournalPath) {
+ $state = Get-Content -LiteralPath $JournalPath -Raw | ConvertFrom-Json
+ if ($state.Version -ne 3 -or $state.Architecture -notin @('x64', 'x86')) {
+ throw 'Unsupported PKCS11 cleanup journal; version 3 is required and older journals need manual recovery'
+ }
+ if ($state.UserSid -ne [Security.Principal.WindowsIdentity]::GetCurrent().User.Value) {
+ throw 'PKCS11 cleanup journal belongs to another user; run recovery as the original user'
+ }
+ if ($state.CleanupPhase -notin @('Active', 'Restored')) {
+ throw 'Invalid PKCS11 cleanup phase'
+ }
+ $runId = [guid]::ParseExact($state.RunId, 'N').ToString('N')
+ $expectedStatePath = [IO.Path]::GetFullPath((Join-Path $stateDirectory "$runId.json"))
+ $root = [IO.Path]::GetFullPath((Join-Path (Get-Pkcs11FixtureDirectory $state.Architecture) $runId))
+ if ([IO.Path]::GetFullPath($JournalPath) -ne $expectedStatePath -or
+ [IO.Path]::GetFullPath($state.Root) -ne $root -or
+ $state.AgentPath -ne [IO.Path]::GetFullPath((Join-Path $state.BinaryDirectory 'ssh-agent.exe'))) {
+ throw 'Invalid PKCS11 cleanup journal'
+ }
+ if ((Test-Path -LiteralPath $root) -and
+ ((Get-Item -LiteralPath $root).Attributes -band [IO.FileAttributes]::ReparsePoint)) {
+ throw 'PKCS11 fixture directory must not be a reparse point'
+ }
+ $service = $null
+ $marker = $null
+ if ($state.ServiceTouched) {
+ $service = Get-Service ssh-agent -ErrorAction SilentlyContinue
+ if ($service) {
+ $serviceInfo = Get-CimInstance Win32_Service -Filter "Name='ssh-agent'"
+ if (-not $serviceInfo -or
+ [IO.Path]::GetFullPath($serviceInfo.PathName.Trim('"')) -ne $state.AgentPath) {
+ throw 'PKCS11 cleanup refused: ssh-agent no longer uses the recorded build'
+ }
+ $marker = (Get-ItemProperty -LiteralPath $serviceRegistryPath).$serviceRunMarkerName
+ # A missing marker is safe only after the restore was journalled:
+ # finalization may have removed it immediately before interruption.
+ if (($marker -and $marker -ne $runId) -or
+ (-not $marker -and $state.CleanupPhase -ne 'Restored')) {
+ throw 'PKCS11 cleanup refused: ssh-agent has no matching test run marker'
+ }
+ }
+ elseif ($state.AgentExisted) {
+ throw 'PKCS11 cleanup refused: the original ssh-agent service is missing'
+ }
+ }
+ $machineAgentPath = if ($state.Architecture -eq 'x86') {
+ 'HKLM:\Software\WOW6432Node\OpenSSH\Agent'
+ } else { 'HKLM:\Software\OpenSSH\Agent' }
+ try {
+ if ($state.CleanupPhase -eq 'Active' -and $state.ServiceTouched) {
+ if ($service -and $service.Status -ne 'Stopped') {
+ if ($service.Status -eq 'Running') {
+ $clear = Invoke-Pkcs11Command (Join-Path $state.BinaryDirectory 'ssh-add.exe') @('-D') -AllowFailure
+ if ($clear.ExitCode -ne 0) { throw 'Could not remove PKCS11 test identities' }
+ }
+ $null = Invoke-Pkcs11Command "$env:SystemRoot/System32/sc.exe" @('stop', 'ssh-agent')
+ (Get-Service ssh-agent).WaitForStatus('Stopped', [TimeSpan]::FromSeconds(60))
+ }
+ # Stop any worker left by a timed-out test client before deleting DLLs.
+ Get-CimInstance Win32_Process -Filter "Name='ssh-agent.exe'" |
+ Where-Object { $_.ExecutablePath -eq $state.AgentPath } |
+ ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop }
+ if ($service -and $state.AgentExisted) {
+ if ($state.EnvironmentPresent) {
+ New-ItemProperty -LiteralPath $serviceRegistryPath -Name Environment -PropertyType MultiString `
+ -Value ([string[]]$state.Environment) -Force | Out-Null
+ }
+ else {
+ Remove-ItemProperty -LiteralPath $serviceRegistryPath -Name Environment -ErrorAction SilentlyContinue
+ }
+ }
+ [Environment]::SetEnvironmentVariable('SOFTHSM2_CONF', $state.UserSoftHSMConfiguration, 'User')
+ foreach ($name in @('Keys', 'PKCS11_Providers')) {
+ $path = "HKCU:\Software\OpenSSH\Agent\$name"
+ if (Test-Path -LiteralPath $path) {
+ Get-ChildItem -LiteralPath $path | ForEach-Object {
+ Remove-Item -LiteralPath $_.PSPath -Recurse -Force
+ }
+ if ($name -notin @($state.UserRootsPresent)) {
+ Remove-Item -LiteralPath $path -Force
+ }
+ }
+ }
+ if (-not $state.UserAgentRootPresent -and (Test-Path 'HKCU:\Software\OpenSSH\Agent')) {
+ Remove-Item -LiteralPath 'HKCU:\Software\OpenSSH\Agent' -Force
+ }
+ if (-not $state.MachineAgentRootPresent -and (Test-Path $machineAgentPath)) {
+ Remove-Item -LiteralPath $machineAgentPath -Recurse -Force
+ }
+ elseif ($state.Status -ne 'Running' -and $state.MachineProcessIDRecorded) {
+ if ($state.MachineProcessIDPresent) {
+ New-ItemProperty -LiteralPath $machineAgentPath -Name ProcessID -PropertyType DWord `
+ -Value $state.MachineProcessID -Force | Out-Null
+ }
+ else { Remove-ItemProperty -LiteralPath $machineAgentPath -Name ProcessID -ErrorAction SilentlyContinue }
+ }
+ if ($service -and $state.AgentExisted) {
+ if ($state.Status -eq 'Running') {
+ Set-Service ssh-agent -StartupType Manual
+ $null = Invoke-Pkcs11Command "$env:SystemRoot/System32/sc.exe" @('start', 'ssh-agent')
+ (Get-Service ssh-agent).WaitForStatus('Running', [TimeSpan]::FromSeconds(60))
+ }
+ Set-Service ssh-agent -StartupType $state.StartType
+ }
+ elseif ($service) {
+ $null = Invoke-Pkcs11Command "$env:SystemRoot/System32/sc.exe" @('delete', 'ssh-agent')
+ $service = $null
+ $marker = $null
+ }
+ }
+ if ($state.CleanupPhase -eq 'Active') {
+ $state.CleanupPhase = 'Restored'
+ Save-Pkcs11State $state $JournalPath
+ }
+ if (Test-Path -LiteralPath $root) {
+ # root is verified against the fixed fixture directory and journal GUID above.
+ Remove-Item -LiteralPath $root -Recurse -Force
+ }
+ if ($marker) {
+ Remove-ItemProperty -LiteralPath $serviceRegistryPath -Name $serviceRunMarkerName -ErrorAction Stop
+ }
+ Remove-Item -LiteralPath $JournalPath -Force
+ }
+ catch { throw ('PKCS11 cleanup failed: ' + $_.Exception.Message) }
+}
+
+if (-not $CleanupOnly) {
+ $ResultsDirectory = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ResultsDirectory)
+ $null = New-Item -ItemType Directory -Path $ResultsDirectory -Force
+}
+if ($Child) {
+ Import-Module Pester -MaximumVersion 4.9.9 -Force
+ $resultPath = Join-Path $ResultsDirectory "PKCS11-$Architecture-$Mode.xml"
+ $result = Invoke-Pester -Script @{ Path = (Join-Path $repoRoot 'regress/pesterTests/PKCS11Certificates.Tests.ps1');
+ Parameters = @{ OpenSSHBinPath = $OpenSSHBinPath; TestDirectory = $TestDirectory; Mode = $Mode } } `
+ -PassThru -OutputFormat NUnitXml -OutputFile $resultPath
+ Assert-Pkcs11TestResult $result -Mode $Mode
+ return
+}
+
+$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
+ [Security.Principal.WindowsBuiltInRole]::Administrator)
+if (-not [Environment]::Is64BitProcess) { throw 'Use 64-bit PowerShell to run or clean up PKCS11 tests, including x86 builds' }
+$mutex = $null
+if ($admin -and ($CleanupOnly -or $CleanupMode -eq 'Local')) {
+ $mutex = [Threading.Mutex]::new($false, 'Global\OpenSSH-PKCS11-Tests')
+ try { $locked = $mutex.WaitOne(0) }
+ catch [Threading.AbandonedMutexException] { $locked = $true }
+ if (-not $locked) {
+ $mutex.Dispose()
+ throw 'Another local PKCS11 test or cleanup is running'
+ }
+}
+try {
+if ($CleanupOnly) {
+ if (-not $admin) { throw 'Administrator privileges required for local PKCS11 cleanup' }
+ if ($StatePath) { Remove-Pkcs11Run $StatePath }
+ elseif (Test-Path -LiteralPath $stateDirectory) {
+ Get-ChildItem -LiteralPath $stateDirectory -Filter '*.json' | ForEach-Object { Remove-Pkcs11Run $_.FullName }
+ }
+ return
+}
+$savedEnvironment = @{}
+foreach ($name in $environmentNames) { $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) }
+$journal = $null
+$runId = [guid]::NewGuid().ToString('N')
+$root = Join-Path $fixtureDirectory $runId
+$summary = [ordered]@{ Mode = $Mode; Architecture = $Architecture; CleanupMode = $CleanupMode;
+ RSA = 'not run'; ECDSA = 'not run'; Success = $false }
+try {
+ $hardware = if ($Mode -eq 'Hardware') { Get-Pkcs11TestConfiguration -Mode Hardware } else { $null }
+ $skipHardware = $Mode -eq 'Hardware' -and $hardware.SkipReason
+ if (-not $skipHardware) {
+ if (-not $admin) { throw 'Administrator privileges required for PKCS11 service tests' }
+ $OpenSSHBinPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OpenSSHBinPath)
+ foreach ($file in @('ssh-agent.exe', 'ssh-add.exe', 'ssh-keygen.exe')) {
+ if ((Get-Pkcs11PeArchitecture (Join-Path $OpenSSHBinPath $file)) -ne $Architecture) {
+ throw "OpenSSH $file architecture mismatch"
+ }
+ }
+ if ($CleanupMode -eq 'Local') {
+ & $PSCommandPath -CleanupOnly
+ Set-Pkcs11PrivateDirectory $stateDirectory
+ }
+ $service = Get-Service ssh-agent -ErrorAction SilentlyContinue
+ if ($service) {
+ $serviceInfo = Get-CimInstance Win32_Service -Filter "Name='ssh-agent'"
+ if ([IO.Path]::GetFullPath($serviceInfo.PathName.Trim('"')) -ne (Join-Path $OpenSSHBinPath 'ssh-agent.exe')) {
+ throw 'ssh-agent service does not use the selected build; install the test build first'
+ }
+ }
+ $userRoots = @()
+ foreach ($name in @('Keys', 'PKCS11_Providers')) {
+ $path = "HKCU:\Software\OpenSSH\Agent\$name"
+ if (Test-Path -LiteralPath $path) {
+ $userRoots += $name
+ if (@(Get-ChildItem -LiteralPath $path).Count) {
+ throw 'PKCS11 runner requires an empty test agent Registry'
+ }
+ }
+ }
+ $environmentProperty = if ($service) { Get-ItemProperty -LiteralPath $serviceRegistryPath } else { $null }
+ if ($environmentProperty -and $environmentProperty.PSObject.Properties[$serviceRunMarkerName]) {
+ throw 'ssh-agent already has a PKCS11 test run marker; recover its journal first'
+ }
+ $machineAgentPath = if ($Architecture -eq 'x86') {
+ 'HKLM:\Software\WOW6432Node\OpenSSH\Agent'
+ } else { 'HKLM:\Software\OpenSSH\Agent' }
+ $machineAgent = Get-ItemProperty -LiteralPath $machineAgentPath -ErrorAction SilentlyContinue
+ $state = @{ Version = 3; Architecture = $Architecture; RunId = $runId; Root = $root; BinaryDirectory = $OpenSSHBinPath;
+ UserSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value;
+ AgentPath = [IO.Path]::GetFullPath((Join-Path $OpenSSHBinPath 'ssh-agent.exe')); CleanupPhase = 'Active';
+ AgentExisted = [bool]$service; Status = $(if ($service) { [string]$service.Status } else { 'Stopped' });
+ StartType = $(if ($service) { [string]$service.StartType } else { 'Manual' });
+ EnvironmentPresent = [bool]($environmentProperty -and $environmentProperty.PSObject.Properties['Environment']);
+ Environment = $(if ($environmentProperty) { @($environmentProperty.Environment) } else { @() });
+ UserSoftHSMConfiguration = [Environment]::GetEnvironmentVariable('SOFTHSM2_CONF', 'User');
+ UserRootsPresent = $userRoots; UserAgentRootPresent = (Test-Path 'HKCU:\Software\OpenSSH\Agent');
+ MachineAgentRootPresent = (Test-Path $machineAgentPath);
+ MachineProcessIDRecorded = $true;
+ MachineProcessIDPresent = [bool]($machineAgent -and $machineAgent.PSObject.Properties['ProcessID']);
+ MachineProcessID = $(if ($machineAgent) { $machineAgent.ProcessID } else { $null });
+ ServiceTouched = $false }
+ if ($CleanupMode -eq 'Local') {
+ $journal = Join-Path $stateDirectory "$runId.json"
+ Save-Pkcs11State $state $journal
+ }
+ Set-Pkcs11PrivateDirectory $root
+ if ($Mode -eq 'SoftHSM') {
+ $archive = Get-Pkcs11Package $CacheDirectory
+ $fixture = New-Pkcs11Fixture $root $OpenSSHBinPath $Architecture $archive
+ $summary.PackageSHA256 = $fixture.PackageSHA256
+ $summary.ProviderSHA256 = (Get-FileHash -LiteralPath $fixture.Provider -Algorithm SHA256).Hash
+ $summary.SoftHSMVersion = '2.5.0'
+ }
+ $state.ServiceTouched = $true
+ if ($journal) { Save-Pkcs11State $state $journal }
+ if (-not $service) {
+ New-Service ssh-agent -BinaryPathName ('"' + (Join-Path $OpenSSHBinPath 'ssh-agent.exe') + '"') -StartupType Manual | Out-Null
+ }
+ if ($CleanupMode -eq 'Local') {
+ New-ItemProperty -LiteralPath $serviceRegistryPath -Name $serviceRunMarkerName -PropertyType String `
+ -Value $runId -ErrorAction Stop | Out-Null
+ }
+ if (-not $service) {
+ $null = Invoke-Pkcs11Command "$env:SystemRoot/System32/sc.exe" @('privs', 'ssh-agent',
+ 'SeAssignPrimaryTokenPrivilege/SeTcbPrivilege/SeBackupPrivilege/SeRestorePrivilege/SeImpersonatePrivilege')
+ }
+ if ($Mode -eq 'SoftHSM') {
+ # CreateEnvironmentBlock for the client token overlays the user
+ # environment on the service environment. Keep both on this fixture.
+ [Environment]::SetEnvironmentVariable('SOFTHSM2_CONF', $fixture.Configuration, 'User')
+ $serviceEnvironment = @($state.Environment | Where-Object {
+ -not ([string]$_).StartsWith('SOFTHSM2_CONF=', [StringComparison]::OrdinalIgnoreCase)
+ }) + @("SOFTHSM2_CONF=$($fixture.Configuration)")
+ New-ItemProperty -LiteralPath $serviceRegistryPath -Name Environment -PropertyType MultiString `
+ -Value ([string[]]$serviceEnvironment) -Force | Out-Null
+ }
+ Set-Service ssh-agent -StartupType Manual
+ Restart-Pkcs11Agent
+ $TestDirectory = Join-Path $root 'tests'
+ }
+ else { $TestDirectory = Join-Path $ResultsDirectory 'unused-hardware-fixture' }
+ $resultPath = Join-Path $ResultsDirectory "PKCS11-$Architecture-$Mode.xml"
+ Remove-Item -LiteralPath $resultPath -Force -ErrorAction SilentlyContinue
+ $powerShell = (Get-Process -Id $PID).Path
+ $childResult = Invoke-Pkcs11Command $powerShell @('-NoProfile', '-NonInteractive', '-File', $PSCommandPath,
+ '-Child', '-OpenSSHBinPath', $OpenSSHBinPath, '-Architecture', $Architecture,
+ '-Mode', $Mode, '-ResultsDirectory', $ResultsDirectory, '-TestDirectory', $TestDirectory) `
+ -TimeoutSeconds 600 -AllowFailure
+ Write-Host (Protect-Pkcs11Output $childResult.StdOut)
+ if ($childResult.StdErr) { Write-Host $childResult.StdErr }
+ if (Test-Path -LiteralPath $resultPath -PathType Leaf) {
+ Protect-Pkcs11TestReport $resultPath
+ }
+ if ($childResult.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $resultPath -PathType Leaf)) {
+ throw 'PKCS11 certificate test process failed or produced no report'
+ }
+ $summary.RSA = if ($skipHardware) { 'skipped' } else { 'passed' }
+ $summary.ECDSA = $summary.RSA
+ $summary.Success = $true
+}
+finally {
+ try {
+ if ($CleanupMode -eq 'Local' -and $journal) { Remove-Pkcs11Run $journal }
+ }
+ catch {
+ $summary.Success = $false
+ throw
+ }
+ finally {
+ if ($CleanupMode -eq 'Local') {
+ foreach ($name in $environmentNames) { [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name]) }
+ }
+ [IO.File]::WriteAllText((Join-Path $ResultsDirectory "PKCS11-$Architecture-$Mode-summary.json"), ($summary | ConvertTo-Json))
+ }
+}
+}
+finally {
+ if ($mutex) { $mutex.ReleaseMutex(); $mutex.Dispose() }
+}
diff --git a/.github/tools/PKCS11TestHelpers.psm1 b/.github/tools/PKCS11TestHelpers.psm1
new file mode 100644
index 000000000000..ac93fd2880e5
--- /dev/null
+++ b/.github/tools/PKCS11TestHelpers.psm1
@@ -0,0 +1,273 @@
+# Shared contracts for the local and Azure PKCS#11 certificate runners.
+$ErrorActionPreference = 'Stop'
+$script:PackageUri = 'https://github.com/disig/SoftHSM2-for-Windows/releases/download/v2.5.0/SoftHSM2-2.5.0-portable.zip'
+$script:PackageHash = '85273BCC1A6B90E877F7BB4F7E90221D57103D8F5241D154A79DD730A135B910'
+$script:RequiredTests = @(
+ 'PKCS11 RSA add/list/sign', 'PKCS11 ECDSA add/list/sign',
+ 'PKCS11 associated certificate lifecycle',
+ 'PKCS11 software identity preservation',
+ 'PKCS11 software identity detachment', 'PKCS11 stale provider isolation'
+)
+
+function Get-Pkcs11RequiredTests { return $script:RequiredTests }
+
+function Get-Pkcs11FixtureDirectory {
+ param([ValidateSet('x64', 'x86')][string]$Architecture)
+ $folder = if ($Architecture -eq 'x86') { 'ProgramFilesX86' } else { 'ProgramFiles' }
+ return Join-Path ([Environment]::GetFolderPath($folder)) 'OpenSSH-PKCS11-Tests'
+}
+
+function Get-Pkcs11TestConfiguration {
+ param([ValidateSet('SoftHSM', 'Hardware')][string]$Mode = 'SoftHSM')
+ $missing = @()
+ foreach ($name in @('PROVIDER', 'PIN', 'PUBLIC_KEYS', 'LABELS')) {
+ $value = [Environment]::GetEnvironmentVariable("OPENSSH_TEST_PKCS11_$name")
+ if (-not (Test-Path "Env:OPENSSH_TEST_PKCS11_$name") -or
+ ($name -ne 'LABELS' -and [string]::IsNullOrEmpty($value))) {
+ $missing += "OPENSSH_TEST_PKCS11_$name"
+ }
+ }
+ if ($missing.Count) {
+ $reason = 'Missing PKCS11 prerequisites: ' + ($missing -join ', ')
+ if ($Mode -eq 'SoftHSM') { throw $reason }
+ return @{ SkipReason = $reason; SoftwareSkipReason = $reason }
+ }
+ $provider = $env:OPENSSH_TEST_PKCS11_PROVIDER
+ $keys = @($env:OPENSSH_TEST_PKCS11_PUBLIC_KEYS.Split(';'))
+ $labels = @($env:OPENSSH_TEST_PKCS11_LABELS.Split(';'))
+ if (-not (Test-Path -LiteralPath $provider -PathType Leaf)) {
+ throw 'Configured PKCS11 provider does not exist'
+ }
+ if ($keys.Count -ne $labels.Count -or $keys.Count -eq 0) {
+ throw 'PKCS11 public keys and labels must have matching counts'
+ }
+ foreach ($key in $keys) {
+ if (-not (Test-Path -LiteralPath $key -PathType Leaf)) {
+ throw 'Configured PKCS11 public key does not exist'
+ }
+ }
+ if ($Mode -eq 'SoftHSM') {
+ if ($keys.Count -ne 2 -or
+ (Get-Content -LiteralPath $keys[0]) -notmatch '^ssh-rsa ' -or
+ (Get-Content -LiteralPath $keys[1]) -notmatch '^ecdsa-sha2-nistp256 ') {
+ throw 'Required SoftHSM fixture must contain RSA and ECDSA P-256'
+ }
+ if (-not $env:SOFTHSM2_CONF -or
+ -not (Test-Path -LiteralPath $env:SOFTHSM2_CONF -PathType Leaf)) {
+ throw 'Required SOFTHSM2_CONF does not exist'
+ }
+ }
+ $software = $env:OPENSSH_TEST_PKCS11_SOFTWARE_KEY
+ $softwareReason = ''
+ if (-not $software -or -not (Test-Path -LiteralPath $software -PathType Leaf)) {
+ $softwareReason = 'Missing OPENSSH_TEST_PKCS11_SOFTWARE_KEY'
+ if ($Mode -eq 'SoftHSM') { throw $softwareReason }
+ }
+ return @{ Provider = $provider; PublicKeys = $keys; Labels = $labels;
+ SoftwareKey = $software; SkipReason = ''; SoftwareSkipReason = $softwareReason }
+}
+
+function Protect-Pkcs11Output {
+ param([string]$Text)
+ foreach ($name in @('OPENSSH_TEST_PKCS11_PIN', 'ASKPASS_PASSWORD')) {
+ $secret = [Environment]::GetEnvironmentVariable($name)
+ if ($secret) { $Text = $Text.Replace($secret, '[redacted]') }
+ }
+ return $Text
+}
+
+function Protect-Pkcs11TestReport {
+ param([Parameter(Mandatory)][string]$Path)
+ $report = [xml](Get-Content -LiteralPath $Path -Raw)
+ foreach ($node in $report.SelectNodes('//failure/message | //failure/stack-trace | //reason/message')) {
+ $node.InnerText = Protect-Pkcs11Output $node.InnerText
+ }
+ $report.Save($Path)
+}
+
+function Invoke-Pkcs11Command {
+ param([Parameter(Mandatory)][string]$FilePath, [string[]]$Arguments = @(),
+ [int]$TimeoutSeconds = 30, [switch]$AllowFailure)
+ $start = [Diagnostics.ProcessStartInfo]::new()
+ $start.FileName = $FilePath
+ $start.WorkingDirectory = (Get-Location).ProviderPath
+ $start.UseShellExecute = $false
+ $start.CreateNoWindow = $true
+ $start.RedirectStandardOutput = $true
+ $start.RedirectStandardError = $true
+ $start.RedirectStandardInput = $true
+ foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
+ $process = [Diagnostics.Process]::new()
+ $process.StartInfo = $start
+ $watch = [Diagnostics.Stopwatch]::StartNew()
+ try {
+ if (-not $process.Start()) { throw 'Could not start PKCS11 test command' }
+ $process.StandardInput.Close()
+ $stdout = $process.StandardOutput.ReadToEndAsync()
+ $stderr = $process.StandardError.ReadToEndAsync()
+ if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
+ $process.Kill($true)
+ $process.WaitForExit()
+ throw "PKCS11 test command timed out: $([IO.Path]::GetFileName($FilePath))"
+ }
+ $remaining = [Math]::Max(0, [int]($TimeoutSeconds * 1000 - $watch.Elapsed.TotalMilliseconds))
+ if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout, $stderr), $remaining)) {
+ throw "PKCS11 test command output timed out: $([IO.Path]::GetFileName($FilePath))"
+ }
+ $result = [pscustomobject]@{ ExitCode = $process.ExitCode;
+ StdOut = $stdout.GetAwaiter().GetResult();
+ StdErr = (Protect-Pkcs11Output $stderr.GetAwaiter().GetResult()) }
+ if (-not $AllowFailure -and $result.ExitCode -ne 0) {
+ throw "PKCS11 command failed: $([IO.Path]::GetFileName($FilePath)) (exit $($result.ExitCode)): $($result.StdErr)"
+ }
+ return $result
+ }
+ finally { $process.Dispose() }
+}
+
+function Assert-Pkcs11TestResult {
+ param($Result, [ValidateSet('SoftHSM', 'Hardware')][string]$Mode = 'SoftHSM')
+ if ($null -eq $Result -or $Result.FailedCount -ne 0) {
+ throw 'PKCS11 tests failed or produced no result'
+ }
+ $actual = @($Result.TestResult)
+ if ($actual.Count -ne $script:RequiredTests.Count) {
+ throw 'PKCS11 result is missing expected test cases'
+ }
+ foreach ($name in $script:RequiredTests) {
+ $matches = @($actual | Where-Object { $_.Name -eq $name -or
+ ($Mode -eq 'Hardware' -and $_.Name.StartsWith("$name [skipped:")) })
+ if ($matches.Count -ne 1 -or
+ ($Mode -eq 'SoftHSM' -and $matches[0].Result -ne 'Passed') -or
+ ($Mode -eq 'Hardware' -and $matches[0].Result -notin @('Passed', 'Skipped'))) {
+ throw "PKCS11 test did not complete: $name"
+ }
+ }
+ if ($Mode -eq 'SoftHSM' -and ($Result.PassedCount -ne $script:RequiredTests.Count -or
+ $Result.SkippedCount -ne 0 -or $Result.PendingCount -ne 0)) {
+ throw 'Required PKCS11 tests must all pass without skips or pending cases'
+ }
+}
+
+function Get-Pkcs11Package {
+ param([Parameter(Mandatory)][string]$CacheDirectory)
+ $null = New-Item -ItemType Directory -Path $CacheDirectory -Force
+ $archive = Join-Path $CacheDirectory 'SoftHSM2-2.5.0-portable.zip'
+ if (-not (Test-Path -LiteralPath $archive -PathType Leaf)) {
+ # Only transport failures may be retried; tests are never retried.
+ for ($attempt = 0; $attempt -lt 3; $attempt++) {
+ try {
+ Invoke-WebRequest -Uri $script:PackageUri -OutFile "$archive.download" -TimeoutSec 60
+ if ((Get-FileHash -LiteralPath "$archive.download" -Algorithm SHA256).Hash -ne $script:PackageHash) {
+ throw 'SoftHSM package SHA256 mismatch'
+ }
+ Move-Item -LiteralPath "$archive.download" -Destination $archive
+ break
+ }
+ catch {
+ Remove-Item -LiteralPath "$archive.download" -Force -ErrorAction SilentlyContinue
+ if ($_.Exception.Message -match 'SHA256' -or $attempt -eq 2) { throw }
+ }
+ }
+ }
+ if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $script:PackageHash) {
+ throw 'SoftHSM package SHA256 mismatch'
+ }
+ return $archive
+}
+
+function Get-Pkcs11PeArchitecture {
+ param([Parameter(Mandatory)][string]$Path)
+ $stream = [IO.File]::OpenRead($Path)
+ $reader = [IO.BinaryReader]::new($stream)
+ try {
+ if ($reader.ReadUInt16() -ne 0x5a4d) { throw 'Not a PE executable' }
+ $stream.Position = 0x3c
+ $offset = $reader.ReadInt32()
+ if ($offset -lt 0 -or $offset -gt $stream.Length - 6) { throw 'Invalid PE header' }
+ $stream.Position = $offset
+ if ($reader.ReadUInt32() -ne 0x4550) { throw 'Invalid PE signature' }
+ switch ($reader.ReadUInt16()) {
+ 0x8664 { return 'x64' }
+ 0x14c { return 'x86' }
+ default { throw 'Unsupported executable architecture' }
+ }
+ }
+ finally { $reader.Dispose() }
+}
+
+function Set-Pkcs11PrivateDirectory {
+ param([string]$Path)
+ $null = New-Item -ItemType Directory -Path $Path -Force
+ $acl = [Security.AccessControl.DirectorySecurity]::new()
+ $acl.SetAccessRuleProtection($true, $false)
+ foreach ($sid in @('S-1-5-18', 'S-1-5-32-544',
+ [Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) {
+ $identity = [Security.Principal.SecurityIdentifier]::new($sid)
+ $rule = [Security.AccessControl.FileSystemAccessRule]::new($identity,
+ 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
+ $acl.AddAccessRule($rule)
+ }
+ Set-Acl -LiteralPath $Path -AclObject $acl
+}
+
+function Restart-Pkcs11Agent {
+ param([string]$BinaryDirectory)
+ $service = Get-Service ssh-agent -ErrorAction Stop
+ if ($service.Status -ne 'Stopped') {
+ $null = Invoke-Pkcs11Command "$env:SystemRoot/System32/sc.exe" @('stop', 'ssh-agent')
+ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(60))
+ }
+ $null = Invoke-Pkcs11Command "$env:SystemRoot/System32/sc.exe" @('start', 'ssh-agent')
+ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(60))
+}
+
+function New-Pkcs11Fixture {
+ param([string]$Root, [string]$BinaryDirectory,
+ [ValidateSet('x64', 'x86')][string]$Architecture, [string]$Archive)
+ Expand-Archive -LiteralPath $Archive -DestinationPath $Root
+ $provider = Join-Path $Root ('SoftHSM2/lib/' +
+ $(if ($Architecture -eq 'x64') { 'softhsm2-x64.dll' } else { 'softhsm2.dll' }))
+ if ((Get-Pkcs11PeArchitecture $provider) -ne $Architecture) {
+ throw 'SoftHSM provider architecture mismatch'
+ }
+ $importModule = Join-Path $Root 'SoftHSM2/lib/softhsm2.dll'
+ $utility = Join-Path $Root 'SoftHSM2/bin/softhsm2-util.exe'
+ if ((Get-Pkcs11PeArchitecture $importModule) -ne (Get-Pkcs11PeArchitecture $utility)) {
+ throw 'SoftHSM import tool and module architecture mismatch'
+ }
+ $tokenDirectory = Join-Path $Root 'tokens'
+ $null = New-Item -ItemType Directory -Path $tokenDirectory
+ $conf = Join-Path $Root 'softhsm2.conf'
+ [IO.File]::WriteAllText($conf, "directories.tokendir = $tokenDirectory`nobjectstore.backend = file`nlog.level = ERROR`nslots.removable = false`n")
+ $env:SOFTHSM2_CONF = $conf
+ $env:OPENSSH_TEST_PKCS11_PIN = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(8))
+ $soPin = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(8))
+ $tokenLabel = [guid]::NewGuid().ToString('N')
+ $null = Invoke-Pkcs11Command $utility @('--module', $importModule, '--init-token', '--free',
+ '--label', $tokenLabel, '--pin', $env:OPENSSH_TEST_PKCS11_PIN, '--so-pin', $soPin)
+ $keys = @()
+ $labels = @('openssh-rsa', '')
+ foreach ($type in @('rsa', 'ecdsa')) {
+ $keyPath = Join-Path $Root $type
+ $key = if ($type -eq 'rsa') { [Security.Cryptography.RSA]::Create(2048) }
+ else { [Security.Cryptography.ECDsa]::Create([Security.Cryptography.ECCurve+NamedCurves]::nistP256) }
+ try { [IO.File]::WriteAllText($keyPath, $key.ExportPkcs8PrivateKeyPem()) }
+ finally { $key.Dispose() }
+ $public = Invoke-Pkcs11Command (Join-Path $BinaryDirectory 'ssh-keygen.exe') @('-y', '-f', $keyPath)
+ [IO.File]::WriteAllText("$keyPath.pub", $public.StdOut)
+ $index = $keys.Count
+ $null = Invoke-Pkcs11Command $utility @('--module', $importModule, '--token', $tokenLabel,
+ '--import', $keyPath, '--id', ('0' + ($index + 1)), '--label', $labels[$index],
+ '--pin', $env:OPENSSH_TEST_PKCS11_PIN)
+ $keys += "$keyPath.pub"
+ }
+ $env:OPENSSH_TEST_PKCS11_PROVIDER = $provider
+ $env:OPENSSH_TEST_PKCS11_PUBLIC_KEYS = $keys -join ';'
+ $env:OPENSSH_TEST_PKCS11_LABELS = $labels -join ';'
+ $env:OPENSSH_TEST_PKCS11_SOFTWARE_KEY = Join-Path $Root 'rsa'
+ $null = Get-Pkcs11TestConfiguration
+ return @{ Provider = $provider; Configuration = $conf; PackageSHA256 = $script:PackageHash }
+}
+
+Export-ModuleMember -Function *-Pkcs11*
diff --git a/contrib/win32/openssh/OpenSSHTestHelper.psm1 b/contrib/win32/openssh/OpenSSHTestHelper.psm1
index 84f3ce07f6d3..1541c77399ed 100644
--- a/contrib/win32/openssh/OpenSSHTestHelper.psm1
+++ b/contrib/win32/openssh/OpenSSHTestHelper.psm1
@@ -138,9 +138,11 @@ WARNING: Following changes will be made to OpenSSH configuration
Copy-Item "$($Script:E2ETestDataDirectory)\sshtest_ca_userkeys.pub" $testSvcConfigDir -Force
$acl = New-Object System.Security.AccessControl.DirectorySecurity
- $rule = New-Object System.Security.AccessControl.FileSystemAccessRule("Administrators","FullControl","Allow")
+ $administratorsSid = New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544")
+ $systemSid = New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18")
+ $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($administratorsSid,"FullControl","Allow")
$acl.AddAccessRule($rule)
- $rule = New-Object System.Security.AccessControl.FileSystemAccessRule("System","FullControl","Allow")
+ $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($systemSid,"FullControl","Allow")
$acl.AddAccessRule($rule)
$acl.SetAccessRuleProtection($true, $true)
@@ -225,8 +227,8 @@ WARNING: Following changes will be made to OpenSSH configuration
$NonAdminUserProfile = Get-LocalUserProfile -User $NonAdminUser
$Global:OpenSSHTestInfo.Add("NonAdminUserProfile", $NonAdminUserProfile)
- #make $AdminUser admin
- net localgroup Administrators $AdminUser /add
+ #make $AdminUser admin; use the well-known SID so this works on localized Windows
+ Add-LocalGroupMember -SID "S-1-5-32-544" -Member $AdminUser
New-Item -ItemType Directory -Path (Join-Path $ssouserProfile .ssh) -Force -ErrorAction SilentlyContinue | out-null
$authorizedKeyPath = Join-Path $ssouserProfile .ssh\authorized_keys
@@ -830,4 +832,4 @@ function Write-Log
Write-Verbose -Verbose -Message $Message
}
-Export-ModuleMember -Function Set-BasicTestInfo, Set-OpenSSHTestEnvironment, Clear-OpenSSHTestEnvironment, Invoke-OpenSSHSetupTest, Invoke-OpenSSHUnitTest, Invoke-OpenSSHE2ETest, Invoke-OpenSSHUninstallTest, Invoke-OpenSSHBashTests
\ No newline at end of file
+Export-ModuleMember -Function Set-BasicTestInfo, Set-OpenSSHTestEnvironment, Clear-OpenSSHTestEnvironment, Invoke-OpenSSHSetupTest, Invoke-OpenSSHUnitTest, Invoke-OpenSSHE2ETest, Invoke-OpenSSHUninstallTest, Invoke-OpenSSHBashTests
diff --git a/contrib/win32/openssh/bash_tests_iterator.ps1 b/contrib/win32/openssh/bash_tests_iterator.ps1
index 570173df715e..e591908064e1 100644
--- a/contrib/win32/openssh/bash_tests_iterator.ps1
+++ b/contrib/win32/openssh/bash_tests_iterator.ps1
@@ -17,6 +17,7 @@ $ErrorActionPreference = 'Continue'
# Resolve the relative paths
$OpenSSHBinPath = Resolve-Path $OpenSSHBinPath -ErrorAction Stop | select -ExpandProperty Path
$BashTestsPath = Resolve-Path $BashTestsPath -ErrorAction Stop | select -ExpandProperty Path
+$BashTestsWindowsPath = $BashTestsPath
$ShellPath = Resolve-Path $ShellPath -ErrorAction Stop | select -ExpandProperty Path
$ArtifactsDirectoryPath = Resolve-Path $ArtifactsDirectoryPath -ErrorAction Stop | select -ExpandProperty Path
if ($TestFilePath) {
@@ -25,6 +26,10 @@ if ($TestFilePath) {
$TestFilePath = $TestFilePath -replace "\\","/"
}
$OriginalSystemPath = [System.Environment]::GetEnvironmentVariable('Path', [System.EnvironmentVariableTarget]::Machine)
+$AgentServiceRegistryPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\ssh-agent'
+$AgentEnvironmentConfigured = $false
+$OriginalAgentEnvironmentPresent = $false
+$OriginalAgentEnvironment = $null
# Make sure config.h exists. It is used in some bashstests (Ex - sftp-glob.sh, cfgparse.sh)
# first check in $BashTestsPath folder. If not then it's parent folder. If not then in the $OpenSSHBinPath
@@ -62,6 +67,12 @@ if(!$SkipInstallSSHD) {
# We need ssh-agent to be installed as service to run some bash tests.
& "$OpenSSHBinPath\install-sshd.ps1"
+ if (-not [string]::IsNullOrEmpty($env:TEST_SSH_PKCS11_PROVIDER)) {
+ $testProvider = (& $ShellPath -c "cygpath -w '$env:TEST_SSH_PKCS11_PROVIDER'").Trim()
+ $agentImagePath = '"{0}" -P "{1}"' -f (Join-Path $OpenSSHBinPath 'ssh-agent.exe'), $testProvider
+ Set-ItemProperty -Path $AgentServiceRegistryPath `
+ -Name ImagePath -Value $agentImagePath -Force
+ }
}
try
@@ -147,6 +158,7 @@ try
$env:TEST_SSH_SFTP = $OpenSSHBinPath_shell_fmt+"/sftp.exe"
$env:TEST_SSH_SFTPSERVER = $OpenSSHBinPath_shell_fmt+"/sftp-server.exe"
$env:TEST_SSH_SCP = $OpenSSHBinPath_shell_fmt+"/scp.exe"
+ $env:TEST_SSH_OPENSSL = ([string](&$ShellPath -c "command -v openssl")).Trim()
$env:BUILDDIR = $BUILDDIR
$env:TEST_WINDOWS_SSH = 1
$env:TEST_SSH_ASKPASS = $TEST_SSH_ASKPASS
@@ -173,6 +185,24 @@ try
$temp_test_path = "temp_test"
$null = Remove-Item -Recurse -Force $temp_test_path -ErrorAction SilentlyContinue
$null = New-Item -ItemType directory -Path $temp_test_path -Force -ErrorAction Stop
+ if (-not [string]::IsNullOrEmpty($env:TEST_SSH_PKCS11_PROVIDER)) {
+ $testSoftHsmConf = Join-Path $BashTestsWindowsPath "$temp_test_path\SOFTHSM\softhsm2.conf"
+ $agentEnvironmentProperty = Get-ItemProperty -Path $AgentServiceRegistryPath `
+ -Name Environment -ErrorAction SilentlyContinue
+ if ($null -ne $agentEnvironmentProperty) {
+ $OriginalAgentEnvironmentPresent = $true
+ $OriginalAgentEnvironment = @($agentEnvironmentProperty.Environment)
+ }
+ $agentEnvironment = @($OriginalAgentEnvironment | Where-Object {
+ -not ([string]$_).StartsWith('SOFTHSM2_CONF=',
+ [StringComparison]::OrdinalIgnoreCase)
+ })
+ $agentEnvironment += "SOFTHSM2_CONF=$testSoftHsmConf"
+ New-ItemProperty -Path $AgentServiceRegistryPath -Name Environment `
+ -PropertyType MultiString -Value $agentEnvironment -Force `
+ -ErrorAction Stop | Out-Null
+ $AgentEnvironmentConfigured = $true
+ }
# remove the summary, output files.
$bash_test_summary = "$ArtifactsDirectoryPath\bash_tests_summary.txt"
@@ -271,6 +301,16 @@ finally
{
# Restore User Path variable in the registry once the tests finish running.
[System.Environment]::SetEnvironmentVariable('Path', $OriginalSystemPath, [System.EnvironmentVariableTarget]::Machine)
+ if ($AgentEnvironmentConfigured) {
+ if ($OriginalAgentEnvironmentPresent) {
+ New-ItemProperty -Path $AgentServiceRegistryPath -Name Environment `
+ -PropertyType MultiString -Value $OriginalAgentEnvironment `
+ -Force -ErrorAction SilentlyContinue | Out-Null
+ } else {
+ Remove-ItemProperty -Path $AgentServiceRegistryPath -Name Environment `
+ -ErrorAction SilentlyContinue
+ }
+ }
# remove temp test directory
if (!$SkipCleanup)
{
diff --git a/contrib/win32/openssh/ssh-agent.vcxproj b/contrib/win32/openssh/ssh-agent.vcxproj
index cc6e9b5e4813..243658da090f 100644
--- a/contrib/win32/openssh/ssh-agent.vcxproj
+++ b/contrib/win32/openssh/ssh-agent.vcxproj
@@ -416,12 +416,18 @@
+
+
+
+
+
+
diff --git a/contrib/win32/openssh/unittest-win32compat.vcxproj b/contrib/win32/openssh/unittest-win32compat.vcxproj
index bbc3ed9b73f4..b52de4bfd072 100644
--- a/contrib/win32/openssh/unittest-win32compat.vcxproj
+++ b/contrib/win32/openssh/unittest-win32compat.vcxproj
@@ -36,6 +36,15 @@
+
+ true
+
+
+ true
+
+
+ true
+
true
@@ -65,6 +74,7 @@
+
diff --git a/contrib/win32/win32compat/pkcs11-cert.c b/contrib/win32/win32compat/pkcs11-cert.c
new file mode 100644
index 000000000000..a50f78a872b2
--- /dev/null
+++ b/contrib/win32/win32compat/pkcs11-cert.c
@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) 2026 Sebastian Ott. All rights reserved.
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include "includes.h"
+
+#include "authfd.h"
+#include "digest.h"
+#include "log.h"
+#include "sshbuf.h"
+#include "ssherr.h"
+#include "sshkey.h"
+#include "xmalloc.h"
+
+#include "pkcs11-cert.h"
+
+char *
+pkcs11_identity_name(const struct sshkey *key, const u_char *blob,
+ size_t blob_len)
+{
+ u_char digest[SSH_DIGEST_MAX_LENGTH];
+ char *name;
+ size_t i, digest_len;
+
+ if (!sshkey_is_cert(key))
+ return sshkey_fingerprint(key, SSH_FP_HASH_DEFAULT,
+ SSH_FP_DEFAULT);
+ digest_len = ssh_digest_bytes(SSH_DIGEST_SHA256);
+ if (ssh_digest_memory(SSH_DIGEST_SHA256, blob, blob_len, digest,
+ sizeof(digest)) != 0)
+ return NULL;
+ name = xmalloc(5 + digest_len * 2 + 1);
+ memcpy(name, "cert-", 5);
+ for (i = 0; i < digest_len; i++)
+ snprintf(name + 5 + i * 2, 3, "%02x", digest[i]);
+ return name;
+}
+
+const char *
+pkcs11_identity_comment(const char *provider, const char *label)
+{
+ return label == NULL || *label == '\0' ? provider : label;
+}
+
+/*
+ * Compare a provider path stored in the Registry, which is not NUL
+ * terminated, with a canonical provider path. Windows paths are case
+ * insensitive, and the whole value must match.
+ */
+int
+pkcs11_provider_equal(const u_char *stored, size_t stored_len,
+ const char *provider)
+{
+ if (stored == NULL || provider == NULL || stored_len == 0 ||
+ strlen(provider) != stored_len)
+ return 0;
+ return strncasecmp((const char *)stored, provider, stored_len) == 0;
+}
+
+/*
+ * Decide whether an existing Registry identity may be reused for the PKCS#11
+ * identity (blob, key_type) of provider. Only identities previously created
+ * for the same key by the same provider qualify: a software key that happens
+ * to have the same public key stores its private key as default value and
+ * must not be adopted by, and later removed with, a provider.
+ */
+int
+pkcs11_identity_entry_matches(const struct pkcs11_identity_entry *e,
+ const u_char *blob, size_t blob_len, int key_type, const char *provider)
+{
+ const u_char *association;
+ size_t association_len;
+
+ if (e == NULL || blob == NULL || blob_len == 0 || provider == NULL)
+ return 0;
+ if (e->pub == NULL || e->pub_len != blob_len ||
+ memcmp(e->pub, blob, blob_len) != 0)
+ return 0;
+ if (e->dflt == NULL || e->dflt_len != blob_len ||
+ memcmp(e->dflt, blob, blob_len) != 0)
+ return 0;
+ if (!e->has_type || e->type != key_type)
+ return 0;
+ /* Entries created before the provider value existed use the comment. */
+ if (e->provider != NULL) {
+ association = e->provider;
+ association_len = e->provider_len;
+ } else {
+ association = e->comment;
+ association_len = e->comment_len;
+ }
+ return pkcs11_provider_equal(association, association_len, provider);
+}
+
+void
+free_pkcs11_certs(struct sshkey **certs, size_t ncerts)
+{
+ size_t i;
+
+ for (i = 0; i < ncerts; i++)
+ sshkey_free(certs[i]);
+ free(certs);
+}
+
+int
+parse_pkcs11_add_constraints(struct sshbuf *m, int *cert_onlyp,
+ struct sshkey ***certsp, size_t *ncertsp)
+{
+ struct sshbuf *b = NULL;
+ struct sshkey *key = NULL;
+ char *ext_name = NULL;
+ u_char ctype, value;
+ int r, seen = 0;
+
+ if (m == NULL || cert_onlyp == NULL || certsp == NULL ||
+ ncertsp == NULL || *certsp != NULL || *ncertsp != 0)
+ return SSH_ERR_INVALID_ARGUMENT;
+ *cert_onlyp = 0;
+
+ while (sshbuf_len(m) != 0) {
+ if ((r = sshbuf_get_u8(m, &ctype)) != 0)
+ goto out;
+ /* Only certificate associations are supported for persisted keys. */
+ if (ctype != SSH_AGENT_CONSTRAIN_EXTENSION) {
+ error_f("unsupported smartcard constraint %u", ctype);
+ r = SSH_ERR_FEATURE_UNSUPPORTED;
+ goto out;
+ }
+ if ((r = sshbuf_get_cstring(m, &ext_name, NULL)) != 0)
+ goto out;
+ if (strcmp(ext_name,
+ "associated-certs-v00@openssh.com") != 0) {
+ error_f("unsupported smartcard constraint \"%s\"",
+ ext_name);
+ r = SSH_ERR_FEATURE_UNSUPPORTED;
+ goto out;
+ }
+ if (seen) {
+ error_f("%s already set", ext_name);
+ r = SSH_ERR_INVALID_FORMAT;
+ goto out;
+ }
+ seen = 1;
+ if ((r = sshbuf_get_u8(m, &value)) != 0 ||
+ (r = sshbuf_froms(m, &b)) != 0)
+ goto out;
+ *cert_onlyp = value != 0;
+ while (sshbuf_len(b) != 0) {
+ if (*ncertsp >= AGENT_MAX_EXT_CERTS) {
+ error_f("too many %s constraints", ext_name);
+ r = SSH_ERR_INVALID_FORMAT;
+ goto out;
+ }
+ if ((r = sshkey_froms(b, &key)) != 0)
+ goto out;
+ *certsp = xrecallocarray(*certsp, *ncertsp,
+ *ncertsp + 1, sizeof(**certsp));
+ (*certsp)[(*ncertsp)++] = key;
+ key = NULL;
+ }
+ sshbuf_free(b);
+ b = NULL;
+ free(ext_name);
+ ext_name = NULL;
+ }
+ r = 0;
+ out:
+ sshkey_free(key);
+ sshbuf_free(b);
+ free(ext_name);
+ return r;
+}
diff --git a/contrib/win32/win32compat/pkcs11-cert.h b/contrib/win32/win32compat/pkcs11-cert.h
new file mode 100644
index 000000000000..f38bc96ce99f
--- /dev/null
+++ b/contrib/win32/win32compat/pkcs11-cert.h
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2026 Sebastian Ott. All rights reserved.
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#pragma once
+
+#include "sshbuf.h"
+#include "sshkey.h"
+
+#define AGENT_MAX_EXT_CERTS 1024
+
+/* Values of an existing Registry identity, NULL when not present. */
+struct pkcs11_identity_entry {
+ const u_char *pub, *dflt, *provider, *comment;
+ size_t pub_len, dflt_len, provider_len, comment_len;
+ int has_type, type;
+};
+
+char *pkcs11_identity_name(const struct sshkey *, const u_char *, size_t);
+const char *pkcs11_identity_comment(const char *, const char *);
+int pkcs11_provider_equal(const u_char *, size_t, const char *);
+int pkcs11_identity_entry_matches(const struct pkcs11_identity_entry *,
+ const u_char *, size_t, int, const char *);
+int parse_pkcs11_add_constraints(struct sshbuf *, int *,
+ struct sshkey ***, size_t *);
+void free_pkcs11_certs(struct sshkey **, size_t);
diff --git a/contrib/win32/win32compat/ssh-agent/keyagent-pkcs11.c b/contrib/win32/win32compat/ssh-agent/keyagent-pkcs11.c
new file mode 100644
index 000000000000..4f39ff6c5c7a
--- /dev/null
+++ b/contrib/win32/win32compat/ssh-agent/keyagent-pkcs11.c
@@ -0,0 +1,867 @@
+/*
+ * Author: Manoj Ampalam
+ * ssh-agent implementation on Windows
+ *
+ * Copyright (c) 2015 Microsoft Corp.
+ * All rights reserved
+ *
+ * Microsoft openssh win32 port
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "agent.h"
+#include "agent-request.h"
+#include "config.h"
+#include "match.h"
+#include
+#include "pkcs11-cert.h"
+#ifdef ENABLE_PKCS11
+#include "ssh-pkcs11.h"
+#endif
+#include "xmalloc.h"
+#include "keyagent-registry.h"
+#include "keyagent-pkcs11.h"
+
+#ifdef ENABLE_PKCS11
+
+#pragma warning(push, 3)
+
+extern char* allowed_providers;
+extern int remote_add_provider;
+
+extern struct sshkey *
+lookup_key(const struct sshkey *k);
+
+extern void
+add_key(struct sshkey *k, char *name);
+
+extern void
+del_all_keys();
+
+struct pkcs11_identity_change {
+ char *name;
+ int created;
+ int had_provider;
+ DWORD provider_type;
+ u_char *provider;
+ DWORD provider_len;
+ int had_comment;
+ DWORD comment_type;
+ u_char *comment;
+ DWORD comment_len;
+};
+
+static void
+free_pkcs11_identity_change(struct pkcs11_identity_change *change)
+{
+ if (change == NULL)
+ return;
+ free(change->name);
+ free(change->provider);
+ free(change->comment);
+ free(change);
+}
+
+static int
+restore_pkcs11_identity_metadata(HKEY key,
+ const struct pkcs11_identity_change *change)
+{
+ int r1, r2;
+
+ r1 = restore_optional_reg_value(key, L"provider",
+ change->had_provider, change->provider_type, change->provider,
+ change->provider_len);
+ r2 = restore_optional_reg_value(key, L"comment", change->had_comment,
+ change->comment_type, change->comment, change->comment_len);
+ return r1 == 0 && r2 == 0 ? 0 : -1;
+}
+
+static int
+pkcs11_identity_reusable(HKEY sub, const struct pkcs11_identity_change *change,
+ const u_char *blob, size_t blob_len, int key_type, const char *provider)
+{
+ struct pkcs11_identity_entry entry;
+ u_char *pub = NULL, *dflt = NULL;
+ DWORD pub_type, dflt_type, pub_len, dflt_len, type, type_kind;
+ DWORD type_len = sizeof(type);
+ int has_pub, has_dflt, reusable = 0;
+
+ memset(&entry, 0, sizeof(entry));
+ if (read_optional_reg_value(sub, L"pub", &has_pub, &pub_type, &pub,
+ &pub_len) != 0 ||
+ read_optional_reg_value(sub, NULL, &has_dflt, &dflt_type, &dflt,
+ &dflt_len) != 0)
+ goto out;
+ if (has_pub && pub_type == REG_BINARY) {
+ entry.pub = pub;
+ entry.pub_len = pub_len;
+ }
+ if (has_dflt && dflt_type == REG_BINARY) {
+ entry.dflt = dflt;
+ entry.dflt_len = dflt_len;
+ }
+ if (RegQueryValueExW(sub, L"type", NULL, &type_kind, (BYTE *)&type,
+ &type_len) == ERROR_SUCCESS && type_kind == REG_DWORD &&
+ type_len == sizeof(type)) {
+ entry.has_type = 1;
+ entry.type = (int)type;
+ }
+ if (change->had_provider) {
+ entry.provider = change->provider;
+ entry.provider_len = change->provider_len;
+ }
+ if (change->had_comment) {
+ entry.comment = change->comment;
+ entry.comment_len = change->comment_len;
+ }
+ reusable = pkcs11_identity_entry_matches(&entry, blob, blob_len,
+ key_type, provider);
+ out:
+ free(pub);
+ free(dflt);
+ return reusable;
+}
+
+static int
+store_pkcs11_identity(HKEY user_root, const struct sshkey *key,
+ const char *provider, const char *comment,
+ struct pkcs11_identity_change **changep)
+{
+ SECURITY_ATTRIBUTES sa = { 0, NULL, 0 };
+ HKEY reg = NULL, sub = NULL;
+ u_char *blob = NULL;
+ size_t blob_len;
+ char *thumbprint = NULL;
+ struct pkcs11_identity_change *change = NULL;
+ DWORD disposition = 0;
+ ULONG sd_len = 0;
+ int success = 0;
+
+ if (changep == NULL || provider == NULL || comment == NULL)
+ return -1;
+ *changep = NULL;
+ sa.nLength = sizeof(sa);
+ if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(REG_KEY_SDDL,
+ SDDL_REVISION_1, &sa.lpSecurityDescriptor, &sd_len) ||
+ sshkey_to_blob(key, &blob, &blob_len) != 0 ||
+ blob_len == 0 || blob_len > MAX_MESSAGE_SIZE ||
+ (thumbprint = pkcs11_identity_name(key, blob, blob_len)) == NULL ||
+ RegCreateKeyExW(user_root, SSH_KEYS_ROOT, 0, NULL, 0,
+ KEY_WRITE | KEY_WOW64_64KEY, &sa, ®, NULL) != ERROR_SUCCESS ||
+ RegCreateKeyExA(reg, thumbprint, 0, NULL, 0,
+ KEY_WRITE | KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sa, &sub,
+ &disposition) != ERROR_SUCCESS) {
+ error_f("failed to persist PKCS11 identity");
+ goto out;
+ }
+ change = xcalloc(1, sizeof(*change));
+ change->name = xstrdup(thumbprint);
+ if (disposition == REG_OPENED_EXISTING_KEY) {
+ if (read_optional_reg_value(sub, L"provider",
+ &change->had_provider, &change->provider_type,
+ &change->provider, &change->provider_len) != 0 ||
+ read_optional_reg_value(sub, L"comment",
+ &change->had_comment, &change->comment_type,
+ &change->comment, &change->comment_len) != 0) {
+ error_f("failed to read PKCS11 identity metadata");
+ goto out;
+ }
+ if (!pkcs11_identity_reusable(sub, change, blob, blob_len,
+ key->type, provider)) {
+ error_f("refusing to replace existing identity %s "
+ "not created for this provider", thumbprint);
+ goto out;
+ }
+ if (RegSetValueExW(sub, L"provider", 0, REG_BINARY,
+ (const BYTE *)provider, (DWORD)strlen(provider)) !=
+ ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"comment", 0, REG_BINARY,
+ (const BYTE *)comment, (DWORD)strlen(comment)) !=
+ ERROR_SUCCESS) {
+ error_f("failed to update PKCS11 identity metadata");
+ if (restore_pkcs11_identity_metadata(sub, change) != 0)
+ error_f("failed to restore PKCS11 identity metadata");
+ goto out;
+ }
+ } else {
+ change->created = 1;
+ if (RegSetValueExW(sub, NULL, 0, REG_BINARY, blob,
+ (DWORD)blob_len) != ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"pub", 0, REG_BINARY, blob,
+ (DWORD)blob_len) != ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"type", 0, REG_DWORD,
+ (const BYTE *)&key->type, sizeof(key->type)) != ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"provider", 0, REG_BINARY,
+ (const BYTE *)provider, (DWORD)strlen(provider)) !=
+ ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"comment", 0, REG_BINARY,
+ (const BYTE *)comment, (DWORD)strlen(comment)) !=
+ ERROR_SUCCESS) {
+ error_f("failed to persist PKCS11 identity");
+ goto out;
+ }
+ }
+ *changep = change;
+ change = NULL;
+ success = 1;
+ out:
+ if (sub != NULL) {
+ RegCloseKey(sub);
+ sub = NULL;
+ }
+ if (!success && disposition == REG_CREATED_NEW_KEY && reg != NULL &&
+ thumbprint != NULL)
+ RegDeleteTreeA(reg, thumbprint);
+ if (reg != NULL)
+ RegCloseKey(reg);
+ if (sa.lpSecurityDescriptor != NULL)
+ LocalFree(sa.lpSecurityDescriptor);
+ free_pkcs11_identity_change(change);
+ free(thumbprint);
+ free(blob);
+ return success ? 0 : -1;
+}
+
+static int
+store_pkcs11_provider(HKEY user_root, struct agent_connection *con,
+ const char *provider, const char *pin, size_t pin_len)
+{
+ SECURITY_ATTRIBUTES sa = { 0, NULL, 0 };
+ HKEY reg = NULL, sub = NULL;
+ char *epin = NULL;
+ DWORD epin_len = 0;
+ DWORD disposition = 0;
+ ULONG sd_len = 0;
+ int success = 0;
+
+ sa.nLength = sizeof(sa);
+ if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(REG_KEY_SDDL,
+ SDDL_REVISION_1, &sa.lpSecurityDescriptor, &sd_len) ||
+ convert_blob(con, pin, (DWORD)pin_len, &epin, &epin_len, TRUE) != 0 ||
+ RegCreateKeyExW(user_root, SSH_PKCS11_PROVIDERS_ROOT, 0, NULL, 0,
+ KEY_WRITE | KEY_WOW64_64KEY, &sa, ®, NULL) != ERROR_SUCCESS ||
+ RegCreateKeyExA(reg, provider, 0, NULL, 0,
+ KEY_WRITE | KEY_WOW64_64KEY, &sa, &sub,
+ &disposition) != ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"provider", 0, REG_BINARY,
+ (const BYTE *)provider, (DWORD)strlen(provider)) != ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"pin", 0, REG_BINARY, (const BYTE *)epin,
+ epin_len) != ERROR_SUCCESS) {
+ error_f("failed to persist PKCS11 provider");
+ goto out;
+ }
+ success = 1;
+ out:
+ if (epin != NULL) {
+ SecureZeroMemory(epin, epin_len);
+ free(epin);
+ }
+ if (sub != NULL) {
+ RegCloseKey(sub);
+ sub = NULL;
+ }
+ if (!success && disposition == REG_CREATED_NEW_KEY && reg != NULL)
+ RegDeleteTreeA(reg, provider);
+ if (reg != NULL)
+ RegCloseKey(reg);
+ if (sa.lpSecurityDescriptor != NULL)
+ LocalFree(sa.lpSecurityDescriptor);
+ return success ? 0 : -1;
+}
+
+static void
+rollback_pkcs11_identities(HKEY user_root,
+ struct pkcs11_identity_change **changes,
+ size_t nidentities)
+{
+ HKEY reg = NULL, sub = NULL;
+ size_t i;
+
+ if (nidentities == 0)
+ return;
+ if (RegOpenKeyExW(user_root, SSH_KEYS_ROOT, 0,
+ DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_WOW64_64KEY,
+ ®) != ERROR_SUCCESS) {
+ error_f("failed to open PKCS11 identities for rollback");
+ return;
+ }
+ for (i = nidentities; i > 0; i--) {
+ if (changes[i - 1]->created) {
+ if (RegDeleteTreeA(reg, changes[i - 1]->name) !=
+ ERROR_SUCCESS)
+ error_f("failed to roll back PKCS11 identity");
+ continue;
+ }
+ if (RegOpenKeyExA(reg, changes[i - 1]->name, 0,
+ KEY_SET_VALUE | KEY_WOW64_64KEY, &sub) != ERROR_SUCCESS ||
+ restore_pkcs11_identity_metadata(sub, changes[i - 1]) != 0)
+ error_f("failed to roll back PKCS11 identity metadata");
+ if (sub != NULL) {
+ RegCloseKey(sub);
+ sub = NULL;
+ }
+ }
+ RegCloseKey(reg);
+}
+
+static int
+remove_pkcs11_identities(HKEY user_root, const char *provider)
+{
+ HKEY root = NULL, sub = NULL;
+ wchar_t sub_name[MAX_KEY_LENGTH];
+ DWORD sub_name_len, type, data_len;
+ u_char *data = NULL;
+ int index = 0, present, remove;
+ LSTATUS status;
+
+ status = RegOpenKeyExW(user_root, SSH_KEYS_ROOT, 0,
+ DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_WOW64_64KEY, &root);
+ if (status == ERROR_FILE_NOT_FOUND)
+ return 0;
+ if (status != ERROR_SUCCESS)
+ return -1;
+ for (;;) {
+ sub_name_len = MAX_KEY_LENGTH;
+ status = RegEnumKeyExW(root, index, sub_name, &sub_name_len,
+ NULL, NULL, NULL, NULL);
+ if (status == ERROR_NO_MORE_ITEMS)
+ break;
+ if (status != ERROR_SUCCESS) {
+ index++;
+ continue;
+ }
+ if (RegOpenKeyExW(root, sub_name, 0,
+ KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub) != ERROR_SUCCESS) {
+ index++;
+ continue;
+ }
+ free(data);
+ data = NULL;
+ if (read_optional_reg_value(sub, L"provider", &present, &type,
+ &data, &data_len) != 0 ||
+ (!present && read_optional_reg_value(sub, L"comment",
+ &present, &type, &data, &data_len) != 0)) {
+ RegCloseKey(sub);
+ sub = NULL;
+ index++;
+ continue;
+ }
+ remove = present && pkcs11_provider_equal(data, data_len, provider);
+ RegCloseKey(sub);
+ sub = NULL;
+ if (remove) {
+ if (RegDeleteTreeW(root, sub_name) != ERROR_SUCCESS) {
+ RegCloseKey(root);
+ free(data);
+ return -1;
+ }
+ } else
+ index++;
+ }
+ RegCloseKey(root);
+ free(data);
+ return 0;
+}
+
+static int
+load_pkcs11_identities(HKEY user_root, const char *provider,
+ struct sshkey **token_keys, int nkeys)
+{
+ HKEY root = NULL, sub = NULL;
+ wchar_t sub_name[MAX_KEY_LENGTH];
+ DWORD sub_name_len, blob_len, comment_len, association_len;
+ u_char *blob = NULL;
+ char *comment = NULL, *association = NULL;
+ struct sshkey *registered = NULL, *cert = NULL;
+ u_char *plain_added = NULL;
+ int i, index = 0, legacy, loaded = 0;
+ LSTATUS status;
+
+ if (nkeys > 0)
+ plain_added = xcalloc((size_t)nkeys, sizeof(*plain_added));
+ status = RegOpenKeyExW(user_root, SSH_KEYS_ROOT, 0,
+ KEY_ENUMERATE_SUB_KEYS | KEY_QUERY_VALUE | KEY_WOW64_64KEY, &root);
+ if (status == ERROR_FILE_NOT_FOUND)
+ goto out;
+ if (status != ERROR_SUCCESS) {
+ error_f("failed to open persisted identities: %ld", status);
+ loaded = -1;
+ goto out;
+ }
+ for (;;) {
+ sub_name_len = MAX_KEY_LENGTH;
+ if (sub != NULL) {
+ RegCloseKey(sub);
+ sub = NULL;
+ }
+ status = RegEnumKeyExW(root, index++, sub_name, &sub_name_len,
+ NULL, NULL, NULL, NULL);
+ if (status == ERROR_NO_MORE_ITEMS)
+ break;
+ if (status != ERROR_SUCCESS)
+ continue;
+ if (RegOpenKeyExW(root, sub_name, 0,
+ KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub) != ERROR_SUCCESS ||
+ RegQueryValueExW(sub, L"pub", NULL, NULL, NULL,
+ &blob_len) != ERROR_SUCCESS ||
+ RegQueryValueExW(sub, L"comment", NULL, NULL, NULL,
+ &comment_len) != ERROR_SUCCESS ||
+ blob_len == 0 || blob_len > MAX_MESSAGE_SIZE ||
+ comment_len > MAX_MESSAGE_SIZE)
+ continue;
+ status = RegQueryValueExW(sub, L"provider", NULL, NULL, NULL,
+ &association_len);
+ if (status == ERROR_FILE_NOT_FOUND) {
+ legacy = 1;
+ association_len = comment_len;
+ } else if (status == ERROR_SUCCESS &&
+ association_len <= MAX_MESSAGE_SIZE)
+ legacy = 0;
+ else
+ continue;
+ free(blob);
+ free(comment);
+ free(association);
+ blob = xmalloc(blob_len);
+ comment = xmalloc((size_t)comment_len + 1);
+ association = xmalloc((size_t)association_len + 1);
+ if (RegQueryValueExW(sub, L"pub", NULL, NULL, blob,
+ &blob_len) != ERROR_SUCCESS ||
+ RegQueryValueExW(sub, L"comment", NULL, NULL,
+ (BYTE *)comment, &comment_len) != ERROR_SUCCESS ||
+ (!legacy && RegQueryValueExW(sub, L"provider", NULL, NULL,
+ (BYTE *)association, &association_len) != ERROR_SUCCESS))
+ continue;
+ comment[comment_len] = '\0';
+ if (legacy)
+ memcpy(association, comment, comment_len);
+ association[association_len] = '\0';
+ if (!pkcs11_provider_equal((u_char *)association, association_len,
+ provider))
+ continue;
+ sshkey_free(registered);
+ registered = NULL;
+ if (sshkey_from_blob(blob, blob_len, ®istered) != 0)
+ continue;
+ for (i = 0; i < nkeys; i++) {
+ if (token_keys[i] == NULL)
+ continue;
+ if (sshkey_is_cert(registered)) {
+ if (!sshkey_equal_public(token_keys[i], registered))
+ continue;
+ if (pkcs11_make_cert(token_keys[i], registered,
+ &cert) != 0)
+ continue;
+ add_key(cert, (char *)provider);
+ cert = NULL;
+ loaded++;
+ break;
+ }
+ if (!plain_added[i] &&
+ sshkey_equal(token_keys[i], registered)) {
+ plain_added[i] = 1;
+ break;
+ }
+ }
+ }
+ for (i = 0; i < nkeys; i++) {
+ if (!plain_added[i] || token_keys[i] == NULL)
+ continue;
+ add_key(token_keys[i], (char *)provider);
+ token_keys[i] = NULL;
+ loaded++;
+ }
+ out:
+ sshkey_free(cert);
+ sshkey_free(registered);
+ free(plain_added);
+ free(association);
+ free(comment);
+ free(blob);
+ if (sub != NULL)
+ RegCloseKey(sub);
+ if (root != NULL)
+ RegCloseKey(root);
+ return loaded;
+}
+
+static void
+free_pkcs11_sign_provider(char **providerp, char **pinp, DWORD pin_len,
+ char **epinp, DWORD epin_len, struct sshkey ***keysp, int nkeys)
+{
+ int i;
+
+ if (*keysp != NULL) {
+ for (i = 0; i < nkeys; i++)
+ sshkey_free((*keysp)[i]);
+ free(*keysp);
+ *keysp = NULL;
+ }
+ free(*providerp);
+ *providerp = NULL;
+ if (*pinp != NULL) {
+ SecureZeroMemory(*pinp, pin_len);
+ free(*pinp);
+ *pinp = NULL;
+ }
+ if (*epinp != NULL) {
+ SecureZeroMemory(*epinp, epin_len);
+ free(*epinp);
+ *epinp = NULL;
+ }
+}
+
+struct sshkey *
+keyagent_pkcs11_lookup_key(const struct sshkey *key)
+{
+ return lookup_key(key);
+}
+
+int
+keyagent_pkcs11_reload_providers(struct agent_connection *con)
+{
+ int count = 0, index = 0, loaded = 0, ret = -1;
+ wchar_t sub_name[MAX_KEY_LENGTH];
+ DWORD sub_name_len = MAX_KEY_LENGTH;
+ DWORD pin_len = 0, epin_len = 0, provider_len = 0;
+ DWORD epin_alloc_len = 0;
+ char *pin = NULL, *npin = NULL, *epin = NULL, *provider = NULL;
+ HKEY root = 0, sub = 0, user_root = 0;
+ struct sshkey **keys = NULL;
+ SECURITY_ATTRIBUTES sa = { 0, NULL, 0 };
+ ULONG sd_len = 0;
+
+ pkcs11_init(0);
+
+ sa.nLength = sizeof(sa);
+ if ((!ConvertStringSecurityDescriptorToSecurityDescriptorW(REG_KEY_SDDL, SDDL_REVISION_1, &sa.lpSecurityDescriptor, &sd_len)) ||
+ get_user_root(con, &user_root) != 0 ||
+ RegCreateKeyExW(user_root, SSH_PKCS11_PROVIDERS_ROOT, 0, 0, 0, KEY_WRITE | STANDARD_RIGHTS_READ | KEY_ENUMERATE_SUB_KEYS | KEY_WOW64_64KEY, &sa, &root, NULL) != 0) {
+ goto out;
+ }
+
+ while (1) {
+ sub_name_len = MAX_KEY_LENGTH;
+ pin_len = epin_len = provider_len = 0;
+ epin_alloc_len = 0;
+ if (sub) {
+ RegCloseKey(sub);
+ sub = NULL;
+ }
+ if (RegEnumKeyExW(root, index++, sub_name, &sub_name_len, NULL, NULL, NULL, NULL) == 0) {
+ if (RegOpenKeyExW(root, sub_name, 0, KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub) == 0 &&
+ RegQueryValueExW(sub, L"provider", 0, NULL, NULL, &provider_len) == 0 &&
+ RegQueryValueExW(sub, L"pin", 0, NULL, NULL, &epin_len) == 0) {
+ if (provider_len == 0 || provider_len >= PATH_MAX ||
+ epin_len == 0 || epin_len > MAX_MESSAGE_SIZE)
+ continue;
+ epin_alloc_len = epin_len;
+ if ((epin = malloc(epin_alloc_len + 1)) == NULL ||
+ (provider = malloc(provider_len + 1)) == NULL ||
+ RegQueryValueExW(sub, L"provider", 0, NULL, provider, &provider_len) != 0 ||
+ RegQueryValueExW(sub, L"pin", 0, NULL, epin, &epin_len) != 0) {
+ free_pkcs11_sign_provider(&provider, &pin, pin_len,
+ &epin, epin_alloc_len, &keys, count);
+ continue;
+ }
+ provider[provider_len] = '\0';
+ epin[epin_len] = '\0';
+ if (convert_blob(con, epin, epin_len, &pin, &pin_len, 0) != 0 ||
+ (npin = realloc(pin, pin_len + 1)) == NULL) {
+ free_pkcs11_sign_provider(&provider, &pin, pin_len,
+ &epin, epin_alloc_len, &keys, count);
+ continue;
+ }
+ pin = npin;
+ pin[pin_len] = '\0';
+ count = pkcs11_add_provider(provider, pin, &keys, NULL);
+ if (count <= 0) {
+ free_pkcs11_sign_provider(&provider, &pin, pin_len,
+ &epin, epin_alloc_len, &keys, count);
+ continue;
+ }
+ loaded = load_pkcs11_identities(user_root, provider,
+ keys, count);
+ free_pkcs11_sign_provider(&provider, &pin, pin_len,
+ &epin, epin_alloc_len, &keys, count);
+ if (loaded < 0)
+ goto out;
+ }
+ }
+ else
+ break;
+ }
+ ret = 0;
+out:
+ free_pkcs11_sign_provider(&provider, &pin, pin_len, &epin, epin_alloc_len,
+ &keys, count);
+ if (sa.lpSecurityDescriptor != NULL)
+ LocalFree(sa.lpSecurityDescriptor);
+ if (user_root)
+ RegCloseKey(user_root);
+ if (root)
+ RegCloseKey(root);
+ if (sub)
+ RegCloseKey(sub);
+ return ret;
+}
+
+void
+keyagent_pkcs11_release(void)
+{
+ del_all_keys();
+ pkcs11_terminate();
+}
+
+LSTATUS
+keyagent_pkcs11_delete_cert_identity(HKEY root, const struct sshkey *key,
+ const u_char *blob, size_t blob_len)
+{
+ char *name;
+ LSTATUS status;
+
+ if ((name = pkcs11_identity_name(key, blob, blob_len)) == NULL)
+ return ERROR_INVALID_DATA;
+ status = delete_matching_identity(root, name, blob, blob_len);
+ free(name);
+ return status;
+}
+
+/*
+ * Resolve provider to the canonical path used as Registry identity, without
+ * the leading slash realpath() puts in front of a Windows drive letter.
+ * canonical must hold PATH_MAX bytes.
+ */
+static int
+canonicalize_provider_path(const char *provider, char *canonical,
+ const char *op)
+{
+ if (realpath(provider, canonical) == NULL) {
+ error("failed PKCS#11 %s of \"%.100s\": realpath: %s",
+ op, provider, strerror(errno));
+ return -1;
+ }
+ if (canonical[0] == '/')
+ memmove(canonical, canonical + 1, strlen(canonical));
+ return 0;
+}
+
+/*
+ * Persist key as identity of provider and remember how to roll it back
+ * in *changesp, which is grown by one entry on success.
+ */
+static int
+store_and_track_pkcs11_identity(HKEY user_root, const struct sshkey *key,
+ const char *provider, const char *comment,
+ struct pkcs11_identity_change ***changesp, size_t *nchangesp)
+{
+ struct pkcs11_identity_change *change = NULL;
+
+ if (store_pkcs11_identity(user_root, key, provider, comment,
+ &change) != 0)
+ return -1;
+ *changesp = xrecallocarray(*changesp, *nchangesp, *nchangesp + 1,
+ sizeof(**changesp));
+ (*changesp)[(*nchangesp)++] = change;
+ return 0;
+}
+
+int
+process_add_smartcard_key(struct sshbuf *request, struct sshbuf *response,
+ struct agent_connection *con)
+{
+ char *provider = NULL, *pin = NULL, canonical_provider[PATH_MAX] = { 0 };
+ char allowed_provider[PATH_MAX], **labels = NULL;
+ const char *comment;
+ int i, j, count = 0, r = 0, request_invalid = 0, success = 0;
+ int cert_only = 0, identities_stored = 0;
+ struct sshkey **keys = NULL, **certs = NULL, *cert = NULL;
+ struct pkcs11_identity_change **identity_changes = NULL;
+ size_t k, pin_len = 0, ncerts = 0, nidentity_changes = 0;
+ HKEY user_root = NULL;
+
+ pkcs11_init(0);
+
+ if ((r = sshbuf_get_cstring(request, &provider, NULL)) != 0 ||
+ (r = sshbuf_get_cstring(request, &pin, &pin_len)) != 0 ||
+ pin_len > 256) {
+ error("add smartcard request is invalid");
+ request_invalid = 1;
+ goto done;
+ }
+ if (sshbuf_len(request) != 0 &&
+ (r = parse_pkcs11_add_constraints(request, &cert_only, &certs,
+ &ncerts)) != 0) {
+ if (r != SSH_ERR_FEATURE_UNSUPPORTED) {
+ error("add smartcard constraints are invalid");
+ request_invalid = 1;
+ }
+ goto done;
+ }
+
+ if (con->nsession_ids != 0 && !remote_add_provider) {
+ verbose("failed PKCS#11 add of \"%.100s\": remote addition of "
+ "providers is disabled", provider);
+ goto done;
+ }
+
+ if (canonicalize_provider_path(provider, canonical_provider,
+ "add") != 0) {
+ request_invalid = 1;
+ goto done;
+ }
+
+ strcpy_s(allowed_provider, sizeof(allowed_provider), canonical_provider);
+ for (i = 0; allowed_provider[i] != '\0'; i++) {
+ if (allowed_provider[i] == '/')
+ allowed_provider[i] = '\\';
+ }
+ to_lower_case(allowed_provider);
+ verbose("provider realpath: \"%.100s\"", canonical_provider);
+ verbose("allowed provider paths: \"%.100s\"", allowed_providers);
+ if (match_pattern_list(allowed_provider, allowed_providers, 1) != 1) {
+ verbose("refusing PKCS#11 add of \"%.100s\": "
+ "provider not allowed", canonical_provider);
+ goto done;
+ }
+
+ count = pkcs11_add_provider(canonical_provider, pin, &keys, &labels);
+ if (count <= 0) {
+ error_f("failed to load provider keys: count:%d", count);
+ goto done;
+ }
+
+ if (get_user_root(con, &user_root) != 0)
+ goto done;
+
+ for (i = 0; i < count; i++) {
+ comment = pkcs11_identity_comment(canonical_provider, labels[i]);
+ for (j = 0; j < (int)ncerts; j++) {
+ if (!sshkey_is_cert(certs[j]) ||
+ !sshkey_equal_public(keys[i], certs[j]))
+ continue;
+ if (pkcs11_make_cert(keys[i], certs[j], &cert) != 0)
+ continue;
+ if (store_and_track_pkcs11_identity(user_root, cert,
+ canonical_provider, comment, &identity_changes,
+ &nidentity_changes) != 0)
+ goto done;
+ sshkey_free(cert);
+ cert = NULL;
+ identities_stored++;
+ }
+ if (cert_only)
+ continue;
+ if (store_and_track_pkcs11_identity(user_root, keys[i],
+ canonical_provider, comment, &identity_changes,
+ &nidentity_changes) != 0)
+ goto done;
+ identities_stored++;
+ }
+
+ if (identities_stored == 0 || store_pkcs11_provider(user_root, con,
+ canonical_provider, pin, pin_len) != 0)
+ goto done;
+ debug("added PKCS11 provider and identities to store");
+ success = 1;
+done:
+ r = 0;
+ if (request_invalid)
+ r = -1;
+ else if (sshbuf_put_u8(response, success ? SSH_AGENT_SUCCESS : SSH_AGENT_FAILURE) != 0)
+ r = -1;
+
+ if (!success && user_root != NULL)
+ rollback_pkcs11_identities(user_root, identity_changes,
+ nidentity_changes);
+
+ sshkey_free(cert);
+ for (k = 0; k < nidentity_changes; k++)
+ free_pkcs11_identity_change(identity_changes[k]);
+ free(identity_changes);
+ for (i = 0; i < count; i++)
+ sshkey_free(keys[i]);
+ free(keys);
+ for (i = 0; i < count; i++)
+ free(labels[i]);
+ free(labels);
+ free_pkcs11_certs(certs, ncerts);
+ pkcs11_terminate();
+ free(provider);
+ if (pin) {
+ SecureZeroMemory(pin, (DWORD)pin_len);
+ free(pin);
+ }
+ if (user_root)
+ RegCloseKey(user_root);
+ return r;
+}
+
+int process_remove_smartcard_key(struct sshbuf* request, struct sshbuf* response, struct agent_connection* con)
+{
+ char *provider = NULL, *pin = NULL, canonical_provider[PATH_MAX];
+ int r = 0, request_invalid = 0, success = 0, index = 0;
+ HKEY user_root = 0;
+
+ if ((r = sshbuf_get_cstring(request, &provider, NULL)) != 0 ||
+ (r = sshbuf_get_cstring(request, &pin, NULL)) != 0) {
+ error("remove smartcard request is invalid");
+ request_invalid = 1;
+ goto done;
+ }
+
+ if (canonicalize_provider_path(provider, canonical_provider,
+ "remove") != 0) {
+ request_invalid = 1;
+ goto done;
+ }
+
+ if (get_user_root(con, &user_root) != 0 ||
+ !is_reg_sub_key_exists(user_root, SSH_PKCS11_PROVIDERS_ROOT, canonical_provider))
+ goto done;
+
+ if (remove_pkcs11_identities(user_root, canonical_provider) != 0 ||
+ remove_matching_subkeys_from_registry(user_root,
+ SSH_PKCS11_PROVIDERS_ROOT, L"provider", canonical_provider) != 0) {
+ goto done;
+ }
+
+ success = 1;
+done:
+ r = 0;
+ if (request_invalid)
+ r = -1;
+ else if (sshbuf_put_u8(response, success ? SSH_AGENT_SUCCESS : SSH_AGENT_FAILURE) != 0)
+ r = -1;
+ if (provider)
+ free(provider);
+ if (pin)
+ free(pin);
+ if (user_root)
+ RegCloseKey(user_root);
+ return r;
+}
+
+#pragma warning(pop)
+
+#endif /* ENABLE_PKCS11 */
diff --git a/contrib/win32/win32compat/ssh-agent/keyagent-pkcs11.h b/contrib/win32/win32compat/ssh-agent/keyagent-pkcs11.h
new file mode 100644
index 000000000000..549d8a2dae32
--- /dev/null
+++ b/contrib/win32/win32compat/ssh-agent/keyagent-pkcs11.h
@@ -0,0 +1,59 @@
+/*
+ * PKCS#11 provider and identity persistence of the Windows ssh-agent.
+ * Split out of keyagent-request.c.
+ *
+ * Without ENABLE_PKCS11 the functions below are no-ops, so callers do not
+ * need any conditional compilation.
+ */
+
+#pragma once
+
+#include
+
+#include "sshkey.h"
+
+struct agent_connection;
+
+#ifdef ENABLE_PKCS11
+
+/* Key that was loaded from a PKCS#11 provider by the current request. */
+struct sshkey *keyagent_pkcs11_lookup_key(const struct sshkey *);
+
+/*
+ * Load all persisted providers and make their identities available to
+ * signing. Must be paired with keyagent_pkcs11_release(), also on failure.
+ */
+int keyagent_pkcs11_reload_providers(struct agent_connection *);
+void keyagent_pkcs11_release(void);
+
+/* Delete the persisted PKCS#11 certificate identity matching key/blob. */
+LSTATUS keyagent_pkcs11_delete_cert_identity(HKEY, const struct sshkey *,
+ const u_char *, size_t);
+
+#else /* ENABLE_PKCS11 */
+
+static __inline struct sshkey *
+keyagent_pkcs11_lookup_key(const struct sshkey *key)
+{
+ return NULL;
+}
+
+static __inline int
+keyagent_pkcs11_reload_providers(struct agent_connection *con)
+{
+ return 0;
+}
+
+static __inline void
+keyagent_pkcs11_release(void)
+{
+}
+
+static __inline LSTATUS
+keyagent_pkcs11_delete_cert_identity(HKEY root, const struct sshkey *key,
+ const u_char *blob, size_t blob_len)
+{
+ return ERROR_FILE_NOT_FOUND;
+}
+
+#endif /* ENABLE_PKCS11 */
diff --git a/contrib/win32/win32compat/ssh-agent/keyagent-registry.c b/contrib/win32/win32compat/ssh-agent/keyagent-registry.c
new file mode 100644
index 000000000000..21552436afc7
--- /dev/null
+++ b/contrib/win32/win32compat/ssh-agent/keyagent-registry.c
@@ -0,0 +1,273 @@
+/*
+ * Author: Manoj Ampalam
+ * ssh-agent implementation on Windows
+ *
+ * Copyright (c) 2015 Microsoft Corp.
+ * All rights reserved
+ *
+ * Microsoft openssh win32 port
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "agent.h"
+#include "config.h"
+#include "pkcs11-cert.h"
+#include "xmalloc.h"
+#include "keyagent-registry.h"
+
+#pragma warning(push, 3)
+
+/*
+ * get registry root where keys are stored
+ * user keys are stored in user's hive
+ * while system keys (host keys) in HKLM
+ */
+int
+get_user_root(struct agent_connection* con, HKEY *root)
+{
+ int r = 0;
+ LONG ret;
+ *root = HKEY_LOCAL_MACHINE;
+
+ if (con->client_type <= ADMIN_USER) {
+ if (ImpersonateLoggedOnUser(con->client_impersonation_token) == FALSE)
+ return -1;
+ *root = NULL;
+ /*
+ * TODO - check that user profile is loaded,
+ * otherwise, this will return default profile
+ */
+ if ((ret = RegOpenCurrentUser(KEY_ALL_ACCESS, root)) != ERROR_SUCCESS) {
+ debug("unable to open user's registry hive, ERROR - %d", ret);
+ r = -1;
+ }
+
+ RevertToSelf();
+ }
+ return r;
+}
+
+int
+convert_blob(struct agent_connection* con, const char *blob, DWORD blen, char **eblob, DWORD *eblen, int encrypt) {
+ int success = 0;
+ DATA_BLOB in, out;
+ errno_t r = 0;
+
+ if (con->client_type <= ADMIN_USER)
+ if (ImpersonateLoggedOnUser(con->client_impersonation_token) == FALSE)
+ return -1;
+
+ in.cbData = blen;
+ in.pbData = (char*)blob;
+ out.cbData = 0;
+ out.pbData = NULL;
+
+ if (encrypt) {
+ if (!CryptProtectData(&in, NULL, NULL, 0, NULL, 0, &out)) {
+ debug("cannot encrypt data");
+ goto done;
+ }
+ } else {
+ if (!CryptUnprotectData(&in, NULL, NULL, 0, NULL, 0, &out)) {
+ debug("cannot decrypt data");
+ goto done;
+ }
+ }
+
+ *eblob = malloc(out.cbData);
+ if (*eblob == NULL)
+ goto done;
+
+ if((r = memcpy_s(*eblob, out.cbData, out.pbData, out.cbData)) != 0) {
+ debug("memcpy_s failed with error: %d.", r);
+ goto done;
+ }
+ *eblen = out.cbData;
+ success = 1;
+done:
+ if (out.pbData)
+ LocalFree(out.pbData);
+ if (con->client_type <= ADMIN_USER)
+ RevertToSelf();
+ return success? 0: -1;
+}
+
+int
+remove_matching_subkeys_from_registry(HKEY user_root, wchar_t const* key_name, wchar_t const* value_name_to_remove, char const* value_data_to_remove) {
+ int index = 0, success = 0;
+ DWORD data_len;
+ HKEY root = 0, sub = 0;
+ char *data = NULL;
+ wchar_t sub_name[MAX_KEY_LENGTH];
+ DWORD sub_name_len = MAX_KEY_LENGTH;
+ LSTATUS retCode;
+
+ if (RegOpenKeyExW(user_root, key_name, 0, DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_WOW64_64KEY, &root) != 0) {
+ goto done;
+ }
+
+ while (1) {
+ sub_name_len = MAX_KEY_LENGTH;
+ if (sub) {
+ RegCloseKey(sub);
+ sub = NULL;
+ }
+ if ((retCode = RegEnumKeyExW(root, index++, sub_name, &sub_name_len, NULL, NULL, NULL, NULL)) == 0) {
+ if (RegOpenKeyExW(root, sub_name, 0, KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub) == 0 &&
+ RegQueryValueExW(sub, value_name_to_remove, 0, NULL, NULL, &data_len) == 0 &&
+ data_len <= MAX_VALUE_DATA_LENGTH) {
+
+ if (data)
+ free(data);
+ data = NULL;
+
+ if ((data = malloc(data_len + 1)) == NULL ||
+ RegQueryValueExW(sub, value_name_to_remove, 0, NULL, data, &data_len) != 0)
+ goto done;
+ data[data_len] = '\0';
+ if (pkcs11_provider_equal((u_char *)data, data_len,
+ value_data_to_remove)) {
+ if (RegDeleteTreeW(root, sub_name) != 0)
+ goto done;
+ --index;
+ }
+ }
+ }
+ else {
+ if (retCode == ERROR_NO_MORE_ITEMS)
+ success = 1;
+ break;
+ }
+ }
+done:
+ if (data)
+ free(data);
+ if (root)
+ RegCloseKey(root);
+ if (sub)
+ RegCloseKey(sub);
+ return success ? 0 : -1;
+}
+
+int
+is_reg_sub_key_exists(HKEY user_root, wchar_t const* key_name, char const* sub_key_name) {
+ int rv = 0;
+ HKEY root = 0, sub = 0;
+
+ if (RegOpenKeyExW(user_root, key_name, 0, STANDARD_RIGHTS_READ | KEY_WOW64_64KEY, &root) != 0 ||
+ RegOpenKeyExA(root, sub_key_name, 0, STANDARD_RIGHTS_READ | KEY_WOW64_64KEY, &sub) != 0 || !sub) {
+ rv = 0;
+ goto done;
+ }
+
+ rv = 1;
+done:
+ if (root)
+ RegCloseKey(root);
+ return rv;
+}
+
+int
+read_optional_reg_value(HKEY key, const wchar_t *name, int *presentp,
+ DWORD *typep, u_char **datap, DWORD *lenp)
+{
+ LSTATUS status;
+
+ *presentp = 0;
+ *datap = NULL;
+ *lenp = 0;
+ status = RegQueryValueExW(key, name, NULL, typep, NULL, lenp);
+ if (status == ERROR_FILE_NOT_FOUND)
+ return 0;
+ if (status != ERROR_SUCCESS || *lenp > MAX_MESSAGE_SIZE)
+ return -1;
+ *datap = xmalloc(*lenp == 0 ? 1 : *lenp);
+ if (RegQueryValueExW(key, name, NULL, typep, *datap,
+ lenp) != ERROR_SUCCESS) {
+ free(*datap);
+ *datap = NULL;
+ return -1;
+ }
+ *presentp = 1;
+ return 0;
+}
+
+int
+restore_optional_reg_value(HKEY key, const wchar_t *name, int present,
+ DWORD type, const u_char *data, DWORD len)
+{
+ LSTATUS status;
+
+ if (present)
+ return RegSetValueExW(key, name, 0, type, data, len) ==
+ ERROR_SUCCESS ? 0 : -1;
+ status = RegDeleteValueW(key, name);
+ return status == ERROR_SUCCESS || status == ERROR_FILE_NOT_FOUND ? 0 : -1;
+}
+
+/*
+ * delete the identity sub key name below root, but only if its stored
+ * public key blob matches blob
+ */
+LSTATUS
+delete_matching_identity(HKEY root, const char *name, const u_char *blob,
+ size_t blob_len)
+{
+ HKEY sub = NULL;
+ u_char *stored_blob = NULL;
+ DWORD stored_blob_len = 0;
+ LSTATUS status;
+
+ status = RegOpenKeyExA(root, name, 0,
+ KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub);
+ if (status != ERROR_SUCCESS)
+ return status;
+ status = RegQueryValueExW(sub, L"pub", NULL, NULL, NULL,
+ &stored_blob_len);
+ if (status != ERROR_SUCCESS)
+ goto out;
+ if (stored_blob_len > MAX_MESSAGE_SIZE) {
+ status = ERROR_INVALID_DATA;
+ goto out;
+ }
+ stored_blob = xmalloc(stored_blob_len == 0 ? 1 : stored_blob_len);
+ status = RegQueryValueExW(sub, L"pub", NULL, NULL, stored_blob,
+ &stored_blob_len);
+ if (status != ERROR_SUCCESS)
+ goto out;
+ if (stored_blob_len != blob_len ||
+ memcmp(stored_blob, blob, blob_len) != 0) {
+ status = ERROR_FILE_NOT_FOUND;
+ goto out;
+ }
+ RegCloseKey(sub);
+ sub = NULL;
+ status = RegDeleteTreeA(root, name);
+ out:
+ free(stored_blob);
+ if (sub != NULL)
+ RegCloseKey(sub);
+ return status;
+}
+
+#pragma warning(pop)
diff --git a/contrib/win32/win32compat/ssh-agent/keyagent-registry.h b/contrib/win32/win32compat/ssh-agent/keyagent-registry.h
new file mode 100644
index 000000000000..54df324c9219
--- /dev/null
+++ b/contrib/win32/win32compat/ssh-agent/keyagent-registry.h
@@ -0,0 +1,31 @@
+/*
+ * Registry and DPAPI helpers of the Windows ssh-agent key store.
+ * Split out of keyagent-request.c.
+ */
+
+#pragma once
+
+#include
+
+#include "sshbuf.h"
+
+struct agent_connection;
+
+#define MAX_KEY_LENGTH 255
+#define MAX_VALUE_NAME_LENGTH 16383
+#define MAX_VALUE_DATA_LENGTH 2048
+
+/* Registry keys are only accessible to SYSTEM and administrators. */
+#define REG_KEY_SDDL L"D:P(A;; GA;;; SY)(A;; GA;;; BA)"
+
+int get_user_root(struct agent_connection *, HKEY *);
+int convert_blob(struct agent_connection *, const char *, DWORD, char **,
+ DWORD *, int);
+int remove_matching_subkeys_from_registry(HKEY, wchar_t const *,
+ wchar_t const *, char const *);
+int is_reg_sub_key_exists(HKEY, wchar_t const *, char const *);
+int read_optional_reg_value(HKEY, const wchar_t *, int *, DWORD *,
+ u_char **, DWORD *);
+int restore_optional_reg_value(HKEY, const wchar_t *, int, DWORD,
+ const u_char *, DWORD);
+LSTATUS delete_matching_identity(HKEY, const char *, const u_char *, size_t);
diff --git a/contrib/win32/win32compat/ssh-agent/keyagent-request.c b/contrib/win32/win32compat/ssh-agent/keyagent-request.c
index b9b4b036e19b..d4411ec89f4f 100644
--- a/contrib/win32/win32compat/ssh-agent/keyagent-request.c
+++ b/contrib/win32/win32compat/ssh-agent/keyagent-request.c
@@ -32,192 +32,13 @@
#include "agent.h"
#include "agent-request.h"
#include "config.h"
-#include "match.h"
#include
-#ifdef ENABLE_PKCS11
-#include "ssh-pkcs11.h"
-#endif
#include "xmalloc.h"
+#include "keyagent-registry.h"
+#include "keyagent-pkcs11.h"
#pragma warning(push, 3)
-#define MAX_KEY_LENGTH 255
-#define MAX_VALUE_NAME_LENGTH 16383
-#define MAX_VALUE_DATA_LENGTH 2048
-
-extern char* allowed_providers;
-extern int remote_add_provider;
-
-/*
- * get registry root where keys are stored
- * user keys are stored in user's hive
- * while system keys (host keys) in HKLM
- */
-
-extern struct sshkey *
-lookup_key(const struct sshkey *k);
-
-extern void
-add_key(struct sshkey *k, char *name);
-
-extern void
-del_all_keys();
-
-static int
-get_user_root(struct agent_connection* con, HKEY *root)
-{
- int r = 0;
- LONG ret;
- *root = HKEY_LOCAL_MACHINE;
-
- if (con->client_type <= ADMIN_USER) {
- if (ImpersonateLoggedOnUser(con->client_impersonation_token) == FALSE)
- return -1;
- *root = NULL;
- /*
- * TODO - check that user profile is loaded,
- * otherwise, this will return default profile
- */
- if ((ret = RegOpenCurrentUser(KEY_ALL_ACCESS, root)) != ERROR_SUCCESS) {
- debug("unable to open user's registry hive, ERROR - %d", ret);
- r = -1;
- }
-
- RevertToSelf();
- }
- return r;
-}
-
-static int
-convert_blob(struct agent_connection* con, const char *blob, DWORD blen, char **eblob, DWORD *eblen, int encrypt) {
- int success = 0;
- DATA_BLOB in, out;
- errno_t r = 0;
-
- if (con->client_type <= ADMIN_USER)
- if (ImpersonateLoggedOnUser(con->client_impersonation_token) == FALSE)
- return -1;
-
- in.cbData = blen;
- in.pbData = (char*)blob;
- out.cbData = 0;
- out.pbData = NULL;
-
- if (encrypt) {
- if (!CryptProtectData(&in, NULL, NULL, 0, NULL, 0, &out)) {
- debug("cannot encrypt data");
- goto done;
- }
- } else {
- if (!CryptUnprotectData(&in, NULL, NULL, 0, NULL, 0, &out)) {
- debug("cannot decrypt data");
- goto done;
- }
- }
-
- *eblob = malloc(out.cbData);
- if (*eblob == NULL)
- goto done;
-
- if((r = memcpy_s(*eblob, out.cbData, out.pbData, out.cbData)) != 0) {
- debug("memcpy_s failed with error: %d.", r);
- goto done;
- }
- *eblen = out.cbData;
- success = 1;
-done:
- if (out.pbData)
- LocalFree(out.pbData);
- if (con->client_type <= ADMIN_USER)
- RevertToSelf();
- return success? 0: -1;
-}
-
-/*
- * in user_root sub tree under key_name key
- * remove all sub keys with value name value_name_to_remove
- * and value data value_data_to_remove
- */
-static int
-remove_matching_subkeys_from_registry(HKEY user_root, wchar_t const* key_name, wchar_t const* value_name_to_remove, char const* value_data_to_remove) {
- int index = 0, success = 0;
- DWORD data_len;
- HKEY root = 0, sub = 0;
- char *data = NULL;
- wchar_t sub_name[MAX_KEY_LENGTH];
- DWORD sub_name_len = MAX_KEY_LENGTH;
- LSTATUS retCode;
-
- if (RegOpenKeyExW(user_root, key_name, 0, DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_WOW64_64KEY, &root) != 0) {
- goto done;
- }
-
- while (1) {
- sub_name_len = MAX_KEY_LENGTH;
- if (sub) {
- RegCloseKey(sub);
- sub = NULL;
- }
- if ((retCode = RegEnumKeyExW(root, index++, sub_name, &sub_name_len, NULL, NULL, NULL, NULL)) == 0) {
- if (RegOpenKeyExW(root, sub_name, 0, KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub) == 0 &&
- RegQueryValueExW(sub, value_name_to_remove, 0, NULL, NULL, &data_len) == 0 &&
- data_len <= MAX_VALUE_DATA_LENGTH) {
-
- if (data)
- free(data);
- data = NULL;
-
- if ((data = malloc(data_len + 1)) == NULL ||
- RegQueryValueExW(sub, value_name_to_remove, 0, NULL, data, &data_len) != 0)
- goto done;
- data[data_len] = '\0';
- if (strncmp(data, value_data_to_remove, data_len) == 0) {
- if (RegDeleteTreeW(root, sub_name) != 0)
- goto done;
- --index;
- }
- }
- }
- else {
- if (retCode == ERROR_NO_MORE_ITEMS)
- success = 1;
- break;
- }
- }
-done:
- if (data)
- free(data);
- if (root)
- RegCloseKey(root);
- if (sub)
- RegCloseKey(sub);
- return success ? 0 : -1;
-}
-
-/*
- * in user_root sub tree under key_name key
- * check whether sub_key_name sub key exists
- */
-static int
-is_reg_sub_key_exists(HKEY user_root, wchar_t const* key_name, char const* sub_key_name) {
- int rv = 0;
- HKEY root = 0, sub = 0;
-
- if (RegOpenKeyExW(user_root, key_name, 0, STANDARD_RIGHTS_READ | KEY_WOW64_64KEY, &root) != 0 ||
- RegOpenKeyExA(root, sub_key_name, 0, STANDARD_RIGHTS_READ | KEY_WOW64_64KEY, &sub) != 0 || !sub) {
- rv = 0;
- goto done;
- }
-
- rv = 1;
-done:
- if (root)
- RegCloseKey(root);
- return rv;
-}
-
-#define REG_KEY_SDDL L"D:P(A;; GA;;; SY)(A;; GA;;; BA)"
-
int
process_unsupported_request(struct sshbuf* request, struct sshbuf* response, struct agent_connection* con)
{
@@ -298,6 +119,7 @@ process_add_identity(struct sshbuf* request, struct sshbuf* response, struct age
char* eblob = NULL;
HKEY reg = 0, sub = 0, user_root = 0;
SECURITY_ATTRIBUTES sa;
+ LSTATUS status;
/* parse input request */
memset(&sa, 0, sizeof(SECURITY_ATTRIBUTES));
@@ -328,7 +150,10 @@ process_add_identity(struct sshbuf* request, struct sshbuf* response, struct age
RegSetValueExW(sub, NULL, 0, REG_BINARY, eblob, eblob_len) != 0 ||
RegSetValueExW(sub, L"pub", 0, REG_BINARY, pubkey_blob, (DWORD)pubkey_blob_len) != 0 ||
RegSetValueExW(sub, L"type", 0, REG_DWORD, (BYTE*)&key->type, 4) != 0 ||
- RegSetValueExW(sub, L"comment", 0, REG_BINARY, comment, (DWORD)comment_len) != 0 ) {
+ RegSetValueExW(sub, L"comment", 0, REG_BINARY, comment, (DWORD)comment_len) != 0 ||
+ /* a software key does not belong to a PKCS#11 provider */
+ ((status = RegDeleteValueW(sub, L"provider")) != ERROR_SUCCESS &&
+ status != ERROR_FILE_NOT_FOUND)) {
error("failed to add key to store");
goto done;
}
@@ -376,16 +201,12 @@ static int sign_blob(const struct sshkey *pubkey, u_char ** sig, size_t *siglen,
struct sshbuf* tmpbuf = NULL;
char *keyblob = NULL;
const char *sk_provider = NULL;
-#ifdef ENABLE_PKCS11
int is_pkcs11_key = 0;
-#endif /* ENABLE_PKCS11 */
*sig = NULL;
*siglen = 0;
-#ifdef ENABLE_PKCS11
- if ((prikey = lookup_key(pubkey)) == NULL) {
-#endif /* ENABLE_PKCS11 */
+ if ((prikey = keyagent_pkcs11_lookup_key(pubkey)) == NULL) {
if ((thumbprint = sshkey_fingerprint(pubkey, SSH_FP_HASH_DEFAULT, SSH_FP_DEFAULT)) == NULL ||
get_user_root(con, &user_root) != 0 ||
RegOpenKeyExW(user_root, SSH_KEYS_ROOT,
@@ -401,11 +222,9 @@ static int sign_blob(const struct sshkey *pubkey, u_char ** sig, size_t *siglen,
error("cannot retrieve and deserialize key from registry");
goto done;
}
-#ifdef ENABLE_PKCS11
}
else
is_pkcs11_key = 1;
-#endif /* ENABLE_PKCS11 */
if (flags & SSH_AGENT_RSA_SHA2_256)
algo = "rsa-sha2-256";
else if (flags & SSH_AGENT_RSA_SHA2_512)
@@ -427,9 +246,7 @@ static int sign_blob(const struct sshkey *pubkey, u_char ** sig, size_t *siglen,
free(regdata);
if (tmpbuf)
sshbuf_free(tmpbuf);
-#ifdef ENABLE_PKCS11
if (!is_pkcs11_key)
-#endif /* ENABLE_PKCS11 */
if (prikey)
sshkey_free(prikey);
if (thumbprint)
@@ -453,73 +270,8 @@ process_sign_request(struct sshbuf* request, struct sshbuf* response, struct age
int r, request_invalid = 0, success = 0;
struct sshkey *key = NULL;
-#ifdef ENABLE_PKCS11
- int i, count = 0, index = 0;;
- wchar_t sub_name[MAX_KEY_LENGTH];
- DWORD sub_name_len = MAX_KEY_LENGTH;
- DWORD pin_len, epin_len, provider_len;
- char *pin = NULL, *npin = NULL, *epin = NULL, *provider = NULL;
- HKEY root = 0, sub = 0, user_root = 0;
- struct sshkey **keys = NULL;
- SECURITY_ATTRIBUTES sa = { 0, NULL, 0 };
-
- pkcs11_init(0);
-
- memset(&sa, 0, sizeof(SECURITY_ATTRIBUTES));
- sa.nLength = sizeof(sa);
- if ((!ConvertStringSecurityDescriptorToSecurityDescriptorW(REG_KEY_SDDL, SDDL_REVISION_1, &sa.lpSecurityDescriptor, &sa.nLength)) ||
- get_user_root(con, &user_root) != 0 ||
- RegCreateKeyExW(user_root, SSH_PKCS11_PROVIDERS_ROOT, 0, 0, 0, KEY_WRITE | STANDARD_RIGHTS_READ | KEY_ENUMERATE_SUB_KEYS | KEY_WOW64_64KEY, &sa, &root, NULL) != 0) {
+ if (keyagent_pkcs11_reload_providers(con) != 0)
goto done;
- }
-
- while (1) {
- sub_name_len = MAX_KEY_LENGTH;
- if (sub) {
- RegCloseKey(sub);
- sub = NULL;
- }
- if (RegEnumKeyExW(root, index++, sub_name, &sub_name_len, NULL, NULL, NULL, NULL) == 0) {
- if (RegOpenKeyExW(root, sub_name, 0, KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub) == 0 &&
- RegQueryValueExW(sub, L"provider", 0, NULL, NULL, &provider_len) == 0 &&
- RegQueryValueExW(sub, L"pin", 0, NULL, NULL, &epin_len) == 0) {
- if ((epin = malloc(epin_len + 1)) == NULL ||
- (provider = malloc(provider_len + 1)) == NULL ||
- RegQueryValueExW(sub, L"provider", 0, NULL, provider, &provider_len) != 0 ||
- RegQueryValueExW(sub, L"pin", 0, NULL, epin, &epin_len) != 0)
- goto done;
- provider[provider_len] = '\0';
- epin[epin_len] = '\0';
- if (convert_blob(con, epin, epin_len, &pin, &pin_len, 0) != 0 ||
- (npin = realloc(pin, pin_len + 1)) == NULL) {
- goto done;
- }
- pin = npin;
- pin[pin_len] = '\0';
- count = pkcs11_add_provider(provider, pin, &keys, NULL);
- for (i = 0; i < count; i++) {
- add_key(keys[i], provider);
- }
- free(keys);
- if (provider)
- free(provider);
- if (pin) {
- SecureZeroMemory(pin, (DWORD)pin_len);
- free(pin);
- }
- if (epin) {
- SecureZeroMemory(epin, (DWORD)epin_len);
- free(epin);
- }
- provider = NULL;
- pin = NULL;
- epin = NULL;
- }
- }
- else
- break;
- }
-#endif /* ENABLE_PKCS11 */
if (sshbuf_get_string_direct(request, &blob, &blen) != 0 ||
sshbuf_get_string_direct(request, &data, &dlen) != 0 ||
@@ -552,26 +304,7 @@ process_sign_request(struct sshbuf* request, struct sshbuf* response, struct age
sshkey_free(key);
if (signature)
free(signature);
-#ifdef ENABLE_PKCS11
- del_all_keys();
- pkcs11_terminate();
- if (provider)
- free(provider);
- if (pin) {
- SecureZeroMemory(pin, (DWORD)pin_len);
- free(pin);
- }
- if (epin) {
- SecureZeroMemory(epin, (DWORD)epin_len);
- free(epin);
- }
- if (user_root)
- RegCloseKey(user_root);
- if (root)
- RegCloseKey(root);
- if (sub)
- RegCloseKey(sub);
-#endif /* ENABLE_PKCS11 */
+ keyagent_pkcs11_release();
return r;
}
@@ -583,6 +316,7 @@ process_remove_key(struct sshbuf* request, struct sshbuf* response, struct agent
size_t blen;
int r = 0, success = 0, request_invalid = 0;
struct sshkey *key = NULL;
+ LSTATUS status;
if (sshbuf_get_string_direct(request, &blob, &blen) != 0 ||
sshkey_from_blob(blob, blen, &key) != 0) {
@@ -590,11 +324,19 @@ process_remove_key(struct sshbuf* request, struct sshbuf* response, struct agent
goto done;
}
- if ((thumbprint = sshkey_fingerprint(key, SSH_FP_HASH_DEFAULT, SSH_FP_DEFAULT)) == NULL ||
+ if ((thumbprint = sshkey_fingerprint(key, SSH_FP_HASH_DEFAULT,
+ SSH_FP_DEFAULT)) == NULL ||
get_user_root(con, &user_root) != 0 ||
RegOpenKeyExW(user_root, SSH_KEYS_ROOT, 0,
- DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_QUERY_VALUE | KEY_WOW64_64KEY, &root) != 0 ||
- RegDeleteTreeA(root, thumbprint) != 0)
+ DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_QUERY_VALUE |
+ KEY_WOW64_64KEY, &root) != 0)
+ goto done;
+ status = delete_matching_identity(root, thumbprint,
+ (const u_char *)blob, blen);
+ if (status == ERROR_FILE_NOT_FOUND && sshkey_is_cert(key))
+ status = keyagent_pkcs11_delete_cert_identity(root, key,
+ (const u_char *)blob, blen);
+ if (status != ERROR_SUCCESS)
goto done;
success = 1;
done:
@@ -640,212 +382,6 @@ process_remove_all(struct sshbuf* request, struct sshbuf* response, struct agent
return r;
}
-#ifdef ENABLE_PKCS11
-int process_add_smartcard_key(struct sshbuf* request, struct sshbuf* response, struct agent_connection* con)
-{
- char *provider = NULL, *pin = NULL, canonical_provider[PATH_MAX];
- int i, count = 0, r = 0, request_invalid = 0, success = 0;
- struct sshkey **keys = NULL;
- struct sshkey* key = NULL;
- size_t pubkey_blob_len, provider_len, pin_len, epin_len;
- u_char *pubkey_blob = NULL;
- char *thumbprint = NULL;
- char *epin = NULL;
- HKEY reg = 0, sub = 0, user_root = 0;
- SECURITY_ATTRIBUTES sa = { 0, NULL, 0 };
-
- pkcs11_init(0);
-
- if ((r = sshbuf_get_cstring(request, &provider, &provider_len)) != 0 ||
- (r = sshbuf_get_cstring(request, &pin, &pin_len)) != 0 ||
- pin_len > 256) {
- error("add smartcard request is invalid");
- request_invalid = 1;
- goto done;
- }
-
- if (con->nsession_ids != 0 && !remote_add_provider) {
- verbose("failed PKCS#11 add of \"%.100s\": remote addition of "
- "providers is disabled", provider);
- goto done;
- }
-
- if (realpath(provider, canonical_provider) == NULL) {
- error("failed PKCS#11 add of \"%.100s\": realpath: %s",
- provider, strerror(errno));
- request_invalid = 1;
- goto done;
- }
-
- to_lower_case(provider);
- verbose("provider realpath: \"%.100s\"", provider);
- verbose("allowed provider paths: \"%.100s\"", allowed_providers);
- if (match_pattern_list(provider, allowed_providers, 1) != 1) {
- verbose("refusing PKCS#11 add of \"%.100s\": "
- "provider not allowed", provider);
- goto done;
- }
-
- // Remove 'drive root' if exists
- if (canonical_provider[0] == '/')
- memmove(canonical_provider, canonical_provider + 1, strlen(canonical_provider));
-
- count = pkcs11_add_provider(canonical_provider, pin, &keys, NULL);
- if (count <= 0) {
- error_f("failed to add key to store. count:%d", count);
- goto done;
- }
-
- // If HKCU registry already has the provider then remove the provider and associated keys.
- // This allows customers to add new keys.
- if (get_user_root(con, &user_root) != 0 ||
- is_reg_sub_key_exists(user_root, SSH_PKCS11_PROVIDERS_ROOT, canonical_provider)) {
- remove_matching_subkeys_from_registry(user_root, SSH_KEYS_ROOT, L"comment", canonical_provider);
- remove_matching_subkeys_from_registry(user_root, SSH_PKCS11_PROVIDERS_ROOT, L"provider", canonical_provider);
- }
-
- for (i = 0; i < count; i++) {
- key = keys[i];
- if (sa.lpSecurityDescriptor)
- LocalFree(sa.lpSecurityDescriptor);
- if (reg) {
- RegCloseKey(reg);
- reg = NULL;
- }
- if (sub) {
- RegCloseKey(sub);
- sub = NULL;
- }
- memset(&sa, 0, sizeof(SECURITY_ATTRIBUTES));
- sa.nLength = sizeof(sa);
- if ((!ConvertStringSecurityDescriptorToSecurityDescriptorW(REG_KEY_SDDL, SDDL_REVISION_1, &sa.lpSecurityDescriptor, &sa.nLength)) ||
- sshkey_to_blob(key, &pubkey_blob, &pubkey_blob_len) != 0 ||
- ((thumbprint = sshkey_fingerprint(key, SSH_FP_HASH_DEFAULT, SSH_FP_DEFAULT)) == NULL) ||
- RegCreateKeyExW(user_root, SSH_KEYS_ROOT, 0, 0, 0, KEY_WRITE | KEY_WOW64_64KEY, &sa, ®, NULL) != 0 ||
- RegCreateKeyExA(reg, thumbprint, 0, 0, 0, KEY_WRITE | KEY_WOW64_64KEY, &sa, &sub, NULL) != 0 ||
- RegSetValueExW(sub, NULL, 0, REG_BINARY, pubkey_blob, (DWORD)pubkey_blob_len) != 0 ||
- RegSetValueExW(sub, L"pub", 0, REG_BINARY, pubkey_blob, (DWORD)pubkey_blob_len) != 0 ||
- RegSetValueExW(sub, L"type", 0, REG_DWORD, (BYTE*)&key->type, 4) != 0 ||
- RegSetValueExW(sub, L"comment", 0, REG_BINARY, canonical_provider, (DWORD)strlen(canonical_provider)) != 0) {
- error_f("failed to add key to store");
- goto done;
- }
- }
-
- debug("added smartcard keys to store");
-
- memset(&sa, 0, sizeof(SECURITY_ATTRIBUTES));
- sa.nLength = sizeof(sa);
- if ((!ConvertStringSecurityDescriptorToSecurityDescriptorW(REG_KEY_SDDL, SDDL_REVISION_1, &sa.lpSecurityDescriptor, &sa.nLength)) ||
- convert_blob(con, pin, (DWORD)pin_len, &epin, (DWORD*)&epin_len, 1) != 0 ||
- RegCreateKeyExW(user_root, SSH_PKCS11_PROVIDERS_ROOT, 0, 0, 0, KEY_WRITE | KEY_WOW64_64KEY, &sa, ®, NULL) != 0 ||
- RegCreateKeyExA(reg, canonical_provider, 0, 0, 0, KEY_WRITE | KEY_WOW64_64KEY, &sa, &sub, NULL) != 0 ||
- RegSetValueExW(sub, L"provider", 0, REG_BINARY, canonical_provider, (DWORD)strlen(canonical_provider)) != 0 ||
- RegSetValueExW(sub, L"pin", 0, REG_BINARY, epin, (DWORD)epin_len) != 0) {
- error("failed to add pkcs11 provider to store");
- goto done;
- }
-
- debug("added pkcs11 provider to store");
- success = 1;
-done:
- r = 0;
- if (request_invalid)
- r = -1;
- else if (sshbuf_put_u8(response, success ? SSH_AGENT_SUCCESS : SSH_AGENT_FAILURE) != 0)
- r = -1;
-
- /* delete created reg keys if not succeeded*/
- if ((success == 0) && reg) {
- if (thumbprint)
- RegDeleteKeyExA(reg, thumbprint, KEY_WOW64_64KEY, 0);
- if (canonical_provider)
- RegDeleteKeyExA(reg, canonical_provider, KEY_WOW64_64KEY, 0);
- }
-
- pkcs11_terminate();
-
- if (sa.lpSecurityDescriptor)
- LocalFree(sa.lpSecurityDescriptor);
- for (i = 0; i < count; i++)
- sshkey_free(keys[i]);
- if (keys)
- free(keys);
- if (thumbprint)
- free(thumbprint);
- if (pubkey_blob)
- free(pubkey_blob);
- if (provider)
- free(provider);
- if (allowed_providers)
- free(allowed_providers);
- if (pin) {
- SecureZeroMemory(pin, (DWORD)pin_len);
- free(pin);
- }
- if (epin) {
- SecureZeroMemory(epin, (DWORD)epin_len);
- free(epin);
- }
- if (user_root)
- RegCloseKey(user_root);
- if (reg)
- RegCloseKey(reg);
- if (sub)
- RegCloseKey(sub);
- return r;
-}
-
-int process_remove_smartcard_key(struct sshbuf* request, struct sshbuf* response, struct agent_connection* con)
-{
- char *provider = NULL, *pin = NULL, canonical_provider[PATH_MAX];
- int r = 0, request_invalid = 0, success = 0, index = 0;
- HKEY user_root = 0;
-
- if ((r = sshbuf_get_cstring(request, &provider, NULL)) != 0 ||
- (r = sshbuf_get_cstring(request, &pin, NULL)) != 0) {
- error("remove smartcard request is invalid");
- request_invalid = 1;
- goto done;
- }
-
- if (realpath(provider, canonical_provider) == NULL) {
- error("failed PKCS#11 add of \"%.100s\": realpath: %s",
- provider, strerror(errno));
- request_invalid = 1;
- goto done;
- }
-
- // Remove 'drive root' if exists
- if (canonical_provider[0] == '/')
- memmove(canonical_provider, canonical_provider + 1, strlen(canonical_provider));
-
- if (get_user_root(con, &user_root) != 0 ||
- !is_reg_sub_key_exists(user_root, SSH_PKCS11_PROVIDERS_ROOT, canonical_provider))
- goto done;
-
- if (remove_matching_subkeys_from_registry(user_root, SSH_KEYS_ROOT, L"comment", canonical_provider) != 0 ||
- remove_matching_subkeys_from_registry(user_root, SSH_PKCS11_PROVIDERS_ROOT, L"provider", canonical_provider) != 0) {
- goto done;
- }
-
- success = 1;
-done:
- r = 0;
- if (request_invalid)
- r = -1;
- else if (sshbuf_put_u8(response, success ? SSH_AGENT_SUCCESS : SSH_AGENT_FAILURE) != 0)
- r = -1;
- if (provider)
- free(provider);
- if (pin)
- free(pin);
- if (user_root)
- RegCloseKey(user_root);
- return r;
-}
-#endif /* ENABLE_PKCS11 */
-
int
process_request_identities(struct sshbuf* request, struct sshbuf* response, struct agent_connection* con)
{
@@ -1046,40 +582,4 @@ process_extension(struct sshbuf* request, struct sshbuf* response, struct agent_
return r;
}
-#if 0
-int process_keyagent_request(struct sshbuf* request, struct sshbuf* response, struct agent_connection* con)
-{
- u_char type;
-
- if (sshbuf_get_u8(request, &type) != 0)
- return -1;
- debug2("process key agent request type %d", type);
-
- switch (type) {
- case SSH2_AGENTC_ADD_IDENTITY:
- return process_add_identity(request, response, con);
- case SSH2_AGENTC_REQUEST_IDENTITIES:
- return process_request_identities(request, response, con);
- case SSH2_AGENTC_SIGN_REQUEST:
- return process_sign_request(request, response, con);
- case SSH2_AGENTC_REMOVE_IDENTITY:
- return process_remove_key(request, response, con);
- case SSH2_AGENTC_REMOVE_ALL_IDENTITIES:
- return process_remove_all(request, response, con);
-#ifdef ENABLE_PKCS11
- case SSH_AGENTC_ADD_SMARTCARD_KEY:
- return process_add_smartcard_key(request, response, con);
- case SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED:
- return process_add_smartcard_key(request, response, con);
- case SSH_AGENTC_REMOVE_SMARTCARD_KEY:
- return process_remove_smartcard_key(request, response, con);
- break;
-#endif /* ENABLE_PKCS11 */
- default:
- debug("unknown key agent request %d", type);
- return -1;
- }
-}
-#endif
-
#pragma warning(pop)
diff --git a/regress/pesterTests/CommonUtils.psm1 b/regress/pesterTests/CommonUtils.psm1
index 67c696e99dbf..a4222185025a 100644
--- a/regress/pesterTests/CommonUtils.psm1
+++ b/regress/pesterTests/CommonUtils.psm1
@@ -67,7 +67,7 @@ function Set-FilePermission
function Add-PasswordSetting
{
param([string] $pass)
- if ($IsWindows) {
+ if ($IsWindows -or $env:OS -eq "Windows_NT") {
if (-not($env:DISPLAY)) {$env:DISPLAY = 1}
$askpass_util = Join-Path $PSScriptRoot "utilities\askpass_util\askpass_util.exe"
$env:SSH_ASKPASS=$askpass_util
diff --git a/regress/pesterTests/KeyUtils.Tests.ps1 b/regress/pesterTests/KeyUtils.Tests.ps1
index 5881f509d57f..167207b2efeb 100644
--- a/regress/pesterTests/KeyUtils.Tests.ps1
+++ b/regress/pesterTests/KeyUtils.Tests.ps1
@@ -215,6 +215,7 @@ Describe "E2E scenarios for ssh key management" -Tags "CI" {
}
}
AfterAll{$tC++}
+ AfterEach { Remove-PasswordSetting }
# Executing ssh-agent will start agent service
# This is to support typical Unix scenarios where
@@ -300,32 +301,96 @@ Describe "E2E scenarios for ssh key management" -Tags "CI" {
ValidateRegistryACL -count $allkeys.count
}
- It "$tC.$tI - ssh-add - pkcs11 library (if available)" {
- $pkcs11Path = "C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"
- if (Test-Path $pkcs11Path) {
- #set up SSH_ASKPASS
- Add-PasswordSetting -Pass $pkcs11Pin
-
- ssh-add -s "$pkcs11Path"
- $LASTEXITCODE | Should Be 0
- #remove SSH_ASKPASS
- Remove-PasswordSetting
+ It "$tC.$tI - ssh-add - remove software certificates" {
+ if ($NoLibreSSL) {
+ Write-Host "skipping software certificate removal test without LibreSSL"
+ return
+ }
- #ensure added keys are listed
- $allkeys = ssh-add -L
- $allKeys -notmatch "The agent has no identities." | Should Be $True
+ $ca = Join-Path $testDir "software-cert-ca"
+ $nullFile = Join-Path $testDir "$tC.$tI.nullfile"
+ $null > $nullFile
+ Remove-Item "$ca*" -Force -ErrorAction SilentlyContinue
+ & ssh-keygen -q -t ed25519 -N $keypassphrase -f $ca
+ $LASTEXITCODE | Should Be 0
- #delete added keys
- iex "cmd /c `"ssh-add -D 2> nul `""
+ # Other core suites use the SSO identity loaded by the test harness.
+ $ssoKeyPath = Join-Path $OpenSSHTestInfo["TestDataPath"] sshtest_userssokey_ed25519
+ $ssoWasLoaded = $false
+ if (Test-Path $ssoKeyPath) {
+ $ssoPublicKey = & ssh-keygen -y -f $ssoKeyPath
+ $LASTEXITCODE | Should Be 0
+ $ssoBlob = ($ssoPublicKey -split ' ')[1]
+ $ssoWasLoaded = @((ssh-add -L 2>$null) | Where-Object {
+ ($_ -split ' ')[1] -eq $ssoBlob
+ }).Count -ne 0
+ }
- #check keys are deleted
- $allkeys = ssh-add -L
- $allKeys -match "The agent has no identities." | Should Be $True
+ try {
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+ Add-PasswordSetting -Pass $keypassphrase
+ $env:SSH_ASKPASS_REQUIRE = "force"
+
+ foreach ($type in @("rsa", "ecdsa")) {
+ $keyPath = Join-Path $testDir "id_$type"
+ & ssh-keygen -q -s $ca -P $keypassphrase `
+ -I "software-$type" -n $env:USERNAME "$keyPath.pub"
+ $LASTEXITCODE | Should Be 0
+ $certPath = "$keyPath-cert.pub"
+
+ cmd /c "ssh-add `"$keyPath`" < `"$nullFile`""
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $certPath
+ $LASTEXITCODE | Should Be 0
+
+ $certBlob = (Get-Content $certPath).Split(' ')[1]
+ @((ssh-add -L) | Where-Object { $_.Contains($certBlob) }).Count |
+ Should Be 1
+ & ssh-add -d $certPath
+ $LASTEXITCODE | Should Be 0
+ @((ssh-add -L) | Where-Object { $_.Contains($certBlob) }).Count |
+ Should Be 0
+ }
}
- else {
- Write-Host "skipping pkcs11 test because provider not found"
+ finally {
+ ssh-add -D | Out-Null
+ if ($ssoWasLoaded) {
+ & ssh-add $ssoKeyPath
+ $LASTEXITCODE | Should Be 0
+ }
+ Remove-Item "$ca*" -Force -ErrorAction SilentlyContinue
+ foreach ($type in @("rsa", "ecdsa")) {
+ Remove-Item (Join-Path $testDir "id_$type-cert.pub") `
+ -Force -ErrorAction SilentlyContinue
+ }
}
}
+
+ $hardwarePrerequisitesMissing = -not $env:OPENSSH_TEST_PKCS11_PROVIDER -or
+ -not $env:OPENSSH_TEST_PKCS11_PIN
+ It "$tC.$tI - ssh-add - pkcs11 library [requires OPENSSH_TEST_PKCS11_PROVIDER and OPENSSH_TEST_PKCS11_PIN]" -Skip:$hardwarePrerequisitesMissing {
+ $pkcs11Path = $env:OPENSSH_TEST_PKCS11_PROVIDER
+ Test-Path -LiteralPath $pkcs11Path | Should Be $true
+ #set up SSH_ASKPASS
+ $testPin = $env:OPENSSH_TEST_PKCS11_PIN
+ Add-PasswordSetting -Pass $testPin
+ $env:SSH_ASKPASS_REQUIRE = "force"
+ ssh-add -s "$pkcs11Path"
+ $LASTEXITCODE | Should Be 0
+
+ #ensure added keys are listed
+ $allkeys = ssh-add -L
+ $allKeys -notmatch "The agent has no identities." | Should Be $True
+
+ #delete added keys
+ iex "cmd /c `"ssh-add -D 2> nul `""
+
+ #check keys are deleted
+ $allkeys = ssh-add -L
+ $allKeys -match "The agent has no identities." | Should Be $True
+ }
+
}
Context "$tC ssh-keygen known_hosts operations" {
diff --git a/regress/pesterTests/PKCS11Certificates.Tests.ps1 b/regress/pesterTests/PKCS11Certificates.Tests.ps1
new file mode 100644
index 000000000000..ade559b7671c
--- /dev/null
+++ b/regress/pesterTests/PKCS11Certificates.Tests.ps1
@@ -0,0 +1,581 @@
+param(
+ [string]$OpenSSHBinPath,
+ [string]$TestDirectory,
+ [ValidateSet('SoftHSM', 'Hardware')][string]$Mode = 'SoftHSM'
+)
+$repoRoot = Split-Path (Split-Path $PSScriptRoot)
+Import-Module (Join-Path $repoRoot '.github/tools/PKCS11TestHelpers.psm1') -Force
+function Get-Pkcs11CaseName($Name, $Reason) {
+ if ($Reason) { return "$Name [skipped: $Reason]" }
+ return $Name
+}
+
+Describe 'Windows PKCS11 certificate integration' -Tags 'PKCS11' {
+ BeforeAll {
+ $config = Get-Pkcs11TestConfiguration -Mode $Mode
+ $skipReason = $config.SkipReason
+ $softwareSkipReason = $config.SoftwareSkipReason
+ if ($skipReason) { return }
+ $testDir = $TestDirectory
+ $null = New-Item -ItemType Directory -Path $testDir -Force
+ # CommonUtils imports this module by name. Make the repository copy
+ # discoverable without requiring a machine-wide module installation.
+ $modules = Join-Path $testDir 'modules'
+ $utils = Join-Path $modules 'OpenSSHUtils'
+ $null = New-Item -ItemType Directory -Path $utils -Force
+ foreach ($file in @('OpenSSHUtils.psd1', 'OpenSSHUtils.psm1')) {
+ Copy-Item -LiteralPath (Join-Path $repoRoot "contrib/win32/openssh/$file") -Destination $utils
+ }
+ $env:PSModulePath = "$modules;$env:PSModulePath"
+ Import-Module OpenSSHUtils -Force -Global
+ Import-Module (Join-Path $PSScriptRoot 'CommonUtils.psm1') -Force
+ $keypassphrase = 'testpassword'
+ $pkcs11Pin = $env:OPENSSH_TEST_PKCS11_PIN
+ $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
+ $currentUserSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
+ $tC = 1
+ $tI = 0
+ function Invoke-Pkcs11TestBinary($Name, [string[]]$Arguments) {
+ $result = Invoke-Pkcs11Command (Join-Path $OpenSSHBinPath $Name) $Arguments -AllowFailure
+ $global:LASTEXITCODE = $result.ExitCode
+ if ($result.StdErr) { Write-Host $result.StdErr.TrimEnd() }
+ if ($result.StdOut) { return ($result.StdOut.TrimEnd() -split '\r?\n') }
+ }
+ function ssh-add { Invoke-Pkcs11TestBinary 'ssh-add.exe' $args }
+ function ssh-keygen { Invoke-Pkcs11TestBinary 'ssh-keygen.exe' $args }
+ function Restart-Service { Restart-Pkcs11Agent }
+ function WaitForStatus($ServiceName, $Status) {
+ (Get-Service $ServiceName).WaitForStatus($Status, [TimeSpan]::FromSeconds(60))
+ }
+ foreach ($type in @('rsa', 'ecdsa')) {
+ ssh-keygen -q -t $type -N $keypassphrase -f (Join-Path $testDir "id_$type")
+ $LASTEXITCODE | Should Be 0
+ }
+ }
+ BeforeEach {
+ if (-not $skipReason) {
+ ssh-add -D | Out-Null
+ $LASTEXITCODE | Should Be 0
+ $tI++
+ }
+ }
+ AfterEach {
+ if (-not $skipReason) {
+ ssh-add -D | Out-Null
+ Remove-PasswordSetting
+ }
+ }
+
+ foreach ($algorithm in @('RSA', 'ECDSA')) {
+ It (Get-Pkcs11CaseName "PKCS11 $algorithm add/list/sign" $skipReason) -Skip:([bool]$skipReason) -TestCases @(@{ Algorithm = $algorithm }) {
+ param($Algorithm)
+ $keys = $config.PublicKeys
+ $key = @($keys | Where-Object {
+ (Get-Content -LiteralPath $_) -match $(if ($Algorithm -eq 'RSA') { '^ssh-rsa ' } else { '^ecdsa-sha2-nistp256 ' })
+ })
+ $key.Count | Should Be 1
+ Add-PasswordSetting -Pass $pkcs11Pin
+ $env:SSH_ASKPASS_REQUIRE = 'force'
+ ssh-add -s $config.Provider
+ $LASTEXITCODE | Should Be 0
+ $blob = (Get-Content -LiteralPath $key[0]).Split(' ')[1]
+ @((ssh-add -L) | Where-Object { $_.Contains($blob) }).Count | Should Be 1
+ ssh-add -T $key[0]
+ $LASTEXITCODE | Should Be 0
+ ssh-add -e $config.Provider
+ $LASTEXITCODE | Should Be 0
+ }
+ }
+ It (Get-Pkcs11CaseName 'PKCS11 associated certificate lifecycle' $skipReason) -Skip:([bool]$skipReason) {
+ $pkcs11Path = $env:OPENSSH_TEST_PKCS11_PROVIDER
+ $publicKeyPaths = @($env:OPENSSH_TEST_PKCS11_PUBLIC_KEYS -split ';' |
+ Where-Object { $_ })
+
+
+ foreach ($publicKeyPath in $publicKeyPaths) {
+ Test-Path $publicKeyPath | Should Be $true
+ }
+ Test-Path Env:OPENSSH_TEST_PKCS11_LABELS | Should Be $true
+ $pkcs11Labels = @($env:OPENSSH_TEST_PKCS11_LABELS.Split(
+ [char[]]@(';'), [StringSplitOptions]::None))
+ $pkcs11Labels.Count | Should Be $publicKeyPaths.Count
+ $canonicalProvider = [IO.Path]::GetFullPath($pkcs11Path).Replace('\', '/')
+ $expectedComments = @($pkcs11Labels | ForEach-Object {
+ if ($_) { $_ } else { $canonicalProvider }
+ })
+
+ function Assert-Pkcs11IdentityComments {
+ param([string[]]$KeyPaths, [string[]]$Comments)
+
+ $longListing = @(ssh-add -L)
+ $shortListing = @(ssh-add -l)
+ $KeyPaths.Count | Should Be $Comments.Count
+ $fingerprints = @($KeyPaths | ForEach-Object {
+ ((ssh-keygen -lf $_) -split ' ')[1]
+ })
+ for ($index = 0; $index -lt $KeyPaths.Count; $index++) {
+ $keyBlob = (Get-Content $KeyPaths[$index]).Split(' ')[1]
+ $longEntry = @($longListing | Where-Object {
+ $_.Contains($keyBlob)
+ })
+ $longEntry.Count | Should Be 1
+ ($longEntry[0] -split ' ', 3)[2] | Should Be $Comments[$index]
+
+ $fingerprint = $fingerprints[$index]
+ $shortEntry = @($shortListing | Where-Object {
+ $_.Contains(" $fingerprint ")
+ })
+ $shortEntry.Count | Should Be @($fingerprints |
+ Where-Object { $_ -eq $fingerprint }).Count
+ foreach ($entry in $shortEntry) {
+ $entry | Should Match (" " +
+ [regex]::Escape($Comments[$index]) + " \([^)]+\)$")
+ }
+ }
+ }
+ $testPin = $env:OPENSSH_TEST_PKCS11_PIN
+
+ $ca = Join-Path $testDir "pkcs11-ca"
+ Remove-Item "$ca*" -Force -ErrorAction SilentlyContinue
+ & ssh-keygen -q -t ed25519 -N $keypassphrase -f $ca
+ $LASTEXITCODE | Should Be 0
+
+ $certPaths = @()
+ $copiedPublicKeyPaths = @()
+ $serial = 1
+ foreach ($publicKeyPath in $publicKeyPaths) {
+ $copiedPublicKeyPath = Join-Path $testDir "pkcs11-$serial.pub"
+ Copy-Item $publicKeyPath $copiedPublicKeyPath -Force
+ & ssh-keygen -q -s $ca -P $keypassphrase -I "pkcs11-$serial" `
+ -n $env:USERNAME -z $serial $copiedPublicKeyPath
+ $LASTEXITCODE | Should Be 0
+ $copiedPublicKeyPaths += $copiedPublicKeyPath
+ $certPaths += $copiedPublicKeyPath.Replace(".pub", "-cert.pub")
+ $serial++
+ }
+ & ssh-keygen -q -s $ca -P $keypassphrase -I "pkcs11-unmatched" `
+ -n $env:USERNAME -z 999 "$ca.pub"
+ $LASTEXITCODE | Should Be 0
+ $unmatchedCertPath = "$ca-cert.pub"
+ $associatedCertPaths = $certPaths + $unmatchedCertPath
+
+ $sequentialPublicKeyPath = Join-Path $testDir "pkcs11-sequential.pub"
+ Copy-Item $publicKeyPaths[0] $sequentialPublicKeyPath -Force
+ & ssh-keygen -q -s $ca -P $keypassphrase -I "pkcs11-sequential" `
+ -n $env:USERNAME -z 1000 $sequentialPublicKeyPath
+ $LASTEXITCODE | Should Be 0
+ $sequentialCertPath = $sequentialPublicKeyPath.Replace(".pub", "-cert.pub")
+
+ Add-PasswordSetting -Pass $testPin
+ $env:SSH_ASKPASS_REQUIRE = "force"
+
+ $addArguments = @("-s", $pkcs11Path) + $associatedCertPaths
+ & ssh-add @addArguments
+ $LASTEXITCODE | Should Be 0
+ $allKeys = @(ssh-add -L)
+ foreach ($keyPath in $copiedPublicKeyPaths + $certPaths) {
+ $keyBlob = (Get-Content $keyPath).Split(' ')[1]
+ @($allKeys | Where-Object { $_.Contains($keyBlob) }).Count | Should Be 1
+ & ssh-add -T $keyPath
+ $LASTEXITCODE | Should Be 0
+ }
+ Assert-Pkcs11IdentityComments `
+ ($copiedPublicKeyPaths + $certPaths) `
+ ($expectedComments + $expectedComments)
+
+ Restart-Service ssh-agent
+ WaitForStatus -ServiceName ssh-agent -Status "Running"
+ foreach ($certPath in $certPaths) {
+ & ssh-add -T $certPath
+ $LASTEXITCODE | Should Be 0
+ }
+ Assert-Pkcs11IdentityComments `
+ ($copiedPublicKeyPaths + $certPaths) `
+ ($expectedComments + $expectedComments)
+
+ # Provider paths and Registry key names are case-insensitive on
+ # Windows. Re-adding only one certificate with alternate casing
+ # must not orphan the identities that retain the original path.
+ $caseVariantProvider = ([IO.Path]::GetFullPath(
+ $pkcs11Path)).ToUpperInvariant()
+ & ssh-add -s $caseVariantProvider -C $certPaths[0]
+ $LASTEXITCODE | Should Be 0
+ Restart-Service ssh-agent
+ WaitForStatus -ServiceName ssh-agent -Status "Running"
+ foreach ($keyPath in $copiedPublicKeyPaths + $certPaths) {
+ & ssh-add -T $keyPath
+ $LASTEXITCODE | Should Be 0
+ }
+ & ssh-add -e $caseVariantProvider
+ $LASTEXITCODE | Should Be 0
+ @(ssh-add -L) -match "The agent has no identities." | Should Be $true
+
+ # Restore the complete set for the remaining deletion scenarios.
+ & ssh-add @addArguments
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -d $certPaths[0]
+ $LASTEXITCODE | Should Be 0
+ $deletedKeyBlob = (Get-Content $certPaths[0]).Split(' ')[1]
+ @((ssh-add -L) | Where-Object { $_.Contains($deletedKeyBlob) }).Count |
+ Should Be 0
+
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+
+ # Separate additions for the same token key must merge certificates.
+ $addArguments = @("-s", $pkcs11Path, "-C", $certPaths[0])
+ & ssh-add @addArguments
+ $LASTEXITCODE | Should Be 0
+ $addArguments = @("-s", $pkcs11Path, "-C", $sequentialCertPath)
+ & ssh-add @addArguments
+ $LASTEXITCODE | Should Be 0
+ $allKeys = @(ssh-add -L)
+ foreach ($certPath in @($certPaths[0], $sequentialCertPath)) {
+ $keyBlob = (Get-Content $certPath).Split(' ')[1]
+ @($allKeys | Where-Object { $_.Contains($keyBlob) }).Count | Should Be 1
+ & ssh-add -T $certPath
+ $LASTEXITCODE | Should Be 0
+ }
+
+ # Re-adding an exact certificate is successful and idempotent.
+ & ssh-add @addArguments
+ $LASTEXITCODE | Should Be 0
+ $sequentialCertBlob = (Get-Content $sequentialCertPath).Split(' ')[1]
+ @((ssh-add -L) | Where-Object { $_.Contains($sequentialCertBlob) }).Count |
+ Should Be 1
+ Assert-Pkcs11IdentityComments `
+ @($certPaths[0], $sequentialCertPath) `
+ @($expectedComments[0], $expectedComments[0])
+
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+
+ # cert-only applies to this request and must preserve plain identities.
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -s $pkcs11Path -C $certPaths[0]
+ $LASTEXITCODE | Should Be 0
+ $allKeys = @(ssh-add -L)
+ foreach ($keyPath in $copiedPublicKeyPaths + $certPaths[0]) {
+ $keyBlob = (Get-Content $keyPath).Split(' ')[1]
+ @($allKeys | Where-Object { $_.Contains($keyBlob) }).Count | Should Be 1
+ }
+ Assert-Pkcs11IdentityComments `
+ ($copiedPublicKeyPaths + $certPaths[0]) `
+ ($expectedComments + $expectedComments[0])
+
+ # A failed unmatched add must not change existing persisted identities.
+ $identitiesBefore = @(ssh-add -L | Sort-Object)
+ & ssh-add -s $pkcs11Path -C $unmatchedCertPath
+ $LASTEXITCODE | Should Not Be 0
+ $identitiesAfter = @(ssh-add -L | Sort-Object)
+ @(Compare-Object $identitiesBefore $identitiesAfter).Count | Should Be 0
+
+ Restart-Service ssh-agent
+ WaitForStatus -ServiceName ssh-agent -Status "Running"
+ foreach ($keyPath in $copiedPublicKeyPaths + $certPaths[0]) {
+ & ssh-add -T $keyPath
+ $LASTEXITCODE | Should Be 0
+ }
+ Assert-Pkcs11IdentityComments `
+ ($copiedPublicKeyPaths + $certPaths[0]) `
+ ($expectedComments + $expectedComments[0])
+
+ & ssh-add -d $certPaths[0]
+ $LASTEXITCODE | Should Be 0
+ foreach ($keyPath in $copiedPublicKeyPaths) {
+ $keyBlob = (Get-Content $keyPath).Split(' ')[1]
+ @((ssh-add -L) | Where-Object { $_.Contains($keyBlob) }).Count |
+ Should Be 1
+ }
+
+ # Legacy identities used comment for their provider association.
+ $identityRootPath = "$currentUserSid\Software\OpenSSH\Agent\Keys"
+ $identityRoot = [Microsoft.Win32.Registry]::Users.OpenSubKey(
+ $identityRootPath, $true)
+ $identityRoot | Should Not Be $null
+ $plainBlob = (Get-Content $copiedPublicKeyPaths[0]).Split(' ')[1]
+ $identityKey = $null
+ foreach ($identityName in $identityRoot.GetSubKeyNames()) {
+ $candidate = $identityRoot.OpenSubKey($identityName, $true)
+ $storedBlob = $candidate.GetValue("pub")
+ if ($storedBlob -is [byte[]] -and
+ [Convert]::ToBase64String($storedBlob) -eq $plainBlob) {
+ $identityKey = $candidate
+ break
+ }
+ $candidate.Dispose()
+ }
+ $identityKey | Should Not Be $null
+ $providerBytes = [Text.Encoding]::UTF8.GetBytes($canonicalProvider)
+ $identityKey.DeleteValue("provider", $false)
+ $identityKey.SetValue("comment", $providerBytes,
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+
+ Restart-Service ssh-agent
+ WaitForStatus -ServiceName ssh-agent -Status "Running"
+ Assert-Pkcs11IdentityComments @($copiedPublicKeyPaths[0]) `
+ @($canonicalProvider)
+ & ssh-add -T $copiedPublicKeyPaths[0]
+ $LASTEXITCODE | Should Be 0
+ $identityKey.GetValue("provider", $null) | Should Be $null
+ [Text.Encoding]::UTF8.GetString($identityKey.GetValue("comment")) |
+ Should Be $canonicalProvider
+
+ # A failed provider update must roll legacy metadata back.
+ $providerRootPath = "$currentUserSid\Software\OpenSSH\Agent\PKCS11_Providers"
+ $providerRoot = [Microsoft.Win32.Registry]::Users.OpenSubKey(
+ $providerRootPath, $true)
+ $providerRoot | Should Not Be $null
+ $providerKey = $providerRoot.OpenSubKey($canonicalProvider,
+ [Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree,
+ [Security.AccessControl.RegistryRights]::FullControl)
+ $providerKey | Should Not Be $null
+ $blockedAcl = $providerKey.GetAccessControl()
+ $denySetValue = New-Object `
+ System.Security.AccessControl.RegistryAccessRule(
+ $systemSid,
+ [System.Security.AccessControl.RegistryRights]::SetValue,
+ [System.Security.AccessControl.AccessControlType]::Deny)
+ $blockedAcl.AddAccessRule($denySetValue) | Out-Null
+ try {
+ $providerKey.SetAccessControl($blockedAcl)
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Not Be 0
+ $identityKey.GetValue("provider", $null) | Should Be $null
+ [Text.Encoding]::UTF8.GetString(
+ $identityKey.GetValue("comment")) |
+ Should Be $canonicalProvider
+ }
+ finally {
+ $blockedAcl.RemoveAccessRuleSpecific($denySetValue)
+ $providerKey.SetAccessControl($blockedAcl)
+ }
+
+ # Re-adding migrates metadata without replacing the key entry.
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ [Text.Encoding]::UTF8.GetString($identityKey.GetValue("provider")) |
+ Should Be $canonicalProvider
+ [Text.Encoding]::UTF8.GetString($identityKey.GetValue("comment")) |
+ Should Be $expectedComments[0]
+ Assert-Pkcs11IdentityComments @($copiedPublicKeyPaths[0]) `
+ @($expectedComments[0])
+
+ # Provider removal accepts both migrated and legacy identities.
+ $identityKey.DeleteValue("provider", $false)
+ $identityKey.SetValue("comment", $providerBytes,
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+ $providerKey.Dispose()
+ $providerRoot.Dispose()
+ $identityKey.Dispose()
+ $identityRoot.Dispose()
+
+ & ssh-add -e $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ @(ssh-add -L) -match "The agent has no identities." | Should Be $true
+ }
+
+ It (Get-Pkcs11CaseName 'PKCS11 software identity preservation' $softwareSkipReason) -Skip:([bool]$softwareSkipReason) {
+ $pkcs11Path = $env:OPENSSH_TEST_PKCS11_PROVIDER
+ $publicKeyPaths = @($env:OPENSSH_TEST_PKCS11_PUBLIC_KEYS -split ';' |
+ Where-Object { $_ })
+ $softwareKeySource = $env:OPENSSH_TEST_PKCS11_SOFTWARE_KEY
+
+
+ $testPin = $env:OPENSSH_TEST_PKCS11_PIN
+
+ $softwareKeyPath = Join-Path $testDir "pkcs11-software"
+ $nullFile = Join-Path $testDir "$tC.$tI.nullfile"
+ $null > $nullFile
+ Copy-Item $softwareKeySource $softwareKeyPath -Force
+ Copy-Item $publicKeyPaths[0] "$softwareKeyPath.pub" -Force
+ Repair-UserKeyPermission $softwareKeyPath -confirm:$false
+ $softwareBlob = ((ssh-keygen -y -f $softwareKeyPath) -split ' ')[1]
+ $softwareBlob | Should Be ((Get-Content $publicKeyPaths[0]).Split(' ')[1])
+
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+ ssh-add $softwareKeyPath
+ @((ssh-add -L) | Where-Object { $_.Contains($softwareBlob) }).Count |
+ Should Be 1
+
+ # The token key has the same public key as the software identity,
+ # so it must not silently take over the software identity.
+ Add-PasswordSetting -Pass $testPin
+ $env:SSH_ASKPASS_REQUIRE = "force"
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Not Be 0
+ Remove-PasswordSetting
+ @((ssh-add -L) | Where-Object { $_.Contains($softwareBlob) }).Count |
+ Should Be 1
+ & ssh-add -T "$softwareKeyPath.pub"
+ $LASTEXITCODE | Should Be 0
+
+ # After removing the software identity the provider can be added.
+ & ssh-add -d $softwareKeyPath
+ $LASTEXITCODE | Should Be 0
+ Add-PasswordSetting -Pass $testPin
+ $env:SSH_ASKPASS_REQUIRE = "force"
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ @((ssh-add -L) | Where-Object { $_.Contains($softwareBlob) }).Count |
+ Should Be 1
+ & ssh-add -e $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ @(ssh-add -L) -match "The agent has no identities." | Should Be $true
+ }
+
+ It (Get-Pkcs11CaseName 'PKCS11 software identity detachment' $softwareSkipReason) -Skip:([bool]$softwareSkipReason) {
+ $pkcs11Path = $env:OPENSSH_TEST_PKCS11_PROVIDER
+ $publicKeyPaths = @($env:OPENSSH_TEST_PKCS11_PUBLIC_KEYS -split ';' |
+ Where-Object { $_ })
+ $softwareKeySource = $env:OPENSSH_TEST_PKCS11_SOFTWARE_KEY
+
+
+ $testPin = $env:OPENSSH_TEST_PKCS11_PIN
+
+ $softwareKeyPath = Join-Path $testDir "pkcs11-software"
+ $nullFile = Join-Path $testDir "$tC.$tI.nullfile"
+ $null > $nullFile
+ Copy-Item $softwareKeySource $softwareKeyPath -Force
+ Copy-Item $publicKeyPaths[0] "$softwareKeyPath.pub" -Force
+ Repair-UserKeyPermission $softwareKeyPath -confirm:$false
+ $softwareBlob = ((ssh-keygen -y -f $softwareKeyPath) -split ' ')[1]
+ $softwareBlob | Should Be ((Get-Content $publicKeyPaths[0]).Split(' ')[1])
+
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+ Add-PasswordSetting -Pass $testPin
+ $env:SSH_ASKPASS_REQUIRE = "force"
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ Remove-PasswordSetting
+
+ # Adding the same key as software key makes it a software identity.
+ # Removing the provider must no longer delete it.
+ ssh-add $softwareKeyPath
+ & ssh-add -e $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ @((ssh-add -L) | Where-Object { $_.Contains($softwareBlob) }).Count |
+ Should Be 1
+ & ssh-add -T "$softwareKeyPath.pub"
+ $LASTEXITCODE | Should Be 0
+
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+ }
+
+ It (Get-Pkcs11CaseName 'PKCS11 stale provider isolation' $skipReason) -Skip:([bool]$skipReason) {
+ $pkcs11Path = $env:OPENSSH_TEST_PKCS11_PROVIDER
+ $publicKeyPaths = @($env:OPENSSH_TEST_PKCS11_PUBLIC_KEYS -split ';' |
+ Where-Object { $_ })
+
+
+ $testPin = $env:OPENSSH_TEST_PKCS11_PIN
+
+ $softwareKeyPath = Join-Path $testDir "id_rsa"
+ $unavailableKeyPath = Join-Path $testDir "id_ecdsa.pub"
+ $nullFile = Join-Path $testDir "$tC.$tI.nullfile"
+ $null > $nullFile
+ $providerRoot = $null
+ $validProviderKey = $null
+ $staleProviderKey = $null
+ $staleProviderPath = Join-Path $testDir `
+ "nonexistent\openssh-stale-provider.dll"
+ $staleProvider = [IO.Path]::GetFullPath($staleProviderPath).Replace(
+ '\', '/')
+ $corruptProviderPath = Join-Path $testDir `
+ "nonexistent\openssh-corrupt-provider.dll"
+ $corruptProvider = [IO.Path]::GetFullPath(
+ $corruptProviderPath).Replace('\', '/')
+ $oversizedProviderPath = Join-Path $testDir `
+ "nonexistent\openssh-oversized-provider.dll"
+ $oversizedProvider = [IO.Path]::GetFullPath(
+ $oversizedProviderPath).Replace('\', '/')
+
+ try {
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+
+ Add-PasswordSetting -Pass $keypassphrase
+ $env:SSH_ASKPASS_REQUIRE = "force"
+ ssh-add $softwareKeyPath
+ $LASTEXITCODE | Should Be 0
+ Remove-PasswordSetting
+
+ Add-PasswordSetting -Pass $testPin
+ $env:SSH_ASKPASS_REQUIRE = "force"
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $softwareKeyPath
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $publicKeyPaths[0]
+ $LASTEXITCODE | Should Be 0
+
+ $providerRootPath = "$currentUserSid\Software\OpenSSH\Agent\PKCS11_Providers"
+ $providerRoot = [Microsoft.Win32.Registry]::Users.OpenSubKey(
+ $providerRootPath, $true)
+ $providerRoot | Should Not Be $null
+ $canonicalProvider = [IO.Path]::GetFullPath($pkcs11Path).Replace('\', '/')
+ $validProviderKey = $providerRoot.OpenSubKey($canonicalProvider)
+ $validProviderKey | Should Not Be $null
+ $encryptedPin = $validProviderKey.GetValue("pin")
+ $encryptedPin -is [byte[]] | Should Be $true
+
+ $staleProviderKey = $providerRoot.CreateSubKey($staleProvider)
+ $staleProviderKey.SetValue("provider",
+ [Text.Encoding]::UTF8.GetBytes($staleProvider),
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+ $staleProviderKey.SetValue("pin", $encryptedPin,
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+
+ & ssh-add -T $softwareKeyPath
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $publicKeyPaths[0]
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $unavailableKeyPath
+ $LASTEXITCODE | Should Not Be 0
+
+ $staleProviderKey.Dispose()
+ $staleProviderKey = $providerRoot.CreateSubKey($corruptProvider)
+ $staleProviderKey.SetValue("provider",
+ [Text.Encoding]::UTF8.GetBytes($corruptProvider),
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+ $staleProviderKey.SetValue("pin",
+ [Text.Encoding]::UTF8.GetBytes("invalid encrypted pin"),
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+
+ & ssh-add -T $softwareKeyPath
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $publicKeyPaths[0]
+ $LASTEXITCODE | Should Be 0
+
+ $staleProviderKey.Dispose()
+ $staleProviderKey = $providerRoot.CreateSubKey($oversizedProvider)
+ $staleProviderKey.SetValue("provider",
+ [Text.Encoding]::UTF8.GetBytes($oversizedProvider),
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+ $staleProviderKey.SetValue("pin", (New-Object byte[] 11000),
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+
+ & ssh-add -T $softwareKeyPath
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $publicKeyPaths[0]
+ $LASTEXITCODE | Should Be 0
+ }
+ finally {
+ if ($staleProviderKey) { $staleProviderKey.Dispose() }
+ if ($validProviderKey) { $validProviderKey.Dispose() }
+ if ($providerRoot) {
+ $providerRoot.DeleteSubKeyTree($staleProvider, $false)
+ $providerRoot.DeleteSubKeyTree($corruptProvider, $false)
+ $providerRoot.DeleteSubKeyTree($oversizedProvider, $false)
+ $providerRoot.Dispose()
+ }
+ ssh-add -D | Out-Null
+ Remove-PasswordSetting
+ }
+ }
+
+}
diff --git a/regress/pesterTests/PKCS11Constraints.Tests.ps1 b/regress/pesterTests/PKCS11Constraints.Tests.ps1
new file mode 100644
index 000000000000..0275f1edfdef
--- /dev/null
+++ b/regress/pesterTests/PKCS11Constraints.Tests.ps1
@@ -0,0 +1,149 @@
+# PKCS#11 constraint rejection must not require a provider DLL or token.
+Describe "Windows agent PKCS11 constraint rejection" -Tags "CI" {
+ BeforeAll {
+ function New-AgentUInt32 {
+ param([int]$Value)
+ return ,([BitConverter]::GetBytes(
+ [Net.IPAddress]::HostToNetworkOrder($Value)))
+ }
+
+ function New-AgentString {
+ param([byte[]]$Value)
+ return ,([byte[]]((New-AgentUInt32 $Value.Length) + $Value))
+ }
+
+ function Read-AgentBytes {
+ param([IO.Pipes.NamedPipeClientStream]$Pipe, [int]$Count)
+ $buffer = New-Object byte[] $Count
+ $offset = 0
+ while ($offset -lt $Count) {
+ $read = $Pipe.BeginRead($buffer, $offset, $Count - $offset,
+ $null, $null)
+ try {
+ if (-not $read.AsyncWaitHandle.WaitOne(5000)) {
+ $Pipe.Dispose()
+ throw "Timed out reading from ssh-agent"
+ }
+ $received = $Pipe.EndRead($read)
+ }
+ finally {
+ $read.AsyncWaitHandle.Close()
+ }
+ if ($received -eq 0) {
+ throw "ssh-agent closed the connection without a reply"
+ }
+ $offset += $received
+ }
+ return ,$buffer
+ }
+
+ function Send-AgentRequest {
+ param([IO.Pipes.NamedPipeClientStream]$Pipe, [byte[]]$Payload)
+ $frame = [byte[]]((New-AgentUInt32 $Payload.Length) + $Payload)
+ $write = $Pipe.BeginWrite($frame, 0, $frame.Length, $null, $null)
+ try {
+ if (-not $write.AsyncWaitHandle.WaitOne(5000)) {
+ $Pipe.Dispose()
+ throw "Timed out writing to ssh-agent"
+ }
+ $Pipe.EndWrite($write)
+ }
+ finally {
+ $write.AsyncWaitHandle.Close()
+ }
+ $header = Read-AgentBytes $Pipe 4
+ $length = [Net.IPAddress]::NetworkToHostOrder(
+ [BitConverter]::ToInt32($header, 0))
+ if ($length -lt 1 -or $length -gt 262144) {
+ throw "Invalid ssh-agent reply length: $length"
+ }
+ return ,(Read-AgentBytes $Pipe $length)
+ }
+
+ function Get-AgentRegistryNames {
+ # Capture names only: never print stored key or PIN values.
+ $names = @()
+ foreach ($rootName in @('Keys', 'PKCS11_Providers')) {
+ $root = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey(
+ "Software\OpenSSH\Agent\$rootName")
+ try {
+ if ($null -ne $root) {
+ $names += "$rootName/"
+ $names += @($root.GetSubKeyNames() | ForEach-Object {
+ "$rootName/$_"
+ })
+ }
+ }
+ finally {
+ if ($null -ne $root) { $root.Dispose() }
+ }
+ }
+ return (($names | Sort-Object) -join "`n")
+ }
+ }
+
+ It "rejects constraints without changing identities and keeps the connection usable" {
+ # Protocol constants from authfd.h: request=26, identities=11/12,
+ # failure=5, lifetime=1, confirm=2, extension=255.
+ $extensionName = New-AgentString ([Text.Encoding]::UTF8.GetBytes(
+ 'restrict-destination-v00@openssh.com'))
+ $constraints = @(
+ @{ Name = 'lifetime'; Blob = [byte[]](@(1) + (New-AgentUInt32 60)) },
+ @{ Name = 'confirm'; Blob = [byte[]]@(2) },
+ @{ Name = 'destinations'; Blob = [byte[]](@(255) + $extensionName +
+ (New-AgentString ([byte[]]@()))) }
+ )
+ $cases = @($constraints)
+ foreach ($keyType in @('rsa', 'ecdsa')) {
+ $certFile = Join-Path $PSScriptRoot "..\unittests\sshkey\testdata\$($keyType)_1-cert.pub"
+ $cert = [Convert]::FromBase64String((Get-Content $certFile).Split(' ')[1])
+ $certList = New-AgentString (New-AgentString $cert)
+ $associatedName = New-AgentString ([Text.Encoding]::UTF8.GetBytes(
+ 'associated-certs-v00@openssh.com'))
+ foreach ($certOnly in @(0, 1)) {
+ $associated = [byte[]](@(255) + $associatedName + @($certOnly) + $certList)
+ foreach ($constraint in $constraints) {
+ $cases += @(
+ @{ Name = "$keyType/$certOnly/$($constraint.Name)/before";
+ Blob = [byte[]]($constraint.Blob + $associated) },
+ @{ Name = "$keyType/$certOnly/$($constraint.Name)/after";
+ Blob = [byte[]]($associated + $constraint.Blob) }
+ )
+ }
+ }
+ }
+ $provider = Join-Path $env:TEMP ("openssh-unsupported-" +
+ [guid]::NewGuid().ToString() + '.dll')
+ $add = [byte[]](@(26) + (New-AgentString (
+ [Text.Encoding]::UTF8.GetBytes($provider))) +
+ (New-AgentString ([byte[]]@())))
+ $pipe = New-Object IO.Pipes.NamedPipeClientStream('.',
+ 'openssh-ssh-agent', [IO.Pipes.PipeDirection]::InOut,
+ [IO.Pipes.PipeOptions]::Asynchronous,
+ [Security.Principal.TokenImpersonationLevel]::Impersonation)
+ try {
+ # Missing test agents fail this CI test instead of skipping it.
+ $pipe.Connect(5000)
+ $identities = Send-AgentRequest $pipe ([byte[]]@(11))
+ $identities[0] | Should Be 12
+ $identitiesBefore = [Convert]::ToBase64String($identities)
+ $registryBefore = Get-AgentRegistryNames
+ foreach ($case in $cases) {
+ try {
+ $reply = Send-AgentRequest $pipe ([byte[]]($add + $case.Blob))
+ $reply.Length | Should Be 1
+ $reply[0] | Should Be 5
+ $identitiesAfter = Send-AgentRequest $pipe ([byte[]]@(11))
+ [Convert]::ToBase64String($identitiesAfter) | Should Be $identitiesBefore
+ Get-AgentRegistryNames | Should Be $registryBefore
+ }
+ catch {
+ throw "PKCS11 constraint $($case.Name): $($_.Exception.Message)"
+ }
+ }
+ }
+ finally {
+ $pipe.Dispose()
+ }
+ }
+}
diff --git a/regress/pesterTests/README.md b/regress/pesterTests/README.md
index afd636e76c6d..8e10d356062e 100644
--- a/regress/pesterTests/README.md
+++ b/regress/pesterTests/README.md
@@ -1,4 +1,4 @@
-Run OpenSSH Pester Tests:
+Run OpenSSH Pester Tests:
==================================
#### To setup the test environment before test run:
@@ -64,3 +64,109 @@ Follow these simple steps for test case indexing
AfterAll{$tC++}
```
- Prefix any test out file with $tC.$tI. You may use pre-created $stderrFile, $stdoutFile, $logFile for this purpose
+
+#### PKCS#11 certificate tests
+
+The Windows agent rejects PKCS#11 adds with lifetime, confirmation, or
+destination constraints because persisted identities cannot enforce them.
+This applies both to plain keys and to associated certificates. Adds without
+these constraints, including certificate-only adds, remain supported. Existing
+Registry identities are not migrated or removed.
+
+`PKCS11Constraints.Tests.ps1` is a required CI test that sends raw agent
+requests without a provider DLL, PIN, or token. It checks rejection of all
+three constraints, including combinations with RSA/ECDSA certificates,
+unchanged identities and Registry subkey names, and continued use of the same
+connection. It requires the test agent to be running and permission to read
+the test user's agent Registry keys; missing prerequisites fail the test.
+
+`PKCS11Certificates.Tests.ps1` runs through the dedicated runner, which is
+mandatory in the x64 Azure core job and in local `Invoke-OpenSSHTests.ps1`
+E2E runs for x64/x86. ARM/ARM64 retain the core tests and report a warning
+that SoftHSM certificate coverage is unavailable. Core Pester runs first;
+the software-certificate removal test restores
+the harness's previously loaded SSO key so later authentication suites can use
+it. The managed harness then removes its remaining
+SSO identity before starting the isolated certificate fixture. Do not invoke
+this suite directly without its fixture.
+
+Run from an elevated 64-bit PowerShell 7.2 or newer, including for x86 builds,
+with Pester 3 or 4 installed (maximum 4.9.9; Pester 5 is incompatible).
+The runner uses the repository's OpenSSHUtils module in its private fixture;
+no machine-wide OpenSSHUtils installation is required.
+
+```powershell
+./.github/tools/Invoke-PKCS11CertificateTests.ps1 -OpenSSHBinPath ./bin/x64/Release
+./.github/tools/Invoke-PKCS11CertificateTests.ps1 -OpenSSHBinPath ./bin/Win32/Release -Architecture x86
+```
+
+The runner downloads the public Disig SoftHSM 2.5.0 portable Windows package
+and requires SHA256
+`85273BCC1A6B90E877F7BB4F7E90221D57103D8F5241D154A79DD730A135B910`.
+A verified cache supports subsequent offline runs. Both provider architectures
+are checked against the selected OpenSSH executables; the bundled 32-bit
+import utility always uses the 32-bit DLL. This package supports ECDSA P-256.
+Fresh RSA-2048 and ECDSA-P-256 keys and random token PINs are created for each
+run. The DLL stays under Program Files, preserving the agent's provider
+allowlist (Program Files (x86) for the 32-bit agent). `SOFTHSM2_CONF` is installed
+in the service and test user's environments: the helper's user environment can
+override the service value. Both take effect before the service starts.
+Restart/reload is exercised during tests.
+
+All six expected cases must pass. Missing prerequisites, failed setup,
+missing/duplicate results, skips, pending cases and timeouts fail the required
+run. Native commands have a 30-second limit, service transitions 60 seconds,
+and the Pester subprocess 10 minutes. Only download transport errors retry.
+The NUnit report and summary contain no PIN, private key or token contents.
+
+Local mode is the default. It requires an empty test agent Registry and an
+absent service or one installed from the selected build. It journals the
+original service configuration before mutation, restores it in `finally`,
+and removes the owned fixture and test Registry entries. Journal version 3
+records the original user's SID, the agent executable path and the cleanup
+phase. A service Registry value, `OpenSSHPkcs11TestRunId`, ties the service to
+the journal's run ID. Recovery checks the user, executable path and marker
+before changing the service, Registry or user environment. Concurrent local
+runs are rejected. After an interrupted process, recover as the original
+test user with:
+
+```powershell
+./.github/tools/Invoke-PKCS11CertificateTests.ps1 -CleanupOnly
+```
+
+The next local run also recovers stale journals. Foreign users, changed or
+unmarked services, and old version-2 journals are rejected and require manual
+recovery; there is no automatic ownership inference or migration. A running
+agent with a disabled startup type is restarted temporarily as Manual before
+restoring Disabled. Restore failures retain the fixture and journal. Once
+restoration is journalled, repeated cleanup only finalizes the owned fixture,
+service marker and journal; it does not reset identities or environments again.
+The RSA/ECDSA certificate integration tests remain mandatory for x64/x86.
+Protected fixture/journal directories stay on the local machine; no external
+VM snapshot is needed.
+Azure uses `-CleanupMode None` on its disposable worker. No additional Azure
+cleanup step is added. A maintainer with repository write access can trigger
+`/azp run`; local validation does not establish that the remote job passed.
+
+Optional hardware runs use `-Mode Hardware` with these environment variables:
+
+* `OPENSSH_TEST_PKCS11_PROVIDER`: absolute path to a PKCS#11 provider DLL.
+* `OPENSSH_TEST_PKCS11_PIN`: token PIN.
+* `OPENSSH_TEST_PKCS11_PUBLIC_KEYS`: semicolon-separated public-key files for
+ both RSA and ECDSA P-256 private keys present on the token.
+* `OPENSSH_TEST_PKCS11_LABELS`: corresponding semicolon-separated labels.
+ An empty item expects the canonical provider path fallback.
+* `OPENSSH_TEST_PKCS11_SOFTWARE_KEY` (optional): unencrypted private key whose
+ public key equals the first public-key entry, for the software identity
+ preservation/detachment cases. Never export a production hardware key.
+
+Absent hardware prerequisites produce actual Pester skips with the missing
+prerequisite in the case name. Incorrect configured paths or failed hardware
+operations fail. PIN input always uses the test askpass helper with forced
+noninteractive input. Real YubiKey validation remains a separate hardware run.
+
+The suite covers add/list/sign for both algorithms, mixed and certificate-only
+identities, unmatched certificates, individual deletion, provider removal,
+service restart/reload, comments and Registry compatibility, rollback after a
+Registry write failure, software identity preservation/detachment, and stale
+or corrupt provider records. The existing encrypted-PIN model is unchanged.
diff --git a/regress/ssh-pkcs11.sh b/regress/ssh-pkcs11.sh
index 96680fca9f74..dab012d212f0 100644
--- a/regress/ssh-pkcs11.sh
+++ b/regress/ssh-pkcs11.sh
@@ -17,6 +17,15 @@ check_all() {
for k in $ED25519 $RSA $EC; do
kshort=`basename "$k"`
verbose "$tag: $kshort"
+ if test "x$TEST_WINDOWS_SSH" = "x1"; then
+ if test "$expect_success" = "y"; then
+ ASKPASS_PASSWORD="$TEST_SSH_PIN"
+ else
+ ASKPASS_PASSWORD="0000"
+ fi
+ export ASKPASS_PASSWORD
+ pinsh="$TEST_SSH_ASKPASS"
+ fi
pub="$k.pub"
cp $pub $OBJ/key.pub
chmod 0600 $OBJ/key.pub
diff --git a/regress/test-exec.sh b/regress/test-exec.sh
index 965018fcbe7e..3ae801db1910 100644
--- a/regress/test-exec.sh
+++ b/regress/test-exec.sh
@@ -1028,6 +1028,25 @@ p11_find_lib() {
done
}
+p11_make_public() {
+ chmod 600 "$1" || fatal "chmod private key failed"
+ if test "x$TEST_WINDOWS_SSH" = "x1"; then
+ /usr/bin/ssh-keygen -y -f "$1" > "$1.pub" || \
+ fatal "Cygwin ssh-keygen public key extraction failed"
+ else
+ ${SSHKEYGEN} -y -f "$1" > "$1.pub" || \
+ fatal "ssh-keygen public key extraction failed"
+ fi
+}
+
+p11_softhsm2_util() {
+ if test -n "$SOFTHSM2_MODULE"; then
+ "$SOFTHSM2_UTIL" --module "$SOFTHSM2_MODULE" "$@"
+ else
+ "$SOFTHSM2_UTIL" "$@"
+ fi
+}
+
# Perform PKCS#11 setup: prepares a softhsm2 token configuration, generated
# keys and loads them into the virtual token.
PKCS11_OK=
@@ -1036,10 +1055,31 @@ p11_setup() {
# XXX we could potentially test ed25519 only in the absence of
# RSA and ECDSA support.
$SSH -Q key | grep ssh-rsa >/dev/null || return 1
- p11_find_lib \
- /usr/local/lib/softhsm/libsofthsm2.so \
- /usr/lib64/pkcs11/libsofthsm2.so \
- /usr/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so
+ test -n "$OPENSSL_BIN" || return 1
+ "$OPENSSL_BIN" version >/dev/null 2>&1 || return 1
+ if test "x$TEST_WINDOWS_SSH" = "x1"; then
+ if test -n "$TEST_SSH_PKCS11_PROVIDER"; then
+ p11_find_lib "$TEST_SSH_PKCS11_PROVIDER"
+ else
+ p11_find_lib \
+ "/cygdrive/c/Program Files/SoftHSM2/lib/softhsm2-x64.dll" \
+ "/cygdrive/c/Program Files/SoftHSM2/lib/softhsm2.dll"
+ fi
+ SOFTHSM2_UTIL="${TEST_SSH_SOFTHSM2_UTIL:-/cygdrive/c/Program Files/SoftHSM2/bin/softhsm2-util.exe}"
+ SOFTHSM2_MODULE="${TEST_SSH_SOFTHSM2_MODULE:-$TEST_SSH_PKCS11}"
+ case "$SOFTHSM2_MODULE" in
+ *softhsm2-x64.dll)
+ SOFTHSM2_MODULE="${SOFTHSM2_MODULE%-x64.dll}.dll"
+ ;;
+ esac
+ else
+ p11_find_lib \
+ /usr/local/lib/softhsm/libsofthsm2.so \
+ /usr/lib64/pkcs11/libsofthsm2.so \
+ /usr/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so
+ SOFTHSM2_UTIL=softhsm2-util
+ SOFTHSM2_MODULE=
+ fi
test -z "$TEST_SSH_PKCS11" && return 1
trace "using token library $TEST_SSH_PKCS11"
TEST_SSH_PIN=1234
@@ -1056,18 +1096,27 @@ p11_setup() {
TOKEN=$SSH_SOFTHSM_DIR/tokendir
mkdir -p $TOKEN
SOFTHSM2_CONF=$SSH_SOFTHSM_DIR/softhsm2.conf
+ SOFTHSM2_CONF_FILE=$SOFTHSM2_CONF
+ TOKEN_CONFIG=$TOKEN
+ if test "x$TEST_WINDOWS_SSH" = "x1"; then
+ TOKEN_CONFIG=$(cygpath -w "$TOKEN")
+ SOFTHSM2_CONF=$(cygpath -w "$SOFTHSM2_CONF")
+ TEST_SSH_PKCS11=$(cygpath -w "$TEST_SSH_PKCS11")
+ SOFTHSM2_MODULE=$(cygpath -w "$SOFTHSM2_MODULE")
+ fi
export SOFTHSM2_CONF
- cat > $SOFTHSM2_CONF << EOF
+ cat > "$SOFTHSM2_CONF_FILE" << EOF
# SoftHSM v2 configuration file
-directories.tokendir = ${TOKEN}
+directories.tokendir = ${TOKEN_CONFIG}
objectstore.backend = file
# ERROR, WARNING, INFO, DEBUG
log.level = DEBUG
# If CKF_REMOVABLE_DEVICE flag should be set
slots.removable = false
EOF
- out=$(softhsm2-util --init-token --free --label token-slot-0 --pin "$TEST_SSH_PIN" --so-pin "$TEST_SSH_SOPIN")
- slot=$(echo -- $out | sed 's/.* //')
+ out=$(p11_softhsm2_util --init-token --free \
+ --label token-slot-0 --pin "$TEST_SSH_PIN" --so-pin "$TEST_SSH_SOPIN")
+ slot=$(echo -- $out | tr -d '\r' | sed 's/.* //')
trace "generating keys"
# RSA key
RSA=${SSH_SOFTHSM_DIR}/RSA
@@ -1075,10 +1124,14 @@ EOF
$OPENSSL_BIN genpkey -algorithm rsa > $RSA 2>/dev/null || \
fatal "genpkey RSA fail"
$OPENSSL_BIN pkcs8 -nocrypt -in $RSA > $RSAP8 || fatal "pkcs8 RSA fail"
- softhsm2-util --slot "$slot" --label 01 --id 01 --pin "$TEST_SSH_PIN" \
- --import $RSAP8 >/dev/null || fatal "softhsm import RSA fail"
- chmod 600 $RSA
- ${SSHKEYGEN} -y -f $RSA > ${RSA}.pub
+ RSAP8_IMPORT=$RSAP8
+ if test "x$TEST_WINDOWS_SSH" = "x1"; then
+ RSAP8_IMPORT=$(cygpath -w "$RSAP8")
+ fi
+ p11_softhsm2_util --slot "$slot" \
+ --label 01 --id 01 --pin "$TEST_SSH_PIN" \
+ --import "$RSAP8_IMPORT" >/dev/null || fatal "softhsm import RSA fail"
+ p11_make_public $RSA
# ECDSA key
ECPARAM=${SSH_SOFTHSM_DIR}/ECPARAM
EC=${SSH_SOFTHSM_DIR}/EC
@@ -1089,10 +1142,14 @@ EOF
$OPENSSL_BIN genpkey -paramfile $ECPARAM > $EC || \
fatal "genpkey EC fail"
$OPENSSL_BIN pkcs8 -nocrypt -in $EC > $ECP8 || fatal "pkcs8 EC fail"
- softhsm2-util --slot "$slot" --label 02 --id 02 --pin "$TEST_SSH_PIN" \
- --import $ECP8 >/dev/null || fatal "softhsm import EC fail"
- chmod 600 $EC
- ${SSHKEYGEN} -y -f $EC > ${EC}.pub
+ ECP8_IMPORT=$ECP8
+ if test "x$TEST_WINDOWS_SSH" = "x1"; then
+ ECP8_IMPORT=$(cygpath -w "$ECP8")
+ fi
+ p11_softhsm2_util --slot "$slot" \
+ --label 02 --id 02 --pin "$TEST_SSH_PIN" \
+ --import "$ECP8_IMPORT" >/dev/null || fatal "softhsm import EC fail"
+ p11_make_public $EC
# Ed25519 key
ED25519=${SSH_SOFTHSM_DIR}/ED25519
ED25519P8=${SSH_SOFTHSM_DIR}/ED25519P8
@@ -1100,11 +1157,15 @@ EOF
fatal "genpkey Ed25519 fail"
$OPENSSL_BIN pkcs8 -nocrypt -in $ED25519 > $ED25519P8 || \
fatal "pkcs8 Ed25519 fail"
- softhsm2-util --slot "$slot" --label 03 --id 03 --pin "$TEST_SSH_PIN" \
- --import $ED25519P8 >/dev/null || \
+ ED25519P8_IMPORT=$ED25519P8
+ if test "x$TEST_WINDOWS_SSH" = "x1"; then
+ ED25519P8_IMPORT=$(cygpath -w "$ED25519P8")
+ fi
+ p11_softhsm2_util --slot "$slot" \
+ --label 03 --id 03 --pin "$TEST_SSH_PIN" \
+ --import "$ED25519P8_IMPORT" >/dev/null || \
fatal "softhsm import ed25519 fail"
- chmod 600 $ED25519
- ${SSHKEYGEN} -y -f $ED25519 > ${ED25519}.pub
+ p11_make_public $ED25519
# Prepare some askpass scripts to load PINs.
PIN_SH=$SSH_SOFTHSM_DIR/pin.sh
cat > $PIN_SH << EOF
@@ -1128,7 +1189,12 @@ EOF
# Peforms ssh-add with the right token PIN.
p11_ssh_add() {
- env SSH_ASKPASS="$PIN_SH" SSH_ASKPASS_REQUIRE=force ${SSHADD} "$@"
+ if test "x$TEST_WINDOWS_SSH" = "x1"; then
+ env ASKPASS_PASSWORD="$TEST_SSH_PIN" SSH_ASKPASS="$TEST_SSH_ASKPASS" \
+ SSH_ASKPASS_REQUIRE=force ${SSHADD} "$@"
+ else
+ env SSH_ASKPASS="$PIN_SH" SSH_ASKPASS_REQUIRE=force ${SSHADD} "$@"
+ fi
}
start_ssh_agent() {
@@ -1140,10 +1206,22 @@ start_ssh_agent() {
export SSH_AUTH_SOCK
rm -f $SSH_AUTH_SOCK $OBJ/agent.log
trace "start agent"
- ${SSHAGENT} ${EXTRA_AGENT_ARGS} -d -a $SSH_AUTH_SOCK \
- > $OBJ/agent.log 2>&1 &
- AGENT_PID=$!
- trap "kill $AGENT_PID" EXIT
+ if test "x$TEST_WINDOWS_SSH" = "x1"; then
+ unset SSH_AUTH_SOCK
+ ${SSHAGENT} > $OBJ/agent.log 2>&1
+ if test "$PKCS11_OK" = "yes"; then
+ ${SSHADD} -e "$TEST_SSH_PKCS11" >/dev/null 2>&1
+ powershell.exe -NoProfile -NonInteractive -Command \
+ "Stop-Service ssh-agent -Force" >/dev/null 2>&1 || \
+ fatal "failed to reset ssh-agent service"
+ ${SSHAGENT} >> $OBJ/agent.log 2>&1
+ fi
+ else
+ ${SSHAGENT} ${EXTRA_AGENT_ARGS} -d -a $SSH_AUTH_SOCK \
+ > $OBJ/agent.log 2>&1 &
+ AGENT_PID=$!
+ trap "kill $AGENT_PID" EXIT
+ fi
for x in 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ; do
# Give it a chance to start
${SSHADD} -l > /dev/null 2>&1
diff --git a/regress/unittests/win32compat/pkcs11_cert_tests.c b/regress/unittests/win32compat/pkcs11_cert_tests.c
new file mode 100644
index 000000000000..2e70fb4a18bb
--- /dev/null
+++ b/regress/unittests/win32compat/pkcs11_cert_tests.c
@@ -0,0 +1,461 @@
+/*
+ * Copyright (c) 2026 Sebastian Ott. All rights reserved.
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include "includes.h"
+
+#include "authfd.h"
+#include "sshbuf.h"
+#include "ssherr.h"
+#include "sshkey.h"
+#include "xmalloc.h"
+
+#include "contrib/win32/win32compat/pkcs11-cert.h"
+#include "../test_helper/test_helper.h"
+#include "tests.h"
+
+#define TEST_CERT \
+ "ecdsa-sha2-nistp256-cert-v01@openssh.com " \
+ "AAAAKGVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20AAAAg" \
+ "OtFRnMigkGliaYfPmX5IidVWfV3tRH6lqRXv0l8bvKoAAAAIbmlzdHAyNTYAAABB" \
+ "BAxZW5ZDq1vcnSlYbTPvQGN3PbGgRO0ht5Rcd/JwWr5AAw2iPY4d/5Lxvybfb6" \
+ "ZttqsKJJUwhg38wpF5CCmlpQcAAAAAAAAABwAAAAIAAAAGanVsaXVzAAAAEgAAAA" \
+ "Vob3N0MQAAAAVob3N0MgAAAAA2jAHwAAAAAE0eYHAAAAAAAAAAAAAAAAAAAABoAA" \
+ "AAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBAxZW5ZDq1vcnS" \
+ "lYbTPvQGN3PbGgRO0ht5Rcd/JwWr5AAw2iPY4d/5Lxvybfb6ZttqsKJJUwhg38w" \
+ "pF5CCmlpQcAAABkAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAABJAAAAIHbxGwTnu" \
+ "e7KxhHXGFvRcxBnekhQ3Qx84vV/Vs4oVCrpAAAAIQC7vk2+d14aS7td7kVXLQn3" \
+ "92oALjEBzMZoDvT1vT/zOA== test"
+
+static struct sshkey *
+load_test_cert(void)
+{
+ struct sshkey *key = NULL;
+ char *line = NULL, *cp;
+
+ line = xstrdup(TEST_CERT);
+ cp = line;
+ key = sshkey_new(KEY_UNSPEC);
+ if (key == NULL || sshkey_read(key, &cp) != 0) {
+ sshkey_free(key);
+ key = NULL;
+ }
+ free(line);
+ return key;
+}
+
+static int
+put_associated_certs(struct sshbuf *m, int cert_only,
+ struct sshkey *cert, size_t ncerts)
+{
+ struct sshbuf *b = NULL;
+ size_t i;
+ int r;
+
+ if ((b = sshbuf_new()) == NULL)
+ return SSH_ERR_ALLOC_FAIL;
+ for (i = 0; i < ncerts; i++) {
+ if ((r = sshkey_puts(cert, b)) != 0)
+ goto out;
+ }
+ if ((r = sshbuf_put_u8(m, SSH_AGENT_CONSTRAIN_EXTENSION)) != 0 ||
+ (r = sshbuf_put_cstring(m,
+ "associated-certs-v00@openssh.com")) != 0 ||
+ (r = sshbuf_put_u8(m, cert_only != 0)) != 0 ||
+ (r = sshbuf_put_stringb(m, b)) != 0)
+ goto out;
+ r = 0;
+ out:
+ sshbuf_free(b);
+ return r;
+}
+
+static void
+test_pkcs11_cert_constraints_valid(void)
+{
+ struct sshbuf *m = NULL;
+ struct sshkey *cert = NULL, **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0, mode;
+
+ TEST_START("PKCS11 associated certificate constraint");
+ ASSERT_PTR_NE(cert = load_test_cert(), NULL);
+ for (mode = 0; mode < 2; mode++) {
+ certs = NULL;
+ ncerts = 0;
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(put_associated_certs(m, mode, cert, 1), 0);
+ ASSERT_INT_EQ(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ ASSERT_INT_EQ(cert_only, mode);
+ ASSERT_SIZE_T_EQ(ncerts, 1);
+ ASSERT_INT_EQ(sshkey_equal(cert, certs[0]), 1);
+ free_pkcs11_certs(certs, ncerts);
+ sshbuf_free(m);
+ }
+ sshkey_free(cert);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_empty(void)
+{
+ struct sshbuf *m = NULL;
+ struct sshkey **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 1;
+
+ TEST_START("PKCS11 empty constraints");
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ ASSERT_INT_EQ(cert_only, 0);
+ ASSERT_PTR_EQ(certs, NULL);
+ ASSERT_SIZE_T_EQ(ncerts, 0);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_unsupported(void)
+{
+ static const struct {
+ const char *name;
+ u_char type;
+ u_int lifetime;
+ const char *destinations;
+ } cases[] = {
+ { "PKCS11 lifetime rejected", SSH_AGENT_CONSTRAIN_LIFETIME,
+ 60, NULL },
+ { "PKCS11 zero lifetime rejected", SSH_AGENT_CONSTRAIN_LIFETIME,
+ 0, NULL },
+ { "PKCS11 confirm rejected", SSH_AGENT_CONSTRAIN_CONFIRM,
+ 0, NULL },
+ { "PKCS11 empty destinations rejected",
+ SSH_AGENT_CONSTRAIN_EXTENSION, 0, "" },
+ { "PKCS11 unparsed destinations rejected",
+ SSH_AGENT_CONSTRAIN_EXTENSION, 0, "bad" }
+ };
+ struct sshbuf *m = NULL;
+ struct sshkey *cert = NULL, **certs = NULL;
+ size_t i, ncerts;
+ int mode, cert_only;
+ char name[128];
+
+ ASSERT_PTR_NE(cert = load_test_cert(), NULL);
+ for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
+ /* Alone, before/after a certificate, with cert-only off/on. */
+ for (mode = 0; mode < 5; mode++) {
+ snprintf(name, sizeof(name), "%s (mode %d)",
+ cases[i].name, mode);
+ TEST_START(name);
+ certs = NULL;
+ ncerts = 0;
+ cert_only = 0;
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ if (mode == 2 || mode == 4)
+ ASSERT_INT_EQ(put_associated_certs(m,
+ mode >= 3, cert, 1), 0);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, cases[i].type), 0);
+ if (cases[i].type == SSH_AGENT_CONSTRAIN_LIFETIME)
+ ASSERT_INT_EQ(sshbuf_put_u32(m,
+ cases[i].lifetime), 0);
+ if (cases[i].type == SSH_AGENT_CONSTRAIN_EXTENSION) {
+ ASSERT_INT_EQ(sshbuf_put_cstring(m,
+ "restrict-destination-v00@openssh.com"), 0);
+ ASSERT_INT_EQ(sshbuf_put_cstring(m,
+ cases[i].destinations), 0);
+ }
+ if (mode == 1 || mode == 3)
+ ASSERT_INT_EQ(put_associated_certs(m,
+ mode >= 3, cert, 1), 0);
+ ASSERT_INT_EQ(parse_pkcs11_add_constraints(m,
+ &cert_only, &certs, &ncerts),
+ SSH_ERR_FEATURE_UNSUPPORTED);
+ ASSERT_SIZE_T_EQ(ncerts,
+ mode == 2 || mode == 4 ? 1 : 0);
+ if (ncerts != 0)
+ ASSERT_INT_EQ(sshkey_equal(cert, certs[0]), 1);
+ free_pkcs11_certs(certs, ncerts);
+ sshbuf_free(m);
+ TEST_DONE();
+ }
+ }
+ sshkey_free(cert);
+}
+
+static void
+test_pkcs11_cert_constraints_unsupported_truncated(void)
+{
+ struct sshbuf *m = NULL;
+ struct sshkey **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0;
+
+ TEST_START("PKCS11 rejects unsupported lifetime before payload parsing");
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, SSH_AGENT_CONSTRAIN_LIFETIME), 0);
+ ASSERT_INT_EQ(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), SSH_ERR_FEATURE_UNSUPPORTED);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_identity_name(void)
+{
+ struct sshkey *cert = NULL, *plain = NULL;
+ u_char *cert_blob = NULL, *plain_blob = NULL;
+ size_t cert_blob_len = 0, plain_blob_len = 0;
+ char *cert_name = NULL, *cert_name_again = NULL, *plain_name = NULL;
+
+ TEST_START("distinct PKCS11 certificate registry identity");
+ ASSERT_PTR_NE(cert = load_test_cert(), NULL);
+ ASSERT_INT_EQ(sshkey_from_private(cert, &plain), 0);
+ ASSERT_INT_EQ(sshkey_drop_cert(plain), 0);
+ ASSERT_INT_EQ(sshkey_to_blob(cert, &cert_blob, &cert_blob_len), 0);
+ ASSERT_INT_EQ(sshkey_to_blob(plain, &plain_blob, &plain_blob_len), 0);
+ ASSERT_PTR_NE(cert_name = pkcs11_identity_name(cert, cert_blob,
+ cert_blob_len), NULL);
+ ASSERT_PTR_NE(cert_name_again = pkcs11_identity_name(cert, cert_blob,
+ cert_blob_len), NULL);
+ ASSERT_PTR_NE(plain_name = pkcs11_identity_name(plain, plain_blob,
+ plain_blob_len), NULL);
+ ASSERT_INT_EQ(strncmp(cert_name, "cert-", 5), 0);
+ ASSERT_STRING_EQ(cert_name, cert_name_again);
+ ASSERT_STRING_NE(cert_name, plain_name);
+ free(plain_name);
+ free(cert_name_again);
+ free(cert_name);
+ free(plain_blob);
+ free(cert_blob);
+ sshkey_free(plain);
+ sshkey_free(cert);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_identity_comment(void)
+{
+ const char *provider = "C:/provider.dll";
+
+ TEST_START("PKCS11 identity comment fallback");
+ ASSERT_STRING_EQ(pkcs11_identity_comment(provider, "token label"),
+ "token label");
+ ASSERT_STRING_EQ(pkcs11_identity_comment(provider, ""), provider);
+ ASSERT_STRING_EQ(pkcs11_identity_comment(provider, NULL), provider);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_duplicate(void)
+{
+ struct sshbuf *m = NULL;
+ struct sshkey *cert = NULL, **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0;
+
+ TEST_START("duplicate PKCS11 certificate constraint");
+ ASSERT_PTR_NE(cert = load_test_cert(), NULL);
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(put_associated_certs(m, 0, cert, 1), 0);
+ ASSERT_INT_EQ(put_associated_certs(m, 0, cert, 1), 0);
+ ASSERT_INT_NE(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ free_pkcs11_certs(certs, ncerts);
+ sshkey_free(cert);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_truncated(void)
+{
+ struct sshbuf *m = NULL;
+ struct sshkey **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0;
+
+ TEST_START("truncated PKCS11 certificate constraint");
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, SSH_AGENT_CONSTRAIN_EXTENSION), 0);
+ ASSERT_INT_EQ(sshbuf_put_cstring(m,
+ "associated-certs-v00@openssh.com"), 0);
+ ASSERT_INT_NE(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ free_pkcs11_certs(certs, ncerts);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_malformed(void)
+{
+ struct sshbuf *m = NULL, *b = NULL;
+ struct sshkey **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0;
+
+ TEST_START("malformed PKCS11 certificate constraint");
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_PTR_NE(b = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(sshbuf_put_string(b, "bad", 3), 0);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, SSH_AGENT_CONSTRAIN_EXTENSION), 0);
+ ASSERT_INT_EQ(sshbuf_put_cstring(m,
+ "associated-certs-v00@openssh.com"), 0);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, 0), 0);
+ ASSERT_INT_EQ(sshbuf_put_stringb(m, b), 0);
+ ASSERT_INT_NE(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ free_pkcs11_certs(certs, ncerts);
+ sshbuf_free(b);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_oversized(void)
+{
+ struct sshbuf *m = NULL;
+ struct sshkey *cert = NULL, **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0;
+
+ TEST_START("oversized PKCS11 certificate constraint");
+ ASSERT_PTR_NE(cert = load_test_cert(), NULL);
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(put_associated_certs(m, 0, cert,
+ AGENT_MAX_EXT_CERTS + 1), 0);
+ ASSERT_INT_NE(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ free_pkcs11_certs(certs, ncerts);
+ sshkey_free(cert);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_provider_equal(void)
+{
+ /* Registry data is not NUL terminated. */
+ const u_char stored[] = { 'C', ':', '\\', 'T', 'o', 'k', 'e', 'n',
+ '.', 'd', 'l', 'l' };
+
+ TEST_START("PKCS11 provider comparison");
+ ASSERT_INT_EQ(pkcs11_provider_equal(stored, sizeof(stored),
+ "C:\\Token.dll"), 1);
+ ASSERT_INT_EQ(pkcs11_provider_equal(stored, sizeof(stored),
+ "c:\\TOKEN.DLL"), 1);
+ /* The whole value must match, not a prefix of either side. */
+ ASSERT_INT_EQ(pkcs11_provider_equal(stored, sizeof(stored),
+ "C:\\Token.dll.old"), 0);
+ ASSERT_INT_EQ(pkcs11_provider_equal(stored, sizeof(stored) - 1,
+ "C:\\Token.dll"), 0);
+ ASSERT_INT_EQ(pkcs11_provider_equal(stored, sizeof(stored),
+ "C:\\Other.dll"), 0);
+ ASSERT_INT_EQ(pkcs11_provider_equal(NULL, 0, "C:\\Token.dll"), 0);
+ ASSERT_INT_EQ(pkcs11_provider_equal(stored, sizeof(stored), NULL), 0);
+ ASSERT_INT_EQ(pkcs11_provider_equal(stored, 0, ""), 0);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_identity_entry_matches(void)
+{
+ const u_char blob[] = "public-key-blob";
+ const u_char other[] = "other-key-blob";
+ const u_char encrypted[] = "encrypted-private-key";
+ const char *provider = "C:\\Token.dll";
+ struct pkcs11_identity_entry e;
+
+ TEST_START("existing PKCS11 registry identity validation");
+ memset(&e, 0, sizeof(e));
+ e.pub = blob; e.pub_len = sizeof(blob);
+ e.dflt = blob; e.dflt_len = sizeof(blob);
+ e.has_type = 1; e.type = KEY_RSA;
+ e.provider = (const u_char *)provider; e.provider_len = strlen(provider);
+ e.comment = (const u_char *)"token label"; e.comment_len = 11;
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, provider), 1);
+ /* The provider path is case insensitive. */
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, "c:\\TOKEN.DLL"), 1);
+ /* Another provider must not take over the identity. */
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, "C:\\Other.dll"), 0);
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_ECDSA, provider), 0);
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, other, sizeof(other),
+ KEY_RSA, provider), 0);
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(NULL, blob, sizeof(blob),
+ KEY_RSA, provider), 0);
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, NULL, 0,
+ KEY_RSA, provider), 0);
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, NULL), 0);
+
+ /* A software key keeps its encrypted private key as default value. */
+ e.dflt = encrypted; e.dflt_len = sizeof(encrypted);
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, provider), 0);
+ e.dflt = blob; e.dflt_len = sizeof(blob);
+
+ /* Stored public key, type, or default value missing or different. */
+ e.pub = other; e.pub_len = sizeof(other);
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, provider), 0);
+ e.pub = NULL; e.pub_len = 0;
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, provider), 0);
+ e.pub = blob; e.pub_len = sizeof(blob);
+ e.dflt = NULL; e.dflt_len = 0;
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, provider), 0);
+ e.dflt = blob; e.dflt_len = sizeof(blob);
+ e.has_type = 0;
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, provider), 0);
+ e.has_type = 1;
+
+ /* Entries from before the provider value existed use the comment. */
+ e.provider = NULL; e.provider_len = 0;
+ e.comment = (const u_char *)provider; e.comment_len = strlen(provider);
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, provider), 1);
+ e.comment = (const u_char *)"user comment"; e.comment_len = 12;
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, provider), 0);
+ e.comment = NULL; e.comment_len = 0;
+ ASSERT_INT_EQ(pkcs11_identity_entry_matches(&e, blob, sizeof(blob),
+ KEY_RSA, provider), 0);
+ TEST_DONE();
+}
+
+void
+pkcs11_cert_tests(void)
+{
+ test_pkcs11_cert_constraints_valid();
+ test_pkcs11_cert_constraints_empty();
+ test_pkcs11_cert_constraints_unsupported();
+ test_pkcs11_cert_constraints_unsupported_truncated();
+ test_pkcs11_cert_identity_name();
+ test_pkcs11_identity_comment();
+ test_pkcs11_provider_equal();
+ test_pkcs11_identity_entry_matches();
+ test_pkcs11_cert_constraints_duplicate();
+ test_pkcs11_cert_constraints_truncated();
+ test_pkcs11_cert_constraints_malformed();
+ test_pkcs11_cert_constraints_oversized();
+}
diff --git a/regress/unittests/win32compat/tests.c b/regress/unittests/win32compat/tests.c
index 756d6b8b394c..34cced7a643e 100644
--- a/regress/unittests/win32compat/tests.c
+++ b/regress/unittests/win32compat/tests.c
@@ -19,6 +19,7 @@ tests()
{
_set_abort_behavior(0, 1);
log_init(NULL, 7, 2, 0);
+ pkcs11_cert_tests();
signal_tests();
socket_tests();
file_tests();
diff --git a/regress/unittests/win32compat/tests.h b/regress/unittests/win32compat/tests.h
index 580cf063f859..64e06f09f0f8 100644
--- a/regress/unittests/win32compat/tests.h
+++ b/regress/unittests/win32compat/tests.h
@@ -3,6 +3,7 @@ void signal_tests();
void socket_tests();
void file_tests();
void miscellaneous_tests();
+void pkcs11_cert_tests(void);
char *dup_str(char *inStr);
void delete_dir_recursive(char *full_dir_path);
diff --git a/ssh-add.c b/ssh-add.c
index e7ac10799e16..b229ae3fb18a 100644
--- a/ssh-add.c
+++ b/ssh-add.c
@@ -861,7 +861,7 @@ main(int argc, char **argv)
skprovider = getenv("SSH_SK_PROVIDER");
#ifdef WINDOWS
- while ((ch = getopt(argc, argv, "vkKlLNcdDTxXE:e:M:m:Qqs:S:t:")) != -1) {
+ while ((ch = getopt(argc, argv, "vkKlLNCcdDTxXE:e:M:m:Qqs:S:t:")) != -1) {
#else
while ((ch = getopt(argc, argv, "vkKlLNCcdDTxXE:e:h:H:M:m:Qqs:S:t:")) != -1) {
#endif