mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
380683c5d0
commit
d84bc0c625
16 files changed
+726
-396
No files matched your search
@@ -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."
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
Reference in new issue
Block a user