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