diff --git a/.github/actions/sign/action.yml b/.github/actions/sign/action.yml index 54e105e..0e1002a 100644 --- a/.github/actions/sign/action.yml +++ b/.github/actions/sign/action.yml @@ -5,10 +5,10 @@ name: Sign release binaries description: >- - Sends the unsigned release artifact to the code-signing provider and - places the signed files in the output directory. When the provider - credentials are not configured, the binaries pass through unsigned - with a workflow warning. + Sends the unsigned release artifact to the code-signing provider, + places the signed files in the output directory and verifies the + signatures. When the provider credentials are not configured, the + binaries pass through unsigned with a workflow warning. inputs: github-artifact-id: @@ -39,6 +39,31 @@ inputs: description: SignPath signing policy slug. required: false default: 'release-signing' + signpath-artifact-configuration-slug: + description: >- + SignPath artifact configuration slug. Leave empty to use the + project's default configuration. + required: false + default: '' + certificate-thumbprint: + description: >- + SHA-1 thumbprint of the expected signing certificate. When set, + verification requires every signature to come from that exact + certificate. Leave empty to accept any chain the default + Authenticode policy trusts, which does not distinguish our + certificate from any other trusted one. + required: false + default: '' + require-trusted-chain: + description: >- + Whether every signature must chain to a root the machine trusts. + Set to false while signing with a SignPath test certificate, which + is deliberately absent from the Windows trust store, so the + pipeline can be rehearsed end to end. Verification then only + asserts that a signature from the expected certificate is present, + which is a rehearsal check, not a release gate. + required: false + default: 'true' outputs: signed: @@ -58,6 +83,7 @@ runs: organization-id: ${{ inputs.signpath-organization-id }} project-slug: ${{ inputs.signpath-project-slug }} signing-policy-slug: ${{ inputs.signpath-signing-policy-slug }} + artifact-configuration-slug: ${{ inputs.signpath-artifact-configuration-slug }} github-artifact-id: ${{ inputs.github-artifact-id }} wait-for-completion: true # Release signing needs a manual approval in the SignPath UI, @@ -65,6 +91,59 @@ runs: wait-for-completion-timeout-in-seconds: '3600' output-artifact-directory: ${{ inputs.output-directory }} + - name: Verify the Authenticode signatures + if: inputs.signpath-api-token != '' + shell: pwsh + env: + OUTPUT_DIR: ${{ inputs.output-directory }} + THUMBPRINT: ${{ inputs.certificate-thumbprint }} + REQUIRE_TRUST: ${{ inputs.require-trusted-chain }} + run: | + $ErrorActionPreference = 'Stop' + $exes = @(Get-ChildItem $env:OUTPUT_DIR -Filter *.exe) + # The release artifact carries rb-x64.exe and rb-arm64.exe; fewer + # means the signing service returned a truncated artifact. + if ($exes.Count -lt 2) { throw "unexpectedly few signed binaries: $($exes.Count)" } + if ($env:REQUIRE_TRUST -eq 'true') { + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\10.*\x64\signtool.exe" -ErrorAction SilentlyContinue | + Sort-Object FullName | Select-Object -Last 1 + if (-not $signtool) { throw 'signtool.exe not found under Windows Kits\10\bin; check the runner image' } + # /tw fails a signature that carries no RFC 3161 timestamp, + # which docs/code-signing.md promises. /sha1 pins the signing + # certificate when a thumbprint is configured; without it the + # default policy accepts any trusted chain, not specifically + # ours. + $flags = @('/pa', '/tw') + if ($env:THUMBPRINT) { $flags += @('/sha1', $env:THUMBPRINT) } + foreach ($exe in $exes) { + & $signtool.FullName verify @flags /q $exe.FullName + if ($LASTEXITCODE -ne 0) { + # /q suppressed the reason, so re-run verbose to record + # whether the signature is missing, the chain is untrusted, + # the timestamp is absent or the certificate is not ours. + & $signtool.FullName verify @flags /v $exe.FullName + throw "signature verification failed: $($exe.Name)" + } + } + } else { + # Rehearsal against a test certificate, which no machine + # trusts. Assert only that a signature from the expected + # certificate is present; chain trust and the timestamp are + # checked by the branch above once the production certificate + # is in place. + Write-Output '::warning::verifying without requiring a trusted chain; this is a rehearsal, not a release gate' + foreach ($exe in $exes) { + $sig = Get-AuthenticodeSignature $exe.FullName + if (-not $sig.SignerCertificate) { throw "no signature: $($exe.Name)" } + if ($env:THUMBPRINT -and $sig.SignerCertificate.Thumbprint -ne $env:THUMBPRINT) { + throw "unexpected signer $($sig.SignerCertificate.Thumbprint) on $($exe.Name)" + } + } + $signer = (Get-AuthenticodeSignature $exes[0].FullName).SignerCertificate + Write-Output "signer: $($signer.Subject) ($($signer.Thumbprint))" + } + Write-Output "verified $($exes.Count) signed binaries" + - name: Warn that signing is skipped if: inputs.signpath-api-token == '' shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 352f8c9..94c976d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,12 +2,19 @@ # and their SHA-256 checksums to a GitHub release. Signing is described # in docs/code-signing.md; when the signing credentials are not # configured, the release is created as an unsigned draft instead. +# +# A workflow_dispatch run is a signing rehearsal: it builds, signs and +# verifies exactly like a release, then uploads the signed binaries as +# a workflow artifact instead of creating a GitHub release. Use it to +# exercise the SignPath pipeline (for example against a test +# certificate) without cutting a release. name: Release on: push: tags: ['v*'] + workflow_dispatch: permissions: contents: read @@ -29,7 +36,22 @@ jobs: id: version shell: pwsh run: | - "version=$('${{ github.ref_name }}'.TrimStart('v'))" >> $env:GITHUB_OUTPUT + if ($env:GITHUB_REF_TYPE -eq 'tag') { + # Anchored, so a tag that is not a plain release version + # never reaches the interpolations below. Git refnames may + # contain quotes, so an unvalidated name could break out of + # a quoted pwsh string. + if ($env:GITHUB_REF_NAME -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.]+)?$') { + Write-Output "::error::tag is not a release version tag" + exit 1 + } + $version = $env:GITHUB_REF_NAME.Substring(1) + } else { + # Rehearsal build from a branch; the version only labels the + # workflow artifact. + $version = '0.0.0-rehearsal' + } + "version=$version" >> $env:GITHUB_OUTPUT - run: dotnet test rbmanager.sln @@ -82,16 +104,11 @@ jobs: signpath-organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} signpath-project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} signpath-signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }} - - - name: Verify the Authenticode signatures - if: steps.sign.outputs.signed == 'true' - shell: pwsh - run: | - foreach ($exe in Get-ChildItem signed -Filter *.exe) { - $sig = Get-AuthenticodeSignature $exe.FullName - Write-Output "$($exe.Name): $($sig.Status) ($($sig.SignerCertificate.Subject))" - if ($sig.Status -ne 'Valid') { exit 1 } - } + signpath-artifact-configuration-slug: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} + certificate-thumbprint: ${{ vars.SIGNPATH_CERTIFICATE_THUMBPRINT }} + # Set SIGNPATH_TEST_CERTIFICATE to true while SignPath issues + # a test certificate, which no machine trusts. + require-trusted-chain: ${{ vars.SIGNPATH_TEST_CERTIFICATE != 'true' }} - name: Generate the SHA-256 checksums shell: pwsh @@ -102,14 +119,17 @@ jobs: } - name: Create the GitHub release + if: github.ref_type == 'tag' shell: pwsh env: GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.build.outputs.version }} + SIGNED: ${{ steps.sign.outputs.signed }} run: | - $ghArgs = @('release', 'create', '${{ github.ref_name }}', - '--title', 'rbmanager ${{ needs.build.outputs.version }}', + $ghArgs = @('release', 'create', $env:GITHUB_REF_NAME, + '--title', "rbmanager $env:VERSION", '--generate-notes', '--verify-tag') - if ('${{ steps.sign.outputs.signed }}' -ne 'true') { + if ($env:SIGNED -ne 'true') { # Never publish unsigned binaries silently; leave the # decision to a human. $ghArgs += '--draft' @@ -117,3 +137,11 @@ jobs: } $ghArgs += Get-ChildItem signed -File | ForEach-Object FullName gh @ghArgs + + - name: Upload the rehearsal binaries + if: github.ref_type != 'tag' + uses: actions/upload-artifact@v7 + with: + name: rb-signed-rehearsal + path: signed/ + if-no-files-found: error diff --git a/docs/code-signing.md b/docs/code-signing.md index d913558..c5f0eb6 100644 --- a/docs/code-signing.md +++ b/docs/code-signing.md @@ -15,8 +15,9 @@ rb.exe is the one binary users download directly with a browser, so it carries the Mark of the Web and faces SmartScreen head-on; unsigned, it is effectively blocked by the "Windows protected your PC" dialog. That makes it the highest-priority signing target of the whole distribution -chain, ahead of the MSI and winget channels. CI builds and anything -not built from a tag stay unsigned. +chain, ahead of the MSI and winget channels. CI builds stay unsigned; +besides tag builds, only the manually dispatched signing rehearsal +(below) submits binaries for signing, and those are never published. ## Provider: SignPath Foundation now, replaceable later @@ -54,8 +55,12 @@ Pushing a `v*` tag runs `.github/workflows/release.yml`: the job starts. 3. `.github/actions/sign` submits the artifact to SignPath and waits (up to an hour) for the manual approval there. -4. The signatures are verified with `Get-AuthenticodeSignature`; any - status other than `Valid` fails the release. +4. The same action verifies the signatures with + `signtool verify /pa /tw`, so a signature without an RFC 3161 + timestamp fails the release. When + `SIGNPATH_CERTIFICATE_THUMBPRINT` is set, `/sha1` additionally pins + the signing certificate; without it any chain the default + Authenticode policy trusts is accepted. 5. `.sha256` checksum files are generated next to the binaries and everything is attached to a GitHub release created from the tag. @@ -63,6 +68,23 @@ When the SignPath credentials are not configured, the signing step is skipped with a workflow warning and the release is created as a draft so unsigned binaries are never published silently. +## Rehearsals and the test certificate period + +Running the Release workflow by hand (workflow_dispatch) is a signing +rehearsal: it builds, signs and verifies exactly like a release, then +uploads the signed binaries as the `rb-signed-rehearsal` workflow +artifact and never creates a GitHub release. This is how the pipeline +is exercised end to end before a real tag, and what SignPath's +onboarding review runs against. + +While SignPath issues a test certificate, no machine trusts its chain, +so `signtool verify` would always fail. Setting the repository +variable `SIGNPATH_TEST_CERTIFICATE=true` switches verification to a +rehearsal check: it only asserts that a signature from the expected +certificate is present (and matches the thumbprint when configured). +That mode is not a release gate; remove the variable as soon as the +production certificate is in place. + The exe's VERSIONINFO (ProductName `rbmanager`, FileDescription, company, version) is set in `src/rbmanager/rbmanager.csproj`; SignPath uses it to check that the artifact matches the project. @@ -80,11 +102,19 @@ Secret (environment-scoped, only the release workflow can read it): Variables (environment or repository level): -- `SIGNPATH_ORGANIZATION_ID` +- `SIGNPATH_ORGANIZATION_ID`: the shared ruby organization on + SignPath, the same one that signs the mswin binary packages - `SIGNPATH_PROJECT_SLUG` +- `SIGNPATH_ARTIFACT_CONFIGURATION_SLUG` (optional; empty uses the + SignPath project's default configuration) - `SIGNPATH_SIGNING_POLICY_SLUG` (defaults to `release-signing` when - unset in the sign action; set it if the SignPath policy is named - differently) + unset in the sign action; `test-signing` during the test + certificate period) +- `SIGNPATH_TEST_CERTIFICATE` (`true` only during the test + certificate period, see above) +- `SIGNPATH_CERTIFICATE_THUMBPRINT` (optional; SHA-1 thumbprint of + the signing certificate, set once the production certificate is + issued) Until these exist, tag pushes still work and produce unsigned draft releases. @@ -103,15 +133,35 @@ Information the application needs: - Code signing policy: this document. - Attribution: present in the README. +rbmanager joins the existing ruby organization on SignPath as its +second project, next to the one signing the mswin binary packages. +SignPath adds it after the Ruby project's test signing review, so the +project and artifact configuration slugs arrive later; the workflow +runs the unsigned draft path until the variables above are filled in. + SignPath-side setup after approval: -1. Install the SignPath GitHub App on the repository and link GitHub - Actions as a trusted build system to the project. -2. Create an artifact configuration for a zip container holding - `rb-x64.exe` and `rb-arm64.exe`, both as Authenticode-signed PE - files (this matches the `rb-unsigned` artifact the build job - uploads). +1. Install the SignPath GitHub App on the repository (already done) + and link GitHub Actions as a trusted build system to the project. +2. Create an artifact configuration for the `rb-unsigned` artifact the + build job uploads, a zip container holding both exes: + + ```xml + + + + + + + + + + + ``` + 3. Create a signing policy for release signing with manual approval and note its slug in `SIGNPATH_SIGNING_POLICY_SLUG`. 4. Create an API token for a CI user with submitter permission and store it as the `SIGNPATH_API_TOKEN` secret. +5. Run a workflow_dispatch rehearsal (see above) and check that the + uploaded binaries carry the expected signature.