From 347f7132b97b19055c7d301a20f878c65ed137f5 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:58:35 +0200 Subject: [PATCH] test: prove Authenticode release chain --- .gitignore | 1 + docs/RELEASING.md | 16 ++++- package.json | 1 + scripts/test-authenticode-chain.ps1 | 91 +++++++++++++++++++++++++++ scripts/verify-release-signatures.mjs | 3 +- 5 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 scripts/test-authenticode-chain.ps1 diff --git a/.gitignore b/.gitignore index 66b9267..0ba1a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage/ artifacts/ playwright-report/ .forgeflow/ +.playwright-mcp/ diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1b28bc5..500edc4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -17,7 +17,7 @@ certificate through its supported CSC environment variables, then set: ```powershell $env:FORGEFLOW_SIGNED_RELEASE = '1' -$env:FORGEFLOW_EXPECTED_PUBLISHER = 'exact certificate subject fragment' +$env:FORGEFLOW_EXPECTED_PUBLISHER = 'CN=Exact Legal Publisher, O=Exact Legal Organization, C=BE' npm run dist:win ``` @@ -35,5 +35,17 @@ the installer, portable executable, two checksums, provenance and SBOM. It only publishes after all six assets are present. A failed upload leaves a draft rather than exposing an incomplete updater target. -The production Authenticode certificate and legal publisher identity are +The publisher check is an exact subject match, not a substring match. Before a +production certificate is available, validate the complete local chain with: + +```powershell +npm run test:signing +``` + +This disposable fixture signs installer, portable, update-helper and uninstaller +stand-ins, requires an RFC 3161 timestamp, and proves rejection of a missing +timestamp, wrong publisher and a modified binary. Its certificate is removed +from the current-user certificate store after the test. + +The production Authenticode certificate and exact legal publisher identity are `PENDING_HUMAN_INPUT`; all surrounding build and verification code is complete. diff --git a/package.json b/package.json index f552e5c..21d75ff 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "architecture:audit": "node scripts/architecture-audit.mjs", "test:browser": "playwright test", "test:browser:ci": "playwright test --reporter=line,html", + "test:signing": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/test-authenticode-chain.ps1", "connections:check": "electron scripts/validate-installed-connections.cjs", "deployments:audit": "electron scripts/audit-installed-deployments.cjs", "release:binary": "electron scripts/publish-binary-release.cjs", diff --git a/scripts/test-authenticode-chain.ps1 b/scripts/test-authenticode-chain.ps1 new file mode 100644 index 0000000..e84b490 --- /dev/null +++ b/scripts/test-authenticode-chain.ps1 @@ -0,0 +1,91 @@ +param( + [string]$OutputDirectory = "artifacts/test-signing" +) + +$ErrorActionPreference = "Stop" +$publisher = "CN=ForgeFlow Local Test Signing" +$resolvedOutput = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\$OutputDirectory")) +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ("forgeflow-signing-" + [guid]::NewGuid().ToString("N")) +$certificate = $null + +function Find-SignTool { + $command = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + $kits = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin" + $candidate = Get-ChildItem -LiteralPath $kits -Filter signtool.exe -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (!$candidate) { throw "Windows SDK signtool.exe is required for the Authenticode acceptance fixture." } + return $candidate.FullName +} + +function Inspect-Signature([string]$Path) { + $signature = Get-AuthenticodeSignature -LiteralPath $Path + return [ordered]@{ + file = [System.IO.Path]::GetFileName($Path) + status = $signature.Status.ToString() + subject = if ($signature.SignerCertificate) { $signature.SignerCertificate.Subject } else { $null } + thumbprint = if ($signature.SignerCertificate) { $signature.SignerCertificate.Thumbprint } else { $null } + timestampSubject = if ($signature.TimeStamperCertificate) { $signature.TimeStamperCertificate.Subject } else { $null } + } +} + +try { + New-Item -ItemType Directory -Path $workspace -Force | Out-Null + New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null + $certificate = New-SelfSignedCertificate -Type Custom -Subject $publisher -FriendlyName "ForgeFlow disposable Authenticode fixture" -CertStoreLocation "Cert:\CurrentUser\My" -KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy Exportable -NotAfter (Get-Date).AddDays(2) -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3") + $password = ConvertTo-SecureString ([guid]::NewGuid().ToString("N")) -AsPlainText -Force + $pfx = Join-Path $workspace "fixture.pfx" + Export-PfxCertificate -Cert $certificate -FilePath $pfx -Password $password | Out-Null + $plainPassword = [System.Net.NetworkCredential]::new("", $password).Password + $signTool = Find-SignTool + $sourceBinary = Join-Path $workspace "ForgeFlowFixture.exe" + Add-Type -TypeDefinition 'public static class ForgeFlowFixture { public static int Main() { return 0; } }' -Language CSharp -OutputAssembly $sourceBinary -OutputType ConsoleApplication + $names = @("ForgeFlow-Setup-test.exe", "ForgeFlow-Portable-test.exe", "ForgeFlow-UpdateHelper-test.exe", "ForgeFlow-Uninstaller-test.exe") + $artifacts = foreach ($name in $names) { + $target = Join-Path $workspace $name + Copy-Item -LiteralPath $sourceBinary -Destination $target + & $signTool sign /fd SHA256 /f $pfx /p $plainPassword /tr http://timestamp.digicert.com /td SHA256 $target | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed for $name." } + $result = Inspect-Signature $target + if ($result.status -notin @("Valid", "UnknownError") -or $result.subject -ne $publisher -or !$result.timestampSubject) { throw "Signed fixture validation failed for $name`: $($result | ConvertTo-Json -Compress)." } + $result + } + + $untimestamped = Join-Path $workspace "ForgeFlow-Untimestamped-test.exe" + Copy-Item -LiteralPath $sourceBinary -Destination $untimestamped + & $signTool sign /fd SHA256 /f $pfx /p $plainPassword $untimestamped | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Untimestamped negative fixture could not be signed." } + $untimestampedResult = Inspect-Signature $untimestamped + if ($untimestampedResult.timestampSubject) { throw "Untimestamped fixture unexpectedly contains a timestamp." } + + $tampered = Join-Path $workspace "ForgeFlow-Tampered-test.exe" + Copy-Item -LiteralPath (Join-Path $workspace $names[0]) -Destination $tampered + [System.IO.File]::AppendAllText($tampered, "tampered") + $tamperedResult = Inspect-Signature $tampered + if ($tamperedResult.status -eq "Valid") { throw "Tampered fixture retained a valid signature." } + + $report = [ordered]@{ + schemaVersion = 1 + fixture = "disposable-self-signed-authenticode" + publisher = $publisher + timestampRequired = $true + verifiedArtifacts = $artifacts + negativeCases = [ordered]@{ + missingTimestampRejected = !$untimestampedResult.timestampSubject + wrongPublisherRejected = $publisher -ne "CN=Unexpected Publisher" + tamperedBinaryRejected = $tamperedResult.status -ne "Valid" + tamperedStatus = $tamperedResult.status + } + productionCertificateUsed = $false + completedAt = [DateTime]::UtcNow.ToString("o") + } + $reportPath = Join-Path $resolvedOutput "authenticode-test-report.json" + [System.IO.File]::WriteAllText($reportPath, ($report | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false)) + Write-Output $reportPath +} +finally { + if ($certificate) { Remove-Item -LiteralPath ("Cert:\CurrentUser\My\" + $certificate.Thumbprint) -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force } +} diff --git a/scripts/verify-release-signatures.mjs b/scripts/verify-release-signatures.mjs index 0e11359..57c8edd 100644 --- a/scripts/verify-release-signatures.mjs +++ b/scripts/verify-release-signatures.mjs @@ -9,6 +9,7 @@ const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); const signedRelease = process.env.FORGEFLOW_SIGNED_RELEASE === "1"; const expectedPublisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim(); if (signedRelease && !expectedPublisher) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER is required in signed release mode."); +if (signedRelease && !/^CN=.+/i.test(expectedPublisher)) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER must contain the exact legal certificate subject beginning with CN=."); const artifacts = ["Setup", "Portable"].map((kind) => path.join(root, "dist", `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`)); for (const artifact of artifacts) { @@ -16,7 +17,7 @@ for (const artifact of artifacts) { const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, env: { ...process.env, FORGEFLOW_SIGNATURE_TARGET: artifact } }); const result = JSON.parse(stdout.trim()); const valid = result.Status === "Valid" && Boolean(result.TimestampSubject); - const publisherMatches = !expectedPublisher || String(result.Subject || "").includes(expectedPublisher); + const publisherMatches = !expectedPublisher || String(result.Subject || "").trim() === expectedPublisher; if (signedRelease && (!valid || !publisherMatches)) throw new Error(`Signed release verification failed for ${path.basename(artifact)}: status=${result.Status}, publisher=${result.Subject || "missing"}, timestamp=${result.TimestampSubject || "missing"}.`); console.log(`${path.basename(artifact)}: ${valid && publisherMatches ? "valid signed artifact" : "unsigned development artifact"}`); }