From 7fb45f20b3a3f28fa802e813cfdceda3ae236d1a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 9 Nov 2026 08:23:20 +0000 Subject: [PATCH 1/2] fix(release): minisign-sign every release artifact and verify it in the installer and self-update The installer shipped a placeholder key (RWQ_REPLACE_ME...) and releases had no .minisig files, so downloads were never verified. Now: - minisign.pub (key id 01A1B54137EC392A) committed at the repo root. - release.yml: the publish job installs a sha256-pinned minisign 0.12, checks each artifact against its .sha256, signs it with the production key (secrets MINISIGN_SECRET_KEY + MINISIGN_PASSWORD), trusted comment "timestamp: version: file:", verifies against the committed key, uploads .minisig, and refuses to publish a release missing any signature. - install.sh: real public key; fails closed on a missing signature, failed verification, or a trusted comment naming another file/release (C0MPUTE_ALLOW_UNSIGNED=1 is the explicit escape hatch). Without minisign installed it fetches the pinned upstream minisign 0.12 release, checked by a hard-coded sha256, into the temp dir (no root). - c0mpute-update: self-update fetches .minisig and verifies it with the compiled-in key (minisign-verify crate, pure Rust) after the sha256 check and before swapping; no signature, no update. Unit tests with a test keypair cover good, tampered, wrong key, wrong file/release, garbage sig, and that RELEASE_PUBKEY matches minisign.pub. - SECURITY.md + README document the key and how to verify by hand. Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 +- .github/workflows/release.yml | 56 ++++++++- Cargo.lock | 7 ++ README.md | 21 ++++ SECURITY.md | 46 ++++++++ minisign.pub | 2 + node/crates/c0mpute-update/Cargo.toml | 1 + node/crates/c0mpute-update/src/lib.rs | 161 ++++++++++++++++++++++++-- scripts/install.sh | 89 ++++++++++++-- 9 files changed, 365 insertions(+), 24 deletions(-) create mode 100644 SECURITY.md create mode 100644 minisign.pub diff --git a/.env.example b/.env.example index 5a04954b..64c9a80b 100644 --- a/.env.example +++ b/.env.example @@ -22,8 +22,12 @@ # C0MPUTE_RELEASE_BASE=https://staging.c0mpute.com/releases # Override the minisign pubkey embedded in the installer (for testing -# pre-release signed builds). Production releases use the embedded key. +# pre-release signed builds). Production releases use the embedded key +# (minisign.pub at the repo root). # C0MPUTE_PUBKEY=RWQ... +# The installer refuses an artifact without a valid .minisig. Escape hatch +# for a private mirror that has no signatures (not recommended): +# C0MPUTE_ALLOW_UNSIGNED=1 # Plugin upstream installer URLs (consumed by plugins//install.sh # and scripts/install.sh's chain_install). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index add770d6..26413e03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,11 @@ name: release # published + marked Latest on the first upload, so for ~3 minutes after a # tag `releases/latest/download/` 404'd for every platform still # building, and `curl -fsSL https://c0mpute.com/install.sh | sh` failed. +# Before publishing, the `publish` job signs every artifact with the +# production minisign key (repo secrets MINISIGN_SECRET_KEY + MINISIGN_PASSWORD; +# public key minisign.pub) and uploads .minisig next to it, which +# scripts/install.sh requires. A release is never published unsigned. +# # A failed platform leaves the draft unpublished (Latest stays on the # previous release); fix and re-run the failed jobs. # @@ -142,6 +147,47 @@ jobs: needs: release runs-on: ubuntu-latest steps: + # minisign from its upstream release, pinned by sha256 (the same pin + # scripts/install.sh uses to verify downloads when minisign is missing). + - name: Install minisign (pinned) + run: | + set -eu + curl -fsSL -o "$RUNNER_TEMP/minisign.tar.gz" https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz + echo "9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73 $RUNNER_TEMP/minisign.tar.gz" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/minisign.tar.gz" -C "$RUNNER_TEMP" minisign-linux/x86_64/minisign + install -m 755 "$RUNNER_TEMP/minisign-linux/x86_64/minisign" /usr/local/bin/minisign 2>/dev/null \ + || sudo install -m 755 "$RUNNER_TEMP/minisign-linux/x86_64/minisign" /usr/local/bin/minisign + minisign -v + + # Sign every artifact with the production key (public half: minisign.pub, + # embedded in scripts/install.sh). Secret key + password live in the + # MINISIGN_SECRET_KEY / MINISIGN_PASSWORD repo secrets (source of truth: + # logicsrc team vault c0mpute--signing). Only this job sees them. + - name: Sign artifacts and upload .minisig files + env: + GH_TOKEN: ${{ github.token }} + MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }} + MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} + MINISIGN_PUBKEY: RWQqOew3QbWhAWH36TA5roreh18RgRqgozSwhE3yZtdza7eeLKmbDbJM + run: | + set -eu + [ -n "$MINISIGN_SECRET_KEY" ] && [ -n "$MINISIGN_PASSWORD" ] || { echo "MINISIGN_SECRET_KEY / MINISIGN_PASSWORD secrets missing; not publishing unsigned" >&2; exit 1; } + umask 077 + work="$RUNNER_TEMP/sign"; mkdir -p "$work"; cd "$work" + printf '%s\n' "$MINISIGN_SECRET_KEY" > "$RUNNER_TEMP/minisign.key" + trap 'rm -f "$RUNNER_TEMP/minisign.key"' EXIT + want="c0mpute-linux-x86_64.tar.gz c0mpute-linux-aarch64.tar.gz c0mpute-darwin-x86_64.tar.gz c0mpute-darwin-aarch64.tar.gz c0mpute-windows-x86_64.zip" + for a in $want; do + gh release download "$TAG" -p "$a" -p "$a.sha256" --clobber + # Sign only what the platform build uploaded: check its checksum first. + sha256sum -c "$a.sha256" + printf '%s\n' "$MINISIGN_PASSWORD" | minisign -S -s "$RUNNER_TEMP/minisign.key" -m "$a" \ + -t "timestamp:$(date +%s) version:$TAG file:$a" >/dev/null + # Must verify against the committed public key (minisign.pub). + minisign -V -P "$MINISIGN_PUBKEY" -m "$a" + gh release upload "$TAG" "$a.minisig" --clobber + done + - name: Check every asset is on the release, then publish it as Latest env: GH_TOKEN: ${{ github.token }} @@ -150,14 +196,16 @@ jobs: want="c0mpute-linux-x86_64.tar.gz c0mpute-linux-aarch64.tar.gz c0mpute-darwin-x86_64.tar.gz c0mpute-darwin-aarch64.tar.gz c0mpute-windows-x86_64.zip" have=$(gh release view "$TAG" --json assets -q '.assets[].name') for a in $want; do - for f in "$a" "$a.sha256"; do + for f in "$a" "$a.sha256" "$a.minisig"; do echo "$have" | grep -qx "$f" || { echo "missing asset $f on $TAG; not publishing" >&2; exit 1; } done done gh release edit "$TAG" --draft=false --latest # The public download URL must now serve every platform. for a in $want; do - code=$(curl -s -o /dev/null -w '%{http_code}' -L "https://github.com/$GH_REPO/releases/download/$TAG/$a") - echo "$a -> $code" - [ "$code" = "200" ] || { echo "asset $a not downloadable after publish" >&2; exit 1; } + for f in "$a" "$a.minisig"; do + code=$(curl -s -o /dev/null -w '%{http_code}' -L "https://github.com/$GH_REPO/releases/download/$TAG/$f") + echo "$f -> $code" + [ "$code" = "200" ] || { echo "asset $f not downloadable after publish" >&2; exit 1; } + done done diff --git a/Cargo.lock b/Cargo.lock index ed755929..288a1b3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -745,6 +745,7 @@ version = "0.2.88" dependencies = [ "anyhow", "hex", + "minisign-verify", "reqwest", "serde", "serde_json", @@ -2692,6 +2693,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + [[package]] name = "mio" version = "1.2.4" diff --git a/README.md b/README.md index 504f1db3..f0728308 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,27 @@ That installs three CLIs into `~/.c0mpute/bin`: The c0mpute installer pulls each from its own release feed. +### Verified downloads + +Every c0mpute release artifact is signed with [minisign](https://jedisct1.github.io/minisign/). +The public key is [`minisign.pub`](minisign.pub): + +``` +RWQqOew3QbWhAWH36TA5roreh18RgRqgozSwhE3yZtdza7eeLKmbDbJM +``` + +The installer checks `.minisig` before it unpacks anything and aborts if the +signature is missing or does not verify. If `minisign` is not installed it fetches a pinned +minisign release (checked against a hard-coded sha256) into a temp dir; no root needed. +`c0mpute` self-updates verify the same signature, with the key compiled in, before swapping +the binary. To check a download by hand: + +```sh +minisign -Vm c0mpute-linux-x86_64.tar.gz -P RWQqOew3QbWhAWH36TA5roreh18RgRqgozSwhE3yZtdza7eeLKmbDbJM +``` + +See [SECURITY.md](SECURITY.md). + ## What c0mpute does ```sh diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..f0f5215a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,46 @@ +# Security + +## Reporting a vulnerability + +Please report security issues privately through +[GitHub security advisories](https://github.com/profullstack/c0mpute/security/advisories/new) +rather than a public issue. + +## Release signing + +Every artifact on a [c0mpute release](https://github.com/profullstack/c0mpute/releases) +(`c0mpute--.tar.gz`, `c0mpute-windows-x86_64.zip`) has a +[minisign](https://jedisct1.github.io/minisign/) signature next to it, `.minisig`. + +Public key ([`minisign.pub`](minisign.pub), key id `01A1B54137EC392A`): + +``` +RWQqOew3QbWhAWH36TA5roreh18RgRqgozSwhE3yZtdza7eeLKmbDbJM +``` + +The signed (trusted) comment reads `timestamp: version: file:`, so a +signature only vouches for that one file of that one release. + +What checks it: + +- **`scripts/install.sh`** (served at `https://c0mpute.com/install.sh`) downloads the + artifact and its `.minisig`, verifies the signature with the embedded key and checks the + trusted comment names the artifact (and the release tag when it is known). It aborts on a + missing signature, a failed verification or a mismatched comment. If `minisign` is not + installed it downloads the pinned upstream minisign 0.12 release and checks it against a + hard-coded sha256 before running it (Linux x86_64/aarch64, macOS arm64; on Intel macs + install minisign with `brew install minisign`). +- **`c0mpute` self-update** (`node/crates/c0mpute-update`) verifies the same signature with + the key compiled into the binary, after the sha256 check and before swapping the binary. + No signature or a bad one means no update. +- **The release workflow** (`.github/workflows/release.yml`) signs in its `publish` job and + refuses to publish a release that is missing any `.minisig`. + +Verify a download by hand: + +```sh +minisign -Vm c0mpute-linux-x86_64.tar.gz -P RWQqOew3QbWhAWH36TA5roreh18RgRqgozSwhE3yZtdza7eeLKmbDbJM +``` + +The installer script itself is fetched over HTTPS from c0mpute.com; read it before piping it +to `sh` if you prefer (`curl -fsSL https://c0mpute.com/install.sh | less`). diff --git a/minisign.pub b/minisign.pub new file mode 100644 index 00000000..bd157c02 --- /dev/null +++ b/minisign.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key 01A1B54137EC392A +RWQqOew3QbWhAWH36TA5roreh18RgRqgozSwhE3yZtdza7eeLKmbDbJM diff --git a/node/crates/c0mpute-update/Cargo.toml b/node/crates/c0mpute-update/Cargo.toml index a7619e1c..a695112e 100644 --- a/node/crates/c0mpute-update/Cargo.toml +++ b/node/crates/c0mpute-update/Cargo.toml @@ -14,3 +14,4 @@ serde_json = { workspace = true } reqwest = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } +minisign-verify = "0.3" diff --git a/node/crates/c0mpute-update/src/lib.rs b/node/crates/c0mpute-update/src/lib.rs index 5459c6cc..c5d64bfd 100644 --- a/node/crates/c0mpute-update/src/lib.rs +++ b/node/crates/c0mpute-update/src/lib.rs @@ -1,8 +1,8 @@ //! Self-upgrade: poll the release manifest, verify signatures, swap binary. //! -//! Status: scaffold for the upgrade flow. Wire format + version compare -//! are real; the actual binary swap is stubbed (printed) until we have -//! signed releases to swap to. +//! Every update is checked twice before the swap: sha256 (corruption) and a +//! minisign signature against the embedded production key [`RELEASE_PUBKEY`] +//! (authenticity). A missing or invalid signature aborts the update. use std::time::Duration; @@ -32,8 +32,8 @@ pub enum Channel { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Artifact { - pub os: String, // "linux" | "darwin" | "windows" - pub arch: String, // "x86_64" | "aarch64" + pub os: String, // "linux" | "darwin" | "windows" + pub arch: String, // "x86_64" | "aarch64" pub url: String, pub sha256_hex: String, pub minisig_url: String, @@ -187,7 +187,7 @@ fn target_os_arch() -> Result<(&'static str, &'static str)> { /// feed-provided artifact (its `sha256_hex` is served from a different origin /// than the binary, a stronger integrity anchor); otherwise falls back to the /// stable pinned-version URL and its companion `.sha256`. -async fn resolve_artifact(manifest: &ReleaseManifest) -> Result<(String, String, String)> { +async fn resolve_artifact(manifest: &ReleaseManifest) -> Result<(String, String, String, String)> { let (os, arch) = target_os_arch()?; let artifact = format!("c0mpute-{os}-{arch}.tar.gz"); @@ -197,7 +197,17 @@ async fn resolve_artifact(manifest: &ReleaseManifest) -> Result<(String, String, .find(|a| a.os == os && a.arch == arch) { if !a.url.is_empty() && !a.sha256_hex.is_empty() { - return Ok((a.url.clone(), a.sha256_hex.to_lowercase(), artifact)); + let sig_url = if a.minisig_url.is_empty() { + format!("{}.minisig", a.url) + } else { + a.minisig_url.clone() + }; + return Ok(( + a.url.clone(), + a.sha256_hex.to_lowercase(), + sig_url, + artifact, + )); } } @@ -220,7 +230,49 @@ async fn resolve_artifact(manifest: &ReleaseManifest) -> Result<(String, String, .next() .context("empty checksum file")? .to_lowercase(); - Ok((url, expected, artifact)) + let sig_url = format!("{url}.minisig"); + Ok((url, expected, sig_url, artifact)) +} + +/// Production minisign public key for c0mpute release artifacts. Must match +/// `minisign.pub` at the repo root (a unit test enforces it) and the key in +/// scripts/install.sh. Kept as a const rather than `include_str!` because the +/// Docker builds copy only `node/` into the Rust build stage. +pub const RELEASE_PUBKEY: &str = "RWQqOew3QbWhAWH36TA5roreh18RgRqgozSwhE3yZtdza7eeLKmbDbJM"; + +/// Verify a minisign signature (`sig_text`, the `.minisig` file contents) over +/// `bytes` with `pubkey_b64`. The signed trusted comment must name `artifact` +/// (`file:`) and, when given, the release tag (`version:`), so a +/// validly signed file for another platform or release can't be swapped in. +pub fn verify_release_signature( + pubkey_b64: &str, + bytes: &[u8], + sig_text: &str, + artifact: &str, + tag: Option<&str>, +) -> Result<()> { + let pk = minisign_verify::PublicKey::from_base64(pubkey_b64) + .map_err(|e| anyhow::anyhow!("bad release public key: {e}"))?; + let sig = minisign_verify::Signature::decode(sig_text) + .map_err(|e| anyhow::anyhow!("malformed signature for {artifact}: {e}"))?; + pk.verify(bytes, &sig, false) + .map_err(|e| anyhow::anyhow!("signature verification FAILED for {artifact}: {e}"))?; + let comment = sig.trusted_comment(); + let names_file = comment + .split_whitespace() + .any(|w| w == format!("file:{artifact}")); + if !names_file { + anyhow::bail!("signature for {artifact} signs a different file ({comment})"); + } + if let Some(tag) = tag { + if !comment + .split_whitespace() + .any(|w| w == format!("version:{tag}")) + { + anyhow::bail!("signature for {artifact} is not for release {tag} ({comment})"); + } + } + Ok(()) } fn http_client() -> Result { @@ -231,7 +283,7 @@ fn http_client() -> Result { /// Download the artifact, verify its checksum, and swap it into place. async fn perform_upgrade(manifest: &ReleaseManifest) -> Result<()> { - let (url, expected_sha, artifact) = resolve_artifact(manifest).await?; + let (url, expected_sha, sig_url, artifact) = resolve_artifact(manifest).await?; info!(%url, "downloading update"); let bytes = http_client()? @@ -250,9 +302,26 @@ async fn perform_upgrade(manifest: &ReleaseManifest) -> Result<()> { hex::encode(h.finalize()) }; if actual_sha != expected_sha { - anyhow::bail!("checksum mismatch for {artifact}: expected {expected_sha}, got {actual_sha}"); + anyhow::bail!( + "checksum mismatch for {artifact}: expected {expected_sha}, got {actual_sha}" + ); } - info!("checksum verified; swapping binary"); + + // Fail closed: the sha256 above comes from the same origin as the binary, + // so it only catches corruption. The minisign signature is what proves the + // release came from us. No signature or a bad one means no swap. + let sig_text = http_client()? + .get(&sig_url) + .send() + .await + .with_context(|| format!("GET {sig_url}"))? + .error_for_status() + .with_context(|| format!("no signature for {artifact} at {sig_url}; refusing to update"))? + .text() + .await?; + let tag = format!("v{}", manifest.version.trim_start_matches('v')); + verify_release_signature(RELEASE_PUBKEY, &bytes, &sig_text, &artifact, Some(&tag))?; + info!("checksum + signature verified; swapping binary"); // Extraction + filesystem swap is blocking work — keep it off the reactor. let bytes = bytes.to_vec(); @@ -341,7 +410,10 @@ pub async fn poll_loop(current_version: String, release_feed_url: String, interv // Small initial jitter (0–60s) so 1000 nodes don't all hit at once. let jitter = fastish_jitter_secs(); - info!(secs = jitter, "auto-upgrade poll loop starting after jitter"); + info!( + secs = jitter, + "auto-upgrade poll loop starting after jitter" + ); tokio::time::sleep(Duration::from_secs(jitter)).await; let mut ticker = tokio::time::interval(interval); @@ -429,6 +501,71 @@ mod tests { assert!(!should_upgrade("0.2.0", &manifest("0.1.99")).unwrap()); } + // Test keypair (not the production key), generated with minisign 0.12. + const TEST_PUBKEY: &str = "RWSgKx30JhkH5l0N/vDWkkaQiHCDHjU9rB8KsX8Wa5/b6CP9AcF3mXX1"; + const OTHER_PUBKEY: &str = "RWTTP1/dlNYVmhsaFYelmTJhVmvUctWmBqnIOBYeewyBgJf/KFdIzQYE"; + const TEST_PAYLOAD: &[u8] = b"c0mpute test artifact\n"; + const TEST_SIG: &str = "untrusted comment: signature from minisign secret key +RUSgKx30JhkH5uRp7VRolVR4bM+n44NjjP5/duvM3raMiSDyRlUhTyEYAKVmuWzEBxhLEQE8VKRybfYLgQ+53uUy38iJOMrkrwg= +trusted comment: timestamp:1 version:v9.9.9 file:c0mpute-linux-x86_64.tar.gz +Blqf7m00l6vWH+cZyF7MfEWS3yzPB72bD9SD4FKBHNxraBa9P0juzgFm7uK6VFEpq3Fn0wr21tqB4o9lMbP2DA== +"; + const ART: &str = "c0mpute-linux-x86_64.tar.gz"; + + #[test] + fn good_signature_passes() { + verify_release_signature(TEST_PUBKEY, TEST_PAYLOAD, TEST_SIG, ART, Some("v9.9.9")).unwrap(); + verify_release_signature(TEST_PUBKEY, TEST_PAYLOAD, TEST_SIG, ART, None).unwrap(); + } + + #[test] + fn tampered_bytes_fail() { + let err = + verify_release_signature(TEST_PUBKEY, b"c0mpute test artifacT\n", TEST_SIG, ART, None) + .unwrap_err(); + assert!(err.to_string().contains("FAILED"), "{err}"); + } + + #[test] + fn wrong_key_fails() { + assert!(verify_release_signature(OTHER_PUBKEY, TEST_PAYLOAD, TEST_SIG, ART, None).is_err()); + assert!( + verify_release_signature(RELEASE_PUBKEY, TEST_PAYLOAD, TEST_SIG, ART, None).is_err() + ); + } + + #[test] + fn wrong_file_or_release_fails() { + assert!(verify_release_signature( + TEST_PUBKEY, + TEST_PAYLOAD, + TEST_SIG, + "c0mpute-darwin-aarch64.tar.gz", + None + ) + .is_err()); + assert!( + verify_release_signature(TEST_PUBKEY, TEST_PAYLOAD, TEST_SIG, ART, Some("v9.9.8")) + .is_err() + ); + } + + #[test] + fn missing_or_garbage_signature_fails() { + assert!(verify_release_signature(TEST_PUBKEY, TEST_PAYLOAD, "", ART, None).is_err()); + assert!( + verify_release_signature(TEST_PUBKEY, TEST_PAYLOAD, "404", ART, None) + .is_err() + ); + } + + #[test] + fn release_pubkey_matches_repo_minisign_pub() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../minisign.pub"); + let file = std::fs::read_to_string(path).expect("read minisign.pub at repo root"); + assert_eq!(file.lines().nth(1).map(str::trim), Some(RELEASE_PUBKEY)); + } + #[test] fn jitter_in_range() { for _ in 0..50 { diff --git a/scripts/install.sh b/scripts/install.sh index 1b96e6ff..561df54d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -218,6 +218,55 @@ recent_release_tags() { | sed -n 's/^ *"tag_name": *"\([^"]*\)".*/\1/p' | head -n 6 } +# Release signing. Every artifact on a c0mpute release has a minisign +# signature next to it (.minisig). The public key is also committed +# as minisign.pub at the repo root; C0MPUTE_PUBKEY overrides it for testing. +C0MPUTE_PUBKEY="${C0MPUTE_PUBKEY:-RWQqOew3QbWhAWH36TA5roreh18RgRqgozSwhE3yZtdza7eeLKmbDbJM}" +# When minisign isn't installed we fetch this pinned upstream release +# (github.com/jedisct1/minisign, static binaries) into the temp dir and check +# it against these hard-coded sha256s before running it. No root needed. +MINISIGN_PIN_VERSION="0.12" +MINISIGN_PIN_LINUX_SHA256="9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73" +MINISIGN_PIN_MACOS_SHA256="89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63" + +c0mpute_sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1 + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -r "$1" | cut -d' ' -f1 + else return 1 + fi +} + +# Print the path of a minisign binary for platform $1, fetching the pinned +# release into dir $2 when none is installed. Fails when neither works. +c0mpute_minisign() { + if [ -z "${C0MPUTE_FORCE_PINNED_MINISIGN:-}" ] && command -v minisign >/dev/null 2>&1; then + command -v minisign + return 0 + fi + case "$1" in + linux-x86_64) ms_asset="minisign-${MINISIGN_PIN_VERSION}-linux.tar.gz"; ms_want="$MINISIGN_PIN_LINUX_SHA256"; ms_member="minisign-linux/x86_64/minisign" ;; + linux-aarch64) ms_asset="minisign-${MINISIGN_PIN_VERSION}-linux.tar.gz"; ms_want="$MINISIGN_PIN_LINUX_SHA256"; ms_member="minisign-linux/aarch64/minisign" ;; + darwin-aarch64) ms_asset="minisign-${MINISIGN_PIN_VERSION}-macos.zip"; ms_want="$MINISIGN_PIN_MACOS_SHA256"; ms_member="minisign" ;; + *) warn "no pinned minisign build for $1"; return 1 ;; + esac + say "minisign not installed; fetching pinned minisign ${MINISIGN_PIN_VERSION} to verify the download" >&2 + curl -fsSL -o "$2/$ms_asset" "https://github.com/jedisct1/minisign/releases/download/${MINISIGN_PIN_VERSION}/${ms_asset}" 2>/dev/null \ + || { warn "could not download minisign"; return 1; } + ms_got=$(c0mpute_sha256 "$2/$ms_asset") || { warn "no sha256 tool (sha256sum/shasum/openssl) to check minisign"; return 1; } + if [ "$ms_got" != "$ms_want" ]; then + warn "minisign download has sha256 ${ms_got}, expected ${ms_want}" + return 1 + fi + mkdir -p "$2/minisign" + case "$ms_asset" in + *.zip) unzip -q -o "$2/$ms_asset" "$ms_member" -d "$2/minisign" >/dev/null 2>&1 || { warn "could not unzip minisign"; return 1; } ;; + *) tar -xzf "$2/$ms_asset" -C "$2/minisign" "$ms_member" 2>/dev/null || [ -f "$2/minisign/$ms_member" ] || { warn "could not unpack minisign"; return 1; } ;; + esac + chmod +x "$2/minisign/$ms_member" + printf '%s\n' "$2/minisign/$ms_member" +} + install_c0mpute() { platform="$1" target="$C0MPUTE_HOME/bin/c0mpute" @@ -279,16 +328,42 @@ install_c0mpute() { EOF exit 1 fi - curl -fsSL "$sig_url" -o "$tmp/$artifact.minisig" 2>/dev/null \ - || warn "no signature published for c0mpute yet; continuing" - - if command -v minisign >/dev/null 2>&1 && [ -f "$tmp/$artifact.minisig" ]; then + # Fail closed: every release artifact ships a minisign signature + # (.minisig, key in minisign.pub at the repo root). No signature, + # no verifier, a bad signature or a signature for a different file all abort. + sig_code=$(curl -sSL -o "$tmp/$artifact.minisig" -w '%{http_code}' "$sig_url" 2>/dev/null || echo "000") + if [ "$sig_code" != "200" ]; then + if [ "${C0MPUTE_ALLOW_UNSIGNED:-0}" = "1" ]; then + warn "no signature at ${sig_url} (HTTP ${sig_code}); installing UNVERIFIED because C0MPUTE_ALLOW_UNSIGNED=1" + else + rm -rf "$tmp" + die "no signature at ${sig_url} (HTTP ${sig_code}); refusing to install an unverified binary (C0MPUTE_ALLOW_UNSIGNED=1 overrides)" + fi + else say "verifying signature for c0mpute" - C0MPUTE_PUBKEY="${C0MPUTE_PUBKEY:-RWQ_REPLACE_ME_WITH_PROD_MINISIGN_PUBKEY}" - if ! minisign -V -P "$C0MPUTE_PUBKEY" -m "$tmp/$artifact" -x "$tmp/$artifact.minisig" >/dev/null 2>&1; then + if ! ms_bin=$(c0mpute_minisign "$platform" "$tmp"); then rm -rf "$tmp" - die "signature verification failed for c0mpute" + die "cannot verify the download: install minisign (brew install minisign / apt install minisign / https://jedisct1.github.io/minisign/) and re-run" + fi + if ! ms_out=$("$ms_bin" -V -P "$C0MPUTE_PUBKEY" -m "$tmp/$artifact" -x "$tmp/$artifact.minisig" 2>&1); then + rm -rf "$tmp" + die "signature verification FAILED for ${artifact} from ${url}; not installing it" + fi + # The signed (trusted) comment names the file and release, so a validly + # signed artifact for another platform or release can't be swapped in. + ms_tag="" + case "$url" in */latest/*) ;; *) ms_tag="${url%/*}"; ms_tag="${ms_tag##*/}" ;; esac + case "$ms_out" in + *"file:${artifact}"*) ;; + *) rm -rf "$tmp"; die "signature for ${artifact} is valid but signs a different file; not installing it" ;; + esac + if [ -n "$ms_tag" ]; then + case "$ms_out" in + *"version:${ms_tag} "*) ;; + *) rm -rf "$tmp"; die "signature for ${artifact} is valid but not for release ${ms_tag}; not installing it" ;; + esac fi + ok "signature verified (minisign key $(printf '%s' "$C0MPUTE_PUBKEY" | cut -c1-12)…)" fi tar -xzf "$tmp/$artifact" -C "$C0MPUTE_HOME/bin" From 92fd2db5530c8da603064ca56607ff5013681398 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 9 Nov 2026 08:30:34 +0000 Subject: [PATCH 2/2] ci(release): read the signing public key from minisign.pub, not an env literal gitleaks' generic-api-key rule flagged the public key in a workflow env var. The publish job now sparse-checks-out minisign.pub and verifies against it; .gitleaksignore covers the earlier branch commit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 12 ++++++++++-- .gitleaksignore | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26413e03..b4bcaaa5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,6 +147,13 @@ jobs: needs: release runs-on: ubuntu-latest steps: + # Only for minisign.pub, the public key every signature must verify against. + - uses: actions/checkout@v5 + with: + ref: ${{ env.TAG }} + sparse-checkout: minisign.pub + sparse-checkout-cone-mode: false + # minisign from its upstream release, pinned by sha256 (the same pin # scripts/install.sh uses to verify downloads when minisign is missing). - name: Install minisign (pinned) @@ -168,11 +175,12 @@ jobs: GH_TOKEN: ${{ github.token }} MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }} MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} - MINISIGN_PUBKEY: RWQqOew3QbWhAWH36TA5roreh18RgRqgozSwhE3yZtdza7eeLKmbDbJM run: | set -eu [ -n "$MINISIGN_SECRET_KEY" ] && [ -n "$MINISIGN_PASSWORD" ] || { echo "MINISIGN_SECRET_KEY / MINISIGN_PASSWORD secrets missing; not publishing unsigned" >&2; exit 1; } umask 077 + pub="$GITHUB_WORKSPACE/minisign.pub" + [ -s "$pub" ] || { echo "minisign.pub missing at $TAG; not publishing" >&2; exit 1; } work="$RUNNER_TEMP/sign"; mkdir -p "$work"; cd "$work" printf '%s\n' "$MINISIGN_SECRET_KEY" > "$RUNNER_TEMP/minisign.key" trap 'rm -f "$RUNNER_TEMP/minisign.key"' EXIT @@ -184,7 +192,7 @@ jobs: printf '%s\n' "$MINISIGN_PASSWORD" | minisign -S -s "$RUNNER_TEMP/minisign.key" -m "$a" \ -t "timestamp:$(date +%s) version:$TAG file:$a" >/dev/null # Must verify against the committed public key (minisign.pub). - minisign -V -P "$MINISIGN_PUBKEY" -m "$a" + minisign -V -p "$pub" -m "$a" gh release upload "$TAG" "$a.minisig" --clobber done diff --git a/.gitleaksignore b/.gitleaksignore index ba038159..04aab7cd 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -34,3 +34,9 @@ c429b7a6b38238f67f348c2323c1fbff41348a5e:node/crates/c0mpute-core/src/webhook.rs # Public ed25519 test vector (docs/protocol/identity.md, seed 0x77) used as a test DID in an earlier commit of PR #107. 7b92c1c6edb562f3ecb746eab039be5b5f1f2135:apps/web/src/lib/chat/chat.test.ts:generic-api-key:17 + +# The c0mpute release minisign PUBLIC key (minisign.pub), which an earlier +# commit of PR #186 put in a release.yml env var. Public by design; it is also +# in minisign.pub, scripts/install.sh and c0mpute-update. Covers the +# pre-squash commit on branch `fix/release-signing`. +7fb45f20b3a3f28fa802e813cfdceda3ae236d1a:.github/workflows/release.yml:generic-api-key:171