From da3eabe527224d40ee660ffa73d8a307eb592f92 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:10:48 -0700 Subject: [PATCH] Preserve uninstaller signing and versioned NSIS caches across approval gates --- .github/workflows/release-windows-signing.yml | 60 ++-- cloud/apps/release-signing/README.md | 268 ++++++++++++----- cloud/infra/release-signing/.gitignore | 1 + .../replace-cached-nsis-elevate.test.mjs | 71 ++--- ...windows-signing-workflow-contract.test.mjs | 281 +++++------------- config/windows-signing/checkpoint.ps1 | 40 +-- .../electron-builder-signing.config.cjs | 14 + config/windows-signing/package-installer.ps1 | 15 +- config/windows-signing/replace-elevate.ps1 | 5 +- .../windows-signing/restore-uninstaller.ps1 | 10 + .../windows-signing/signing-control.test.ps1 | 46 ++- config/windows-signing/stage-inner.ps1 | 5 + .../verify-shipped-uninstaller.ps1 | 48 +++ config/windows-signing/verify-uninstaller.ps1 | 16 + 14 files changed, 506 insertions(+), 374 deletions(-) create mode 100644 config/windows-signing/electron-builder-signing.config.cjs create mode 100644 config/windows-signing/restore-uninstaller.ps1 create mode 100644 config/windows-signing/verify-shipped-uninstaller.ps1 create mode 100644 config/windows-signing/verify-uninstaller.ps1 diff --git a/.github/workflows/release-windows-signing.yml b/.github/workflows/release-windows-signing.yml index 289b557cdd0..7af0633823b 100644 --- a/.github/workflows/release-windows-signing.yml +++ b/.github/workflows/release-windows-signing.yml @@ -42,13 +42,14 @@ jobs: SIGNING_POLICY: ${{ inputs.signing_policy }} ENVIRONMENT_PREFIX: ${{ inputs.environment_prefix }} TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }} - run: |- + run: | $ErrorActionPreference = 'Stop' if ($env:PUBLISH -eq 'true') { if ($env:SIGNING_POLICY -ne 'release-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows') { throw 'Publication requires production signing gates.' } } elseif ($env:SIGNING_POLICY -ne 'test-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows-rehearsal' -or $env:TEST_CERTIFICATE_THUMBPRINT -notmatch '^[a-fA-F0-9]{40}$') { throw 'Rehearsal requires isolated gates and a pinned test certificate.' } + "ELECTRON_BUILDER_CACHE=$(Join-Path $env:RUNNER_TEMP 'signing-electron-builder-cache')" >> $env:GITHUB_ENV - name: Checkout uses: actions/checkout@v6 with: @@ -64,6 +65,7 @@ jobs: git archive "$WORKFLOW_SHA" config/windows-signing | tar -x -C "$RUNNER_TEMP/signing-control" git checkout "$WORKFLOW_SHA" -- .github/actions/install-signpath-module git checkout "$WORKFLOW_SHA" -- config/scripts/resolve-7za-path.mjs config/scripts/generate-windows-blockmap.mjs + git checkout "$WORKFLOW_SHA" -- config/scripts/windows-uninstaller-signing.cjs config/scripts/replace-cached-nsis-elevate.mjs - name: Setup pnpm uses: pnpm/setup@v2 with: @@ -78,11 +80,9 @@ jobs: - name: Cache electron-builder downloads uses: actions/cache@v5 with: - path: |- - ~\AppData\Local\electron\Cache - ~\AppData\Local\electron-builder\Cache - key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: electron-builder-win- + path: ${{ runner.temp }}/signing-electron-builder-cache + key: electron-builder-signing-${{ inputs.signing_policy }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: electron-builder-signing-${{ inputs.signing_policy }}- if: github.run_attempt == 1 - name: Install dependencies uses: nick-fields/retry@v4 @@ -136,9 +136,10 @@ jobs: timeout_minutes: 30 max_attempts: 3 retry_wait_seconds: 30 - command: node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never + command: if (Test-Path -LiteralPath $env:ORCA_WIN_UNINSTALLER_EXPORT_PATH) { Remove-Item -LiteralPath $env:ORCA_WIN_UNINSTALLER_EXPORT_PATH -Force -ErrorAction Stop }; node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config "$env:RUNNER_TEMP/signing-control/config/windows-signing/electron-builder-signing.config.cjs" --win --publish never env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ORCA_WIN_UNINSTALLER_EXPORT_PATH: ${{ runner.temp }}/uninstaller-signing/unsigned/orca-uninstaller.exe if: github.run_attempt == 1 - name: Verify Windows node-pty ConPTY runtime shell: pwsh @@ -289,13 +290,14 @@ jobs: SIGNING_POLICY: ${{ inputs.signing_policy }} ENVIRONMENT_PREFIX: ${{ inputs.environment_prefix }} TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }} - run: |- + run: | $ErrorActionPreference = 'Stop' if ($env:PUBLISH -eq 'true') { if ($env:SIGNING_POLICY -ne 'release-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows') { throw 'Publication requires production signing gates.' } } elseif ($env:SIGNING_POLICY -ne 'test-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows-rehearsal' -or $env:TEST_CERTIFICATE_THUMBPRINT -notmatch '^[a-fA-F0-9]{40}$') { throw 'Rehearsal requires isolated gates and a pinned test certificate.' } + "ELECTRON_BUILDER_CACHE=$(Join-Path $env:RUNNER_TEMP 'signing-electron-builder-cache')" >> $env:GITHUB_ENV - name: Checkout uses: actions/checkout@v6 with: @@ -311,6 +313,7 @@ jobs: git archive "$WORKFLOW_SHA" config/windows-signing | tar -x -C "$RUNNER_TEMP/signing-control" git checkout "$WORKFLOW_SHA" -- .github/actions/install-signpath-module git checkout "$WORKFLOW_SHA" -- config/scripts/resolve-7za-path.mjs config/scripts/generate-windows-blockmap.mjs + git checkout "$WORKFLOW_SHA" -- config/scripts/windows-uninstaller-signing.cjs config/scripts/replace-cached-nsis-elevate.mjs - name: Setup pnpm uses: pnpm/setup@v2 with: @@ -325,11 +328,9 @@ jobs: - name: Cache electron-builder downloads uses: actions/cache@v5 with: - path: |- - ~\AppData\Local\electron\Cache - ~\AppData\Local\electron-builder\Cache - key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: electron-builder-win- + path: ${{ runner.temp }}/signing-electron-builder-cache + key: electron-builder-signing-${{ inputs.signing_policy }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: electron-builder-signing-${{ inputs.signing_policy }}- if: github.run_attempt == 1 - name: Install dependencies uses: nick-fields/retry@v4 @@ -371,6 +372,14 @@ jobs: SIGNING_POLICY: ${{ inputs.signing_policy }} TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }} if: github.run_attempt == 1 + - name: Restore signed uninstaller for the installer rebuild + shell: pwsh + run: '& "$env:RUNNER_TEMP/signing-control/config/windows-signing/restore-uninstaller.ps1"' + env: &a1 + TAG: ${{ inputs.tag }} + SIGNING_POLICY: ${{ inputs.signing_policy }} + TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }} + if: github.run_attempt == 1 - name: Replace cached elevate.exe with the signed copy shell: pwsh run: '& "$env:RUNNER_TEMP/signing-control/config/windows-signing/replace-elevate.ps1"' @@ -444,7 +453,7 @@ jobs: CUT_BY: ${{ github.triggering_actor || github.actor }} REPO_URL: ${{ github.server_url }}/${{ github.repository }} GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - INNER_SIGNING_SUBMITTED: 'true' + INNER_SIGNING_SUBMITTED: "true" run: | if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) { throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.' @@ -521,13 +530,14 @@ jobs: SIGNING_POLICY: ${{ inputs.signing_policy }} ENVIRONMENT_PREFIX: ${{ inputs.environment_prefix }} TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }} - run: |- + run: | $ErrorActionPreference = 'Stop' if ($env:PUBLISH -eq 'true') { if ($env:SIGNING_POLICY -ne 'release-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows') { throw 'Publication requires production signing gates.' } } elseif ($env:SIGNING_POLICY -ne 'test-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows-rehearsal' -or $env:TEST_CERTIFICATE_THUMBPRINT -notmatch '^[a-fA-F0-9]{40}$') { throw 'Rehearsal requires isolated gates and a pinned test certificate.' } + "ELECTRON_BUILDER_CACHE=$(Join-Path $env:RUNNER_TEMP 'signing-electron-builder-cache')" >> $env:GITHUB_ENV - name: Checkout uses: actions/checkout@v6 with: @@ -543,6 +553,7 @@ jobs: git archive "$WORKFLOW_SHA" config/windows-signing | tar -x -C "$RUNNER_TEMP/signing-control" git checkout "$WORKFLOW_SHA" -- .github/actions/install-signpath-module git checkout "$WORKFLOW_SHA" -- config/scripts/resolve-7za-path.mjs config/scripts/generate-windows-blockmap.mjs + git checkout "$WORKFLOW_SHA" -- config/scripts/windows-uninstaller-signing.cjs config/scripts/replace-cached-nsis-elevate.mjs - name: Setup pnpm uses: pnpm/setup@v2 with: @@ -555,11 +566,9 @@ jobs: - name: Cache electron-builder downloads uses: actions/cache@v5 with: - path: |- - ~\AppData\Local\electron\Cache - ~\AppData\Local\electron-builder\Cache - key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: electron-builder-win- + path: ${{ runner.temp }}/signing-electron-builder-cache + key: electron-builder-signing-${{ inputs.signing_policy }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: electron-builder-signing-${{ inputs.signing_policy }}- - name: Install dependencies uses: nick-fields/retry@v4 with: @@ -605,6 +614,15 @@ jobs: TAG: ${{ inputs.tag }} SIGNING_POLICY: ${{ inputs.signing_policy }} TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }} + - name: Verify embedded uninstaller signature and receipt + shell: pwsh + run: '& "$env:RUNNER_TEMP/signing-control/config/windows-signing/verify-uninstaller.ps1"' + env: *a1 + - name: Verify shipped uninstaller during rehearsal + shell: pwsh + run: '& "$env:RUNNER_TEMP/signing-control/config/windows-signing/verify-shipped-uninstaller.ps1"' + env: *a1 + if: "!inputs.publish" - name: Upload Windows inner signing evidence if: always() uses: actions/upload-artifact@v7 @@ -613,6 +631,8 @@ jobs: path: | inner-signing-evidence.txt inner-signing-list.txt + + uninstaller-signing-evidence.txt if-no-files-found: ignore retention-days: 30 - name: Publish signed Windows release artifacts diff --git a/cloud/apps/release-signing/README.md b/cloud/apps/release-signing/README.md index 6b01f20537d..f52ff3e1bad 100644 --- a/cloud/apps/release-signing/README.md +++ b/cloud/apps/release-signing/README.md @@ -1,7 +1,7 @@ # Windows release signing gates Windows releases wait for SignPath approval without reserving a runner. The same -GitHub Actions run builds unsigned inner binaries, waits at an environment gate, +GitHub Actions run builds unsigned inner binaries and exports the NSIS uninstaller, waits at an environment gate, packages those signed binaries into NSIS, waits at a second gate, then verifies and uploads the signed installer to the draft release. Publication depends on successful finalization and the other platforms. Both signing waves remain @@ -18,6 +18,11 @@ GitHub-hosted runners perform every signing stage. The existing isolated macOS workflow can continue using Blacksmith. SignPath Foundation human approval is unchanged. Rehearsal uses this exact stage graph with separate environments, the test-signing policy and a pinned test certificate; it never publishes release assets. +The uninstaller shares the first signing request, so there is no third approval. +Its signed bytes and fresh embedding receipt survive checkpoint restoration; the +rehearsal additionally checks the uninstaller extracted or installed from the final EXE. +Tool caches use separate test/production keys and an isolated directory, including +versioned NSIS bundles resolved by the existing cached-elevate script. ## Recovery @@ -36,85 +41,204 @@ test-signing policy and a pinned test certificate; it never publishes release as - Service/API outages keep releases waiting. Check Cloud Run logs and Cloud Scheduler failures. Do not remove the protection rule to recover a waiting release. -## Deployment and activation +## Setup: complete these steps before merging -Do not activate the production workflow until the rehearsal below passes. The release -preflight intentionally fails when the coordinator or protection configuration is missing. -No service, App or webhook is created by merely merging these files. +Merging changes the production release workflow immediately. It does **not** create +any infrastructure. If merged before setup, new release builds stop at signing +preflight. Deploy and rehearse from this PR branch first; merge after that succeeds. +There is no live coordinator URL yet. You enter the SignPath webhook settings in +**step 8**, after deployment gives you that URL. -1. Register a dedicated GitHub App owned by `stablyai`. Grant **Actions: read**, - **Contents: read**, **Deployments: read/write**, subscribe to - `deployment_protection_rule`, and install only on `stablyai/orca`. This service - does not need a user access token or repository administration access. Enable - the App's custom deployment protection rule support in GitHub settings. - Record its App ID and installation ID, and generate a private key. Leave the - webhook inactive until the service URL is available. -2. Create five separate Secret Manager secrets in the chosen GCP project: - `release-signing-github-private-key`, `release-signing-github-webhook`, - `release-signing-signpath-webhook`, `release-signing-reconcile`, and - `release-signing-signpath-api-token`. Generate distinct random 32-byte secrets - for the two webhooks and reconciliation. Store the App PEM as the first secret - and an Orca SignPath API credential as the last. Do not put credentials in git, - chat, the image, or ordinary Terraform environment settings. -3. Build the coordinator from the cloud workspace and push it to an existing - Artifact Registry repository. Cloud Run requires a Linux amd64 image: +The cloud/repository administrator performs steps 1–7; the SignPath configurator +performs step 8. The administrator then runs step 9. Commands below use Bash +(macOS/Linux or Git Bash on Windows), with authenticated `gh`, `gcloud`, Docker +Buildx and Terraform. Replace all capitalized placeholders with your own values. - ```sh - docker buildx build --platform linux/amd64 \ - -f cloud/apps/release-signing/Dockerfile \ - -t REGION-docker.pkg.dev/PROJECT/REPOSITORY/release-signing:COMMIT \ - --push cloud - ``` +### 1. Create and install the GitHub App -4. Use the **separate** Terraform root `cloud/infra/release-signing`, with a protected - remote state backend (configure using an ignored backend override for your GCS - bucket). Copy `terraform.tfvars.example` to ignored `terraform.tfvars`; fill App - IDs, project and immutable image digest. Enable Cloud Run, Secret Manager and - Cloud Scheduler APIs. Supply `TF_VAR_reconcile_token` from the reconciliation - secret. That value enters Scheduler and Terraform state; restrict access to both. - Run `terraform init`, `terraform plan`, then `terraform apply`. This root grants - the coordinator access only to its five secrets and uses no relay credentials. - Secret rotation requires a new service revision; Scheduler's header must match - the deployed reconciliation secret. -5. Set the App webhook URL to `/webhooks/github`, its webhook secret - to the GitHub webhook secret, and activate it. Require successful GitHub ping - delivery. Configure the four environments using a repository administrator token: +Open **stablyai organization → Settings → Developer settings → GitHub Apps → New GitHub App** +([create App](https://github.com/organizations/stablyai/settings/apps/new)). - ```sh - GH_TOKEN="$(gh auth token)" SIGNING_GATE_APP_ID=APP_ID \ - node config/windows-signing/configure-environments.mjs - ``` +- Name: `Orca Release Signing` (or another available name). +- Homepage: `https://github.com/stablyai/orca`. +- Repository permissions: **Actions — Read**, **Contents — Read**, **Deployments — Read and write**. +- Subscribe to **Deployment protection rule** (`deployment_protection_rule`). +- Keep webhook delivery inactive until step 6. +- Create the App, then **Install App → stablyai → Only select repositories → orca**. +- From the App's General page, record its **App ID** and generate/download a private key. +- Record the **installation ID** from the installation settings URL (the number after `/installations/`). - The script creates `windows-{inner,installer}-signing` and - `windows-rehearsal-{inner,installer}-signing`, disables administrator bypass, - restricts branches to `main`, and enables the App rule. It refuses to overwrite - existing reviewer/timer or unexpected branch rules. For pre-merge rehearsal, - explicitly set `SIGNING_REHEARSAL_BRANCH` and the coordinator's rehearsal policy - branch to the review branch; restore both to `main` after merge. -6. Set repository variables `SIGNING_GATE_URL`, `SIGNING_GATE_APP_ID`, and - `SIGNPATH_TEST_CERTIFICATE_THUMBPRINT` (the 40-hex SHA-1 thumbprint of the SignPath - test signing certificate). `GET /ready` must return the App ID, - repository and all four protected environments. -7. **In SignPath's webhook UI:** URL `/webhooks/signpath`, - Authorization header `Bearer `. - Leave the experimental custom body template **off**. The standard payload is: +These are two different IDs. Do not paste the private key into the PR or chat. - ```json - {"OrganizationId":"c37aa192-a27a-4377-9c90-5d6c95912dc0","SigningRequestId":"REQUEST_UUID","Status":"Completed"} - ``` +### 2. Confirm SignPath can sign every required file + +In the Orca project, confirm `windows-inner-binaries-zip` covers the normal inner +PE files **and `uninstaller/orca-uninstaller.exe`**. The upstream uninstaller flow +could continue without that file; this PR deliberately stops the release if it is +missing or not signed. Keep `github-actions-windows-installer` for the outer EXE. +Both `release-signing` and `test-signing` must support this flow. + +Record the **40-character certificate thumbprint** for the test-signing certificate. +This is a public certificate identifier, not an API token. Keep the existing +GitHub `SIGNPATH_API_TOKEN` Actions secret in place. + +### 3. Store the coordinator's five credentials in Google Secret Manager + +Choose the GCP project that will host this service. In that project's **Security → +Secret Manager**, create the following secrets with these exact names: + +| Secret name | Secret value | +| --- | --- | +| `release-signing-github-private-key` | Entire downloaded GitHub App PEM file | +| `release-signing-github-webhook` | New random secret, at least 32 characters | +| `release-signing-signpath-webhook` | A different new random secret, at least 32 characters | +| `release-signing-reconcile` | A third new random secret, at least 32 characters | +| `release-signing-signpath-api-token` | SignPath API token with access to Orca signing requests | + +Use a password manager to generate/store the three random values, or use +`openssl rand -hex 32` separately for each. The SignPath webhook secret is **not** +the SignPath API token. Never commit any of these values. + +### 4. Build and push the service image from the PR branch + +From the repository root: + +```sh +git switch nwparker/async-release-signing +export SIGNING_PROJECT=YOUR_GCP_PROJECT +export SIGNING_REGION=us-central1 +export SIGNING_REGISTRY=YOUR_EXISTING_ARTIFACT_REGISTRY_REPOSITORY +export SIGNING_IMAGE="$SIGNING_REGION-docker.pkg.dev/$SIGNING_PROJECT/$SIGNING_REGISTRY/release-signing:$(git rev-parse HEAD)" +gcloud services enable run.googleapis.com secretmanager.googleapis.com cloudscheduler.googleapis.com artifactregistry.googleapis.com --project "$SIGNING_PROJECT" +gcloud auth configure-docker "$SIGNING_REGION-docker.pkg.dev" +docker buildx build --platform linux/amd64 \ + -f cloud/apps/release-signing/Dockerfile -t "$SIGNING_IMAGE" --push cloud +gcloud artifacts docker images describe "$SIGNING_IMAGE" --project "$SIGNING_PROJECT" --format='value(image_summary.fully_qualified_digest)' +``` + +Record the final `...@sha256:...` image reference. If there is no Artifact Registry +repository yet, create a **Docker** repository in the chosen region first. + +### 5. Deploy the coordinator and the recovery scheduler + +Copy `cloud/infra/release-signing/terraform.tfvars.example` to +`cloud/infra/release-signing/terraform.tfvars` (ignored by git). Fill in: + +- `project`, `region`, and the full immutable image reference from step 4. +- `GITHUB_APP_ID` and `GITHUB_INSTALLATION_ID` from step 1. +- Leave the organization UUID and secret names at their supplied Orca values. +- In `SIGNING_POLICIES`, keep the **production** branch as `main`. +- Set the **rehearsal** branch to `nwparker/async-release-signing` for the pre-merge test. + +For local Terraform runs, authenticate once with `gcloud auth application-default login`. +Use a protected remote Terraform state bucket for this separate root. Create +`cloud/infra/release-signing/backend_override.tf` (already ignored by git) containing: + +```hcl +terraform { + backend "gcs" {} +} +``` + +Initialize with your bucket below. Do not use the relay Terraform state. +The scheduler's secret enters Terraform state, so only its administrators should +have access to that bucket. + +```sh +export TF_VAR_reconcile_token="$(gcloud secrets versions access latest --secret=release-signing-reconcile --project "$SIGNING_PROJECT")" +terraform -chdir=cloud/infra/release-signing init \ + -backend-config="bucket=YOUR_PROTECTED_STATE_BUCKET" \ + -backend-config="prefix=release-signing" +terraform -chdir=cloud/infra/release-signing plan +terraform -chdir=cloud/infra/release-signing apply +export SIGNING_GATE_URL="$(terraform -chdir=cloud/infra/release-signing output -raw url)" +unset TF_VAR_reconcile_token +``` + +The Terraform **`url` output is the actual service URL**. The service scales down +when idle; Cloud Scheduler calls it every five minutes to recover missed callbacks. + +### 6. Connect the GitHub App and protect the environments + +Return to the App's **General** settings: + +- Webhook URL: append `/webhooks/github` to the service URL from step 5. +- Webhook secret: value of **`release-signing-github-webhook`**. +- Enable webhook delivery. Verify a successful ping under **Recent deliveries**. + +Then, from the repository root, run this with a repository administrator's `gh` login: + +```sh +GH_TOKEN="$(gh auth token)" SIGNING_GATE_APP_ID=YOUR_APP_ID \ + SIGNING_REHEARSAL_BRANCH=nwparker/async-release-signing \ + node config/windows-signing/configure-environments.mjs +``` + +It enables the App rule on four environments, disables administrator bypass, and +restricts production to `main` and rehearsal to the PR branch. It refuses to replace +unexpected pre-existing rules; inspect any reported conflict in **Repository Settings → Environments**. + +### 7. Set repository variables and check readiness + +Open **stablyai/orca → Settings → Secrets and variables → Actions → Variables**. +Create these **repository variables** (not secrets): + +| Variable | Value | +| --- | --- | +| `SIGNING_GATE_URL` | Service URL from step 5, without `/webhooks/...` | +| `SIGNING_GATE_APP_ID` | App ID from step 1, not the installation ID | +| `SIGNPATH_TEST_CERTIFICATE_THUMBPRINT` | 40-character test certificate thumbprint from step 2 | + +Open `/ready` or run `curl --fail "$SIGNING_GATE_URL/ready"`. +It must return HTTP 200 with the correct App ID, `stablyai/orca`, and all four +signing environment names. If it fails, finish the App/environment configuration +before proceeding. `/health` alone does not verify the protection rules. + +### 8. Enter the webhook in SignPath — this is the SignPath UI step + +In SignPath's webhook configuration, enter: + +| SignPath field | Exact value to supply | +| --- | --- | +| **URL** | Service URL from step 5 followed by **`/webhooks/signpath`** | +| **Authorization header** | **`Bearer `** followed by the value of **`release-signing-signpath-webhook`** | +| **Use custom body template (experimental)** | **Off** | + +Include the space after `Bearer`. Use the dedicated SignPath webhook secret from +step 3; do not use either the API token or the GitHub webhook secret. Save/enable +the webhook before running the rehearsal. The standard SignPath body is supported. +If the UI offers event selection, include **Completed, Failed, Denied, Canceled**. + +### 9. Rehearse before merging + +In **GitHub Actions → Windows signing rehearsal → Run workflow**: + +- Branch: **`nwparker/async-release-signing`**. +- Tag: an existing Orca stable or RC release tag. +- Run it. This uses the test certificate and does not publish release assets. + +The operator must verify both waits release their runner and resume through the App; +SignPath's API provenance matches the GitHub run and workflow commit; and the evidence +contains verified inner binaries, `elevate.exe`, and the actual shipped uninstaller. +The uninstaller uses the first request, so there should still be only **two** requests. +Also exercise denial, duplicate delivery, rerun finalization, and missed-webhook +recovery. A green unit-test run is not a substitute for this live rehearsal. + +### 10. Merge, then return rehearsal settings to main + +After the live rehearsal, review, and required CI pass, merge the PR. The next +production release uses the new signing gates and still requires normal Foundation +approvals. Keep all four custom protection rules enabled. + +For future rehearsals, change the branch policy on **each** +`windows-rehearsal-*-signing` environment from the PR branch to **`main`** in GitHub's +Environment settings. Change the coordinator's rehearsal branch in `SIGNING_POLICIES` +to `main` too, and apply the Terraform update (load `TF_VAR_reconcile_token` again as +in step 5). Production branch settings already remain on `main` throughout. + +If credentials are rotated later, deploy a new Cloud Run revision; the scheduler's +Authorization value must match that revision's reconciliation secret. -8. Dispatch `windows-signing-rehearsal.yml` against an existing stable/RC tag. - Verify both gates enter waiting before runner allocation and resume through the - App. Confirm the SignPath API reports `origin.buildData.url` as the GitHub run URL - (optionally `/job/ID`) and `origin.repositoryData.commitId` as the run's `head_sha`. - The built tag commit is separately pinned in the checkpoint. If actual API - semantics differ, correct the binding and regression tests before activation; - do not relax provenance checks. Exercise denial, duplicate delivery, rerun - finalization, and missed-webhook recovery via `/reconcile`. Download the evidence - and verify the nested installer payload passed under the pinned test certificate. -9. After successful rehearsal and review, activate the production caller. A real - release requires the normal Foundation approvals. Confirm both signing gates and - the signed installer evidence before allowing publication. ## Checks diff --git a/cloud/infra/release-signing/.gitignore b/cloud/infra/release-signing/.gitignore index deb93e85984..8b0798ade82 100644 --- a/cloud/infra/release-signing/.gitignore +++ b/cloud/infra/release-signing/.gitignore @@ -3,3 +3,4 @@ *.tfplan *.tfvars *.tfvars.json +backend_override.tf diff --git a/config/scripts/replace-cached-nsis-elevate.test.mjs b/config/scripts/replace-cached-nsis-elevate.test.mjs index a88461703c3..360246c1739 100644 --- a/config/scripts/replace-cached-nsis-elevate.test.mjs +++ b/config/scripts/replace-cached-nsis-elevate.test.mjs @@ -320,45 +320,40 @@ describe('a cached elevate.exe miss is not silent', () => { }) }) -describe('release-cut.yml swaps the cached elevate.exe through the resolver', () => { - function swapStep() { - const workflow = parse( - readFileSync(join(projectRoot, '.github/workflows/release-cut.yml'), 'utf8') +describe('Windows signing swaps the cached elevate.exe through the resolver', () => { + const workflow = parse( + readFileSync(join(projectRoot, '.github/workflows/release-windows-signing.yml'), 'utf8') + ) + const step = workflow.jobs.package.steps.find( + (candidate) => candidate.name === 'Replace cached elevate.exe with the signed copy' + ) + const script = readFileSync( + join(projectRoot, 'config/windows-signing/replace-elevate.ps1'), + 'utf8' + ) + it('delegates to the authoritative toolset resolver', () => { + expect(step.run).toContain('replace-elevate.ps1') + expect(script).toContain('node config/scripts/replace-cached-nsis-elevate.mjs $signed') + expect(script).not.toContain('electron-builder\\Cache\\nsis') + }) + it('blocks the rebuild when the resolver reports a miss', () => { + expect(script).toMatch(/if \(\$LASTEXITCODE -ne 0\) \{ throw /) + expect(step['continue-on-error']).toBeUndefined() + }) + it('requires the correct certificate and isolates test caches from production', () => { + expect(script).toContain('Assert-SigningCertificate') + const policy = readFileSync( + join(projectRoot, 'config/windows-signing/signature-policy.ps1'), + 'utf8' ) - const step = workflow.jobs.build.steps.find( - (candidate) => candidate.name === 'Replace cached elevate.exe with the signed copy' + expect(policy).toContain("$signature.Status -ne 'Valid'") + expect(policy).toContain( + "$signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'" ) - expect(step).toBeDefined() - return step - } - - it('delegates the cache lookup to the script instead of an inline path', () => { - const step = swapStep() - expect(step.run).toContain('node config/scripts/replace-cached-nsis-elevate.mjs $signed') - // The hardcoded miss that shipped v1.4.193/v1.4.194 unsigned. - expect(step.run).not.toContain('electron-builder\\Cache\\nsis') - expect(step.run).not.toContain('-ErrorAction SilentlyContinue') - }) - - it('fails the step when the swap reports a miss', () => { - const step = swapStep() - // Matched as an executed statement: downgrading this to a Write-Host restores - // the silent fail-open that let the unsigned helper ship. - expect(step.run).toMatch(/if \(\$LASTEXITCODE -ne 0\) \{/) - expect(step.run).toMatch(/^\s*throw \$message\s*$/m) - expect(step.run).toContain('GITHUB_STEP_SUMMARY') - }) - - // Why kept: windows-signing-rehearsal.yml shares the electron-builder-win- - // cache key, so dropping this guard would let a test certificate reach a release cache. - it('still refuses to stage anything but a SignPath-signed helper', () => { - const step = swapStep() - expect(step.run).toContain("$signature.Status -ne 'Valid'") - expect(step.run).toContain("$subject -notlike '*CN=SignPath Foundation*'") - }) - - // The inner-signing chain stays fail-open: a loud red step, not an unbuildable release. - it('keeps the step unable to fail the release job', () => { - expect(swapStep()['continue-on-error']).toBe(true) + const cache = workflow.jobs.package.steps.find( + (s) => s.name === 'Cache electron-builder downloads' + ) + expect(cache.with.key).toContain('${{ inputs.signing_policy }}') + expect(cache.with['restore-keys']).toContain('${{ inputs.signing_policy }}') }) }) diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs index 1365562042b..a79895f4469 100644 --- a/config/scripts/windows-signing-workflow-contract.test.mjs +++ b/config/scripts/windows-signing-workflow-contract.test.mjs @@ -1,5 +1,4 @@ import { readFileSync } from 'node:fs' -import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' @@ -204,229 +203,83 @@ describe('Windows signing workflow contract', () => { // CI can sign it is the export/import relay through win.signtoolOptions.sign. // Every link is asserted here the way Orca.exe and conpty_console_list.node are. describe('Windows NSIS uninstaller signing', () => { - const releaseSteps = () => readWorkflow('.github/workflows/release-cut.yml').jobs.build.steps - const stepNamed = (steps, name) => steps.find((step) => step.name === name) + const workflow = readWorkflow('.github/workflows/release-windows-signing.yml') + const allSteps = Object.values(workflow.jobs).flatMap((job) => job.steps) + const step = (name) => allSteps.find((s) => s.name === name) + const script = (name) => readFileSync(join(projectDir, `config/windows-signing/${name}`), 'utf8') - const EXPORT_ENV = 'ORCA_WIN_UNINSTALLER_EXPORT_PATH' - const SIGNED_ENV = 'ORCA_WIN_UNINSTALLER_SIGNED_PATH' - - it('exports the uninstaller from the first Windows build', () => { - const build = stepNamed(releaseSteps(), 'Build Windows release artifacts') - - expect(build.env[EXPORT_ENV]).toContain('uninstaller-signing') - expect(build.env[EXPORT_ENV]).toContain('orca-uninstaller.exe') + it('exports outside the checkout and includes the uninstaller in the first request', () => { + const build = step('Build Windows release artifacts') + expect(build.env.ORCA_WIN_UNINSTALLER_EXPORT_PATH).toContain('${{ runner.temp }}') + expect(build.with.command).toContain('electron-builder-signing.config.cjs') + expect(script('stage-inner.ps1')).toContain('uninstaller/orca-uninstaller.exe') + expect(script('stage-inner.ps1')).toContain( + "throw 'The NSIS build did not export an uninstaller" + ) + expect(allSteps.filter((s) => s.uses?.startsWith('signpath/'))).toHaveLength(2) }) - // Why this is a test and not a comment: `files` in the electron-builder config - // is all-negation, so app-builder packs whatever is left in the checkout root. - // These steps retry, and a retried attempt would pack an unsigned .exe into - // app.asar — the very defect this chain removes. Every relay path must live - // outside the checkout. - it('keeps every relay path out of the packed checkout', () => { - const relayEnvValues = [ - ...releaseSteps(), - ...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps - ].flatMap((step) => [step.env?.[EXPORT_ENV], step.env?.[SIGNED_ENV]].filter(Boolean)) + it('restores the signed uninstaller before rebuilding and rejects missing or invalid signatures', () => { + const names = workflow.jobs.package.steps.map((s) => s.name) + expect(names.indexOf('Restore signed uninstaller for the installer rebuild')).toBeLessThan( + names.indexOf('Rebuild NSIS installer from signed unpacked app') + ) + expect(script('restore-uninstaller.ps1')).toContain('Assert-SigningCertificate') + expect(script('package-installer.ps1')).toContain('ORCA_WIN_UNINSTALLER_SIGNED_PATH') + expect(script('package-installer.ps1')).toContain('Remove-Item -LiteralPath $receipt') + expect(script('package-installer.ps1')).toContain('verify-uninstaller.ps1') + }) - expect(relayEnvValues.length).toBe(4) - for (const value of relayEnvValues) { - expect(value).toContain('runner.temp') - expect(value).not.toContain('github.workspace') - } - - const relayScripts = [ - ...releaseSteps(), - ...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps - ] - .map((step) => step.run ?? '') - .filter((run) => run.includes('uninstaller-signing')) - - expect(relayScripts.length).toBeGreaterThan(0) - for (const run of relayScripts) { - // Why count occurrences rather than assert `toContain` once: a step - // carrying two relay paths could root the first in RUNNER_TEMP and leave - // the second bare-relative — which resolves against the checkout, and is - // exactly the shape of the defect this test exists to catch. - const mentions = run.match(/uninstaller-signing/g) ?? [] - const rooted = run.match(/Join-Path \$env:RUNNER_TEMP 'uninstaller-signing/g) ?? [] - - expect(rooted.length, run).toBe(mentions.length) - expect(run).not.toContain('$env:GITHUB_WORKSPACE') + it('preserves the relay hook when the release tag predates it', () => { + const wrapper = script('electron-builder-signing.config.cjs') + expect(wrapper).toContain('config/electron-builder.config.cjs') + expect(wrapper).toContain('sign: signWindowsUninstallerViaSignPath') + for (const job of Object.values(workflow.jobs)) { + const load = job.steps.find((s) => s.name === 'Load signing control from the workflow commit') + expect(load.run).toContain( + 'git checkout "$WORKFLOW_SHA" -- config/scripts/windows-uninstaller-signing.cjs' + ) } }) - it('stages the uninstaller into the same request as the inner binaries', () => { - const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') - - expect(stage.run).toContain('uninstaller-signing\\unsigned\\orca-uninstaller.exe') - expect(stage.run).toContain('uninstaller\\orca-uninstaller.exe') - // No third SignPath request: exactly two submissions, as budgeted for the - // 1h + 4h approval waits inside the 360-minute job cap. - const submissions = releaseSteps().filter( - (step) => step.uses === 'signpath/github-action-submit-signing-request@v2' - ) - expect(submissions).toHaveLength(2) - }) - - // A staged-but-unreturned uninstaller must not fail the inner chain, or a - // SignPath artifact-configuration gap would cost the inner-binary signatures. - it('keeps the uninstaller out of the inner-binary copy-back list', () => { - const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') - const restoreInner = stepNamed( - releaseSteps(), - 'Restore signed inner binaries into unpacked app' - ) - - expect(stage.run).not.toMatch(/\$list\.Add\(['"]uninstaller/) - expect(restoreInner.run).not.toContain('orca-uninstaller.exe') - }) - - // This step's outcome gates the upload of every inner binary, so a filesystem - // error while staging the uninstaller must not escape — otherwise one - // uninstaller-specific failure costs every inner-binary signature, which is - // strictly worse than the behaviour before this chain existed. - it('cannot let an uninstaller staging failure cost the inner-binary signatures', () => { - const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') - const uninstallerBlock = stage.run.slice(stage.run.indexOf('$exportedUninstaller')) - - expect(stage.run).toMatch(/try \{[\s\S]*\$exportedUninstaller[\s\S]*\} catch \{/) - expect(uninstallerBlock).toContain('::warning::Could not stage the NSIS uninstaller') - expect(uninstallerBlock).not.toContain('throw') - // Explicit, so the catch does not silently depend on GitHub's - // $ErrorActionPreference='Stop' default for `shell: pwsh`. - expect(uninstallerBlock).toContain('New-Item -ItemType Directory -Force -Path (Split-Path') - expect(uninstallerBlock).toMatch(/New-Item[^\r\n]*-ErrorAction Stop/) - expect(uninstallerBlock).toMatch(/Copy-Item[^\r\n]*-ErrorAction Stop/) - // The upload it gates still keys off this step, so the catch is load-bearing. - expect(stepNamed(releaseSteps(), 'Upload unsigned inner binaries for SignPath').if).toContain( - "steps.stage-inner.outcome == 'success'" - ) - }) - - it('re-injects the signed uninstaller into the rebuilt installer', () => { - const steps = releaseSteps() - const restore = stepNamed(steps, 'Restore signed uninstaller for the installer rebuild') - const rebuild = stepNamed(steps, 'Rebuild NSIS installer from signed unpacked app') - const names = steps.map((step) => step.name) - - expect(restore.if).toContain('github.run_attempt == 1') - expect(restore.if).toContain("steps.restore-signed-inner.outcome == 'success'") - expect(restore.run).toContain('orca-uninstaller.exe') - expect(names.indexOf(restore.name)).toBeLessThan(names.indexOf(rebuild.name)) - expect(rebuild.env[SIGNED_ENV]).toContain('uninstaller-signing') - // The rebuild must not depend on the uninstaller leg: a missing signed - // uninstaller ships today's installer, it does not skip the rebuild. - expect(rebuild.if).not.toContain('restore-signed-uninstaller') - }) - - // NSIS hides the uninstaller in a compressed data section the bundled 7za - // cannot read, so the gate proves it from the sign hook's digest receipt - // instead of extracting it — and only when the relay actually ran. - it('reports the embedded uninstaller in the inner-binary evidence gate', () => { - const gate = stepNamed(releaseSteps(), 'Verify Windows inner binary signatures') - - expect(gate.env.UNINSTALLER_SIGNING_COMPLETED).toBe( - "${{ steps.restore-signed-uninstaller.outcome == 'success' }}" - ) - expect(gate.run).toContain('.embedded-sha256') - expect(gate.run).toContain("$env:UNINSTALLER_SIGNING_COMPLETED -eq 'true'") - expect(gate.run).toContain('not signed by SignPath Foundation: Uninstall Orca.exe') - // The uninstaller must not join the 7z payload loop, which cannot see it. - expect(gate.run).not.toContain("$targets += 'Uninstall Orca.exe'") - }) - - it('rehearses the uninstaller leg end to end', () => { - const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse - .steps - const names = steps.map((step) => step.name) - const pack = stepNamed(steps, 'Package Windows app and export the NSIS uninstaller') - const rebuild = stepNamed(steps, 'Build NSIS installer from signed unpacked app') - const verify = stepNamed(steps, 'Verify signatures end to end') - - // --dir never produces an uninstaller, so the rehearsal has to build the - // installer the way release-cut's first Windows pass does. - expect(pack.run).toContain('--win --publish never') - expect(pack.run).not.toContain('--dir') - expect(pack.env[EXPORT_ENV]).toContain('orca-uninstaller.exe') - expect(names).toContain('Restore signed uninstaller for the installer rebuild') - expect(rebuild.env[SIGNED_ENV]).toContain('orca-uninstaller.exe') - expect(verify.run).toContain('.embedded-sha256') - // The receipt only proves the import leg ran. The rehearsal is where the - // shipped uninstaller itself gets checked — the release job cannot install - // onto the runner it publishes from. - expect(verify.run).toContain('shipped: Uninstall Orca.exe') - expect(verify.run).toContain('-tnsis') - expect(verify.run).toContain("-ArgumentList '/S'") - }) - - // This workflow is the merge gate, so it must not be able to fail on its own - // artefact: 7-Zip's NSIS handler is unreliable enough that its output has to - // be corroborated before a signature verdict is drawn from it. - it('never lets an unreliable extract fail the rehearsal', () => { - const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse - .steps - const verify = stepNamed(steps, 'Verify signatures end to end') - - // The 7-Zip route is only trusted when it reproduces the relayed bytes; - // otherwise it falls through to the install route rather than failing. - expect(verify.run).toContain( - 'Write-Host "7-Zip\'s NSIS output did not match the relayed digest; falling back to a silent install."' - ) - expect(verify.run).toMatch(/\$installedUninstaller = \$null\r?\n\s*\}/) - - // The comparison that is not tautological: a file NSIS wrote out, against - // the digest the sign hook recorded. - expect(verify.run).toContain('$shippedDigest -ne $expectedDigest') - expect(verify.run).toContain('the uninstaller the installer ships is not the relayed one') - - // An installer that prompts must not hang to the 360-minute job cap, and - // the app it launches must not outlive the step holding install-dir handles. - expect(verify.run).toContain('-PassThru') - expect(verify.run).toContain('$installerProcess.WaitForExit(300000)') - expect(verify.run).toContain('the silent install did not exit within 5 minutes') - expect(verify.run).toMatch(/for \(\$attempt = 0; \$attempt -lt 20; \$attempt\+\+\)/) - expect(verify.run).toContain("Get-Process -Name 'orca-terminal-daemon'") - }) - - // resources\elevate.exe is downgraded to advisory because app-builder-lib's - // CopyElevateHelper clobbers it on every nsis pack — a pre-existing defect - // that predates the uninstaller relay and is being tracked separately. The - // escape hatch it needed is the kind that quietly grows until the gate - // asserts nothing, so pin it to exactly that one file. - it('confines the advisory escape hatch to elevate.exe', () => { - const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse - .steps - const verify = stepNamed(steps, 'Verify signatures end to end') - const advisoryCalls = verify.run - .split('\n') - .filter((line) => line.includes('-Advisory') && line.includes('Test-Signature')) - - expect(advisoryCalls).toHaveLength(1) - expect(advisoryCalls[0]).toContain('installed: $relative') - expect(verify.run).toContain("if ($relative -eq 'resources\\elevate.exe')") - - // Both uninstaller verdicts stay fatal — the whole point of the gate. - for (const call of ['relayed: orca-uninstaller.exe', 'shipped: Uninstall Orca.exe']) { - const line = verify.run - .split('\n') - .find((it) => it.includes(`Test-Signature`) && it.includes(call)) - expect(line, call).toBeDefined() - expect(line, call).not.toContain('-Advisory') + it('keeps certificate policies and versioned tool caches isolated', () => { + for (const job of Object.values(workflow.jobs)) { + expect(job.steps[0].run).toContain('ELECTRON_BUILDER_CACHE=$(Join-Path $env:RUNNER_TEMP') + const cache = job.steps.find((s) => s.name === 'Cache electron-builder downloads') + expect(cache.with.key).toContain('${{ inputs.signing_policy }}') + expect(cache.with['restore-keys']).toContain('${{ inputs.signing_policy }}') } - - // An advisory must still reach the evidence artifact, or downgrading it - // becomes indistinguishable from deleting the check. - expect(verify.run).toContain('ADVISORY (known pre-existing') - expect(verify.run).toContain('$script:advisories.Add($problem)') + expect(script('replace-elevate.ps1')).toContain( + 'node config/scripts/replace-cached-nsis-elevate.mjs' + ) + expect(script('replace-elevate.ps1')).toContain('Assert-SigningCertificate') + expect(script('checkpoint.ps1')).toContain('$env:ELECTRON_BUILDER_CACHE') + expect(script('checkpoint.ps1')).not.toContain("@('nsis', 'nsis-resources')") }) - it('wires the electron-builder sign hook that the relay depends on', () => { - const require = createRequire(import.meta.url) - const configPath = resolve(projectDir, 'config/electron-builder.config.cjs') - delete require.cache[require.resolve(configPath)] - const config = require(configPath) + it('checkpoints and verifies the signed bytes and fresh embedding receipt before publishing', () => { + expect(script('checkpoint.ps1')).toContain('orca-uninstaller.exe.embedded-sha256') + expect(script('verify-uninstaller.ps1')).toContain('Get-FileHash') + expect(script('verify-uninstaller.ps1')).toContain('Assert-SigningCertificate') + const names = workflow.jobs.finalize.steps.map((s) => s.name) + expect(names.indexOf('Verify embedded uninstaller signature and receipt')).toBeLessThan( + names.indexOf('Publish signed Windows release artifacts') + ) + expect(step('Verify embedded uninstaller signature and receipt').if).toBeUndefined() + expect(step('Upload Windows inner signing evidence').with.path).toContain( + 'uninstaller-signing-evidence.txt' + ) + }) - expect(typeof config.win.signtoolOptions.sign).toBe('function') - delete require.cache[require.resolve(configPath)] + it('retains the shipped-uninstaller rehearsal with bounded install fallback', () => { + expect(step('Verify shipped uninstaller during rehearsal').if).toBe('!inputs.publish') + const verify = script('verify-shipped-uninstaller.ps1') + expect(verify).toContain('-tnsis') + expect(verify).toContain('WaitForExit(300000)') + expect(verify).toContain('-ArgumentList "/S /D=$installRoot"') + expect(verify).toContain('$actual -cne $expectedDigest') + expect(verify).toContain("'shipped: Uninstall Orca.exe'") + expect(verify).not.toContain('-Advisory') + expect(verify).toContain("Get-Process -Name 'orca-terminal-daemon'") }) }) diff --git a/config/windows-signing/checkpoint.ps1 b/config/windows-signing/checkpoint.ps1 index 64f90b23d24..630311e3fd5 100644 --- a/config/windows-signing/checkpoint.ps1 +++ b/config/windows-signing/checkpoint.ps1 @@ -10,16 +10,21 @@ if ($env:MODE -eq 'save') { if ($env:GITHUB_RUN_ATTEMPT -ne '1') { throw 'Never create a signing checkpoint on a rerun.' } if ($env:REQUEST_ID -notmatch '^[a-fA-F0-9]{8}(-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$') { throw 'Invalid signing request ID.' } New-Item -ItemType Directory -Force $directory | Out-Null - $cache = Join-Path $env:GITHUB_WORKSPACE 'signing-nsis-cache' - New-Item -ItemType Directory -Force $cache | Out-Null - foreach ($name in @('nsis', 'nsis-resources')) { - $from = Join-Path "$env:LOCALAPPDATA/electron-builder/Cache" $name - if (Test-Path -LiteralPath $from) { - Copy-Item -LiteralPath $from -Destination $cache -Recurse -Force + $cache = Join-Path $env:GITHUB_WORKSPACE 'signing-tool-cache' + if (-not $env:ELECTRON_BUILDER_CACHE -or -not (Test-Path -LiteralPath $env:ELECTRON_BUILDER_CACHE -PathType Container)) { throw 'The isolated electron-builder cache is required.' } + if (Test-Path -LiteralPath $cache) { Remove-Item -LiteralPath $cache -Recurse -Force } + Copy-Item -LiteralPath $env:ELECTRON_BUILDER_CACHE -Destination $cache -Recurse -Force + if (@(Get-ChildItem $cache -Recurse -File -Filter elevate.exe).Count -eq 0) { throw 'The NSIS tool cache must be checkpointed with the build.' } + $uninstaller = Join-Path $env:GITHUB_WORKSPACE 'signing-uninstaller' + if (Test-Path -LiteralPath $uninstaller) { Remove-Item -LiteralPath $uninstaller -Recurse -Force } + New-Item -ItemType Directory -Force $uninstaller | Out-Null + if ($stage -eq 'installer') { + & "$PSScriptRoot/verify-uninstaller.ps1" + foreach ($name in @('orca-uninstaller.exe', 'orca-uninstaller.exe.embedded-sha256')) { + Copy-Item -LiteralPath "$env:RUNNER_TEMP/uninstaller-signing/signed/$name" -Destination $uninstaller -Force } } - if (-not (Test-Path -LiteralPath "$cache/nsis")) { throw 'The NSIS tool cache must be checkpointed with the build.' } - tar -czf "$directory/checkpoint.tar.gz" dist/win-unpacked dist/orca-windows-setup.exe dist/latest.yml inner-signing-list.txt signing-nsis-cache + tar -czf "$directory/checkpoint.tar.gz" dist/win-unpacked dist/orca-windows-setup.exe dist/latest.yml inner-signing-list.txt signing-tool-cache signing-uninstaller if ($LASTEXITCODE -ne 0) { throw 'Could not archive the exact Windows build.' } $manifest = @{ version = 1; repository = $env:GITHUB_REPOSITORY; runId = $env:GITHUB_RUN_ID @@ -48,21 +53,20 @@ if ($env:MODE -eq 'save') { $entries = @(tar -tzf "$directory/checkpoint.tar.gz") if ($LASTEXITCODE -ne 0) { throw 'Cannot inspect checkpoint archive.' } foreach ($entry in $entries) { - if ($entry -match '(^[/\\]|^[A-Za-z]:|(^|[/\\])\.\.([/\\]|$))' -or $entry -notmatch '^(dist/(win-unpacked(/|$)|orca-windows-setup\.exe$|latest\.yml$)|inner-signing-list\.txt$|signing-nsis-cache(/|$))') { throw "Unexpected checkpoint entry: $entry" } + if ($entry -match '(^[/\\]|^[A-Za-z]:|(^|[/\\])\.\.([/\\]|$))' -or $entry -notmatch '^(dist/(win-unpacked(/|$)|orca-windows-setup\.exe$|latest\.yml$)|inner-signing-list\.txt$|signing-tool-cache(/|$)|signing-uninstaller(/|$))') { throw "Unexpected checkpoint entry: $entry" } } $types = @(tar -tvzf "$directory/checkpoint.tar.gz") if ($LASTEXITCODE -ne 0 -or @($types | Where-Object { $_ -notmatch '^[d-]' }).Count -gt 0) { throw 'Checkpoint links and special files are not supported.' } tar -xzf "$directory/checkpoint.tar.gz" -C $env:GITHUB_WORKSPACE if ($LASTEXITCODE -ne 0) { throw 'Could not restore Windows build checkpoint.' } - $cacheRoot = "$env:LOCALAPPDATA/electron-builder/Cache" - New-Item -ItemType Directory -Force $cacheRoot | Out-Null - foreach ($name in @('nsis', 'nsis-resources')) { - $from = Join-Path "$env:GITHUB_WORKSPACE/signing-nsis-cache" $name - $to = Join-Path $cacheRoot $name - if (Test-Path -LiteralPath $from) { - if (Test-Path -LiteralPath $to) { Remove-Item -LiteralPath $to -Recurse -Force } - Copy-Item -LiteralPath $from -Destination $to -Recurse -Force - } + if (-not $env:ELECTRON_BUILDER_CACHE) { throw 'Missing isolated tool cache destination.' } + if (Test-Path -LiteralPath $env:ELECTRON_BUILDER_CACHE) { Remove-Item -LiteralPath $env:ELECTRON_BUILDER_CACHE -Recurse -Force } + Copy-Item -LiteralPath "$env:GITHUB_WORKSPACE/signing-tool-cache" -Destination $env:ELECTRON_BUILDER_CACHE -Recurse -Force + if ($stage -eq 'installer') { + $signedDirectory = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed' + if (Test-Path -LiteralPath $signedDirectory) { Remove-Item -LiteralPath $signedDirectory -Recurse -Force } + New-Item -ItemType Directory -Force (Split-Path $signedDirectory) | Out-Null + Copy-Item -LiteralPath "$env:GITHUB_WORKSPACE/signing-uninstaller" -Destination $signedDirectory -Recurse -Force } "SIGNPATH_REQUEST_ID=$($manifest.requestId)" >> $env:GITHUB_ENV "SIGNING_CHECKPOINT_SOURCE_SHA=$sourceSha" >> $env:GITHUB_ENV diff --git a/config/windows-signing/electron-builder-signing.config.cjs b/config/windows-signing/electron-builder-signing.config.cjs new file mode 100644 index 00000000000..24facdd4334 --- /dev/null +++ b/config/windows-signing/electron-builder-signing.config.cjs @@ -0,0 +1,14 @@ +const { join } = require('node:path') +const base = require(join(process.cwd(), 'config/electron-builder.config.cjs')) +const { signWindowsUninstallerViaSignPath } = require( + join(process.cwd(), 'config/scripts/windows-uninstaller-signing.cjs') +) + +// Load the release tag's packaging settings, including tags predating the relay hook. +module.exports = { + ...base, + win: { + ...base.win, + signtoolOptions: { ...base.win?.signtoolOptions, sign: signWindowsUninstallerViaSignPath } + } +} diff --git a/config/windows-signing/package-installer.ps1 b/config/windows-signing/package-installer.ps1 index e7bd8057191..27b4091c83d 100644 --- a/config/windows-signing/package-installer.ps1 +++ b/config/windows-signing/package-installer.ps1 @@ -1,6 +1,11 @@ $ErrorActionPreference = 'Stop' -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' -} +. "$PSScriptRoot/signature-policy.ps1" +$env:ORCA_WIN_UNINSTALLER_SIGNED_PATH = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed/orca-uninstaller.exe' +if (-not (Test-Path -LiteralPath $env:ORCA_WIN_UNINSTALLER_SIGNED_PATH)) { throw 'Missing signed NSIS uninstaller.' } +Assert-SigningCertificate (Get-AuthenticodeSignature -FilePath $env:ORCA_WIN_UNINSTALLER_SIGNED_PATH) 'Uninstall Orca.exe' +$receipt = "$env:ORCA_WIN_UNINSTALLER_SIGNED_PATH.embedded-sha256" +if (Test-Path -LiteralPath $receipt) { Remove-Item -LiteralPath $receipt -Force } +pnpm exec electron-builder --config "$PSScriptRoot/electron-builder-signing.config.cjs" --win --publish never --prepackaged "$env:GITHUB_WORKSPACE/dist/win-unpacked" +if ($LASTEXITCODE -ne 0) { throw 'The signed NSIS rebuild failed.' } +if (-not (Test-Path 'dist/orca-windows-setup.exe')) { throw 'The NSIS rebuild did not produce an installer.' } +& "$PSScriptRoot/verify-uninstaller.ps1" diff --git a/config/windows-signing/replace-elevate.ps1 b/config/windows-signing/replace-elevate.ps1 index 9ea42e8005b..ba35a9aa83b 100644 --- a/config/windows-signing/replace-elevate.ps1 +++ b/config/windows-signing/replace-elevate.ps1 @@ -3,6 +3,5 @@ $ErrorActionPreference = 'Stop' $signed = 'dist/win-unpacked/resources/elevate.exe' if (-not (Test-Path -LiteralPath $signed)) { throw 'Missing elevate.exe in the checkpoint.' } Assert-SigningCertificate (Get-AuthenticodeSignature -FilePath $signed) $signed -$cached = @(Get-ChildItem "$env:LOCALAPPDATA/electron-builder/Cache/nsis" -Recurse -Filter elevate.exe) -if ($cached.Count -eq 0) { throw 'The restored NSIS cache has no elevate.exe to replace.' } -foreach ($file in $cached) { Copy-Item -LiteralPath $signed -Destination $file.FullName -Force } +node config/scripts/replace-cached-nsis-elevate.mjs $signed +if ($LASTEXITCODE -ne 0) { throw 'Could not replace the NSIS toolset elevate.exe used by the rebuild.' } diff --git a/config/windows-signing/restore-uninstaller.ps1 b/config/windows-signing/restore-uninstaller.ps1 new file mode 100644 index 00000000000..e4bf68fa401 --- /dev/null +++ b/config/windows-signing/restore-uninstaller.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot/signature-policy.ps1" +$signed = 'signed-inner/uninstaller/orca-uninstaller.exe' +if (-not (Test-Path -LiteralPath $signed -PathType Leaf)) { + throw 'SignPath must return uninstaller/orca-uninstaller.exe; include it in windows-inner-binaries-zip.' +} +Assert-SigningCertificate (Get-AuthenticodeSignature -FilePath $signed) $signed +$directory = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed' +New-Item -ItemType Directory -Force $directory | Out-Null +Copy-Item -LiteralPath $signed -Destination "$directory/orca-uninstaller.exe" -Force diff --git a/config/windows-signing/signing-control.test.ps1 b/config/windows-signing/signing-control.test.ps1 index 3a8b15f17c8..bb8855f1735 100644 --- a/config/windows-signing/signing-control.test.ps1 +++ b/config/windows-signing/signing-control.test.ps1 @@ -7,7 +7,7 @@ function Test-Case([string]$name, [scriptblock]$body) { Write-Host "PASS $name" } function Assert-Throws([scriptblock]$body, [string]$message) { - try { & $body } catch { + try { & $body 6> $null } catch { if ("$_" -notlike "*$message*") { throw "Expected '$message', received '$_'" } return } @@ -96,6 +96,28 @@ try { & "$control/restore-inner.ps1" if ((Get-Content 'dist/win-unpacked/Orca.exe') -ne 'signed-fixture') { throw 'Restore failed' } } + $env:RUNNER_TEMP = Join-Path $temporary 'runner' + New-Item -ItemType Directory -Force 'signed-inner/uninstaller' | Out-Null + Test-Case 'missing signed uninstaller blocks rebuild' { + Assert-Throws { & "$control/restore-uninstaller.ps1" } 'SignPath must return' + } + Set-Content 'signed-inner/uninstaller/orca-uninstaller.exe' 'signed-uninstaller' + Test-Case 'signed uninstaller restored outside checkout' { & "$control/restore-uninstaller.ps1" } + $signedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed/orca-uninstaller.exe' + Test-Case 'missing embedding receipt blocks publication' { + Assert-Throws { & "$control/verify-uninstaller.ps1" } 'must embed' + } + Test-Case 'wrong embedding receipt blocks publication' { + Set-Content "$signedUninstaller.embedded-sha256" ('a' * 64) + Assert-Throws { & "$control/verify-uninstaller.ps1" } 'does not match' + } + (Get-FileHash -LiteralPath $signedUninstaller -Algorithm SHA256).Hash.ToLowerInvariant() | Set-Content "$signedUninstaller.embedded-sha256" + Test-Case 'signed uninstaller and embedding receipt verified' { & "$control/verify-uninstaller.ps1" } + Test-Case 'bad uninstaller signature blocks publication' { + $global:OrcaSigningTestBadSignature = $true + Assert-Throws { & "$control/verify-uninstaller.ps1" } 'Unexpected rehearsal' + $global:OrcaSigningTestBadSignature = $false + } $env:GITHUB_WORKSPACE = $temporary $env:GITHUB_REPOSITORY = 'stablyai/orca' $env:GITHUB_RUN_ID = '123' @@ -107,9 +129,10 @@ try { $env:MODE = 'save' $env:REQUEST_ID = '11111111-1111-4111-8111-111111111111' $env:LOCALAPPDATA = Join-Path $temporary 'local' + $env:ELECTRON_BUILDER_CACHE = "$env:LOCALAPPDATA/electron-builder/Cache" $env:GITHUB_ENV = Join-Path $temporary 'github-env' - New-Item -ItemType Directory -Force "$env:LOCALAPPDATA/electron-builder/Cache/nsis" | Out-Null - Set-Content "$env:LOCALAPPDATA/electron-builder/Cache/nsis/elevate.exe" 'cache' + New-Item -ItemType Directory -Force "$env:LOCALAPPDATA/electron-builder/Cache/nsis@1.2.1/nsis-bundle" | Out-Null + Set-Content "$env:LOCALAPPDATA/electron-builder/Cache/nsis@1.2.1/nsis-bundle/elevate.exe" 'cache' Set-Content 'dist/orca-windows-setup.exe' 'installer' Set-Content 'dist/latest.yml' 'metadata' function git { $global:LASTEXITCODE = 0; return ('b' * 40) } @@ -123,7 +146,7 @@ try { function gh { $global:LASTEXITCODE = 0 if ($args[0] -eq 'api') { - return (@{total_count = 1; artifacts = @(@{name = "orca-signing-inner-123-1-$env:REQUEST_ID"; expired = $false; workflow_run = @{id = 123; head_sha = $env:GITHUB_SHA}})} | ConvertTo-Json -Depth 5) + return (@{total_count = 1; artifacts = @(@{name = "orca-signing-$env:STAGE-123-1-$env:REQUEST_ID"; expired = $false; workflow_run = @{id = 123; head_sha = $env:GITHUB_SHA}})} | ConvertTo-Json -Depth 5) } Copy-Item 'original-checkpoint' 'signing-checkpoint' -Recurse } @@ -131,6 +154,21 @@ try { & "$control/checkpoint.ps1" if ((Get-Content $env:GITHUB_ENV -Raw) -notlike "*SIGNPATH_REQUEST_ID=$env:REQUEST_ID*") { throw 'Request identity missing' } } + Test-Case 'installer checkpoint restores receipt and versioned tool cache on a fresh runner' { + $env:MODE = 'save' + $env:STAGE = 'installer' + $env:GITHUB_RUN_ATTEMPT = '1' + & "$control/checkpoint.ps1" + Remove-Item 'original-checkpoint' -Recurse -Force + Copy-Item 'signing-checkpoint' 'original-checkpoint' -Recurse + Remove-Item "$env:RUNNER_TEMP/uninstaller-signing" -Recurse -Force + Remove-Item $env:ELECTRON_BUILDER_CACHE -Recurse -Force + $env:MODE = 'restore' + $env:GITHUB_RUN_ATTEMPT = '2' + & "$control/checkpoint.ps1" + & "$control/verify-uninstaller.ps1" + if (-not (Test-Path "$env:ELECTRON_BUILDER_CACHE/nsis@1.2.1/nsis-bundle/elevate.exe")) { throw 'Versioned tool cache lost on resume' } + } Test-Case 'corrupt checkpoint rejected' { Add-Content 'original-checkpoint/checkpoint.tar.gz' 'corrupt' Assert-Throws { & "$control/checkpoint.ps1" } 'SHA-256 mismatch' diff --git a/config/windows-signing/stage-inner.ps1 b/config/windows-signing/stage-inner.ps1 index 1f6f6094a04..5ba8e4049e7 100644 --- a/config/windows-signing/stage-inner.ps1 +++ b/config/windows-signing/stage-inner.ps1 @@ -31,3 +31,8 @@ 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 " $_" } + +$exported = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/unsigned/orca-uninstaller.exe' +if (-not (Test-Path -LiteralPath $exported -PathType Leaf)) { throw 'The NSIS build did not export an uninstaller for signing.' } +New-Item -ItemType Directory -Force (Join-Path $stage.FullName 'uninstaller') | Out-Null +Copy-Item -LiteralPath $exported -Destination (Join-Path $stage.FullName 'uninstaller/orca-uninstaller.exe') -Force diff --git a/config/windows-signing/verify-shipped-uninstaller.ps1 b/config/windows-signing/verify-shipped-uninstaller.ps1 new file mode 100644 index 00000000000..0447503162d --- /dev/null +++ b/config/windows-signing/verify-shipped-uninstaller.ps1 @@ -0,0 +1,48 @@ +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot/verify-uninstaller.ps1" +. "$PSScriptRoot/signature-policy.ps1" +$signed = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed/orca-uninstaller.exe' +$expectedDigest = (Get-FileHash -LiteralPath $signed -Algorithm SHA256).Hash.ToLowerInvariant() +try { + $installedUninstaller = $null + $full7z = 'C:/Program Files/7-Zip/7z.exe' + $extract = Join-Path $env:RUNNER_TEMP 'uninstaller-nsis-extract' + if (Test-Path -LiteralPath $extract) { Remove-Item -LiteralPath $extract -Recurse -Force } + if (Test-Path -LiteralPath $full7z) { + & $full7z x -tnsis 'dist/orca-windows-setup.exe' "-o$extract" -y 2>&1 | Out-Null + # NSIS extraction is trustworthy only when it reproduces the relayed bytes. + $matches = @(Get-ChildItem $extract -Recurse -File -Filter 'Uninstall*.exe' -ErrorAction SilentlyContinue | + Where-Object { (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() -eq $expectedDigest }) + if ($matches.Count -eq 1) { $installedUninstaller = $matches[0] } + } + if ($null -eq $installedUninstaller) { + $installRoot = Join-Path $env:RUNNER_TEMP 'uninstaller-rehearsal-install' + if (Test-Path -LiteralPath $installRoot) { Remove-Item -LiteralPath $installRoot -Recurse -Force } + $installerProcess = Start-Process -FilePath (Resolve-Path 'dist/orca-windows-setup.exe') -ArgumentList "/S /D=$installRoot" -PassThru + try { + if (-not $installerProcess.WaitForExit(300000)) { throw 'The silent install did not exit within 5 minutes.' } + if ($installerProcess.ExitCode -ne 0) { throw "The silent install failed: $($installerProcess.ExitCode)" } + } finally { + if (-not $installerProcess.HasExited) { $installerProcess | Stop-Process -Force -ErrorAction SilentlyContinue } + for ($attempt = 0; $attempt -lt 20; $attempt++) { + $running = @(Get-Process -Name 'Orca' -ErrorAction SilentlyContinue) + if ($running.Count -gt 0) { + $running | Stop-Process -Force -ErrorAction SilentlyContinue + break + } + Start-Sleep -Milliseconds 500 + } + Get-Process -Name 'orca-terminal-daemon' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + $matches = @(Get-ChildItem $installRoot -Recurse -File -Filter 'Uninstall*.exe' -ErrorAction SilentlyContinue) + if ($matches.Count -ne 1) { throw 'The silent install must produce exactly one uninstaller.' } + $installedUninstaller = $matches[0] + } + $actual = (Get-FileHash -LiteralPath $installedUninstaller.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -cne $expectedDigest) { throw 'The shipped uninstaller does not match the signed bytes.' } + Assert-SigningCertificate (Get-AuthenticodeSignature -FilePath $installedUninstaller.FullName) 'shipped: Uninstall Orca.exe' + 'VERIFIED shipped Uninstall Orca.exe: signature and digest match' | Add-Content 'uninstaller-signing-evidence.txt' +} catch { + "VERDICT: FAILED — $_" | Add-Content 'uninstaller-signing-evidence.txt' + throw +} diff --git a/config/windows-signing/verify-uninstaller.ps1 b/config/windows-signing/verify-uninstaller.ps1 new file mode 100644 index 00000000000..0746985cf5a --- /dev/null +++ b/config/windows-signing/verify-uninstaller.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot/signature-policy.ps1" +$signed = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed/orca-uninstaller.exe' +$receipt = "$signed.embedded-sha256" +try { + if (-not (Test-Path -LiteralPath $signed -PathType Leaf) -or -not (Test-Path -LiteralPath $receipt -PathType Leaf)) { + throw 'The installer rebuild must embed the signed uninstaller and produce a receipt.' + } + Assert-SigningCertificate (Get-AuthenticodeSignature -FilePath $signed) $signed + $actual = (Get-FileHash -LiteralPath $signed -Algorithm SHA256).Hash.ToLowerInvariant() + if ((Get-Content -LiteralPath $receipt -Raw).Trim() -cne $actual) { throw 'Embedded uninstaller receipt does not match the signed bytes.' } + "VERIFIED Uninstall Orca.exe: signed bytes handed to NSIS ($actual)" | Set-Content 'uninstaller-signing-evidence.txt' +} catch { + "VERDICT: FAILED — $_" | Set-Content 'uninstaller-signing-evidence.txt' + throw +}