diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index e5f048c..261e063 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -3,7 +3,7 @@ # MotherSphere AUR account — scope is limited to AUR push access, revocable by # removing that key from the AUR profile if ever compromised. # -# Invoked as a reusable workflow from release-please.yml after the release +# Invoked as a reusable workflow from release.yml after the release # assets are built, uploaded and signed. It deliberately does NOT trigger on # `release: published`: our releases are created by release-please with the # default GITHUB_TOKEN, and GitHub suppresses workflow triggers for events diff --git a/.github/workflows/release-please.yml b/.github/workflows/release.yml similarity index 59% rename from .github/workflows/release-please.yml rename to .github/workflows/release.yml index efc02c8..791be8a 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release.yml @@ -1,23 +1,38 @@ +# Colony release workflow, from templates/sign-and-publish-caller.yml of +# Project-Colony-Resources. +# +# The build legs only build, check and upload artifacts; they never see a key. +# Authenticode (SignPath, once approved), ed25519 signing, verification and +# publishing all happen in .github/workflows/sign-and-publish.yml of +# Project-Colony-Resources, in this same run. The signing key is only ever in +# that workflow's own job, which checks out nothing and builds nothing. See +# design/releases.md, section "Shared signing workflow". +# +# On top of the template: a `target` job that refuses to rebuild a published +# release, a check of the Windows version resource, and the AUR bump once the +# release is public. +# +# Action refs are pinned to commit SHAs on purpose: this workflow can publish +# releases, and whoever can move a floating tag can change what it runs. + name: Release on: push: branches: [main] # Recovery path. release-please only ever emits `release_created` once per - # release, so once a tag exists there is no way to re-run build and sign for - # it: "Re-run all jobs" makes release-please report nothing to do and every - # downstream job skip, while the run goes green. That is how v0.10.0 ended up - # tagged with an empty draft release and no way to finish it. - # - # Dispatch with the tag to build, sign and publish an existing DRAFT release, - # and start it FROM that tag: `gh workflow run release-please.yml --ref - # v0.10.0 -f tag=v0.10.0`. Once SignPath is on, sign-and-publish refuses a run + # release, so once a tag exists "Re-run all jobs" makes release-please report + # nothing to do and every job below skip, while the run goes green. That is + # how v0.10.0 ended up tagged with an empty draft release. This input + # finishes an existing DRAFT release whose run failed. Start it from the tag + # ("Use workflow from: v0.10.0", or `gh workflow run release.yml --ref + # v0.10.0 -f tag=v0.10.0`): with SignPath on, sign-and-publish refuses a run # whose commit is not the tag's, since SignPath records the run's commit as # the source of what it signs. workflow_dispatch: inputs: tag: - description: 'Existing release tag to build, sign and publish (e.g. v0.10.0)' + description: "Existing draft release to build, sign and publish (e.g. v0.10.0). Run from that same tag." required: true type: string @@ -25,10 +40,18 @@ on: # script, so they only get to read. permissions: {} +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + env: CARGO_TERM_COLOR: always jobs: + # Assembles a release PR from the conventional commits since the last + # release. Merging that PR is what creates the tag. Push only: on a recovery + # dispatch the tag already exists, and running release-please would open or + # update a release PR as a side effect of repairing an old release. release-please: if: github.event_name == 'push' runs-on: ubuntu-latest @@ -38,45 +61,36 @@ jobs: outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} - upload_url: ${{ steps.release.outputs.upload_url }} steps: + # No `release-type` input: when it is set, the action ignores both files. - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 id: release with: - release-type: rust + config-file: release-please-config.json + manifest-file: .release-please-manifest.json - # release-please's own step PUBLISHES the release - and moves - # /releases/latest - before a single binary exists. For the whole - # build+sign window every running Colony therefore showed an update badge - # whose every click failed: a missing asset first, then a fail-closed - # signature refusal once `build` had uploaded but `sign` had not. And if - # any build leg failed, that state was permanent until someone noticed. - # - # Held as a draft by this one gh call, which narrows the window from - # minutes to the second between the two steps. release-please's own - # `draft` setting lives in a config file, and the action ignores config - # files whenever `release-type` is set, as it is here. - - name: Hold the release as a draft until it is signed + # release-please publishes the release, and moves /releases/latest, + # before any binary exists. For the whole build and sign window every + # running Colony would show an update badge whose every click fails. + # Held as a draft; sign-and-publish refuses anything else and publishes + # it last. `-R` is not optional: this job has no checkout, so `gh` has no + # git remote to infer the repository from, and a failure here once left + # v0.10.0 published at /releases/latest with no assets at all. + - name: Hold the release as a draft if: ${{ steps.release.outputs.release_created }} env: GH_TOKEN: ${{ github.token }} - # `-R` is not optional: this job has no `actions/checkout`, so `gh` - # has no git remote to infer the repository from and dies with - # "fatal: not a git repository". aur-publish.yml already carries this - # exact warning; this call was added without heeding it, and the cost - # was a published, empty v0.10.0 sitting at /releases/latest. - run: gh release edit "${{ steps.release.outputs.tag_name }}" -R "${{ github.repository }}" --draft=true + TAG: ${{ steps.release.outputs.tag_name }} + run: gh release edit "$TAG" -R "$GITHUB_REPOSITORY" --draft=true # One place that answers "which tag are we building?", so the jobs below do # not each have to know whether this run came from a merge or a dispatch. target: name: Resolve the target release needs: release-please - # NOT `always()`: that would also run this - and everything after it - when - # release-please FAILED, which is the brake that stopped the v0.10.0 - # incident from going further than an empty draft. `!cancelled() && - # !failure()` still lets the job run when release-please is SKIPPED, which - # is what a workflow_dispatch does. + # Runs on a merge that created a release, or on a recovery dispatch (when + # release-please is skipped). NOT `always()`: that would also run this, and + # everything after it, when release-please FAILED. if: ${{ !cancelled() && !failure() && (needs.release-please.outputs.release_created || github.event_name == 'workflow_dispatch') }} runs-on: ubuntu-latest permissions: @@ -116,22 +130,16 @@ jobs: echo "building $tag (draft)" build: - name: Build ${{ matrix.asset }} + name: build ${{ matrix.asset }} needs: target - # Explicit, because a SKIPPED job propagates transitively: `target` runs on - # a dispatch thanks to its own status-function `if:`, but that does not - # clear the skip inherited from `release-please` for the jobs after it. - # Without this, a recovery dispatch resolves the tag and then silently - # builds nothing - which is exactly what the first v0.10.0 dispatch did. - # `!cancelled()` is load-bearing, not decoration. A job `if:` containing NO - # status-check function gets an implicit `success()` ANDed onto it, and that - # `success()` accounts for the whole ancestor graph - so the skipped - # `release-please` made it false and this job skipped even though its own - # condition was true and its direct dependency had succeeded. Naming any - # status function suppresses the implicit one. + # `!cancelled()` is load-bearing. A job `if:` with NO status-check function + # gets an implicit `success()` ANDed onto it, and that `success()` covers + # the whole ancestor graph, so on a recovery dispatch the skipped + # `release-please` would skip this job too, even with `target` green. The + # first v0.10.0 dispatch resolved the tag and then built nothing that way. if: ${{ !cancelled() && needs.target.result == 'success' }} - # GitHub-hosted runners only: SignPath rejects a signing request if any job - # leading to it ran on a self-hosted runner. + # GitHub-hosted runners only: SignPath rejects a signing request if any + # job leading to it ran on a self-hosted runner. runs-on: ${{ matrix.os }} permissions: contents: read @@ -145,18 +153,17 @@ jobs: - target: x86_64-pc-windows-msvc os: windows-latest asset: colony-windows.exe + # "macos" is Apple Silicon, "macos-x86" is Intel. Colony picks + # between them at runtime. - target: aarch64-apple-darwin os: macos-latest asset: colony-macos - target: x86_64-apple-darwin os: macos-latest asset: colony-macos-x86 - steps: - # Check out the TAG, not the branch. On a recovery dispatch the default - # ref is the branch the workflow was dispatched from, so without this the - # rebuild would produce binaries from a different commit than the one the - # tag names - and the .meta sidecar binds those bytes to that version. + # The tag, not the branch: a recovery dispatch must rebuild the tagged + # commit, since the .meta sidecar binds these bytes to that version. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.target.outputs.tag }} @@ -164,7 +171,7 @@ jobs: # Pinned to master with `toolchain: stable` rather than to the `stable` # branch: that branch is rebased onto every master commit, so a SHA - # pinned on it stops being reachable from any branch. Bumped by hand. + # pinned on it stops being reachable from any branch. - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master, 2026-10-09 with: toolchain: stable @@ -182,21 +189,20 @@ jobs: - name: Build run: cargo build --release --target ${{ matrix.target }} - - name: Rename binary (Unix) + - name: Name the artifact (Unix) if: runner.os != 'Windows' run: cp target/${{ matrix.target }}/release/colony ${{ matrix.asset }} - - name: Rename binary (Windows) + - name: Name the artifact (Windows) if: runner.os == 'Windows' run: copy target\${{ matrix.target }}\release\colony.exe ${{ matrix.asset }} - # The pipeline's signature checks prove provenance and say nothing about - # whether the binary RUNS. An asset built for the wrong architecture, or - # one that aborts under the release profile's LTO, would be signed, - # verified, published and hashed into the AUR PKGBUILD with every check - # green. `--version` exits before opening a window, so this is a genuine - # end-to-end smoke test of the shipped artefact. - - name: Smoke-test the artefact + # The signature proves provenance and says nothing about whether the + # binary RUNS. An artifact built for the wrong architecture, or one that + # aborts under the release profile's LTO, would otherwise be signed, + # published, installed and hashed into the AUR PKGBUILD with every check + # green. `--version` exits before opening a window. + - name: Smoke-test the artifact if: matrix.target != 'x86_64-apple-darwin' shell: bash run: | @@ -222,11 +228,10 @@ jobs: exit 1 } - # The Intel macOS asset is cross-compiled on an arm64 runner and cannot - # be executed there, so assert the architecture instead - which is the - # regression that would otherwise survive longest, on the leg with the - # fewest users. - - name: Check the cross-compiled artefact's architecture + # The Intel macOS artifact is cross-compiled on an arm64 runner and + # cannot be executed there, so assert its architecture instead: that is + # the leg with the fewest users, where a regression survives longest. + - name: Check the cross-compiled artifact's architecture if: matrix.target == 'x86_64-apple-darwin' shell: bash run: | @@ -234,10 +239,11 @@ jobs: file "${{ matrix.asset }}" | grep -q 'x86_64' \ || { echo "::error::${{ matrix.asset }} is not an x86_64 binary"; exit 1; } - # To sign-and-publish as an artifact, at its root and under its release - # name, and never to the release from here: Authenticode rewrites the - # .exe, so nothing may reach the release before every byte is final. - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + # The file at the artifact root, under its release name, and never to + # the release from here: Authenticode rewrites the .exe, so nothing may + # reach the release before every byte is final. The name prefix is what + # `artifact-pattern` below matches. + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: build-${{ matrix.asset }} path: ${{ matrix.asset }} @@ -245,15 +251,13 @@ jobs: retention-days: 1 # Self-update verification is fail-closed: a release without .sig assets - # bricks "Download update" for every existing install (this happened on - # v0.7.0, which shipped unsigned because signing was a manual step). The - # organisation's shared workflow, in this same run: Authenticode through - # SignPath for the .exe (once SignPath is on), then .sig, .meta and .meta.sig - # over the FINAL bytes with COLONY_SIGNING_KEY_PEM, upload to the draft, - # download again and verify, and only then publish. It fails the release if - # the key is missing, so an unsigned release can never ship silently. - # Pinned by SHA: it holds the release key. See design/releases.md, section - # "Shared signing workflow", in Project-Colony-Resources. + # bricks "Download update" for every existing install (v0.7.0 shipped + # unsigned because signing was a manual step). In the same run as the + # builds, chained by `needs:` rather than `on: release`: a release created + # with the default GITHUB_TOKEN fires no release events, so a workflow + # written that way never runs. The shared workflow fails the release if the + # key is missing. Pinned to the organisation's commit of the shared + # workflow; design/releases.md says when that pin moves. sign-and-publish: needs: [target, build] # Every dependency named explicitly. Guarding on `target` alone would let @@ -263,7 +267,7 @@ jobs: permissions: actions: read contents: write - uses: Project-Colony/Project-Colony-Resources/.github/workflows/sign-and-publish.yml@619460ff4dc0049f129955f0988d368427b9eedb # main + uses: Project-Colony/Project-Colony-Resources/.github/workflows/sign-and-publish.yml@619460ff4dc0049f129955f0988d368427b9eedb # organisation pin with: tag: ${{ needs.target.outputs.tag }} # The four names self-update asks for (launcher_asset_name), each @@ -273,18 +277,17 @@ jobs: # Uncomment once SignPath has approved this project and its project # exists in the Project-Colony SignPath organisation: # signpath-project-slug: "colony" + # Leave the default `code-signing-policy: true` only while README.md has + # a "## Code signing policy" section: the release notes link to it. # Named, not `inherit`: the called workflow gets these two and nothing else. secrets: COLONY_SIGNING_KEY_PEM: ${{ secrets.COLONY_SIGNING_KEY_PEM }} SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} - # Chained here (not on `release: published`) because release-please creates - # the release with the default GITHUB_TOKEN, whose events do not trigger - # other workflows - the standalone trigger never fired once in nine - # releases. For the same reason the publish inside sign-and-publish, made - # with GITHUB_TOKEN, would not start it either. Running after - # sign-and-publish also guarantees the assets are signed and public before - # the AUR job hashes them. + # Chained here (not on `release: published`) for the same reason as + # sign-and-publish: the standalone trigger never fired once in nine + # releases. Running after sign-and-publish also guarantees the assets are + # signed and public before the AUR job hashes them. aur: name: Publish colony-bin to AUR needs: [target, sign-and-publish] diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e1206f4 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.10.4" +} diff --git a/docs/release-signing.md b/docs/release-signing.md index e5c2023..a4e92b2 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -1,24 +1,32 @@ # Release signing -Colony verifies its own launcher self-updates with an **ed25519 signature** -before applying them. This is mandatory and fail-closed: if the detached -signature is missing, malformed, or does not verify against the embedded public -key, the self-update is refused and the running binary is left untouched. - -- Verification: [`src/signing.rs`](../src/signing.rs) — embeds the public key and - verifies with the pure-Rust `ed25519-dalek` crate (no OpenSSL in the shipped - binary). +Colony checks an **ed25519 signature** before it runs anything it downloads. +The trusted public keys are embedded in [`src/signing.rs`](../src/signing.rs) +(`RELEASE_PUBLIC_KEYS`) and verified with the pure-Rust `ed25519-dalek` crate, +so the shipped binary needs no OpenSSL. + - Signature format: the **raw 64-byte ed25519 signature** over the asset bytes, exactly what `openssl pkeyutl -sign -rawin` emits (base64 text is also accepted). Published as `.sig` next to each release asset. -- Enforced only for the **launcher** (`colony-[.exe]`). Third-party - app installs continue to use the optional `sha256` field in `colony.json`, - plus `"signed": true` to require a signature (pinned client-side once an app - has been installed with one, so a repo cannot silently stop signing). +- **Launcher self-updates** (`colony-[.exe]`): mandatory and + fail-closed. If the signature or the signed sidecar (below) is missing, + malformed or does not verify, the update is refused and the running binary + is left untouched. +- **App installs and updates**: any app release that publishes `.sig` + MUST verify against the same keys, or the install is refused. An app without + a `.sig` installs as a legacy unsigned app, unless its `colony.json` declares + `"signed": true`. Once an install of an app has verified a signature, that + app is pinned: a later release that stops publishing `.sig` is refused, + whatever the manifest now says. The same applies to the `.meta` sidecar + ([`src/download.rs`](../src/download.rs), `download_release_asset`). The + optional `sha256` field in `colony.json` is checked on top of that. + +The organisation signs every program with one key, so the keys embedded here +are the trust root for the launcher and for every signed app at once. ### Why a signature alone is not enough -A signature over raw bytes proves only *these bytes came from the release key* — +A signature over raw bytes proves only *these bytes came from the release key* - not **which** artefact or **which** version they are. Anything able to control what the release host serves could therefore replay an older, genuinely signed build as an "update" (a downgrade), or serve the macOS asset where the Linux one @@ -36,7 +44,9 @@ asset it asked for, that its digest matches the bytes actually downloaded, that its version equals the tag the update check resolved, and that this version is **strictly newer** than the running build. That last check is the anti-rollback. Both the signature and the sidecar are re-verified at install time, not only at -download time, so the staging file cannot be swapped in between. +download time, so the staging file cannot be swapped in between. Apps get the +same bindings with one difference: their version must be **no older** than the +installed one, since an app pinned to a fixed tag must stay reinstallable. ## Every release MUST ship signatures and sidecars @@ -46,39 +56,49 @@ fail for users on that channel. The release workflow's `sign-and-publish` job checks all three exist and verify for all four platforms and fails the release otherwise. -## Signing (CI or local) - -The private key never lives in the repo. Point `COLONY_SIGNING_KEY` at the -ed25519 private key (PEM), set `COLONY_RELEASE_VERSION` to the release tag (it is -bound into each sidecar), and run: - -**Sign the PUBLISHED bytes, never a local rebuild.** Download the assets from -the release first: - -```sh -gh release download v1.2.3 --dir dist \ - --pattern colony-linux --pattern colony-windows.exe \ - --pattern colony-macos --pattern colony-macos-x86 - -COLONY_SIGNING_KEY=/path/to/colony-release.pem \ -COLONY_RELEASE_VERSION=v1.2.3 \ - ./scripts/sign-release.sh dist/colony-linux dist/colony-windows.exe \ - dist/colony-macos dist/colony-macos-x86 +Every other program's release depends on Colony's too: the organisation's +release template downloads Colony's latest `colony-linux`, verifies its `.sig` +and `.meta` against Colony's public key, and runs its `validate-manifest` on +the program's `colony.json`. A Colony release whose signatures do not verify, +or whose `validate-manifest` regresses, stops every program in the organisation +from releasing. + +## How releases are signed + +Signing happens in CI and nowhere else. Since the v0.7.0 incident (a release +shipped unsigned because signing was a manual step, bricking self-update for +every existing install), there is no hand-signing procedure and no signing +script in this repository. + +The release workflow is `.github/workflows/release.yml`. Merging the release +PR that release-please opens creates the tag and a release, which the workflow +immediately holds as a draft. Its build legs only build, smoke-test and upload +each binary as a workflow artifact; they never see a key. The +`sign-and-publish` job then calls the organisation's shared workflow, +`.github/workflows/sign-and-publish.yml` in +[Project-Colony-Resources](https://github.com/Project-Colony/Project-Colony-Resources), +pinned by commit, which in that same run: -gh release upload v1.2.3 dist/*.sig dist/*.meta --clobber -``` +1. checks the release is still a draft; +2. sends `colony-windows.exe` to SignPath for Authenticode, once SignPath is + turned on for this repository (`signpath-project-slug`), and waits for a + person to approve the request; +3. writes `.sig`, `.meta` and `.meta.sig` over the final bytes with the + `COLONY_SIGNING_KEY_PEM` organisation secret, in a job that checks out + nothing and builds nothing; +4. uploads everything to the draft, downloads it again and verifies every + signature and digest against what users will download; +5. publishes the release. -The download step is not optional. `sign-release.sh` hashes whatever local file -you hand it into the `.meta` sidecar, and the client then enforces that digest -against the bytes it downloaded. Rust release builds are not bit-reproducible -across machines, so signing a fresh `cargo build --release` produces a sidecar -whose digest does not match what users receive - and every install fails -verification, which is worse than being unsigned because it also fails -fail-closed. +The order is the point: Authenticode rewrites the `.exe`, so a `.sig` or +`.meta` computed before it would describe bytes that no longer exist, and the +launcher would refuse the update. The job **fails the release** if the secret is +missing or any file is missing, empty or does not verify, so a release the +launcher cannot verify can no longer ship silently. Only after that does the +`aur` job bump `colony-bin`. -For each asset this writes `.sig`, `.meta` and `.meta.sig`, -every signature self-verified before it is kept. Upload all of them as release -assets, then confirm the count: +A published release carries 16 assets (four binaries, each with `.sig`, +`.meta` and `.meta.sig`): ```sh gh release view v1.2.3 --json assets --jq '.assets|length' # must be 16 @@ -92,8 +112,15 @@ tells you which state you are in. **Still a draft, incomplete.** Nobody is affected: `/releases/latest` still points at the previous version and no client has been offered anything. Fix the cause and use **"Re-run failed jobs"**. The build artifacts are kept for one -day; after that, dispatch the workflow again from the tag itself: -`gh workflow run release-please.yml --ref -f tag=`. +day; after that, dispatch the workflow again, started from the tag itself: + +```sh +gh workflow run release.yml --ref -f tag= +``` + +The dispatch rebuilds the tagged commit, signs it and publishes it. It must +start from the tag, not from `main`: with SignPath on, the shared workflow +refuses a run whose commit is not the tag's. **Never "Re-run all jobs".** release-please re-runs against a `main` whose release already exists, emits an empty `release_created`, and every downstream @@ -101,60 +128,37 @@ job skips - while the run reports green. That looks like a successful recovery and is the opposite of one. **Published but unsigned or partial.** Clients are being offered an update that -cannot be applied. Take it out of `latest` first, then complete it: +cannot be applied. Take it out of `latest` by turning it back into a draft, +confirm the fallback, then run the same dispatch: ```sh -gh release edit --prerelease # assets exist; keeps their URLs alive -# or: gh release edit --draft=true # release is empty anyway -gh api repos/Project-Colony/Colony/releases/latest --jq .tag_name # confirm the fallback +gh release edit --draft=true +gh api repos/Project-Colony/Colony/releases/latest --jq .tag_name # the previous version +gh workflow run release.yml --ref -f tag= ``` -then follow the manual signing procedure above and re-publish with -`gh release edit --draft=false --prerelease=false`. - -### In CI (the normal path) - -Since the v0.7.0 incident (a release shipped unsigned because signing was a -manual step, bricking self-update for every existing install), signing is a -mandatory part of the release workflow, `.github/workflows/release-please.yml`. -Its build legs only build, smoke-test and upload each binary as a workflow -artifact. The `sign-and-publish` job then calls the organisation's shared -workflow, `.github/workflows/sign-and-publish.yml` in -[Project-Colony-Resources](https://github.com/Project-Colony/Project-Colony-Resources), -pinned by commit, which in that same run: - -1. checks the release is still a draft; -2. sends `colony-windows.exe` to SignPath for Authenticode, once SignPath is - turned on for this repository (`signpath-project-slug`), and waits for a - person to approve the request; -3. writes `.sig`, `.meta` and `.meta.sig` over the final bytes with the - `COLONY_SIGNING_KEY_PEM` secret (the PEM contents), in the same format as - `scripts/sign-release.sh`; -4. uploads everything to the draft, downloads it again and verifies every - signature and digest against what users will download; -5. publishes the release. - -The order is the point: Authenticode rewrites the `.exe`, so a `.sig` or -`.meta` computed before it would describe bytes that no longer exist, and the -launcher would refuse the update. The job **fails the release** if the secret is -missing or any file is missing, empty or does not verify, so a release the -launcher cannot verify can no longer ship silently. The manual procedure above -remains for re-signing an old release by hand. +The workflow refuses to touch a release that is still published, because +replacing live binaries would leave signatures describing bytes users no longer +receive. Drafting it first is what makes the rebuild possible. ## Key custody -- The current private key was generated locally and stored at - `~/.config/colony/release-signing/colony-release.pem` (mode `600`), with the - public key beside it (`colony-release.pub.pem`). **Back it up somewhere - durable and secret** (password manager / offline media). If it is lost, you - must rotate (below); if it leaks, rotate immediately. -- For CI, store the PEM contents as an encrypted secret - (`COLONY_SIGNING_KEY_PEM`), not in the repo. +- The private key exists in one place: the `COLONY_SIGNING_KEY_PEM` + organisation secret (the PEM contents). Only the signing job of the shared + workflow receives it. There is no copy on any machine and none in any + repository. +- GitHub never returns a secret's value, so nobody, maintainers included, can + read the key back. If the secret is deleted or overwritten, the key is gone + and the only way forward is a rotation (below). If it leaks, rotate + immediately. +- Every program in the organisation is signed with this same key, so a + rotation is an organisation-wide event, not a Colony-only one. ## Generating / rotating the key ```sh -# 1. New keypair +# 1. New keypair, on a trusted machine. The private half goes into the org +# secret and is then deleted from disk. openssl genpkey -algorithm ed25519 -out colony-release.pem openssl pkey -in colony-release.pem -pubout -out colony-release.pub.pem @@ -177,21 +181,43 @@ Rotate over three releases: | N+1 | `[new, old]` | **new** | everyone on N or later | | N+2 | `[new]` | **new** | everyone on N or later; `old` is now revoked | -Two rules make this safe: - -- **N must be signed with the OLD key.** Its whole job is to widen the trusted - set on machines that only trust `old`. Signing it with `new` is the mistake - that strands the install base. -- **Do not skip to N+2.** Anyone still on N-1 or earlier when `old` is dropped - can no longer self-update and must reinstall by hand. Leave N and N+1 in the - field long enough for that to be a rounding error, and check the release - download counts before shipping N+2. +Step by step: + +1. **Ship N carrying both keys, signed with the old one.** Add the new key to + `RELEASE_PUBLIC_KEYS`, update the length assertion in the test below, and + release as usual: the org secret still holds `old`. N's whole job is to + widen the trusted set on machines that only trust `old`. Signing it with + `new` is the mistake that strands the install base. +2. **Wait.** Leave N in the field long enough for installs older than N to be + a rounding error, and check the release download counts. +3. **Switch the org secret to `new`.** From then on the shared workflow signs + every program with `new`, not only Colony. Every Colony install still on + N-1 or older trusts only `old`, so it refuses **every signed app** it tries + to install or update, not only its own self-update. That is why step 2 + comes first. +4. **Ship N+1**, signed with `new`. At the same time, replace Colony's public + key PEM everywhere it is copied: + - in Project-Colony-Resources, `templates/sign-and-publish-caller.yml` (the + `Validate colony.json` step verifies Colony's latest `colony-linux` + against it) and the `## Code signing policy` section of + `templates/program/README.md`; + - in every program, its copy of that release workflow and the public key in + its README's `## Code signing policy`; + - in this file, under "Verifying a signature by hand". + + Until a program's workflow carries the new PEM, its releases stop at the + manifest check once N+1 is Colony's latest release. +5. **Ship N+2** with `old` removed from `RELEASE_PUBLIC_KEYS`. Do not skip + to it: anyone still on N-1 or earlier when `old` is dropped can no longer + self-update and must reinstall by hand. For an EMERGENCY rotation after a key leak, the same sequence applies but the overlap is a liability rather than a courtesy: the attacker holding `old` can sign anything the field will accept until N+2 ships. Publish N and N+1 back to back, keep the window to hours, and say so publicly - a compromised key is not a -quiet fix. +quiet fix. If the old key is lost rather than leaked, N cannot be signed with +it: every install older than the first release embedding the new key has to +reinstall by hand. The test `any_trusted_key_verifies_and_an_untrusted_one_does_not` in `src/signing.rs` asserts both halves: any listed key validates, an unlisted one @@ -200,9 +226,18 @@ requires deliberately updating that assertion. ## Verifying a signature by hand +With OpenSSL 3 and the public key: + ```sh -openssl pkey -pubin -in colony-release.pub.pem -out /dev/null # sanity: key parses -openssl pkeyutl -verify -pubin -inkey colony-release.pub.pem \ - -rawin -in colony-linux -sigfile colony-linux.sig -# -> "Signature Verified Successfully" +cat > colony-release.pub.pem <<'EOF' +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEARNjg3Nn8H6/aBg1unwGjkUTcrdTxERNefVaqU8cFu0s= +-----END PUBLIC KEY----- +EOF +a=colony-linux +openssl pkeyutl -verify -pubin -inkey colony-release.pub.pem -rawin -in "$a" -sigfile "$a.sig" +openssl pkeyutl -verify -pubin -inkey colony-release.pub.pem -rawin -in "$a.meta" -sigfile "$a.meta.sig" +# -> "Signature Verified Successfully", twice +cat "$a.meta" # version=, asset=, sha256= +sha256sum "$a" # the digest must equal the sha256 line ``` diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..01c9512 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "rust", + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false, + "draft": false, + "prerelease": false, + "bump-minor-pre-major": false, + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Fixes" + }, + { + "type": "perf", + "section": "Performance" + }, + { + "type": "refactor", + "section": "Internals" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "chore", + "section": "Chores", + "hidden": true + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "ci", + "section": "CI", + "hidden": true + } + ] + } + }, + "pull-request-header": "The next release, assembled from the commits since the last one. Merging this tags it, and the tag is what builds and publishes." +} diff --git a/scripts/sign-release.sh b/scripts/sign-release.sh deleted file mode 100755 index 43d496d..0000000 --- a/scripts/sign-release.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -# -# Sign a Colony release asset with the ed25519 release key, producing a detached -# ".sig" that the launcher verifies before applying a self-update. -# -# The signature is the raw 64-byte ed25519 signature over the asset bytes, as -# produced by `openssl pkeyutl -sign -rawin` — the same format src/signing.rs -# verifies against the embedded public key. openssl is the only dependency. -# -# A signature over raw bytes proves only "these bytes came from the org" — not -# WHICH artefact or WHICH version they are. So each asset also gets a signed -# metadata sidecar binding the bytes to a version and a filename: -# -# .meta version=\nasset=\nsha256=\n -# .meta.sig ed25519 signature over the .meta bytes -# -# The launcher verifies the sidecar and refuses anything that is not strictly -# newer than itself; app installs verify the same sidecar and refuse anything -# OLDER than what is already installed (equal is fine - an app pinned to a fixed -# tag must stay reinstallable). Either way, that is what stops a replay of an -# older org-signed build. -# -# Emitting sidecars for an app repo is safe to adopt at any time: Colony treats -# them as opportunistic, and only makes them mandatory for a given app once it -# has verified one (so a repo cannot silently stop publishing them). -# -# Usage: -# COLONY_SIGNING_KEY=/path/to/colony-release.pem \ -# COLONY_RELEASE_VERSION=v1.2.3 ./scripts/sign-release.sh [ ...] -# -# In CI, provide the private key via a secret (e.g. write it to a temp file from -# a GitHub Actions secret) and set COLONY_SIGNING_KEY to its path. Upload every -# generated ".sig", ".meta" and ".meta.sig" as release -# assets alongside their binary. -set -euo pipefail - -KEY="${COLONY_SIGNING_KEY:-$HOME/.config/colony/release-signing/colony-release.pem}" -VERSION="${COLONY_RELEASE_VERSION:-}" - -if [[ ! -f "$KEY" ]]; then - echo "error: signing key not found at '$KEY'" >&2 - echo "set COLONY_SIGNING_KEY to the ed25519 private key (PEM)." >&2 - exit 1 -fi -if [[ $# -eq 0 ]]; then - echo "usage: COLONY_SIGNING_KEY= COLONY_RELEASE_VERSION= $0 [ ...]" >&2 - exit 2 -fi -if [[ -z "$VERSION" ]]; then - echo "error: COLONY_RELEASE_VERSION is not set" >&2 - echo "it is bound into each signed .meta sidecar so the launcher can reject downgrades." >&2 - exit 2 -fi - -# Sign into .sig, then verify the signature before returning. -sign_and_verify() { - local file="$1" sig="$1.sig" pub - openssl pkeyutl -sign -inkey "$KEY" -rawin -in "$file" -out "$sig" - pub="$(mktemp)" - openssl pkey -in "$KEY" -pubout -out "$pub" 2>/dev/null - if ! openssl pkeyutl -verify -pubin -inkey "$pub" -rawin -in "$file" -sigfile "$sig" >/dev/null 2>&1; then - echo "error: self-verification failed for $file" >&2 - rm -f "$pub" - return 1 - fi - rm -f "$pub" -} - -for asset in "$@"; do - if [[ ! -f "$asset" ]]; then - echo "error: asset not found: $asset" >&2 - exit 1 - fi - # Detached signature over the asset bytes. - sign_and_verify "$asset" - echo "signed $asset -> ${asset}.sig" - - # Signed metadata binding those bytes to a version and a filename. - meta="${asset}.meta" - digest="$(openssl dgst -sha256 -r "$asset" | cut -d' ' -f1)" - printf 'version=%s\nasset=%s\nsha256=%s\n' "$VERSION" "$(basename "$asset")" "$digest" > "$meta" - sign_and_verify "$meta" - echo "signed $meta -> ${meta}.sig (version=$VERSION sha256=${digest:0:16}...)" -done diff --git a/src/signing.rs b/src/signing.rs index dc52af0..69b680a 100644 --- a/src/signing.rs +++ b/src/signing.rs @@ -1,15 +1,16 @@ //! Detached ed25519 signature verification for signed launcher self-updates. //! -//! Colony's own release binaries are signed with an ed25519 private key held -//! off-machine (see `docs/release-signing.md`); the matching public key is -//! embedded below. Before a launcher self-update is applied, the downloaded -//! binary is verified against a detached `.sig` signature. Verification -//! is mandatory and fail-closed: a missing, malformed, or invalid signature -//! aborts the update rather than installing untrusted code. +//! Colony's own release binaries are signed with an ed25519 private key that +//! only the organisation's shared signing workflow can use (see +//! `docs/release-signing.md`); the matching public key is embedded below. +//! Before a launcher self-update is applied, the downloaded binary is verified +//! against a detached `.sig` signature. Verification is mandatory and +//! fail-closed: a missing, malformed, or invalid signature aborts the update +//! rather than installing untrusted code. //! //! The signature format is the raw 64-byte ed25519 signature emitted by //! `openssl pkeyutl -sign -rawin` (base64 text is also accepted), so releases -//! can be signed with the ubiquitous `openssl` CLI in CI — no special tooling. +//! can be signed with the ubiquitous `openssl` CLI in CI, no special tooling. use anyhow::Result; use base64::Engine; @@ -18,7 +19,7 @@ use ed25519_dalek::{Signature, VerifyingKey}; /// Colony release signing public keys (ed25519, raw 32 bytes each). /// /// A LIST, not a single key, because rotation is otherwise not expressible. -/// With one embedded key, `sign-release.sh` emits exactly one `.sig`, +/// With one embedded key, the signing workflow emits exactly one `.sig`, /// which is either old-key (refused by every updated client) or new-key /// (refused by every client in the field) - and verification is fail-closed, so /// the refusal is permanent. The documented rotation procedure could not be @@ -54,7 +55,8 @@ pub const METADATA_SUFFIX: &str = ".meta"; /// WHICH artefact or version they are, so an attacker able to control what the /// release host serves could replay an older, genuinely signed build. This /// sidecar binds the bytes to a version and a filename, and is signed with the -/// same key; `scripts/sign-release.sh` emits it for every asset. +/// same key; the organisation's shared signing workflow emits it for every +/// asset. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReleaseMetadata { /// Release tag the asset belongs to, e.g. `v1.2.3`. @@ -243,8 +245,8 @@ mod tests { } #[test] - fn metadata_parses_sign_release_output() { - // Byte-for-byte what scripts/sign-release.sh writes. + fn metadata_parses_signing_workflow_output() { + // Byte-for-byte what the shared signing workflow writes. let raw = b"version=v0.9.1\nasset=colony-linux\nsha256=B1A5AF3D\n"; let meta = ReleaseMetadata::parse(raw).unwrap(); assert_eq!(meta.version, "v0.9.1");