feat(trust): verify release-root signature on the OTA manifest

check_for_updates now fetches the manifest as raw JSON and runs
trust::verify_detached before parsing: a tampered or wrong-signer
signature rejects the mirror outright, and unsigned manifests are
offered for MANUAL apply only — the 3 AM auto-apply scheduler refuses
them, closing the unattended remote-root hole (§A of the 1.8.0
hardening plan). UpdateState gains manifest_signed so the UI can
surface authenticity.

Publisher side: create-release.sh signs the manifest during the
release (ceremony, mnemonic via TTY/env only), publish-release-assets
hard-refuses to ship an unsigned manifest (grep + new 'ceremony
verify' cryptographic gate), and scripts/sign-manifest.sh covers
re-signing outside a release run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-02 12:33:01 -04:00
parent 1977bdefb5
commit 51647b21cd
6 changed files with 257 additions and 10 deletions

View File

@ -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 <file.json>
//! 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 <file.json>")?;
cmd_sign(&file)
}
"verify" => {
let file = std::env::args()
.nth(3)
.context("usage: archipelago ceremony verify <file.json>")?;
cmd_verify(&file)
}
other => {
bail!(
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file>",
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file> | verify <file>",
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<SigningKey> {
let phrase = read_mnemonic()?;

View File

@ -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<String>,
/// 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<UpdateState> {
// 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<UpdateState> {
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::<UpdateManifest>().await
Ok(resp) if resp.status().is_success() => match resp
.json::<serde_json::Value>()
.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<UpdateState> {
);
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();

View File

@ -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

View File

@ -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"

View File

@ -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://*@*) ;;

47
scripts/sign-manifest.sh Executable file
View File

@ -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