Skip to content
Merged
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
61 changes: 47 additions & 14 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,25 @@ jobs:
needs: [release-please, build]
if: ${{ needs.release-please.outputs.release_created }}
runs-on: ubuntu-latest
env:
# Single source of truth for the four launcher assets. Declared once so the
# download, sign, verify and post-upload steps cannot drift apart: a list
# repeated per step means adding a platform silently leaves it unsigned,
# which is exactly how v0.7.0 shipped.
ASSETS: colony-linux colony-windows.exe colony-macos colony-macos-x86
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.release-please.outputs.tag_name }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Download release binaries
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
mkdir dist
gh release download "$TAG" --dir dist \
--pattern colony-linux \
--pattern colony-windows.exe \
--pattern colony-macos \
--pattern colony-macos-x86
for a in $ASSETS; do PATTERNS="$PATTERNS --pattern $a"; done
gh release download "$TAG" --dir dist $PATTERNS
for a in $ASSETS; do
[ -s "dist/$a" ] || { echo "::error::release asset $a is missing or empty"; exit 1; }
done

- name: Sign
env:
Expand All @@ -116,15 +121,43 @@ jobs:
fi
printf '%s' "$KEY" > /tmp/colony-release.pem
chmod 600 /tmp/colony-release.pem
for a in $ASSETS; do PATHS="$PATHS dist/$a"; done
COLONY_SIGNING_KEY=/tmp/colony-release.pem \
./scripts/sign-release.sh dist/colony-linux dist/colony-windows.exe dist/colony-macos dist/colony-macos-x86
COLONY_RELEASE_VERSION="$TAG" \
./scripts/sign-release.sh $PATHS
rm -f /tmp/colony-release.pem

- name: Upload signatures
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: gh release upload "$TAG" dist/*.sig --clobber
# Self-update is fail-closed on BOTH the signature and the signed metadata
# sidecar that binds it to a version, so a release missing either one is
# un-updatable. Check locally before uploading...
- name: Verify every asset got a signature and a signed sidecar
run: |
for a in $ASSETS; do
for required in "dist/$a.sig" "dist/$a.meta" "dist/$a.meta.sig"; do
[ -s "$required" ] || { echo "::error::missing or empty $required"; exit 1; }
done
grep -qx "version=$TAG" "dist/$a.meta" \
|| { echo "::error::dist/$a.meta does not bind version=$TAG"; exit 1; }
grep -qx "asset=$a" "dist/$a.meta" \
|| { echo "::error::dist/$a.meta does not bind asset=$a"; exit 1; }
done
echo "all assets carry .sig + .meta + .meta.sig, correctly bound"

- name: Upload signatures and metadata
run: gh release upload "$TAG" dist/*.sig dist/*.meta --clobber

# ...and again against the PUBLISHED release, because a local file proves
# nothing about what users can actually download.
- name: Verify the published release carries every asset
run: |
gh release view "$TAG" --json assets --jq '.assets[].name' | sort > /tmp/published
for a in $ASSETS; do
for required in "$a" "$a.sig" "$a.meta" "$a.meta.sig"; do
grep -qx "$required" /tmp/published \
|| { echo "::error::$required is not published on $TAG - self-update would refuse this release"; exit 1; }
done
done
echo "published release verified"

# Chained here (not on `release: published`) because release-please creates
# the release with the default GITHUB_TOKEN, whose events do not trigger
Expand Down
29 changes: 20 additions & 9 deletions docs/colony-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,17 +220,28 @@ signing is adopted per-app, no flag day - **unless** the manifest declares:
{ "signed": true }
```

With `"signed": true`, a missing signature ABORTS the install: this closes the
remaining hole where a compromised repository could simply omit signatures.
Declare it once every release of the app ships `.sig` assets (all Project-
Colony apps have signed releases as of 2026-07-20). Sign with:
With `"signed": true`, a missing signature ABORTS the install. Declare it once
every release of the app ships `.sig` assets (all Project-Colony apps have signed
releases as of 2026-07-20). Sign with:

```sh
COLONY_SIGNING_KEY=/path/to/colony-release.pem ./scripts/sign-release.sh <asset>
COLONY_SIGNING_KEY=/path/to/colony-release.pem \
COLONY_RELEASE_VERSION=<tag> ./scripts/sign-release.sh <asset>
```

Note the trust boundary: signatures protect against tampered release assets
`COLONY_RELEASE_VERSION` is required because the script also emits a signed
`<asset>.meta` sidecar binding the bytes to a version and a filename. Only the
launcher's own self-update consumes that sidecar today; for app assets the
`.sig` is what matters, and the extra files are harmless if published.

Note the trust boundary. Signatures protect against tampered release assets
(e.g. an asset swapped after publication), because forging one requires the
org's private signing key, which never lives in any repository. They do not
yet protect against a fully compromised repository *omitting* signatures;
enforcement ("this app must be signed") is a planned follow-up.
org's private signing key, which never lives in any repository. Enforcement is
implemented: `"signed": true` refuses an install with no signature, and the
launcher **pins** the requirement client-side once an app has been installed with
a verified signature, so flipping `signed` back to false in a compromised
repository no longer downgrades that app to unsigned. What remains open is that
an app signature is not bound to a version or an asset name, so an attacker with
push access to a catalogue repo could still replay a *different* org-signed
artefact; binding it needs the `.meta` sidecar rolled out to the ecosystem app
release workflows first.
54 changes: 42 additions & 12 deletions docs/release-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,67 @@ key, the self-update is refused and the running binary is left untouched.
exactly what `openssl pkeyutl -sign -rawin` emits (base64 text is also
accepted). Published as `<asset>.sig` next to each release asset.
- Enforced only for the **launcher** (`colony-<platform>[.exe]`). Third-party
app installs continue to use the optional `sha256` field in `colony.json`.
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).

## Every release MUST ship signatures
### Why a signature alone is not enough

Because verification is fail-closed, a release published **without** the
`colony-<platform>.sig` assets will make self-update fail for users on that
channel. Sign every launcher asset and upload the `.sig` files alongside them.
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
was requested. So every asset also gets a **signed metadata sidecar**:

```
<asset>.meta version=v1.2.3
asset=colony-linux
sha256=<hex of the asset bytes>
<asset>.meta.sig ed25519 signature over the .meta bytes
```

The launcher verifies the sidecar's signature, then requires that it names the
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.

## Every release MUST ship signatures and sidecars

Because verification is fail-closed, a release published without the
`colony-<platform>.sig`, `.meta` and `.meta.sig` assets will make self-update
fail for users on that channel. The CI `sign` job checks all three exist 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) and run:
ed25519 private key (PEM), set `COLONY_RELEASE_VERSION` to the release tag (it is
bound into each sidecar), and run:

```sh
COLONY_SIGNING_KEY=/path/to/colony-release.pem \
COLONY_RELEASE_VERSION=v1.2.3 \
./scripts/sign-release.sh colony-linux colony-windows.exe colony-macos colony-macos-x86
```

This writes `colony-linux.sig`, `colony-windows.exe.sig`, … each self-verified
before it is written. Upload every `.sig` as a release asset.
For each asset this writes `<asset>.sig`, `<asset>.meta` and `<asset>.meta.sig`,
every signature self-verified before it is kept. Upload all of them as release
assets.

### 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 job in the release workflow: `.github/workflows/release-please.yml`
(`sign` job) downloads the four built binaries, signs them with the
`COLONY_SIGNING_KEY_PEM` secret (the PEM contents), and uploads the `.sig`
assets. The job **fails the release** if the secret is missing, so an unsigned
release can no longer ship silently. The manual procedure above remains for
re-signing an old release by hand.
`COLONY_SIGNING_KEY_PEM` secret (the PEM contents), verifies that every asset
came out with a `.sig`, a `.meta` and a `.meta.sig`, and uploads them. The job
**fails the release** if the secret is missing or if any of those files is
missing or empty, 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.

## Key custody

Expand Down
63 changes: 46 additions & 17 deletions scripts/sign-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,71 @@
# 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:
#
# <asset>.meta version=<tag>\nasset=<basename>\nsha256=<hex>\n
# <asset>.meta.sig ed25519 signature over the .meta bytes
#
# The launcher verifies the sidecar and refuses anything that is not strictly
# newer than itself, which is what stops a replay of an older org-signed build.
#
# Usage:
# COLONY_SIGNING_KEY=/path/to/colony-release.pem ./scripts/sign-release.sh <asset> [<asset> ...]
# COLONY_SIGNING_KEY=/path/to/colony-release.pem \
# COLONY_RELEASE_VERSION=v1.2.3 ./scripts/sign-release.sh <asset> [<asset> ...]
#
# 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 each
# generated "<asset>.sig" as a release asset alongside its binary.
# a GitHub Actions secret) and set COLONY_SIGNING_KEY to its path. Upload every
# generated "<asset>.sig", "<asset>.meta" and "<asset>.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=<key.pem> $0 <asset> [<asset> ...]" >&2
echo "usage: COLONY_SIGNING_KEY=<key.pem> COLONY_RELEASE_VERSION=<tag> $0 <asset> [<asset> ...]" >&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

for asset in "$@"; do
if [[ ! -f "$asset" ]]; then
echo "error: asset not found: $asset" >&2
exit 1
fi
sig="${asset}.sig"
openssl pkeyutl -sign -inkey "$KEY" -rawin -in "$asset" -out "$sig"
# Self-check: verify what we just produced before publishing it.
# Sign <file> into <file>.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 "$asset" -sigfile "$sig" >/dev/null 2>&1; then
echo "signed $asset -> $sig"
else
echo "error: self-verification failed for $asset" >&2
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"
exit 1
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
Loading