diff --git a/.github/workflows/release-engine.yml b/.github/workflows/release-engine.yml index 3d1cf80a8..2f6fad744 100644 --- a/.github/workflows/release-engine.yml +++ b/.github/workflows/release-engine.yml @@ -11,8 +11,10 @@ name: release-engine on: push: tags: ['engine-v*'] +# Same-run artifact transfers use ACTIONS_RUNTIME_TOKEN, not GITHUB_TOKEN; +# they do not require actions: read/write. Keep downloads scoped to this run. permissions: - contents: write + contents: read jobs: build: strategy: @@ -53,15 +55,79 @@ jobs: run: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }} engine-probe - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: impeccable-${{ matrix.short }} + # Keep unsigned Windows output outside the publish job's pattern. + name: ${{ matrix.short == 'windows-x64' && 'unsigned-windows-x64' || format('impeccable-{0}', matrix.short) }} path: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }} if-no-files-found: error - publish: + sign-windows: needs: build + runs-on: windows-latest + timeout-minutes: 15 + environment: windows-signing + permissions: + contents: read + id-token: write + steps: + # A fresh runner signs only this run's engine. It does not check out or + # execute repository code with the Azure identity available. + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: unsigned-windows-x64 + path: unsigned + - name: Azure login (OIDC) + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Sign Windows engine + uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2 + with: + endpoint: https://eus.codesigning.azure.net/ + signing-account-name: impeccable-signing + certificate-profile-name: impeccable-windows + files: ${{ github.workspace }}\unsigned\impeccable.exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + description: Impeccable engine + description-url: https://impeccable.style + exclude-environment-credential: true + # Only the preceding OIDC Azure CLI login is used. Other credential + # types are excluded by this pinned action's defaults. + exclude-azure-cli-credential: false + cache-dependencies: false + - name: Verify signed engine + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $signature = Get-AuthenticodeSignature -LiteralPath 'unsigned/impeccable.exe' + if ($signature.Status -ne 'Valid') { + throw "Invalid Windows signature: $($signature.Status) — $($signature.StatusMessage)" + } + $publisher = $signature.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) + if ($publisher -cne 'Renaissance Geek, Inc.') { + throw "Unexpected Windows publisher: $publisher" + } + if ($null -eq $signature.TimeStamperCertificate) { + throw 'The Windows signature has no timestamp.' + } + Write-Output "Verified publisher: $publisher; certificate: $($signature.SignerCertificate.Thumbprint)" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: impeccable-windows-x64 + path: unsigned/impeccable.exe + if-no-files-found: error + publish: + needs: [build, sign-windows] runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: { path: artifacts } + with: + pattern: impeccable-* + path: artifacts - name: Lay out release assets with checksums run: | set -e diff --git a/docs/WINDOWS-SIGNING.md b/docs/WINDOWS-SIGNING.md new file mode 100644 index 000000000..2924f7f97 --- /dev/null +++ b/docs/WINDOWS-SIGNING.md @@ -0,0 +1,52 @@ +# Windows engine signing + +Engine releases sign `impeccable.exe` with Azure Artifact Signing before +computing release checksums. The publisher is **Renaissance Geek, Inc.** +Existing release assets are immutable; never replace a shipped unsigned binary +with signed bytes under the same version. + +## Access boundary + +- Azure account: `impeccable-signing`, East US (`https://eus.codesigning.azure.net/`). +- Public Trust certificate profile: `impeccable-windows`. +- User-assigned managed identity: `impeccable-release-signing`, in the same resource group. +- Its only Azure role is **Artifact Signing Certificate Profile Signer**, scoped + to `impeccable-signing/certificateProfiles/impeccable-windows`, not the account + or subscription. +- Federated credential: `github-windows-signing`, issuer + `https://token.actions.githubusercontent.com`, audience `api://AzureADTokenExchange`, + subject `repo:pbakaus/impeccable:environment:windows-signing`. +- GitHub environment: `windows-signing`, restricted to **tags** matching + `engine-v*`, with `pbakaus` as required reviewer and administrator bypass off. + Self-review allows the required reviewer to approve releases they trigger. +- Environment variables `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and + `AZURE_SUBSCRIPTION_ID` contain public identifiers, not secrets. No client + secret, private key, or PFX is stored in GitHub. + +## Release and verification + +Run the normal `bun run release:engine` flow, then review and approve the +`windows-signing` deployment for that tag in GitHub Actions. Check the tag's +commit and workflow before approving: the environment approval grants that job +the ability to sign as the company. + +The build job uploads Windows output as `unsigned-windows-x64`. A separate +Windows runner downloads that artifact from the same run, signs exactly +`impeccable.exe`, and requires a valid Authenticode signature, the expected +publisher, and a timestamp before uploading `impeccable-windows-x64`. That +runner does not check out repository code or execute the downloaded engine. +Only its job receives an OIDC token; only the publish job can write releases. +Publication waits for successful signing and downloads only `impeccable-*` +artifacts, so it cannot package the unsigned intermediate. + +RFC 3161 timestamping is required because Azure issues short-lived signing +certificates. Don't pin a leaf certificate thumbprint: Azure rotates them. +If signing or verification fails, fix the cause and retry; don't add an +unsigned fallback or weaken the environment gate. + +The workflow configuration is regression-tested by +`bun test tests/release-engine-workflow.test.js`. An actual protected engine +release is still needed to verify Azure OIDC and Authenticode end to end. + +References: [Azure signing roles](https://learn.microsoft.com/en-us/azure/artifact-signing/tutorial-assign-roles), +[official signing action](https://github.com/Azure/artifact-signing-action). diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index dc10248e7..3401c063f 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -33,6 +33,7 @@ export const SUITES = { /^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/)/, /^ENGINE_VERSION$/, /^README(\.npm)?\.md$/, + /^\.github\/workflows\/release-engine\.yml$/, /^cli\/bin\//, ], commands: [ @@ -50,6 +51,7 @@ export const SUITES = { 'tests/validate-plugin-versions.test.js', 'tests/validate-plugin-manifest.test.js', 'tests/plugin-paths.test.js', + 'tests/release-engine-workflow.test.js', ], }, { diff --git a/tests/release-engine-workflow.test.js b/tests/release-engine-workflow.test.js new file mode 100644 index 000000000..d85d4ceaf --- /dev/null +++ b/tests/release-engine-workflow.test.js @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const workflow = Bun.YAML.parse(readFileSync(new URL('../.github/workflows/release-engine.yml', import.meta.url), 'utf8')); +const action = (job, name) => job.steps.find(step => step.uses?.startsWith(`${name}@`)); + +describe('engine release signing boundary', () => { + test('only the protected signing job receives an OIDC token', () => { + expect(workflow.on).toEqual({ push: { tags: ['engine-v*'] } }); + expect(workflow.permissions).toEqual({ contents: 'read' }); + const sign = workflow.jobs['sign-windows']; + expect(sign.environment).toBe('windows-signing'); + expect(sign.needs).toBe('build'); + expect(sign.permissions).toEqual({ contents: 'read', 'id-token': 'write' }); + expect(workflow.jobs.build.permissions?.['id-token']).toBeUndefined(); + expect(workflow.jobs.publish.permissions).toEqual({ contents: 'write' }); + expect(sign.steps.some(step => step.uses?.startsWith('actions/checkout@'))).toBe(false); + }); + + test('publication waits for signing and cannot collect the unsigned artifact', () => { + const unsignedName = 'unsigned-windows-x64'; + expect(action(workflow.jobs.build, 'actions/upload-artifact').with.name).toContain(unsignedName); + const sign = workflow.jobs['sign-windows']; + expect(action(sign, 'actions/download-artifact').with.name).toBe(unsignedName); + expect(action(sign, 'actions/upload-artifact').with.name).toBe('impeccable-windows-x64'); + expect(workflow.jobs.publish.needs).toEqual(['build', 'sign-windows']); + expect(action(workflow.jobs.publish, 'actions/download-artifact').with.pattern).toBe('impeccable-*'); + expect(unsignedName.startsWith('impeccable-')).toBe(false); + expect(workflow.jobs.publish.steps.find(step => step.name === 'Lay out release assets with checksums').run).toContain('sha256sum'); + }); + + test('artifact downloads stay on the same-run runtime-token path', () => { + for (const name of ['sign-windows', 'publish']) { + const download = action(workflow.jobs[name], 'actions/download-artifact'); + // Supplying github-token opts into the public API path, which requires + // separate Actions permissions and can read other workflow runs. + for (const input of ['github-token', 'repository', 'run-id']) { + expect(download.with[input]).toBeUndefined(); + } + } + }); + + test('signs exactly the engine with timestamping, then verifies before upload', () => { + const sign = workflow.jobs['sign-windows']; + const signing = action(sign, 'azure/artifact-signing-action'); + expect(signing.with.files).toBe('${{ github.workspace }}\\unsigned\\impeccable.exe'); + expect(signing.with['certificate-profile-name']).toBe('impeccable-windows'); + expect(signing.with['signing-account-name']).toBe('impeccable-signing'); + expect(signing.with['timestamp-rfc3161']).toBe('http://timestamp.acs.microsoft.com'); + expect(signing.with['file-digest']).toBe('SHA256'); + expect(signing.with['timestamp-digest']).toBe('SHA256'); + expect(signing.with['exclude-environment-credential']).toBe(true); + expect(signing.with['cache-dependencies']).toBe(false); + const verify = sign.steps.find(step => step.name === 'Verify signed engine'); + expect(sign.steps.indexOf(verify)).toBeGreaterThan(sign.steps.indexOf(signing)); + expect(sign.steps.indexOf(verify)).toBeLessThan(sign.steps.indexOf(action(sign, 'actions/upload-artifact'))); + expect(verify.run).toContain("$signature.Status -ne 'Valid'"); + expect(verify.run).toContain("$publisher -cne 'Renaissance Geek, Inc.'"); + expect(verify.run).toContain('$null -eq $signature.TimeStamperCertificate'); + expect(verify.run).toContain('throw'); + expect(sign.steps.some(step => step['continue-on-error'])).toBe(false); + expect(action(sign, 'actions/upload-artifact').if).toBeUndefined(); + }); + + test('every third-party action is pinned to a commit', () => { + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps) { + if (step.uses) expect(step.uses).toMatch(/@[a-f0-9]{40}$/); + } + } + }); +});