diff --git a/core/archipelago/src/ceremony.rs b/core/archipelago/src/ceremony.rs index 1036cc4c..3460233a 100644 --- a/core/archipelago/src/ceremony.rs +++ b/core/archipelago/src/ceremony.rs @@ -19,6 +19,11 @@ //! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert //! `signature` + `signed_by` over the canonical form, matching exactly //! what `trust::verify_detached` recomputes on every node. +//! +//! archipelago ceremony verify +//! Verify a signed JSON document against the compiled-in release-root +//! anchor. Exits non-zero unless the signature verifies AND the signer +//! is the pinned anchor. Needs no mnemonic — used as the publish gate. //! ``` use anyhow::{bail, Context, Result}; @@ -47,9 +52,15 @@ pub fn run() -> Result<()> { .context("usage: archipelago ceremony sign ")?; cmd_sign(&file) } + "verify" => { + let file = std::env::args() + .nth(3) + .context("usage: archipelago ceremony verify ")?; + cmd_verify(&file) + } other => { bail!( - "unknown ceremony subcommand {:?}; expected gen | pubkey | sign ", + "unknown ceremony subcommand {:?}; expected gen | pubkey | sign | verify ", other ) } @@ -107,6 +118,33 @@ fn cmd_sign(path: &str) -> Result<()> { Ok(()) } +fn cmd_verify(path: &str) -> Result<()> { + let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?; + let value: serde_json::Value = + serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?; + match signed_doc::verify_detached(&value)? { + signed_doc::SignatureStatus::Verified { + signer_did, + anchored: true, + } => { + eprintln!("✓ {path} verified — signed by the pinned release root"); + eprintln!(" signed_by: {signer_did}"); + Ok(()) + } + signed_doc::SignatureStatus::Verified { + signer_did, + anchored: false, + } => { + // Only reachable if no anchor is compiled in/overridden — the + // signature is self-consistent but proves nothing about identity. + bail!("{path} signed by {signer_did}, but no release-root anchor is pinned to compare against") + } + signed_doc::SignatureStatus::Unsigned => { + bail!("{path} is NOT signed (no `signature` field)") + } + } +} + /// Derive the release-root signing key from the mnemonic in env/stdin. fn load_release_root_key() -> Result { let phrase = read_mnemonic()?; diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 762ccdad..2eda9c1a 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -338,6 +338,26 @@ fn rewrite_manifest_origins(manifest: &mut UpdateManifest, manifest_url: &str) { } } +/// Parse a fetched manifest body, verifying its detached release-root +/// signature if one is present. Returns the manifest plus whether it was +/// signed by the pinned release-root anchor. +/// +/// * Present-but-invalid signature (tampered payload, wrong signer, bad +/// hex…) → `Err`; the caller treats the mirror as failed. This is the +/// teeth: a MITM can strip the signature but can never forge one. +/// * Unsigned → accepted with `signed == false` during the migration +/// window; the scheduler refuses to auto-apply such manifests and the +/// state surfaces `manifest_signed` to the UI. +fn parse_and_verify_manifest(raw: serde_json::Value) -> Result<(UpdateManifest, bool)> { + let signed = match crate::trust::verify_detached(&raw).context("manifest signature")? { + crate::trust::SignatureStatus::Verified { anchored, .. } => anchored, + crate::trust::SignatureStatus::Unsigned => false, + }; + let manifest: UpdateManifest = + serde_json::from_value(raw).context("parse update manifest")?; + Ok((manifest, signed)) +} + /// Which manifest URL to try FIRST — operator override via env wins, /// otherwise the first entry in the mirrors list, otherwise the hard /// default. Callers that need the full mirror walk should use @@ -394,6 +414,14 @@ pub struct UpdateState { /// their node actually hit (vs. just which is configured primary). #[serde(default)] pub manifest_mirror: Option, + /// True when `available_update` came from a manifest whose detached + /// Ed25519 signature verified against the pinned release-root anchor + /// (`trust::verify_detached`). Unsigned manifests are still offered for + /// MANUAL apply during the signing-migration window, but the scheduler + /// refuses to auto-apply them — otherwise a mirror/MITM could push an + /// arbitrary root binary to the fleet unattended. + #[serde(default)] + pub manifest_signed: bool, } impl Default for UpdateState { @@ -406,6 +434,7 @@ impl Default for UpdateState { rollback_available: false, schedule: UpdateSchedule::DailyCheck, manifest_mirror: None, + manifest_signed: false, } } } @@ -621,6 +650,7 @@ pub async fn load_state(data_dir: &Path) -> Result { // if there's genuinely something newer. state.available_update = None; state.manifest_mirror = None; + state.manifest_signed = false; changed = true; } @@ -710,19 +740,33 @@ pub async fn check_for_updates(data_dir: &Path) -> Result { tokio::time::sleep(std::time::Duration::from_secs(2)).await; } match client.get(manifest_url).send().await { - Ok(resp) if resp.status().is_success() => match resp.json::().await + Ok(resp) if resp.status().is_success() => match resp + .json::() + .await + .map_err(anyhow::Error::from) + .and_then(parse_and_verify_manifest) { - Ok(mut manifest) => { + Ok((mut manifest, signed)) => { rewrite_manifest_origins(&mut manifest, manifest_url); if is_newer(&manifest.version, &state.current_version) { + if !signed { + warn!( + available = %manifest.version, + mirror = %manifest_url, + "Update manifest is NOT signed by the release root — \ + offering for manual apply only; auto-apply is refused" + ); + } info!( current = %state.current_version, available = %manifest.version, mirror = %manifest_url, + signed, "Update available" ); state.available_update = Some(manifest); state.manifest_mirror = Some(manifest_url.clone()); + state.manifest_signed = signed; } else { // Manifest version matches us or is behind // us — either we're current, or this mirror @@ -737,6 +781,7 @@ pub async fn check_for_updates(data_dir: &Path) -> Result { ); state.manifest_mirror = None; state.available_update = None; + state.manifest_signed = false; handled = true; continue 'mirrors; } @@ -1846,6 +1891,23 @@ pub async fn run_update_scheduler( info!("Update scheduler: 3 AM auto-apply window"); match check_for_updates(&data_dir).await { Ok(s) if s.available_update.is_some() => { + if !s.manifest_signed { + // Unattended apply of an unauthenticated manifest is + // the §A supply-chain hole: a mirror/MITM could ship + // an arbitrary root binary fleet-wide at 3 AM. Manual + // apply from the UI remains possible during the + // signing-migration window; auto-apply does not. + warn!( + available = %s + .available_update + .as_ref() + .map(|m| m.version.as_str()) + .unwrap_or("?"), + "Update scheduler: manifest is not signed by the \ + release root — refusing unattended auto-apply" + ); + continue; + } info!("Update scheduler: downloading update"); if let Err(e) = download_update(&data_dir).await { debug!("Update scheduler: download failed: {}", e); @@ -1884,6 +1946,68 @@ pub async fn run_update_scheduler( mod tests { use super::*; + fn sample_manifest_value() -> serde_json::Value { + serde_json::json!({ + "version": "9.9.9", + "release_date": "2026-07-02", + "changelog": ["test release"], + "components": [{ + "name": "archipelago", + "current_version": "0.0.0", + "new_version": "9.9.9", + "download_url": "http://example.invalid/archipelago", + "sha256": "00", + "size_bytes": 1 + }] + }) + } + + /// Same key + env-pin convention as `trust::signed_doc::tests` — every + /// caller pins the identical value, so parallel tests stay consistent. + fn test_release_key() -> ed25519_dalek::SigningKey { + let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + std::env::set_var( + "ARCHY_RELEASE_ROOT_PUBKEY", + hex::encode(key.verifying_key().to_bytes()), + ); + key + } + + fn sign_value(key: &ed25519_dalek::SigningKey, mut doc: serde_json::Value) -> serde_json::Value { + let (sig, did) = crate::trust::signed_doc::sign_detached(key, &doc).unwrap(); + let obj = doc.as_object_mut().unwrap(); + obj.insert("signed_by".into(), serde_json::json!(did)); + obj.insert("signature".into(), serde_json::json!(sig)); + doc + } + + #[test] + fn unsigned_manifest_parses_but_reports_unsigned() { + let (manifest, signed) = parse_and_verify_manifest(sample_manifest_value()).unwrap(); + assert_eq!(manifest.version, "9.9.9"); + assert!(!signed, "unsigned manifest must not report as signed"); + } + + #[test] + fn anchor_signed_manifest_reports_signed() { + let key = test_release_key(); + let doc = sign_value(&key, sample_manifest_value()); + let (manifest, signed) = parse_and_verify_manifest(doc).unwrap(); + assert_eq!(manifest.version, "9.9.9"); + assert!(signed); + } + + #[test] + fn tampered_signed_manifest_is_rejected() { + let key = test_release_key(); + let mut doc = sign_value(&key, sample_manifest_value()); + // Version swap after signing — exactly what a malicious mirror would do. + doc.as_object_mut() + .unwrap() + .insert("version".into(), serde_json::json!("99.0.0")); + assert!(parse_and_verify_manifest(doc).is_err()); + } + #[test] fn test_update_schedule_default_is_daily_check() { let schedule = UpdateSchedule::default(); @@ -2043,6 +2167,7 @@ mod tests { rollback_available: true, schedule: UpdateSchedule::AutoApply, manifest_mirror: None, + manifest_signed: false, }; let json = serde_json::to_string(&state).unwrap(); let deserialized: UpdateState = serde_json::from_str(&json).unwrap(); @@ -2170,6 +2295,7 @@ mod tests { "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json" .to_string(), ), + manifest_signed: false, }; save_state(dir.path(), &state).await.unwrap(); let loaded = load_state(dir.path()).await.unwrap(); diff --git a/docs/1.8.0-RELEASE-HARDENING-PLAN.md b/docs/1.8.0-RELEASE-HARDENING-PLAN.md index 51fb4ac8..cd154ce6 100644 --- a/docs/1.8.0-RELEASE-HARDENING-PLAN.md +++ b/docs/1.8.0-RELEASE-HARDENING-PLAN.md @@ -48,12 +48,16 @@ arbitrary app catalog to the entire fleet — fully unattended under pinned-anchor binary must actually be built + shipped for enforcement to be live on nodes; (b) flip "accept unsigned" → "reject unsigned" only after the whole fleet is on the pinned binary (`container/app_catalog.rs:397`, the `Unsigned` arm) — see the next item. -- [ ] 🔴 **Enforce a signature on the OTA manifest before trusting it.** - `update.rs:68` fetches `http://146.59.87.168:3000/.../manifest.json` over cleartext - and parses/trusts it with no `trust::verify_detached` call; component sha256/blake3 - are only checked against that same unauthenticated manifest → remote root RCE. - Move to HTTPS + pinned cert, require an Ed25519 release-root signature, and - **refuse `auto_apply` until the anchor is pinned.** +- [~] 🔴 **Enforce a signature on the OTA manifest before trusting it.** Signature + verification LANDED 2026-07-02: `check_for_updates` now fetches raw JSON and runs + `trust::verify_detached` — a present-but-invalid/wrong-signer signature hard-rejects + the mirror; unsigned manifests are offered for MANUAL apply only (`manifest_signed` + surfaced in `UpdateState`) and **auto-apply refuses them**. Publisher side: + `create-release.sh` signs the manifest inline (ceremony), `publish-release-assets.sh` + hard-refuses to ship unsigned (grep + `ceremony verify` crypto gate), and + `scripts/sign-manifest.sh` exists for re-signs. **Still open:** move the mirror + to HTTPS + pinned cert (tracked with the next item); flip unsigned-manual-apply → + hard-reject once the fleet is on a pinned-anchor binary. - [ ] 🔴 **Implement container image signature verification (cosign).** `container/src/podman_client.rs:255` — `pull_image(.., _signature)` silently discards the signature that the manifest threads all the way down diff --git a/scripts/create-release.sh b/scripts/create-release.sh index 16472da0..dad07e0b 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -177,9 +177,28 @@ fi echo "[6/8] Creating release manifest..." mkdir -p "$PROJECT_ROOT/releases" "$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$" + +# §A supply-chain: the OTA manifest must carry the release-root signature. +# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh +# hard-refuses to ship one. The mnemonic is read interactively (or from +# RELEASE_MASTER_MNEMONIC) — it must never land in files or shell history. +SIGNER="$PROJECT_ROOT/core/target/release/archipelago" +if [ ! -x "$SIGNER" ]; then + echo "Error: release binary not found at $SIGNER — cannot sign manifest" >&2 + exit 1 +fi +if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then + echo "[6b/8] Signing release manifest (paste the release master mnemonic when prompted)..." + "$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json" + "$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" +else + echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED." + echo " Sign it before publishing: bash scripts/sign-manifest.sh" + echo " (publish-release-assets.sh refuses to ship an unsigned manifest)" +fi cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json" -echo "[6b/8] Staging release artifacts for validation..." +echo "[6c/8] Staging release artifacts for validation..." VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}" FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz" mkdir -p "$VERSION_DIR" diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index cd227515..1527ca9a 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -25,6 +25,19 @@ fail() { echo "Error: $*" >&2; exit 1; } "$SCRIPT_DIR/check-release-manifest.sh" +# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes +# with the pinned release-root anchor refuse to auto-apply unsigned manifests, +# and enforcement will tighten to hard-reject — an unsigned publish would +# strand them. Grep proves presence; ceremony verify proves the crypto. +EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur" +grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \ + && grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \ + || fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh" +if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then + "$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \ + || fail "manifest signature failed cryptographic verification" +fi + remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE") case "$remote_url" in http://*@*) ;; diff --git a/scripts/sign-manifest.sh b/scripts/sign-manifest.sh new file mode 100755 index 00000000..7bb0f989 --- /dev/null +++ b/scripts/sign-manifest.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# One-step OTA-manifest signer (counterpart to sign-catalog.sh). +# +# Run: bash scripts/sign-manifest.sh +# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D. +# +# Signs releases/manifest.json in place and cryptographically verifies the +# result against the pinned release-root anchor. The mnemonic is read from the +# terminal only (never stored, never in shell history, never passed to Claude). +# +# Normally create-release.sh signs the manifest inline; this script exists for +# re-signing (e.g. a manifest edited after creation) or signing on a box where +# the release run was non-interactive. +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MANIFEST="$REPO/releases/manifest.json" + +# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in +# the earlier catalog ceremony). Prefer the repo's release build. +BIN="" +for candidate in "$REPO/core/target/release/archipelago" /tmp/archy-sign-bin/release/archipelago; do + if [[ -x "$candidate" ]]; then BIN="$candidate"; break; fi +done +if [[ -z "$BIN" ]]; then + echo "⏳ No prebuilt signer found. Build one first:" + echo " (cd core && cargo build --release -p archipelago)" + echo " Nothing was changed." + exit 0 +fi + +echo "════════════════════════════════════════════════════════════════" +echo " Paste your 24-word release master mnemonic below, press Enter," +echo " then press Ctrl-D on a new line." +echo "════════════════════════════════════════════════════════════════" +"$BIN" ceremony sign "$MANIFEST" + +echo +if "$BIN" ceremony verify "$MANIFEST"; then + echo "✅ SUCCESS — manifest signed by the pinned release root." + echo " Commit + push releases/manifest.json (and release-manifest.json if present)." + cp "$MANIFEST" "$REPO/release-manifest.json" 2>/dev/null || true +else + echo "❌ Signature did NOT verify against the pinned release-root anchor." + echo " Do NOT commit. Check the mnemonic and re-run." + exit 1 +fi