Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 83 additions & 4 deletions .github/actions/sign/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -58,13 +83,67 @@ 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,
# so leave ample time before giving up.
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
Expand Down
56 changes: 42 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -102,18 +119,29 @@ 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'
Write-Output "::warning::Created a DRAFT release with unsigned binaries."
}
$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
76 changes: 63 additions & 13 deletions docs/code-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -54,15 +55,36 @@ 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.

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.
Expand All @@ -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.
Expand All @@ -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
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="rb-x64.exe">
<authenticode-sign/>
</pe-file>
<pe-file path="rb-arm64.exe">
<authenticode-sign/>
</pe-file>
</zip-file>
</artifact-configuration>
```

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.
Loading