mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Sign Windows inner binaries during release via fail-open SignPath flow
SignPath cannot deep-sign inside NSIS installers, so unsigned inner PE files (Orca.exe, node-pty *.node, DLLs) ship with zero AV reputation and get quarantined by enterprise antivirus (issue #7785). Add a two-request flow to the Windows release leg: stage unsigned PE files from win-unpacked, sign them via the windows-inner-binaries-zip SignPath configuration, rebuild the NSIS installer from the signed tree, then continue into the existing installer signing request. Every step is fail-open (continue-on-error + outcome gating): any failure ships the original installer exactly like releases did before this chain existed. Add an evidence gate that extracts the shipped installer and verifies every staged binary is Valid + CN=SignPath Foundation, warn-only until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is flipped to 'true'. Supersedes the Orca.exe-only gate from #7170 by covering .node and .dll files too. Rehearsed end to end with test-signing (run 28987534795) and release-signing (run 28988432001); disarm the rehearsal workflow's branch push trigger now that both rehearsals passed.
This commit is contained in:
@@ -958,6 +958,203 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# ── Windows inner-binary signing (issue #7785) ─────────────────────
|
||||
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
|
||||
# files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip
|
||||
# request, then the installer is rebuilt from the signed tree before the
|
||||
# existing installer signing request below. Every step in this chain is
|
||||
# fail-open (continue-on-error + outcome gating): any failure ships the
|
||||
# original installer with unsigned inner binaries, exactly like releases
|
||||
# did before this chain existed. Rehearsed end to end in run 28988432001
|
||||
# (.github/workflows/windows-signing-rehearsal.yml).
|
||||
|
||||
# Why: only unsigned PE files go to SignPath. Files that already carry a
|
||||
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
|
||||
- name: Stage unsigned inner PE files for signing
|
||||
id: stage-inner
|
||||
if: matrix.platform == 'win'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
|
||||
$list = New-Object System.Collections.Generic.List[string]
|
||||
$skipped = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Get-ChildItem -Path $root -Recurse -File |
|
||||
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
|
||||
ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
|
||||
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
|
||||
if ($signature.Status -eq 'Valid') {
|
||||
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
|
||||
return
|
||||
}
|
||||
$destination = Join-Path $stage.FullName $relative
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
|
||||
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||
$list.Add($relative)
|
||||
}
|
||||
|
||||
if (-not ($list -contains 'Orca.exe')) {
|
||||
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
|
||||
}
|
||||
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
|
||||
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
|
||||
}
|
||||
|
||||
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
|
||||
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
|
||||
$list | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host "Skipped $($skipped.Count) already-signed files:"
|
||||
$skipped | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
- name: Upload unsigned inner binaries for SignPath
|
||||
id: upload-unsigned-inner
|
||||
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
|
||||
path: signing-stage/**
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit inner binaries signing request
|
||||
id: submit-inner-signing
|
||||
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
project-slug: orca
|
||||
signing-policy-slug: release-signing
|
||||
artifact-configuration-slug: windows-inner-binaries-zip
|
||||
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
|
||||
- name: Notify Slack that inner-binary signing is waiting for approval
|
||||
id: notify-inner-signing
|
||||
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||||
SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
||||
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
|
||||
}
|
||||
|
||||
$requestUrl = $env:SIGNPATH_REQUEST_URL
|
||||
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
|
||||
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
|
||||
}
|
||||
|
||||
$message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
|
||||
$payload = @{
|
||||
text = $message
|
||||
blocks = @(
|
||||
@{
|
||||
type = 'section'
|
||||
text = @{
|
||||
type = 'mrkdwn'
|
||||
text = $message
|
||||
}
|
||||
}
|
||||
)
|
||||
} | ConvertTo-Json -Depth 5
|
||||
|
||||
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
|
||||
|
||||
# Why gate on the notify outcome too: if nobody was told to approve,
|
||||
# don't hold the release for the 4-hour approval window — fall through
|
||||
# and ship like today instead.
|
||||
- name: Download signed inner binaries from SignPath
|
||||
id: download-signed-inner
|
||||
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||||
run: |
|
||||
Get-SignedArtifact `
|
||||
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
|
||||
-ApiToken $env:SIGNPATH_API_TOKEN `
|
||||
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
||||
-OutputArtifactPath signed-inner.zip `
|
||||
-Force `
|
||||
-WaitForCompletionTimeoutInSeconds 14400
|
||||
|
||||
New-Item -ItemType Directory -Path signed-inner -Force
|
||||
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
|
||||
|
||||
# Why: copy back strictly by the staged list so a layout mismatch in the
|
||||
# returned artifact fails loudly (into fail-open) instead of silently
|
||||
# shipping a mix of signed and unsigned binaries.
|
||||
- name: Restore signed inner binaries into unpacked app
|
||||
id: restore-signed-inner
|
||||
if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||||
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
|
||||
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $signed) {
|
||||
$failures.Add("missing from signed artifact: $relative")
|
||||
continue
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
||||
if ($null -eq $signature.SignerCertificate) {
|
||||
$failures.Add("returned without a signature: $relative")
|
||||
continue
|
||||
}
|
||||
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
|
||||
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
|
||||
}
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
|
||||
}
|
||||
|
||||
- name: Rebuild NSIS installer from signed unpacked app
|
||||
id: rebuild-nsis-signed
|
||||
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Why: keep the pre-rebuild artifacts so a failed rebuild can fall
|
||||
# back to shipping them unchanged (fail-open).
|
||||
New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null
|
||||
Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force
|
||||
Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force
|
||||
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
|
||||
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
|
||||
}
|
||||
|
||||
- name: Roll back to original installer after failed rebuild
|
||||
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
|
||||
Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force
|
||||
Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force
|
||||
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
|
||||
}
|
||||
# ── End Windows inner-binary signing ───────────────────────────────
|
||||
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
if: matrix.platform == 'win'
|
||||
id: upload-unsigned-windows-installer
|
||||
@@ -992,6 +1189,7 @@ jobs:
|
||||
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
|
||||
run: |
|
||||
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
||||
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
|
||||
@@ -1002,7 +1200,9 @@ jobs:
|
||||
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
|
||||
}
|
||||
|
||||
$message = "Orca Windows release $env:TAG is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
|
||||
# Why: releases where inner signing fell through have only this one request.
|
||||
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
|
||||
$message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
|
||||
$payload = @{
|
||||
text = $message
|
||||
blocks = @(
|
||||
@@ -1085,6 +1285,73 @@ jobs:
|
||||
}
|
||||
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
|
||||
|
||||
# Why: evidence gate for inner-binary signing (issue #7785, supersedes
|
||||
# PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node
|
||||
# by extracting the shipped installer). Warn-only until the flow has been
|
||||
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
|
||||
# to 'true' so unsigned inner binaries block the release.
|
||||
- name: Verify Windows inner binary signatures
|
||||
if: matrix.platform == 'win'
|
||||
shell: pwsh
|
||||
env:
|
||||
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
|
||||
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
|
||||
run: |
|
||||
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
|
||||
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
|
||||
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
|
||||
if ($required) { throw $message }
|
||||
Write-Host "::warning::$message"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$report = New-Object System.Collections.Generic.List[string]
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
# Why: verify the files a user actually gets on disk, not the build
|
||||
# tree — 7z parses the NSIS exe directly as its embedded payload.
|
||||
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
||||
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
|
||||
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
|
||||
|
||||
$root = Resolve-Path 'inner-evidence-extract'
|
||||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||||
$path = Join-Path $root $relative
|
||||
if (-not (Test-Path $path)) {
|
||||
$failures.Add("missing from installer payload: $relative")
|
||||
continue
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $path
|
||||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||||
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject
|
||||
$report.Add($line)
|
||||
Write-Host $line
|
||||
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
||||
$failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)")
|
||||
}
|
||||
}
|
||||
|
||||
Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n")
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::warning::$_" }
|
||||
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
|
||||
if ($required) { throw $message }
|
||||
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
|
||||
} else {
|
||||
Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries in the shipped installer are signed by SignPath Foundation."
|
||||
}
|
||||
|
||||
- name: Upload Windows inner signing evidence
|
||||
if: always() && matrix.platform == 'win'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
|
||||
path: |
|
||||
inner-signing-evidence.txt
|
||||
inner-signing-list.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Publish signed Windows release artifacts
|
||||
if: matrix.platform == 'win'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -8,19 +8,12 @@
|
||||
# anything — so the release pipeline on main is never at risk while we verify.
|
||||
#
|
||||
# Runs only via manual dispatch. Use the test-signing policy for iteration
|
||||
# (auto-approved test certificate) and release-signing once for the final
|
||||
# production rehearsal (requires manual SignPath approvals).
|
||||
# (auto-approved test certificate) and release-signing to rehearse the
|
||||
# production flow (requires manual SignPath approvals). Rehearsed successfully
|
||||
# with test-signing (run 28987534795) and release-signing (run 28988432001).
|
||||
name: Windows signing rehearsal
|
||||
|
||||
on:
|
||||
# Why: workflow_dispatch only works for files on the default branch, and this
|
||||
# rehearsal must stay off main. Trigger on pushes that touch this file on the
|
||||
# rehearsal branch instead; re-run by amending the trigger comment below.
|
||||
push:
|
||||
branches:
|
||||
- Jinwoo-H/code-signing-windows
|
||||
paths:
|
||||
- .github/workflows/windows-signing-rehearsal.yml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
signing-policy-slug:
|
||||
@@ -35,8 +28,6 @@ on:
|
||||
type: string
|
||||
default: windows-inner-binaries-zip
|
||||
|
||||
# Trigger nonce: 3 (stage 3: production release-signing rehearsal)
|
||||
|
||||
jobs:
|
||||
rehearse:
|
||||
# Why: SignPath origin verification requires GitHub-hosted runners, and
|
||||
@@ -172,7 +163,7 @@ jobs:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
project-slug: orca
|
||||
signing-policy-slug: ${{ inputs.signing-policy-slug || 'release-signing' }}
|
||||
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
artifact-configuration-slug: ${{ inputs.inner-artifact-configuration-slug || 'windows-inner-binaries-zip' }}
|
||||
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
@@ -246,7 +237,7 @@ jobs:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
project-slug: orca
|
||||
signing-policy-slug: ${{ inputs.signing-policy-slug || 'release-signing' }}
|
||||
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
artifact-configuration-slug: github-actions-windows-installer
|
||||
github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
@@ -306,7 +297,7 @@ jobs:
|
||||
- name: Verify signatures end to end
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'release-signing' }}
|
||||
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
run: |
|
||||
$report = New-Object System.Collections.Generic.List[string]
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
@@ -356,7 +347,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'release-signing' }}-${{ github.run_id }}
|
||||
name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'test-signing' }}-${{ github.run_id }}
|
||||
path: |
|
||||
signing-evidence.txt
|
||||
inner-signing-list.txt
|
||||
|
||||
Reference in New Issue
Block a user