diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e575d2..b65029b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -135,8 +135,13 @@ jobs: name: Create GitHub Release needs: [build-release] runs-on: ubuntu-latest + # Least privilege, scoped to this job only: + # contents: write -> create the GitHub Release and upload assets + # id-token: write -> mint the GitHub OIDC token cosign exchanges for a + # short-lived Sigstore (Fulcio) signing certificate (#591) permissions: contents: write + id-token: write steps: - uses: actions/checkout@v6 @@ -156,6 +161,36 @@ jobs: sha256sum * > checksums-sha256.txt cat checksums-sha256.txt + # Keyless signing (#591). Ordering matters: sign BEFORE creating the + # release. If signing fails the job stops here and no release is + # published, so we never ship a release that lacks its signature. + # One signature over the checksums file covers every archive, because + # install.sh / `cora upgrade` verify each archive against that file. + # sigstore/cosign-installer v3.10.1, pinned by commit SHA. + - name: Install cosign + uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 + with: + cosign-release: 'v2.6.5' + + - name: Sign checksums with cosign (keyless) + working-directory: release-artifacts + run: | + cosign sign-blob --yes \ + --bundle checksums-sha256.txt.sigstore.json \ + checksums-sha256.txt + test -s checksums-sha256.txt.sigstore.json + + # Fail the job (before publishing) if the bundle does not verify against + # this exact workflow identity. + - name: Verify the fresh signature + working-directory: release-artifacts + run: | + cosign verify-blob \ + --bundle checksums-sha256.txt.sigstore.json \ + --certificate-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@${GITHUB_REF}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums-sha256.txt + - name: Extract version from tag id: version run: | diff --git a/AGENT.md b/AGENT.md index b548e50..6edaa60 100644 --- a/AGENT.md +++ b/AGENT.md @@ -404,6 +404,7 @@ After release completes, verify: - [ ] GitHub Release page shows vX.Y.Z with correct changelog - [ ] 4 platform binaries attached to release - [ ] SHA256 checksums file included +- [ ] `checksums-sha256.txt.sigstore.json` asset exists and verifies (`cosign verify-blob` command in `docs/installation.md#verify-release-signatures`); `CORA_REQUIRE_SIGNATURE=1 CORA_VERSION=vX.Y.Z sh install.sh` succeeds - [ ] `crates.io` shows new version: `cargo search cora-code` - [ ] `codecora.dev` reflects new docs - [ ] Marketplace action still works (test on a test PR) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab4579..4b39161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **Release checksums are signed with cosign keyless and verified by `install.sh` and `cora upgrade` (#591).** `release.yml` now signs `checksums-sha256.txt` (which covers every archive) via Sigstore/GitHub OIDC before creating the release and uploads `checksums-sha256.txt.sigstore.json`; a signing failure aborts the job so no unsigned release is published. `install.sh` and `cora upgrade` verify the bundle with `cosign verify-blob` (identity: this repo's `release.yml` on a `v*` tag, issuer `token.actions.githubusercontent.com`) when `cosign` is on `PATH`; an invalid signature aborts. Without `cosign`, or for releases without a bundle, they print a notice and fall back to checksum-only. `CORA_REQUIRE_SIGNATURE=1` makes a missing `cosign` or bundle fatal. See `docs/installation.md`. + ### Fixed - **`rules_engine.max_findings` is applied after suppression and truncation is reported (#624).** The cap (default 5) used to be applied inside each scanner, before `ignore.rules` / `cora-ignore`, so suppressed findings consumed slots and pushed real findings out, and the rest were dropped silently. Scanners now run uncapped and the cap is enforced once in post-processing on deterministic findings only (LLM issues are never capped), highest severity first. `cora scan` and `cora review` print `⚠ N more deterministic findings not shown (...)` on stderr when findings are cut, and the `cora scan` summary mentions it; stdout formats are unchanged. `max_findings: 0` or `null` now means unlimited (previously `0` hid every finding). diff --git a/README.md b/README.md index 63b0d9e..ad4cbd4 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ cargo install --git https://github.com/codecoradev/cora-code > Pin a version: `CORA_VERSION=v0.6.1 curl -fsSL ... | sh` -**Upgrading:** run `cora upgrade` (downloads the latest release, verifies its SHA-256 checksum, replaces the binary) or `cora upgrade --check` to just see if one is available. If you installed via `cargo install --path .`, re-run that instead. +**Upgrading:** run `cora upgrade` (downloads the latest release, verifies its SHA-256 checksum and, when `cosign` is installed, its Sigstore signature, replaces the binary) or `cora upgrade --check` to just see if one is available. If you installed via `cargo install --path .`, re-run that instead. **Verify which `cora` you're running** — `which -a cora` will reveal stale copies from other channels: diff --git a/SECURITY.md b/SECURITY.md index 0a447df..196addf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,6 +45,15 @@ Security-related areas of the codebase: - `src/hook/` — Pre-commit hook integration - `src/index/` — File access and SQLite storage +## Release Artifact Signing + +Release checksums are signed with cosign keyless (Sigstore, GitHub OIDC) by the +`release.yml` workflow, and `install.sh` / `cora upgrade` verify the signature +when `cosign` is installed (`CORA_REQUIRE_SIGNATURE=1` makes it mandatory). +Manual verification, the exact `cosign verify-blob` flags, and the scope and +limits of this guarantee are documented in +[docs/installation.md](docs/installation.md#verify-release-signatures). + ## Threat Model: Adversarial Source-Code Comments (ALIBI) LLM-based reviewers are vulnerable to adversarial comments in the code under diff --git a/docs/installation.md b/docs/installation.md index cd07858..bbc0b28 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -88,6 +88,55 @@ $ echo 'source ~/.cora/completion.zsh' >> ~/.zshrc $ cora completion fish > ~/.config/fish/completions/cora.fish ``` +## Verify Release Signatures + +Every release (from the first signed release onward) publishes +`checksums-sha256.txt.sigstore.json`, a [Sigstore](https://www.sigstore.dev/) +bundle produced by the release workflow with **cosign keyless signing** (GitHub +OIDC, no long-lived key). One signature over `checksums-sha256.txt` covers every +archive, because each archive is verified against that file. + +Verify manually (requires [cosign](https://docs.sigstore.dev/cosign/system_config/installation/)): + +```bash +TAG=vX.Y.Z +BASE=https://github.com/codecoradev/cora-code/releases/download/$TAG +curl -fsSLO $BASE/checksums-sha256.txt +curl -fsSLO $BASE/checksums-sha256.txt.sigstore.json +cosign verify-blob \ + --bundle checksums-sha256.txt.sigstore.json \ + --certificate-identity-regexp '^https://github.com/codecoradev/cora-code/\.github/workflows/release\.yml@refs/tags/v' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums-sha256.txt +sha256sum -c --ignore-missing checksums-sha256.txt # shasum -a 256 -c on macOS +``` + +`install.sh` and `cora upgrade` perform the same check automatically when +`cosign` is on your `PATH`: + +| Situation | Default | `CORA_REQUIRE_SIGNATURE=1` | +|-----------|---------|----------------------------| +| Bundle present, `cosign` installed, signature valid | install | install | +| Bundle present, `cosign` installed, signature invalid | **abort** | **abort** | +| Bundle present, `cosign` not installed | install, prints a "checksum-only" notice | **abort** | +| No bundle published (release predates signing) | install, prints a notice | **abort** | +| Bundle download fails (not a 404) | **abort** | **abort** | + +`CORA_REQUIRE_SIGNATURE=1` also conflicts with `CORA_SKIP_CHECKSUM=1` +(`CORA_UPGRADE_SKIP_CHECKSUM` for `cora upgrade`): skipping the checksum file +would silently skip the signature, so the combination is rejected. + +**What this covers:** the checksums file (and therefore the archives) was +produced by this repository's `release.yml` workflow on a `v*` tag, and is +recorded in the Sigstore transparency log. A compromised release page or CDN +cannot forge it without also compromising the workflow. + +**What it does not cover:** a compromised repository or workflow itself, +releases published before signing was introduced, and installs where `cosign` +is absent and `CORA_REQUIRE_SIGNATURE` is not set (those fall back to +checksum-only, which protects against corruption but not a tampered release). +`cargo install` is verified by crates.io, not by this mechanism. + ## Verify Installation Confirm cora is installed correctly: diff --git a/install.sh b/install.sh index c83ac67..e7b6542 100755 --- a/install.sh +++ b/install.sh @@ -89,6 +89,63 @@ get_target() { esac } +# Keyless cosign (Sigstore) identity that must have signed the release (#591). +COSIGN_IDENTITY_REGEXP="^https://github.com/${REPO}/\\.github/workflows/release\\.yml@refs/tags/v" +COSIGN_OIDC_ISSUER="https://token.actions.githubusercontent.com" + +# Is CORA_REQUIRE_SIGNATURE on? Sets REQUIRE_SIG=1|0. Unknown values are an +# error rather than a silent "off" so a requested verification is never lost. +parse_require_signature() { + case "${CORA_REQUIRE_SIGNATURE:-}" in + ""|0|false) REQUIRE_SIG=0;; + 1|true) REQUIRE_SIG=1;; + *) error "CORA_REQUIRE_SIGNATURE must be 1 or 0 (got '${CORA_REQUIRE_SIGNATURE}')";; + esac +} + +# Verify the cosign keyless signature over the checksums file. +# Policy (see docs/installation.md): +# bundle present + cosign present -> verify; failure is fatal (fail closed) +# cosign missing -> notice (checksum-only); fatal if REQUIRE_SIG=1 +# bundle absent (HTTP 404) -> notice (older release); fatal if REQUIRE_SIG=1 +# any other bundle download error -> fatal +verify_signature() { + BUNDLE_URL="${CHECKSUMS_URL}.sigstore.json" + BUNDLE_FILE="${CHECKSUM_FILE}.sigstore.json" + + HTTP_CODE=$(curl -sSL --connect-timeout 10 --max-time 30 --max-filesize 1048576 \ + -w '%{http_code}' "$BUNDLE_URL" -o "$BUNDLE_FILE") || HTTP_CODE="000" + case "$HTTP_CODE" in + 200) ;; + 404) + if [ "$REQUIRE_SIG" = "1" ]; then + error "No signature bundle published for ${VERSION} but CORA_REQUIRE_SIGNATURE=1. Refusing to install." + fi + warn "No cosign signature published for ${VERSION}; verified by SHA-256 checksum only." + return 0 + ;; + *) error "Failed to download signature bundle (HTTP ${HTTP_CODE}). Refusing to install (set CORA_SKIP_CHECKSUM=1 to bypass all verification, unsafe).";; + esac + + if ! command -v cosign >/dev/null 2>&1; then + if [ "$REQUIRE_SIG" = "1" ]; then + error "cosign not found but CORA_REQUIRE_SIGNATURE=1. Install cosign: https://docs.sigstore.dev/cosign/system_config/installation/" + fi + warn "Signature NOT verified (checksum-only): cosign not found. Install it to verify: https://docs.sigstore.dev/cosign/system_config/installation/" + return 0 + fi + + info "Verifying cosign signature..." + if ! cosign verify-blob \ + --bundle "$BUNDLE_FILE" \ + --certificate-identity-regexp "$COSIGN_IDENTITY_REGEXP" \ + --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \ + "$CHECKSUM_FILE" >/dev/null 2>&1; then + error "cosign signature verification FAILED for checksums of ${VERSION}. Refusing to install." + fi + info "Signature verified (cosign keyless, GitHub Actions release workflow)" +} + # Download and install install() { info "Detected: $OS $ARCH" @@ -111,13 +168,18 @@ install() { # Verify SHA256 checksum (prevents MITM / corrupted download). # Mandatory: a missing checksums file or entry is fatal unless the user # explicitly opts out with CORA_SKIP_CHECKSUM=1. + parse_require_signature if [ "${CORA_SKIP_CHECKSUM:-}" = "1" ]; then + if [ "$REQUIRE_SIG" = "1" ]; then + error "CORA_SKIP_CHECKSUM=1 conflicts with CORA_REQUIRE_SIGNATURE=1 (the signature covers the checksums file). Unset one." + fi warn "CORA_SKIP_CHECKSUM=1 set - checksum verification DISABLED. The binary is NOT verified." else info "Downloading checksums..." if ! curl -fsSL --connect-timeout 10 --max-time 30 --max-filesize 1048576 "$CHECKSUMS_URL" -o "$CHECKSUM_FILE"; then error "Failed to download checksums. Refusing to install an unverified binary (set CORA_SKIP_CHECKSUM=1 to override, unsafe)." fi + verify_signature info "Verifying SHA256 checksum..." # Exact filename match (optionally prefixed with '*' or './'). EXPECTED=$(awk -v n="$ARCHIVE_NAME" '{f=$2; sub(/^\*/, "", f); sub(/^\.\//, "", f); if (f == n) {print $1; exit}}' "$CHECKSUM_FILE") diff --git a/scripts/test_install_signature.sh b/scripts/test_install_signature.sh new file mode 100644 index 0000000..760267c --- /dev/null +++ b/scripts/test_install_signature.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# Decision-matrix test for install.sh verify_signature() (#591). +# No network: uses a stub curl and a stub cosign. Usage: sh scripts/test_install_signature.sh +set -u +ROOT=$(cd "$(dirname "$0")/.." && pwd) +T=$(mktemp -d) +trap 'rm -rf "$T"' EXIT +mkdir "$T/with" "$T/without" + +cat > "$T/with/curl" <<'STUB' +#!/bin/sh +out=""; w="" +while [ $# -gt 0 ]; do case "$1" in -o) out="$2"; shift;; -w) w="$2"; shift;; esac; shift; done +[ "$FAKE_CODE" = "ERR" ] && exit 28 +[ "$FAKE_CODE" = "200" ] && echo bundle > "$out" +[ -n "$w" ] && printf '%s' "$FAKE_CODE" +exit 0 +STUB +cat > "$T/with/cosign" <<'STUB' +#!/bin/sh +echo "$@" > "$COSIGN_ARGS" +exit "${FAKE_COSIGN_RC:-0}" +STUB +chmod +x "$T/with/curl" "$T/with/cosign" +cp "$T/with/curl" "$T/without/curl" +# install.sh without its trailing `main` call +sed '$d' "$ROOT/install.sh" > "$T/lib.sh" + +FAIL=0 +# check +check() { + name=$1; code=$2; has=$3; crc=$4; req=$5; want=$6; sub=$7 + if [ "$has" = 1 ]; then P="$T/with"; else P="$T/without"; fi + out=$( ( + FAKE_CODE=$code FAKE_COSIGN_RC=$crc CORA_REQUIRE_SIGNATURE=$req COSIGN_ARGS="$T/args" + export FAKE_CODE FAKE_COSIGN_RC CORA_REQUIRE_SIGNATURE COSIGN_ARGS + PATH="$P:/usr/bin:/bin" + # shellcheck disable=SC1091 + . "$T/lib.sh" + VERSION=v9.9.9 + CHECKSUMS_URL=http://x/checksums-sha256.txt + CHECKSUM_FILE="$T/checksums-sha256.txt" + : > "$CHECKSUM_FILE" + parse_require_signature + verify_signature + ) 2>&1 ) + rc=$? + case "$out" in *"$sub"*) okout=1;; *) okout=0;; esac + if [ "$rc" = "$want" ] && [ "$okout" = 1 ]; then + echo "PASS $name" + else + echo "FAIL $name (rc=$rc want=$want) $out" + FAIL=1 + fi +} + +check "verified" 200 1 0 "" 0 "Signature verified" +check "cosign rejects -> fatal" 200 1 1 "" 1 "FAILED" +check "cosign missing -> notice" 200 0 0 "" 0 "NOT verified" +check "cosign missing + REQUIRE" 200 0 0 1 1 "cosign not found" +check "no bundle -> notice" 404 1 0 "" 0 "checksum only" +check "no bundle + REQUIRE" 404 1 0 1 1 "CORA_REQUIRE_SIGNATURE=1" +check "HTTP 500 -> fatal" 500 1 0 "" 1 "Failed to download signature" +check "network error -> fatal" ERR 1 0 "" 1 "Failed to download signature" +check "invalid env -> fatal" 200 1 0 yes 1 "must be 1 or 0" + +# Re-run the success case so $T/args holds the verified invocation, then check flags. +check "verified (args)" 200 1 0 "" 0 "Signature verified" +if grep -q -- "--certificate-oidc-issuer https://token.actions.githubusercontent.com" "$T/args" \ + && grep -q -- 'release\\.yml@refs/tags/v' "$T/args" \ + && grep -q -- "--bundle" "$T/args"; then + echo "PASS cosign args" +else + echo "FAIL cosign args: $(cat "$T/args")" + FAIL=1 +fi + +exit "$FAIL" diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index 4694ff8..9a530b4 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -22,6 +22,8 @@ const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024; const MAX_SMALL_BYTES: u64 = 1024 * 1024; /// Maximum accepted size for the extracted binary. const MAX_BINARY_BYTES: u64 = 512 * 1024 * 1024; +/// OIDC issuer that must appear in the signing certificate (GitHub Actions). +const COSIGN_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com"; const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); @@ -150,6 +152,22 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { } }; + let require_sig = + match parse_require_signature(std::env::var("CORA_REQUIRE_SIGNATURE").ok().as_deref()) { + Ok(v) => v, + Err(msg) => { + eprintln!("{} {msg}", "[ERROR]".red()); + return Ok(1); + } + }; + if skip_checksum && require_sig { + eprintln!( + "{} CORA_UPGRADE_SKIP_CHECKSUM conflicts with CORA_REQUIRE_SIGNATURE=1 (the signature covers the checksums file). Unset one.", + "[ERROR]".red() + ); + return Ok(1); + } + if skip_checksum { eprintln!( "{} !!! CHECKSUM VERIFICATION DISABLED (CORA_UPGRADE_SKIP_CHECKSUM + CORA_UPGRADE_I_UNDERSTAND) !!!", @@ -160,8 +178,8 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { "[WARN]".yellow().bold() ); } else { - let checksums_text = match download_async(&checksums_url, MAX_SMALL_BYTES).await { - Ok(b) => String::from_utf8_lossy(&b).to_string(), + let checksums_bytes = match download_async(&checksums_url, MAX_SMALL_BYTES).await { + Ok(b) => b, Err(e) => { eprintln!("{} Failed to download checksums: {e}", "[ERROR]".red()); eprintln!( @@ -171,6 +189,35 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { } }; + let checksums_text = String::from_utf8_lossy(&checksums_bytes).to_string(); + + // Keyless cosign signature over the checksums file (#591). + let checksums_path = temp_dir.join("checksums-sha256.txt"); + fs::write(&checksums_path, &checksums_bytes) + .map_err(|e| anyhow::anyhow!("Failed to write checksums: {e}"))?; + let bundle_state = + match download_optional_async(&bundle_url(&latest_version), MAX_SMALL_BYTES).await { + Ok(Some(b)) => BundleState::Present(b), + Ok(None) => BundleState::Absent, + Err(e) => BundleState::Error(e.to_string()), + }; + match verify_signature_policy( + require_sig, + bundle_state, + &SystemCosign, + &temp_dir, + &checksums_path, + ) { + Ok(SigOutcome::Verified) => { + println!("{} Signature verified (cosign keyless)", "[INFO]".green()) + } + Ok(SigOutcome::Unverified(note)) => eprintln!("{} {note}", "[WARN]".yellow()), + Err(msg) => { + eprintln!("{} {msg}", "[ERROR]".red()); + return Ok(1); + } + } + let expected = match parse_checksum(&checksums_text, &archive_name) { Some(h) => h, None => { @@ -272,12 +319,22 @@ fn http_client(follow_redirects: bool) -> anyhow::Result { /// Download a URL with timeouts and a hard cap on the body size. async fn download_async(url: &str, max_bytes: u64) -> anyhow::Result> { + download_optional_async(url, max_bytes) + .await? + .ok_or_else(|| anyhow::anyhow!("HTTP 404 Not Found")) +} + +/// Like [`download_async`] but maps HTTP 404 to `Ok(None)`. +async fn download_optional_async(url: &str, max_bytes: u64) -> anyhow::Result>> { let mut resp = http_client(true)? .get(url) .send() .await .map_err(|e| anyhow::anyhow!("HTTP request failed: {e}"))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } if !resp.status().is_success() { anyhow::bail!("HTTP {}", resp.status()); } @@ -298,7 +355,130 @@ async fn download_async(url: &str, max_bytes: u64) -> anyhow::Result> { } buf.extend_from_slice(&chunk); } - Ok(buf) + Ok(Some(buf)) +} + +/// URL of the cosign bundle published next to the checksums file. +fn bundle_url(tag: &str) -> String { + format!("https://github.com/{REPO}/releases/download/{tag}/checksums-sha256.txt.sigstore.json") +} + +/// Regexp the signing certificate identity must match: the release workflow of +/// this repository, run from a `v*` tag. (`.` is escaped so only the real file matches.) +fn cosign_identity_regexp() -> String { + format!("^https://github.com/{REPO}/\\.github/workflows/release\\.yml@refs/tags/v") +} + +/// Parse `CORA_REQUIRE_SIGNATURE`. Unknown values are an error, never a silent "off". +fn parse_require_signature(v: Option<&str>) -> Result { + match v { + None | Some("") | Some("0") | Some("false") => Ok(false), + Some("1") | Some("true") => Ok(true), + Some(o) => Err(format!("CORA_REQUIRE_SIGNATURE must be 1 or 0 (got '{o}')")), + } +} + +/// Result of fetching the signature bundle. +enum BundleState { + Present(Vec), + /// HTTP 404: release predates signing. + Absent, + Error(String), +} + +#[derive(Debug, PartialEq, Eq)] +enum SigOutcome { + Verified, + /// Proceeding on checksum-only; the string is the notice to show. + Unverified(String), +} + +/// Seam over the external `cosign` binary so the policy is unit-testable. +trait CosignRunner { + fn available(&self) -> bool; + fn verify(&self, bundle: &std::path::Path, blob: &std::path::Path) -> Result<(), String>; +} + +struct SystemCosign; + +impl CosignRunner for SystemCosign { + fn available(&self) -> bool { + std::process::Command::new("cosign") + .arg("version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok() + } + + fn verify(&self, bundle: &std::path::Path, blob: &std::path::Path) -> Result<(), String> { + let out = std::process::Command::new("cosign") + .arg("verify-blob") + .arg("--bundle") + .arg(bundle) + .arg("--certificate-identity-regexp") + .arg(cosign_identity_regexp()) + .arg("--certificate-oidc-issuer") + .arg(COSIGN_OIDC_ISSUER) + .arg(blob) + .output() + .map_err(|e| format!("failed to run cosign: {e}"))?; + if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } + } +} + +const COSIGN_INSTALL_HINT: &str = "https://docs.sigstore.dev/cosign/system_config/installation/"; + +/// Signature policy (same as install.sh): +/// bundle + cosign => verify, failure fatal; cosign missing or bundle absent => +/// notice, fatal when `require`; any other bundle fetch error => fatal. +fn verify_signature_policy( + require: bool, + bundle: BundleState, + runner: &dyn CosignRunner, + dir: &std::path::Path, + checksums_path: &std::path::Path, +) -> Result { + let bytes = match bundle { + BundleState::Present(b) => b, + BundleState::Absent if require => { + return Err( + "No signature bundle published for this release but CORA_REQUIRE_SIGNATURE=1. Refusing to upgrade." + .into(), + ) + } + BundleState::Absent => { + return Ok(SigOutcome::Unverified( + "No cosign signature published for this release; verified by SHA-256 checksum only." + .into(), + )) + } + BundleState::Error(e) => { + return Err(format!( + "Failed to download signature bundle: {e}. Refusing to upgrade." + )) + } + }; + if !runner.available() { + if require { + return Err(format!( + "cosign not found but CORA_REQUIRE_SIGNATURE=1. Install cosign: {COSIGN_INSTALL_HINT}" + )); + } + return Ok(SigOutcome::Unverified(format!( + "Signature NOT verified (checksum-only): cosign not found. Install it to verify: {COSIGN_INSTALL_HINT}" + ))); + } + let bundle_path = dir.join("checksums-sha256.txt.sigstore.json"); + fs::write(&bundle_path, bytes).map_err(|e| format!("Failed to write bundle: {e}"))?; + runner + .verify(&bundle_path, checksums_path) + .map(|()| SigOutcome::Verified) + .map_err(|e| format!("cosign signature verification FAILED: {e}. Refusing to upgrade.")) } /// Decide whether checksum verification may be skipped. @@ -606,4 +786,106 @@ mod tests { let a = write_archive(d.path(), &tgz); assert!(extract_binary(&a, out.path()).is_err()); } + + struct FakeCosign { + available: bool, + ok: bool, + calls: std::cell::Cell, + } + + impl CosignRunner for FakeCosign { + fn available(&self) -> bool { + self.available + } + fn verify(&self, bundle: &std::path::Path, _blob: &std::path::Path) -> Result<(), String> { + self.calls.set(self.calls.get() + 1); + assert!(bundle.exists()); + if self.ok { + Ok(()) + } else { + Err("bad sig".into()) + } + } + } + + fn policy( + require: bool, + b: BundleState, + available: bool, + ok: bool, + ) -> (Result, u32) { + let d = tempfile::tempdir().unwrap(); + let blob = d.path().join("checksums-sha256.txt"); + fs::write(&blob, b"x").unwrap(); + let f = FakeCosign { + available, + ok, + calls: std::cell::Cell::new(0), + }; + let r = verify_signature_policy(require, b, &f, d.path(), &blob); + (r, f.calls.get()) + } + + #[test] + fn signature_policy_matrix() { + let present = || BundleState::Present(b"{}".to_vec()); + assert_eq!( + policy(false, present(), true, true), + (Ok(SigOutcome::Verified), 1) + ); + assert_eq!( + policy(true, present(), true, true), + (Ok(SigOutcome::Verified), 1) + ); + // cosign rejects => fatal regardless of require + for req in [false, true] { + let (r, calls) = policy(req, present(), true, false); + assert!(r.unwrap_err().contains("FAILED")); + assert_eq!(calls, 1); + } + // cosign missing + let (r, calls) = policy(false, present(), false, true); + assert!(matches!(r, Ok(SigOutcome::Unverified(ref m)) if m.contains("cosign not found"))); + assert_eq!(calls, 0); + assert!(policy(true, present(), false, true).0.is_err()); + // bundle absent (older release) + assert!(matches!( + policy(false, BundleState::Absent, true, true).0, + Ok(SigOutcome::Unverified(_)) + )); + assert!(policy(true, BundleState::Absent, true, true).0.is_err()); + // other fetch errors are always fatal + for req in [false, true] { + assert!( + policy(req, BundleState::Error("HTTP 500".into()), true, true) + .0 + .is_err() + ); + } + } + + #[test] + fn require_signature_parsing() { + assert_eq!(parse_require_signature(None), Ok(false)); + assert_eq!(parse_require_signature(Some("0")), Ok(false)); + assert_eq!(parse_require_signature(Some("1")), Ok(true)); + assert_eq!(parse_require_signature(Some("true")), Ok(true)); + assert!(parse_require_signature(Some("yes")).is_err()); + } + + #[test] + fn signature_constants() { + assert_eq!( + bundle_url("v1.2.3"), + "https://github.com/codecoradev/cora-code/releases/download/v1.2.3/checksums-sha256.txt.sigstore.json" + ); + assert_eq!( + cosign_identity_regexp(), + "^https://github.com/codecoradev/cora-code/\\.github/workflows/release\\.yml@refs/tags/v" + ); + assert_eq!( + COSIGN_OIDC_ISSUER, + "https://token.actions.githubusercontent.com" + ); + } }