From 068c71bb27bde486d4edd0f6da7f3919c1e05dfe Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:19:57 -0400 Subject: [PATCH] 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. --- .github/workflows/release-cut.yml | 269 +++++++++++++++++- .../workflows/windows-signing-rehearsal.yml | 23 +- 2 files changed, 275 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 96af9bd0cd1..fb7c3673681 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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 ") + 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) { '' } 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 diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 2cd4ae3c405..c4f382f5fba 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -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