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:
parent
1977bdefb5
commit
51647b21cd
@ -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()?;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
47
scripts/sign-manifest.sh
Executable 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
|
||||
Loading…
x
Reference in New Issue
Block a user