test: prove Authenticode release chain
This commit is contained in:
@@ -6,3 +6,4 @@ coverage/
|
||||
artifacts/
|
||||
playwright-report/
|
||||
.forgeflow/
|
||||
.playwright-mcp/
|
||||
|
||||
+14
-2
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user