|
| 1 | +#Requires -Version 7.0 |
| 2 | +# Command-boundary regressions; no Azure credentials, certificate store changes, |
| 3 | +# network calls, or real signatures. The protected job verifies real artifacts. |
| 4 | +$ErrorActionPreference = 'Stop' |
| 5 | +Set-StrictMode -Version Latest |
| 6 | +$passed = 0 |
| 7 | +function Assert-True([bool]$Condition, [string]$Message) { |
| 8 | + if (-not $Condition) { throw $Message } |
| 9 | +} |
| 10 | +function Assert-Throws([scriptblock]$Operation, [string]$Message) { |
| 11 | + try { & $Operation | Out-Null } catch { |
| 12 | + if ($_.Exception.Message -notlike "*$Message*") { throw } |
| 13 | + $script:passed++ |
| 14 | + return |
| 15 | + } |
| 16 | + throw "Expected failure containing: $Message" |
| 17 | +} |
| 18 | + |
| 19 | +$directory = Join-Path ([System.IO.Path]::GetTempPath()) "openbot signing $([Guid]::NewGuid())" |
| 20 | +$installerDirectory = Join-Path $directory 'installers' |
| 21 | +$evidenceDirectory = Join-Path $directory 'evidence' |
| 22 | +New-Item -ItemType Directory -Path $installerDirectory -Force | Out-Null |
| 23 | +$app = Join-Path $directory 'OpenBot app.exe' |
| 24 | +$installer = Join-Path $installerDirectory 'OpenBot test-setup.exe' |
| 25 | +Set-Content -LiteralPath $app -Value 'unsigned app fixture' |
| 26 | +Set-Content -LiteralPath $installer -Value 'unsigned installer fixture' |
| 27 | +$environmentNames = @('WINDOWS_SIGNING', 'AZURE_KEY_VAULT_URL', 'CODE_SIGNING_CERT_NAME', 'AZURE_ACCESS_TOKEN') |
| 28 | +$originalEnvironment = @{} |
| 29 | +foreach ($name in $environmentNames) { $originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) } |
| 30 | + |
| 31 | +# Native exit statuses are separate from PowerShell exceptions. These stubs |
| 32 | +# exercise exactly that boundary, including a successful command with empty output. |
| 33 | +$global:SigningTestState = @{ |
| 34 | + azExit = 0; signExit = 0; verifyExit = 0; token = 'synthetic-test-token' |
| 35 | + signCalls = 0; signArguments = @(); verifyCalls = @(); status = 'Valid' |
| 36 | + publisher = 'Tawkit, Inc.'; timestamp = $true; invalidFile = '' |
| 37 | +} |
| 38 | +function global:az { |
| 39 | + $global:LASTEXITCODE = $global:SigningTestState.azExit |
| 40 | + $global:SigningTestState.token |
| 41 | +} |
| 42 | +function global:AzureSignTool.exe { |
| 43 | + $global:SigningTestState.signCalls++ |
| 44 | + $global:SigningTestState.signArguments = $args |
| 45 | + $global:LASTEXITCODE = $global:SigningTestState.signExit |
| 46 | +} |
| 47 | +function global:Test-SignTool { |
| 48 | + $global:SigningTestState.verifyCalls += ,$args |
| 49 | + $global:LASTEXITCODE = $global:SigningTestState.verifyExit |
| 50 | + 'Synthetic SignTool verification output' |
| 51 | +} |
| 52 | +function global:Get-AuthenticodeSignature { |
| 53 | + param([string]$LiteralPath) |
| 54 | + $certificate = [pscustomobject]@{ Subject = 'CN="Tawkit, Inc."'; Thumbprint = 'TEST-CERTIFICATE' } |
| 55 | + $certificate | Add-Member ScriptMethod GetNameInfo { return $global:SigningTestState.publisher } |
| 56 | + [pscustomobject]@{ |
| 57 | + Status = if ($global:SigningTestState.invalidFile -eq '' -or $LiteralPath -eq $global:SigningTestState.invalidFile) { $global:SigningTestState.status } else { 'Valid' } |
| 58 | + SignerCertificate = $certificate |
| 59 | + TimeStamperCertificate = if ($global:SigningTestState.timestamp) { $certificate } else { $null } |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +try { |
| 64 | + $sign = Join-Path $PSScriptRoot 'sign-windows.ps1' |
| 65 | + $verify = Join-Path $PSScriptRoot 'verify-windows-signatures.ps1' |
| 66 | + $verifyParameters = @{ |
| 67 | + AppPath = $app; InstallerDirectory = $installerDirectory |
| 68 | + EvidenceDirectory = $evidenceDirectory; SignToolPath = 'Test-SignTool'; SourceSha = 'test-source-sha' |
| 69 | + } |
| 70 | + $env:WINDOWS_SIGNING = '' |
| 71 | + Assert-Throws { & $sign -Path $app } 'WINDOWS_SIGNING=keyvault' |
| 72 | + $env:WINDOWS_SIGNING = 'keyvault' |
| 73 | + foreach ($missing in @('AZURE_KEY_VAULT_URL', 'CODE_SIGNING_CERT_NAME')) { |
| 74 | + $env:AZURE_KEY_VAULT_URL = 'https://test.vault.azure.net' |
| 75 | + $env:CODE_SIGNING_CERT_NAME = 'test-certificate' |
| 76 | + [Environment]::SetEnvironmentVariable($missing, '') |
| 77 | + Assert-Throws { & $sign -Path $app } "Missing required signing configuration: $missing" |
| 78 | + } |
| 79 | + $env:CODE_SIGNING_CERT_NAME = 'test-certificate' |
| 80 | + Assert-Throws { & $sign -Path (Join-Path $directory 'absent.exe') } 'Signing input does not exist' |
| 81 | + Assert-True ($global:SigningTestState.signCalls -eq 0) 'Refused input reached signer.' |
| 82 | + $global:SigningTestState.azExit = 1 |
| 83 | + Assert-Throws { & $sign -Path $app } 'Could not obtain' |
| 84 | + Assert-True ([string]::IsNullOrEmpty($env:AZURE_ACCESS_TOKEN)) 'Token survived failed acquisition.' |
| 85 | + $global:SigningTestState.azExit = 0 |
| 86 | + $global:SigningTestState.token = '' |
| 87 | + Assert-Throws { & $sign -Path $app } 'Could not obtain' |
| 88 | + $global:SigningTestState.token = 'synthetic-test-token' |
| 89 | + $global:SigningTestState.signExit = 1 |
| 90 | + Assert-Throws { & $sign -Path $app } 'AzureSignTool failed' |
| 91 | + Assert-True ([string]::IsNullOrEmpty($env:AZURE_ACCESS_TOKEN)) 'Token survived signer failure.' |
| 92 | + $global:SigningTestState.signExit = 0 |
| 93 | + $signOutput = & $sign -Path $app 6>&1 | Out-String |
| 94 | + Assert-True ($signOutput.Contains('::add-mask::synthetic-test-token')) 'Token was not registered for masking.' |
| 95 | + Assert-True ($global:SigningTestState.signArguments[-1] -eq $app) 'Path with spaces was split.' |
| 96 | + Assert-True ([string]::IsNullOrEmpty($env:AZURE_ACCESS_TOKEN)) 'Token survived successful signing.' |
| 97 | + $passed++ |
| 98 | + |
| 99 | + # Both the app and installer must reject unsigned and tampered signatures. |
| 100 | + foreach ($invalidFile in @($app, $installer)) { |
| 101 | + $global:SigningTestState.invalidFile = $invalidFile |
| 102 | + foreach ($status in @('NotSigned', 'HashMismatch', 'NotTrusted')) { |
| 103 | + $global:SigningTestState.status = $status |
| 104 | + Assert-Throws { & $verify @verifyParameters } "Invalid Authenticode signature on $([System.IO.Path]::GetFileName($invalidFile)): $status" |
| 105 | + } |
| 106 | + } |
| 107 | + $global:SigningTestState.status = 'Valid' |
| 108 | + $global:SigningTestState.publisher = 'Tawkit, Inc. imposter' |
| 109 | + Assert-Throws { & $verify @verifyParameters } 'Unexpected publisher' |
| 110 | + $global:SigningTestState.publisher = 'Tawkit, Inc.' |
| 111 | + $global:SigningTestState.timestamp = $false |
| 112 | + Assert-Throws { & $verify @verifyParameters } 'Missing timestamp' |
| 113 | + $global:SigningTestState.timestamp = $true |
| 114 | + foreach ($verifyExit in @(1, 2)) { |
| 115 | + $global:SigningTestState.verifyExit = $verifyExit |
| 116 | + Assert-Throws { & $verify @verifyParameters } 'SignTool verification failed' |
| 117 | + } |
| 118 | + $global:SigningTestState.verifyExit = 0 |
| 119 | + $global:SigningTestState.verifyCalls = @() |
| 120 | + & $verify @verifyParameters | Out-Null |
| 121 | + $report = Get-Content -LiteralPath (Join-Path $evidenceDirectory 'signatures.json') -Raw | ConvertFrom-Json |
| 122 | + Assert-True ($report.files.Count -eq 2 -and $report.sourceSha -eq 'test-source-sha') 'Evidence does not identify both files and source.' |
| 123 | + Assert-True ($report.files[0].sha256 -eq (Get-FileHash -LiteralPath $app).Hash) 'App evidence digest is incorrect.' |
| 124 | + Assert-True ($report.files[1].sha256 -eq (Get-FileHash -LiteralPath $installer).Hash) 'Installer evidence digest is incorrect.' |
| 125 | + Assert-True ($global:SigningTestState.verifyCalls.Count -eq 2) 'SignTool did not verify both files.' |
| 126 | + foreach ($call in $global:SigningTestState.verifyCalls) { |
| 127 | + Assert-True (($call[0..4] -join ' ') -eq 'verify /pa /all /v /tw') 'Trust or timestamp verification was omitted.' |
| 128 | + } |
| 129 | + $passed++ |
| 130 | + Remove-Item -LiteralPath $app |
| 131 | + Assert-Throws { & $verify @verifyParameters } 'Application executable is missing' |
| 132 | + Set-Content -LiteralPath $app -Value 'restored fixture' |
| 133 | + Remove-Item -LiteralPath $installer |
| 134 | + Assert-Throws { & $verify @verifyParameters } 'Expected exactly one NSIS installer' |
| 135 | + Set-Content -LiteralPath $installer -Value 'restored installer' |
| 136 | + Set-Content -LiteralPath (Join-Path $installerDirectory 'stale-setup.exe') -Value 'stale installer' |
| 137 | + Assert-Throws { & $verify @verifyParameters } 'Expected exactly one NSIS installer' |
| 138 | + |
| 139 | + # Tauri restores the unsigned build output after packaging. Exercise the |
| 140 | + # default paths against that real layout, without overriding AppPath. |
| 141 | + $layout = Join-Path $directory 'packaged desktop' |
| 142 | + $release = Join-Path $layout 'src-tauri/target/release' |
| 143 | + foreach ($relative in @('scripts', 'signed-app', 'src-tauri/target/release/bundle/nsis')) { |
| 144 | + New-Item -ItemType Directory -Path (Join-Path $layout $relative) -Force | Out-Null |
| 145 | + } |
| 146 | + Copy-Item -LiteralPath $verify -Destination (Join-Path $layout 'scripts/verify-windows-signatures.ps1') |
| 147 | + $restored = Join-Path $release 'openbot-desktop.exe' |
| 148 | + $payload = Join-Path $layout 'signed-app/openbot-desktop.exe' |
| 149 | + Set-Content -LiteralPath $restored -Value 'unsigned restored build output' |
| 150 | + Set-Content -LiteralPath $payload -Value 'signed installer payload' |
| 151 | + Set-Content -LiteralPath (Join-Path $release 'bundle/nsis/OpenBot test-setup.exe') -Value 'signed installer' |
| 152 | + $global:SigningTestState.invalidFile = $restored |
| 153 | + $global:SigningTestState.status = 'NotSigned' |
| 154 | + & (Join-Path $layout 'scripts/verify-windows-signatures.ps1') -SignToolPath Test-SignTool -SourceSha test-source-sha | Out-Null |
| 155 | + $payloadReport = Get-Content (Join-Path $layout 'signing-evidence/signatures.json') -Raw | ConvertFrom-Json |
| 156 | + Assert-True ($payloadReport.files[0].sha256 -eq (Get-FileHash -LiteralPath $payload).Hash) 'Default verification selected restored build output instead of installer payload.' |
| 157 | + $passed++ |
| 158 | + |
| 159 | + $baseConfig = Get-Content "$PSScriptRoot/../src-tauri/tauri.conf.json" -Raw | ConvertFrom-Json -AsHashtable |
| 160 | + Assert-True (-not $baseConfig.bundle.ContainsKey('windows') -or -not $baseConfig.bundle.windows.ContainsKey('signCommand')) 'Base Tauri build enables signing.' |
| 161 | + Assert-True (-not (Test-Path "$PSScriptRoot/../src-tauri/tauri.windows.conf.json")) 'Signing overlay could be loaded automatically.' |
| 162 | + $passed++ |
| 163 | + Write-Host "Passed $passed Windows signing regression cases." |
| 164 | +} finally { |
| 165 | + foreach ($name in $environmentNames) { [Environment]::SetEnvironmentVariable($name, $originalEnvironment[$name]) } |
| 166 | + Remove-Item Function:az, Function:AzureSignTool.exe, Function:Test-SignTool, Function:Get-AuthenticodeSignature |
| 167 | + Remove-Variable SigningTestState -Scope Global |
| 168 | + Remove-Item -LiteralPath $directory -Recurse -Force |
| 169 | +} |
0 commit comments