ci(release-studio): sign Windows with a Studio-owned Certum certificate

SignPath has no quota left, so Studio Windows builds could only ship
unsigned. Port the 1.x Certum SimplySign signer, pointed at Studio's own
account and certificate.

- Signing runs only when STUDIO_SIGNING_ENABLED=true, as three jobs, so
  the SimplySign session is never up while third-party code runs:
  windows-sign-payload signs the window, kernel and computer-use helper;
  windows-package builds the installer and zip on a separate runner with
  no environment or secrets; windows-sign-installer signs the installer.
  The two signing jobs check out only workflow_sha, install no
  toolchain, leave Defender on, and stop SimplySign after signing.
- Verification checks trust, the pinned thumbprint, the
  STUDIO_SIGNING_SUBJECT subject, an issuer and a timestamp on every
  signed executable, on the installer, and on the zip's copies.
- The SimplySign MSI is pinned by SHA-256 as well as by its signature.
- With the switch on, a missing secret or subject, or any signing
  failure, blocks publish. The release body's Windows sentence follows
  windows-sign-installer's result.
- Credentials are STUDIO_CERTUM_USERNAME, STUDIO_CERTUM_OTP_URI and
  STUDIO_CERTUM_KEY_ID only. The concurrency group is
  studio-certum-signing, shared with a new non-publishing smoke workflow.
- publish downloads every package artifact by its exact name, taking
  Windows from windows-sign-installer when it ran, and fails when two
  artifacts carry the same file name.
- signing-contract validates and fingerprints both the tag's tree and the
  workflow_sha tree.
- The SignPath request steps, artifact configurations and request helper
  are removed. The contract now covers the Certum files, and its call
  graph detects Certum credentials.
- The runbook lists the repository settings to decide on before enabling
  and says which PE files stay unsigned (Electron DLLs, elevate.exe, the
  NSIS uninstaller).
This commit is contained in:
esengine committed 2026-09-27 03:30:29 -07:00
1 parent 380683c5d0
commit d84bc0c625
16 files changed
+726 -396

No files matched your search

-161
View File
@@ -1,161 +0,0 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F-]{36}$")]
[string]$OrganizationId,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F-]{36}$")]
[string]$SigningRequestId,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$ExpectedSigningPolicySlug,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$OutputArtifactDirectory,
[ValidateNotNullOrEmpty()]
[string]$ApiUrl = "https://app.signpath.io/api",
[ValidateRange(30, 7200)]
[int]$TimeoutSeconds = 1800,
[ValidateRange(1, 60)]
[int]$PollIntervalSeconds = 5,
[switch]$WaitForExternalApproval
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$apiToken = $env:SIGNPATH_API_TOKEN
if ([string]::IsNullOrWhiteSpace($apiToken)) {
throw "SIGNPATH_API_TOKEN is required to approve and download a SignPath request."
}
$parsedApiUrl = $null
if (
-not [Uri]::TryCreate($ApiUrl, [UriKind]::Absolute, [ref]$parsedApiUrl) -or
$parsedApiUrl.Scheme -ne "https"
) {
throw "ApiUrl must be an absolute HTTPS URL."
}
$workspace = if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) {
[IO.Path]::GetFullPath((Get-Location).Path)
} else {
[IO.Path]::GetFullPath($env:GITHUB_WORKSPACE)
}
$outputDirectory = if ([IO.Path]::IsPathRooted($OutputArtifactDirectory)) {
[IO.Path]::GetFullPath($OutputArtifactDirectory)
} else {
[IO.Path]::GetFullPath((Join-Path $workspace $OutputArtifactDirectory))
}
$workspacePrefix = $workspace.TrimEnd(
[IO.Path]::DirectorySeparatorChar,
[IO.Path]::AltDirectorySeparatorChar
) + [IO.Path]::DirectorySeparatorChar
if (-not $outputDirectory.StartsWith($workspacePrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "OutputArtifactDirectory must resolve inside GITHUB_WORKSPACE."
}
$headers = @{
Authorization = "Bearer $apiToken"
}
$requestBaseUrl = (
$ApiUrl.TrimEnd("/") +
"/v1/$OrganizationId/SigningRequests/$SigningRequestId"
)
$request = Invoke-RestMethod `
-Method Get `
-Uri $requestBaseUrl `
-Headers $headers
if ($request.signingPolicySlug -ne $ExpectedSigningPolicySlug) {
throw (
"SignPath request policy mismatch: got '$($request.signingPolicySlug)', " +
"expected '$ExpectedSigningPolicySlug'."
)
}
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds)
$approvalSubmitted = $false
$externalApprovalNoticeWritten = $false
while ($true) {
$status = Invoke-RestMethod `
-Method Get `
-Uri "$requestBaseUrl/Status" `
-Headers $headers
Write-Host (
"SignPath request $SigningRequestId status: " +
"$($status.status) ($($status.workflowStatus))"
)
if ($status.isFinalStatus) {
if ($status.status -ne "Completed") {
throw (
"SignPath request ended with status '$($status.status)' " +
"and workflow status '$($status.workflowStatus)'."
)
}
break
}
if ($status.status -eq "WaitingForApproval" -and -not $approvalSubmitted) {
if ($WaitForExternalApproval) {
if (-not $externalApprovalNoticeWritten) {
Write-Host (
"Waiting for an authorized SignPath user to approve request " +
"$SigningRequestId."
)
$externalApprovalNoticeWritten = $true
}
} else {
Invoke-RestMethod `
-Method Post `
-Uri "$requestBaseUrl/Approve" `
-Headers $headers | Out-Null
$approvalSubmitted = $true
Write-Host "Approved SignPath request $SigningRequestId through the release CI identity."
}
}
if ([DateTimeOffset]::UtcNow -ge $deadline) {
throw "Timed out waiting for SignPath request $SigningRequestId."
}
Start-Sleep -Seconds $PollIntervalSeconds
}
$temporaryRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
[IO.Path]::GetTempPath()
} else {
$env:RUNNER_TEMP
}
$temporaryArchive = Join-Path $temporaryRoot "signpath-$SigningRequestId.zip"
try {
Invoke-WebRequest `
-Method Get `
-Uri "$requestBaseUrl/SignedArtifact" `
-Headers $headers `
-OutFile $temporaryArchive
if (-not (Test-Path -LiteralPath $temporaryArchive -PathType Leaf)) {
throw "SignPath did not return a signed artifact."
}
if ((Get-Item -LiteralPath $temporaryArchive).Length -eq 0) {
throw "SignPath returned an empty signed artifact."
}
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
Expand-Archive `
-LiteralPath $temporaryArchive `
-DestinationPath $outputDirectory `
-Force
} finally {
Remove-Item -LiteralPath $temporaryArchive -Force -ErrorAction SilentlyContinue
}
Write-Host "Downloaded and extracted signed artifact to $outputDirectory."
+46
View File
@@ -0,0 +1,46 @@
param(
[Parameter(Mandatory = $true, ParameterSetName = 'Payload')]
[string]$PayloadDirectory,
[Parameter(Mandatory = $true, ParameterSetName = 'File')]
[string]$FilePath
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$thumbprint = $env:CERTUM_KEY_ID
if ($thumbprint -notmatch '^[0-9a-fA-F]{40}$') { throw 'Invalid CERTUM_KEY_ID' }
$tool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $tool) { throw 'Windows SDK SignTool not found' }
if ($PSCmdlet.ParameterSetName -eq 'Payload') {
$root = (Resolve-Path -LiteralPath $PayloadDirectory).Path
$prefix = $root.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
$entries = @(Get-Content -LiteralPath (Join-Path $root 'signing-files.txt') |
ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
if ($entries.Count -eq 0) { throw 'Empty signing manifest' }
$files = @($entries | ForEach-Object {
$candidate = [IO.Path]::GetFullPath((Join-Path $root $_))
if (-not $candidate.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Signing manifest path escapes payload directory'
}
$candidate
})
} else {
$files = @((Resolve-Path -LiteralPath $FilePath).Path)
}
# Validate the entire manifest before the first signing operation.
foreach ($file in $files) {
if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Missing signing input: $file" }
}
foreach ($file in $files) {
& $tool.FullName sign /sha1 $thumbprint /fd SHA256 /tr http://timestamp.certum.pl /td SHA256 $file
if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed: $file" }
& $tool.FullName verify /pa /all /tw $file
if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed: $file" }
$signature = Get-AuthenticodeSignature -LiteralPath $file
if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Thumbprint -ne $thumbprint -or -not $signature.TimeStamperCertificate) {
throw "Unexpected signer, untrusted signature, or missing timestamp: $file"
}
}
+42 -13
View File
@@ -2,32 +2,40 @@
# ship. A signature the release job believes it applied but that is not on the
# bytes in dist/ is the one failure the signing requests cannot catch about
# themselves, so every artifact is checked from disk rather than trusted from
# the order the steps ran in.
# the order the steps ran in. Given only the payload, it checks the payload.
[CmdletBinding(DefaultParameterSetName = "Payload")]
param(
[Parameter(Mandatory = $true)]
[string]$PayloadDirectory,
[Parameter(Mandatory = $true)]
[Parameter(Mandatory = $true, ParameterSetName = "Release")]
[string]$InstallerPath,
[Parameter(Mandatory = $true)]
[Parameter(Mandatory = $true, ParameterSetName = "Release")]
[string]$PortableArchivePath,
[switch]$RequireTrusted
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F]{40}$')]
[string]$ExpectedThumbprint,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$ExpectedSubject
)
$ErrorActionPreference = "Stop"
# The executables Studio ships of its own: the window, and the kernel it spawns.
# Everything else the bundle carries belongs to Chromium or to electron-builder
# -- resources/elevate.exe is the one that is also a PE file -- and arrives from
# its own publisher, so this verifies what we signed, not what we shipped.
# The executables Studio builds itself: the window, the kernel it spawns, and
# the computer-use helper. The release signs these and the installer, nothing
# else. Electron's DLLs, electron-builder's resources/elevate.exe and the NSIS
# uninstaller the installer writes ship with whatever signature their builder
# gave them, which for most of them is none.
#
# Keyed by the path inside the bundle; the flat signing payload carries the leaf
# name, because SignPath receives a directory rather than a tree.
# Keyed by the path inside the bundle; the flat signing payload carries the leaf.
$signedExecutables = [ordered]@{
"Reasonix Studio.exe" = "Reasonix Studio.exe"
"resources/bin/reasonix-studio-host.exe" = "reasonix-studio-host.exe"
"Reasonix Studio.exe" = "Reasonix Studio.exe"
"resources/bin/reasonix-studio-host.exe" = "reasonix-studio-host.exe"
"resources/bin/reasonix-computer-helper.exe" = "reasonix-computer-helper.exe"
}
function Assert-AuthenticodeSignature {
@@ -43,9 +51,25 @@ function Assert-AuthenticodeSignature {
if ($null -eq $signature.SignerCertificate -or $signature.SignatureType -eq "None") {
throw "Authenticode signature is missing: $Path"
}
if ($RequireTrusted -and $signature.Status -ne "Valid") {
if ($signature.Status -ne "Valid") {
throw "Authenticode signature is not trusted for $Path`: $($signature.Status) $($signature.StatusMessage)"
}
$certificate = $signature.SignerCertificate
if ($certificate.Thumbprint -ne $ExpectedThumbprint.ToUpperInvariant()) {
throw "Unexpected signer thumbprint $($certificate.Thumbprint): $Path"
}
if ($certificate.Subject -cne $ExpectedSubject) {
throw "Unexpected signer subject '$($certificate.Subject)': $Path"
}
# The thumbprint pins one certificate and so its issuer; a Valid status
# already means that issuer chains to a trusted root.
if ([string]::IsNullOrWhiteSpace($certificate.Issuer)) {
throw "Signer certificate has no issuer: $Path"
}
Write-Host "Signer: $($certificate.Subject) / issuer: $($certificate.Issuer)"
if ($null -eq $signature.TimeStamperCertificate) {
throw "Authenticode timestamp is missing: $Path"
}
Write-Host "Authenticode $($signature.Status): $Path"
}
@@ -59,6 +83,11 @@ foreach ($leaf in $signedExecutables.Values) {
$payloadPaths[$leaf] = $path
}
if ($PSCmdlet.ParameterSetName -eq "Payload") {
Write-Host "Windows Authenticode payload verified."
exit 0
}
Assert-AuthenticodeSignature -Path $InstallerPath
$extractRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("reasonix-authenticode-" + [guid]::NewGuid().ToString("N"))