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:
co-authored by
Claude Fable 5
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();
|
||||
|
||||
Reference in New Issue
Block a user