diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9879a98b0..fa2a92fad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,8 @@ jobs: run: bash tools/ci/no-conflict-markers.sh - name: copied sources are re-stamped before a build run: bash tools/ci/copied-sources-check.sh + - name: no script pipes the OTA signer into grep -q under pipefail (self-test first, then the tree) + run: bash tools/ci/pipe-grep-q-check.sh --self-test && bash tools/ci/pipe-grep-q-check.sh - name: pinned guest programs match their manifest and are built only by pin-guests.sh run: bash tools/ci/pinned-guests-check.sh - name: no secret file names and no 64-hex secrets in the tree (self-test first, then the tree) diff --git a/app/igneum-app/src/bin/ota-sign.rs b/app/igneum-app/src/bin/ota-sign.rs index 32c26df46..4b5d3d45a 100644 --- a/app/igneum-app/src/bin/ota-sign.rs +++ b/app/igneum-app/src/bin/ota-sign.rs @@ -3,8 +3,11 @@ //! //! igneum-ota-sign keygen 32-byte seed as hex (0600) and the public key as hex //! igneum-ota-sign sign prints the detached signature (128 hex) -//! igneum-ota-sign verify exit 0 when it verifies and parses -//! igneum-ota-sign embedded prints the public key compiled into the app and its fingerprint +//! igneum-ota-sign verify exit 0 when it verifies and parses +//! igneum-ota-sign embedded prints every public key compiled into the app (K1, then K2 once +//! it is set: docs/security/keys.md section 4), each followed by +//! its fingerprint line; line 1 and 2 are K1, as before +//! `embedded` as a key argument below means all of those keys, as the apps verify //! igneum-ota-sign fingerprint //! igneum-ota-sign sha256 the file's sha256 and size, for the manifest //! igneum-ota-sign sign-jobs the remote-jobs file (src/jobs.rs), same key @@ -36,6 +39,24 @@ fn read_key_arg(a: &str) -> String { } } +/// A key argument: `embedded` is every key compiled into the app; anything else is one key file or hex. +fn read_keys_arg(a: &str) -> Vec { + if a == "embedded" { + manifest::embedded_keys().iter().map(|k| k.to_string()).collect() + } else { + vec![read_key_arg(a)] + } +} + +fn refs(keys: &[String]) -> Vec<&str> { + keys.iter().map(|k| k.as_str()).collect() +} + +/// "sha256:<8 hex>" of the key that verified, for the ok lines. +fn by(k: &str) -> String { + format!("sha256:{}", manifest::fingerprint8(k)) +} + fn die(msg: &str) -> ! { eprintln!("igneum-ota-sign: {msg}"); std::process::exit(2) @@ -75,17 +96,24 @@ fn main() { println!("{}", manifest::hex_encode(&sk.sign(&bytes).to_bytes())); } Some("verify") if args.len() == 4 => { - let pk = read_key_arg(&args[1]); + let keys = read_keys_arg(&args[1]); let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2]))); let sig = std::fs::read_to_string(&args[3]).unwrap_or_else(|e| die(&format!("{}: {e}", args[3]))); - match manifest::verify_and_parse(&bytes, sig.trim(), &pk) { - Ok(m) => println!("ok: version {} ({}), mac {}, windows {}", m.version, m.channel, m.mac.map(|e| e.url).unwrap_or_else(|| "none".into()), m.windows.map(|e| e.url).unwrap_or_else(|| "none".into())), + let signer = manifest::verify_signature(&bytes, sig.trim(), &refs(&keys)).unwrap_or_else(|e| die(&e)); + match manifest::verify_and_parse(&bytes, sig.trim(), &refs(&keys)) { + Ok(m) => { + println!("ok: version {} ({}), mac {}, windows {}", m.version, m.channel, m.mac.map(|e| e.url).unwrap_or_else(|| "none".into()), m.windows.map(|e| e.url).unwrap_or_else(|| "none".into())); + println!("signed by {}{}", by(signer), if m.revoked_keys.is_empty() { String::new() } else { format!("; revokes {}", m.revoked_keys.iter().map(|f| format!("sha256:{}", &f[..8])).collect::>().join(", ")) }); + } Err(e) => die(&e), } } Some("embedded") if args.len() == 1 => { - println!("{}", manifest::OTA_PUBLIC_KEY_HEX); - println!("fingerprint sha256:{}", manifest::fingerprint(manifest::OTA_PUBLIC_KEY_HEX)); + // K1 on lines 1 and 2 (the publish scripts read `head -1` and `sed -n 2p`), then K2 when it is set + for k in manifest::embedded_keys() { + println!("{k}"); + println!("fingerprint sha256:{}", manifest::fingerprint(k)); + } } Some("fingerprint") if args.len() == 2 => { let pk = read_key_arg(&args[1]); @@ -112,10 +140,10 @@ fn main() { println!("{}", manifest::hex_encode(&sk.sign(&bytes).to_bytes())); } Some("verify-jobs") if args.len() == 4 => { - let pk = read_key_arg(&args[1]); + let keys = read_keys_arg(&args[1]); let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2]))); let sig = std::fs::read_to_string(&args[3]).unwrap_or_else(|e| die(&format!("{}: {e}", args[3]))); - match jobs::verify_and_parse(&bytes, sig.trim(), &pk) { + match jobs::verify_and_parse(&bytes, sig.trim(), &refs(&keys)) { Ok(f) => { println!("ok: {} job(s), published {}", f.jobs.len(), f.published_at); for j in &f.jobs { @@ -126,18 +154,18 @@ fn main() { } } Some("envelope-jobs") if args.len() == 4 => { - let pk = read_key_arg(&args[1]); + let keys = read_keys_arg(&args[1]); let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2]))); let sig = std::fs::read_to_string(&args[3]).unwrap_or_else(|e| die(&format!("{}: {e}", args[3]))); - jobs::verify_and_parse(&bytes, sig.trim(), &pk).unwrap_or_else(|e| die(&format!("refusing to wrap: {e}"))); - let env = jobs::signed_envelope(&bytes, sig.trim(), &pk).unwrap_or_else(|e| die(&e)); - jobs::verify_and_parse_signed(env.as_bytes(), &pk).unwrap_or_else(|e| die(&format!("the envelope does not read back: {e}"))); + jobs::verify_and_parse(&bytes, sig.trim(), &refs(&keys)).unwrap_or_else(|e| die(&format!("refusing to wrap: {e}"))); + let env = jobs::signed_envelope(&bytes, sig.trim(), &refs(&keys)).unwrap_or_else(|e| die(&e)); + jobs::verify_and_parse_signed(env.as_bytes(), &refs(&keys)).unwrap_or_else(|e| die(&format!("the envelope does not read back: {e}"))); println!("{env}"); } Some("verify-signed-jobs") if args.len() == 3 => { - let pk = read_key_arg(&args[1]); + let keys = read_keys_arg(&args[1]); let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2]))); - match jobs::verify_and_parse_signed(&bytes, &pk) { + match jobs::verify_and_parse_signed(&bytes, &refs(&keys)) { Ok(f) => println!("ok: {} job(s), published {}, file and signature in one object", f.jobs.len(), f.published_at), Err(e) => die(&e), } @@ -160,13 +188,14 @@ fn main() { println!("{}", manifest::hex_encode(&sk.sign(&bytes).to_bytes())); } Some("verify-inputs") if args.len() >= 4 => { - let pk = if args[1] == "embedded" { manifest::OTA_PUBLIC_KEY_HEX.to_string() } else { read_key_arg(&args[1]) }; + let keys = read_keys_arg(&args[1]); let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2]))); let sig_text = std::fs::read_to_string(&args[3]).unwrap_or_else(|e| die(&format!("{}: {e}", args[3]))); let sig = inputs::read_signature(&sig_text).unwrap_or_else(|e| die(&e)); - let m = inputs::verify_and_parse(&bytes, &sig, &pk).unwrap_or_else(|e| die(&format!("inputs signature: {e}"))); + let signer = manifest::verify_signature(&bytes, &sig, &refs(&keys)).unwrap_or_else(|e| die(&format!("inputs signature: {e}"))); + let m = inputs::verify_and_parse(&bytes, &sig, &refs(&keys)).unwrap_or_else(|e| die(&format!("inputs signature: {e}"))); let mut i = 4; - let mut checked: Vec = vec![format!("signature by {}", manifest::fingerprint(&pk))]; + let mut checked: Vec = vec![format!("signature by {}", manifest::fingerprint(signer))]; while i < args.len() { match (args[i].as_str(), args.get(i + 1)) { ("--zip", Some(z)) => { @@ -189,7 +218,7 @@ fn main() { println!("ok: inputs built {} from node commit {} ({}); checked: {}", m.built_at, m.node_source_commit, m.node_source_branch, checked.join(", ")); } _ => { - eprintln!("usage: igneum-ota-sign keygen | sign | verify | embedded | fingerprint | sha256 | sign-jobs | verify-jobs | envelope-jobs | verify-signed-jobs | sign-inputs | verify-inputs [--zip z] [--dir d] [--node-commit c]"); + eprintln!("usage: igneum-ota-sign keygen | sign | verify | embedded | fingerprint | sha256 | sign-jobs | verify-jobs | envelope-jobs | verify-signed-jobs | sign-inputs | verify-inputs [--zip z] [--dir d] [--node-commit c] ( = a key file or hex; embedded = every key compiled into the app)"); std::process::exit(2); } } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 850754324..165ef4188 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -596,6 +596,8 @@ impl Engine { // which intake and which downloads folder this build reports to and checks (fingerprints, never the values; // rotation phase 2 reads this line from every machine's upload: docs/plans/rotation-phase-2.md) self.shared.log(&self.shared.packaged.describe()); + // which signing keys this build trusts and which a manifest revoked (fingerprints, never the values) + self.shared.log(&self.ota.describe_keys()); // the prover service (proving v0): its own thread, idle until the setting is on crate::prover::start(self.shared.clone(), self.bins.dir.clone()); self.shared.log(&format!("node binary: {} ({v})", self.bins.node.display())); diff --git a/app/igneum-app/src/inputs.rs b/app/igneum-app/src/inputs.rs index 08ada0899..bc882be90 100644 --- a/app/igneum-app/src/inputs.rs +++ b/app/igneum-app/src/inputs.rs @@ -8,7 +8,7 @@ //! //! Now `packaging/windows/push-inputs.sh` writes `payload-inputs.json` (this format), signs it on the Mac with the //! OTA key (`igneum-ota-sign sign-inputs`) and uploads the signature beside it. The workflow verifies the signature -//! with the public key compiled into the app (`manifest::OTA_PUBLIC_KEY_HEX`) before it builds anything, checks +//! with the public keys compiled into the app (`manifest::OTA_PUBLIC_KEYS`) before it builds anything, checks //! the zip's sha256 and every unpacked file against the manifest, and checks the pinned node source commit //! against `packaging/windows/node-source.pin` in the commit it builds. `fetch-ci-artifacts.sh` refuses to sign an //! update manifest unless the run's verified inputs manifest re-verifies on the Mac. @@ -102,9 +102,9 @@ pub fn parse(text: &str) -> Result { Ok(m) } -/// Verifies the detached signature over the exact bytes, then parses. -pub fn verify_and_parse(bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result { - verify_signature(bytes, sig_hex, pub_hex)?; +/// Verifies the detached signature over the exact bytes with any of the keys, then parses. +pub fn verify_and_parse(bytes: &[u8], sig_hex: &str, pub_keys: &[&str]) -> Result { + verify_signature(bytes, sig_hex, pub_keys)?; let text = std::str::from_utf8(bytes).map_err(|_| "inputs manifest is not UTF-8")?; parse(text) } @@ -231,18 +231,23 @@ mod tests { let sig = hex_encode(&sk.sign(&bytes).to_bytes()); assert_eq!(read_signature(&format!("{sig}\n")).unwrap(), sig); assert!(read_signature("abc").is_err()); - let m = verify_and_parse(&bytes, &sig, &pk).unwrap(); + let m = verify_and_parse(&bytes, &sig, &[pk.as_str()]).unwrap(); assert_eq!(m.node_source_commit, COMMIT); // one byte changed anywhere: the signature no longer verifies let mut tampered = bytes.clone(); let i = tampered.iter().position(|b| *b == b'1').unwrap(); tampered[i] = b'2'; - assert!(verify_and_parse(&tampered, &sig, &pk).is_err()); + assert!(verify_and_parse(&tampered, &sig, &[pk.as_str()]).is_err()); // a different key: refused let other = hex_encode(SigningKey::from_bytes(&[9u8; 32]).verifying_key().as_bytes()); - assert!(verify_and_parse(&bytes, &sig, &other).is_err()); - // the embedded OTA key refuses a signature from this test key - assert!(verify_and_parse(&bytes, &sig, crate::manifest::OTA_PUBLIC_KEY_HEX).is_err()); + assert!(verify_and_parse(&bytes, &sig, &[other.as_str()]).is_err()); + // the embedded OTA keys refuse a signature from this test key + assert!(verify_and_parse(&bytes, &sig, crate::manifest::OTA_PUBLIC_KEYS).is_err()); + // a second key in the slice verifies, as the app will with K2 (docs/security/keys.md section 4) + let (sk2, pk2) = (SigningKey::from_bytes(&[8u8; 32]), hex_encode(SigningKey::from_bytes(&[8u8; 32]).verifying_key().as_bytes())); + let sig2 = hex_encode(&sk2.sign(&bytes).to_bytes()); + assert!(verify_and_parse(&bytes, &sig2, &[pk.as_str(), pk2.as_str()]).is_ok()); + assert!(verify_and_parse(&bytes, &sig2, &[pk.as_str()]).is_err()); } #[test] diff --git a/app/igneum-app/src/jobrun.rs b/app/igneum-app/src/jobrun.rs index bc833a21e..829711793 100644 --- a/app/igneum-app/src/jobrun.rs +++ b/app/igneum-app/src/jobrun.rs @@ -128,6 +128,8 @@ pub struct Jobs { active: Option, needs_logged: std::collections::HashSet, fingerprint: String, + /// /updates/revoked.json: the keys a signed manifest revoked (manifest.rs); read at every fetch + revoked_path: PathBuf, wake: Arc, /// a wake arrived while a fetch was in flight: fetch again as soon as it ends wake_pending: bool, @@ -153,6 +155,8 @@ impl Jobs { let first = env("IGNEUM_APP_JOBS_FIRST_SECS").and_then(|v| v.parse().ok()).unwrap_or(40 + jitter % 20); let allowed = shared.settings.lock().unwrap().remote_jobs; let data_root = crate::platform::data_root(); + // the revocation record the updater keeps (ota.rs); the jobs file is verified with the same trusted keys + let revoked_path = app_dir.join("updates").join(manifest::REVOKED_FILE); let j = Jobs { url, allowed, @@ -165,7 +169,8 @@ impl Jobs { queue: Vec::new(), active: None, needs_logged: std::collections::HashSet::new(), - fingerprint: manifest::fingerprint(manifest::OTA_PUBLIC_KEY_HEX), + fingerprint: key_fingerprints(&revoked_path), + revoked_path, wake: Arc::new(WakeCtl { on: AtomicBool::new(allowed) }), wake_pending: false, last_published: String::new(), @@ -310,11 +315,12 @@ impl Jobs { self.busy = true; let url = self.url.clone(); let dir = self.dir.clone(); + let revoked_path = self.revoked_path.clone(); let seen: std::collections::HashSet = self.ledger.records.keys().cloned().collect(); let machine_id = shared.runtime.machine_id.clone(); let shared2 = shared.clone(); std::thread::spawn(move || { - let r = fetch_jobs(&url, &dir).map(|(f, skipped)| { + let r = fetch_jobs(&url, &dir, &revoked_path).map(|(f, skipped)| { for id in &skipped { shared2.log(&format!("job {id}: its kind is unknown to this version ({}); skipped, it waits for an app update", crate::engine::VERSION)); } @@ -786,7 +792,9 @@ fn curl(args: &[&str], limit: Duration) -> Result<(), String> { /// fetched `igneum-jobs.json` and then `.sig` while a deploy was landing on the edge and refused the pair). When /// the folder has no envelope (a publisher before 0.3.9), the pair is fetched as before. `Cache-Control: no-cache` /// asks the edge for the current object, as the Mac's own verify does. -fn fetch_jobs(url: &str, dir: &Path) -> Result<(jobs::JobsFile, Vec), String> { +fn fetch_jobs(url: &str, dir: &Path, revoked_path: &Path) -> Result<(jobs::JobsFile, Vec), String> { + let revoked = manifest::load_revoked(revoked_path); + let trusted = manifest::trusted_keys(manifest::OTA_PUBLIC_KEYS, &revoked); let jf = dir.join("jobs.json.new"); let sf = dir.join("jobs.json.sig.new"); let ef = dir.join("jobs.signed.json.new"); @@ -797,7 +805,7 @@ fn fetch_jobs(url: &str, dir: &Path) -> Result<(jobs::JobsFile, Vec), St let (f, skipped) = match curl(&["-fsSL", "--max-time", "20", "-H", "Cache-Control: no-cache", "-o", &ef.display().to_string(), &signed_url], Duration::from_secs(25)) { Ok(()) => { let bytes = std::fs::read(&ef).map_err(|e| e.to_string())?; - let (f, skipped, inner) = jobs::verify_and_parse_signed_lenient(&bytes, manifest::OTA_PUBLIC_KEY_HEX)?; + let (f, skipped, inner) = jobs::verify_and_parse_signed_lenient(&bytes, &trusted)?; let _ = std::fs::write(&jf, &inner); let _ = std::fs::rename(&ef, dir.join("jobs.signed.json")); (f, skipped) @@ -808,7 +816,7 @@ fn fetch_jobs(url: &str, dir: &Path) -> Result<(jobs::JobsFile, Vec), St curl(&["-fsSL", "--max-time", "20", "-H", "Cache-Control: no-cache", "-o", &sf.display().to_string(), &format!("{url}.sig")], Duration::from_secs(25)).map_err(|e| format!("jobs signature: {e}"))?; let bytes = std::fs::read(&jf).map_err(|e| e.to_string())?; let sig = std::fs::read_to_string(&sf).map_err(|e| e.to_string())?; - let r = jobs::verify_and_parse_lenient(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEY_HEX)?; + let r = jobs::verify_and_parse_lenient(&bytes, sig.trim(), &trusted)?; let _ = std::fs::rename(&sf, dir.join("jobs.json.sig")); r } @@ -818,6 +826,13 @@ fn fetch_jobs(url: &str, dir: &Path) -> Result<(jobs::JobsFile, Vec), St Ok((f, skipped)) } +/// The fingerprints of the keys this app trusts right now, for the dashboard ("signing key sha256:..."): every +/// embedded key minus the revoked ones, joined with ", ". +fn key_fingerprints(revoked_path: &Path) -> String { + let revoked = manifest::load_revoked(revoked_path); + manifest::trusted_keys(manifest::OTA_PUBLIC_KEYS, &revoked).iter().map(|k| manifest::fingerprint(k)).collect::>().join(", ") +} + /// What the toolchain probe runs inside the distro: the cargo and sp1 paths set by hand (a login shell from a /// process without a console need not source ~/.cargo/env), then the three things the prover needs. const PROVER_PROBE: &str = "export PATH=\"$HOME/.cargo/bin:$HOME/.sp1/bin:$PATH\"; [ -f \"$HOME/.cargo/env\" ] && . \"$HOME/.cargo/env\"; command -v cargo && ls -d \"$HOME/.sp1\" \"$HOME/igneum-prove\" && echo \"user $(id -un) home $HOME\""; diff --git a/app/igneum-app/src/jobs.rs b/app/igneum-app/src/jobs.rs index e54805bf9..e38f4242e 100644 --- a/app/igneum-app/src/jobs.rs +++ b/app/igneum-app/src/jobs.rs @@ -154,8 +154,8 @@ pub fn signed_jobs_url(jobs_url: &str) -> String { /// The envelope text for a jobs file and its detached signature: what publish-jobs.sh writes next to the pair /// (through `igneum-ota-sign envelope-jobs`). The signature is checked here, so a pair that does not belong /// together is never wrapped. Keys in sorted order, no whitespace, as the jobs file itself. -pub fn signed_envelope(file: &[u8], sig_hex: &str, pub_hex: &str) -> Result { - manifest::verify_signature(file, sig_hex.trim(), pub_hex).map_err(|_| "jobs file signature does not verify; not wrapping it".to_string())?; +pub fn signed_envelope(file: &[u8], sig_hex: &str, pub_keys: &[&str]) -> Result { + manifest::verify_signature(file, sig_hex.trim(), pub_keys).map_err(|_| "jobs file signature does not verify; not wrapping it".to_string())?; let text = std::str::from_utf8(file).map_err(|_| "jobs file is not UTF-8")?; Ok(format!("{{\"file\":{},\"format\":\"{}\",\"sig\":\"{}\"}}", Value::String(text.to_string()), JOBS_SIGNED_FORMAT, sig_hex.trim())) } @@ -178,16 +178,16 @@ pub fn open_envelope(bytes: &[u8]) -> Result<(Vec, String), String> { } /// The signer's check of an envelope: the inner file and signature verify and parse (strict). -pub fn verify_and_parse_signed(bytes: &[u8], pub_hex: &str) -> Result { +pub fn verify_and_parse_signed(bytes: &[u8], pub_keys: &[&str]) -> Result { let (file, sig) = open_envelope(bytes)?; - verify_and_parse(&file, &sig, pub_hex) + verify_and_parse(&file, &sig, pub_keys) } /// The runner's check of an envelope: as `verify_and_parse_lenient` over the inner pair. Also returns the inner /// file bytes, which the runner keeps on disk as jobs.json for the dashboard. -pub fn verify_and_parse_signed_lenient(bytes: &[u8], pub_hex: &str) -> Result<(JobsFile, Vec, Vec), String> { +pub fn verify_and_parse_signed_lenient(bytes: &[u8], pub_keys: &[&str]) -> Result<(JobsFile, Vec, Vec), String> { let (file, sig) = open_envelope(bytes)?; - let (f, skipped) = verify_and_parse_lenient(&file, &sig, pub_hex)?; + let (f, skipped) = verify_and_parse_lenient(&file, &sig, pub_keys)?; Ok((f, skipped, file)) } @@ -481,16 +481,17 @@ pub fn validate_params(job: &Job) -> Result<(), String> { Ok(()) } -/// Verifies the detached signature over the exact bytes, then parses (strict: the signer's check). -pub fn verify_and_parse(bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result { - manifest::verify_signature(bytes, sig_hex, pub_hex).map_err(|_| "jobs file signature does not verify".to_string())?; +/// Verifies the detached signature over the exact bytes with any of the keys, then parses (strict: the signer's +/// check). The app passes `manifest::trusted_keys` (the embedded keys minus the revoked ones, jobrun.rs). +pub fn verify_and_parse(bytes: &[u8], sig_hex: &str, pub_keys: &[&str]) -> Result { + manifest::verify_signature(bytes, sig_hex, pub_keys).map_err(|_| "jobs file signature does not verify".to_string())?; let text = std::str::from_utf8(bytes).map_err(|_| "jobs file is not UTF-8")?; parse(text) } /// The runner's variant: the same signature check, unknown kinds skipped (their ids come back). -pub fn verify_and_parse_lenient(bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<(JobsFile, Vec), String> { - manifest::verify_signature(bytes, sig_hex, pub_hex).map_err(|_| "jobs file signature does not verify".to_string())?; +pub fn verify_and_parse_lenient(bytes: &[u8], sig_hex: &str, pub_keys: &[&str]) -> Result<(JobsFile, Vec), String> { + manifest::verify_signature(bytes, sig_hex, pub_keys).map_err(|_| "jobs file signature does not verify".to_string())?; let text = std::str::from_utf8(bytes).map_err(|_| "jobs file is not UTF-8")?; parse_lenient(text) } @@ -869,9 +870,9 @@ mod tests { assert_eq!(skipped, vec!["future-1".to_string()]); // the signature still has to verify, and a bad job of a known kind still rejects the file let sig = manifest::hex_encode(&sk.sign(text.as_bytes()).to_bytes()); - let (f2, s2) = verify_and_parse_lenient(text.as_bytes(), &sig, &pk).unwrap(); + let (f2, s2) = verify_and_parse_lenient(text.as_bytes(), &sig, &[pk.as_str()]).unwrap(); assert_eq!((f2.jobs.len(), s2.len()), (2, 1)); - assert!(verify_and_parse_lenient(text.replace("future-1", "future-2").as_bytes(), &sig, &pk).is_err()); + assert!(verify_and_parse_lenient(text.replace("future-1", "future-2").as_bytes(), &sig, &[pk.as_str()]).is_err()); let bad = text.replace("\"kind\":\"collect\"", "\"kind\":\"restart\""); assert!(parse_lenient(&bad).unwrap_err().contains("restart")); // a duplicate id is a duplicate even when one of them is unknown @@ -888,36 +889,36 @@ mod tests { fn signed_envelope_binds_file_and_signature() { let (sk, pk) = key(); let sig = manifest::hex_encode(&sk.sign(SAMPLE.as_bytes()).to_bytes()); - let env = signed_envelope(SAMPLE.as_bytes(), &sig, &pk).unwrap(); + let env = signed_envelope(SAMPLE.as_bytes(), &sig, &[pk.as_str()]).unwrap(); assert!(env.starts_with("{\"file\":\"{") && env.ends_with(&format!("\",\"format\":\"{JOBS_SIGNED_FORMAT}\",\"sig\":\"{sig}\"}}")), "{env}"); assert!(!env.contains('\n'), "one line, like the jobs file"); // reading it back gives the exact inner bytes and the same parse as the pair let (file, s2) = open_envelope(env.as_bytes()).unwrap(); assert_eq!((file.as_slice(), s2.as_str()), (SAMPLE.as_bytes(), sig.as_str())); - let f = verify_and_parse_signed(env.as_bytes(), &pk).unwrap(); + let f = verify_and_parse_signed(env.as_bytes(), &[pk.as_str()]).unwrap(); assert_eq!(f.jobs.len(), 2); - let (f2, skipped, inner) = verify_and_parse_signed_lenient(env.as_bytes(), &pk).unwrap(); + let (f2, skipped, inner) = verify_and_parse_signed_lenient(env.as_bytes(), &[pk.as_str()]).unwrap(); assert_eq!((f2.jobs.len(), skipped.len(), inner.as_slice()), (2, 0, SAMPLE.as_bytes())); // a stale pair cannot be wrapped: the signature of another publish over this file let other_file = SAMPLE.replace("collect-1", "collect-2"); let other_sig = manifest::hex_encode(&sk.sign(other_file.as_bytes()).to_bytes()); - assert_eq!(signed_envelope(SAMPLE.as_bytes(), &other_sig, &pk).unwrap_err(), "jobs file signature does not verify; not wrapping it"); + assert_eq!(signed_envelope(SAMPLE.as_bytes(), &other_sig, &[pk.as_str()]).unwrap_err(), "jobs file signature does not verify; not wrapping it"); // and a mixed envelope made by hand is refused on reading with the same words the app logs let mixed = format!("{{\"file\":{},\"format\":\"{JOBS_SIGNED_FORMAT}\",\"sig\":\"{other_sig}\"}}", Value::String(SAMPLE.to_string())); - assert_eq!(verify_and_parse_signed(mixed.as_bytes(), &pk).unwrap_err(), "jobs file signature does not verify"); + assert_eq!(verify_and_parse_signed(mixed.as_bytes(), &[pk.as_str()]).unwrap_err(), "jobs file signature does not verify"); // a byte changed inside the inner text after wrapping let tampered = env.replace("shard-20261004-150000", "shard-20261004-150001"); - assert_eq!(verify_and_parse_signed(tampered.as_bytes(), &pk).unwrap_err(), "jobs file signature does not verify"); + assert_eq!(verify_and_parse_signed(tampered.as_bytes(), &[pk.as_str()]).unwrap_err(), "jobs file signature does not verify"); // another key let other_key = manifest::hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes()); - assert!(verify_and_parse_signed(env.as_bytes(), &other_key).is_err()); + assert!(verify_and_parse_signed(env.as_bytes(), &[other_key.as_str()]).is_err()); // shape errors are named assert!(open_envelope(b"nope").unwrap_err().contains("not JSON")); assert!(open_envelope(env.replace(JOBS_SIGNED_FORMAT, "igneum-jobs-signed-9").as_bytes()).unwrap_err().contains("format")); assert!(open_envelope(env.replace("\"file\":", "\"body\":").as_bytes()).unwrap_err().contains("\"file\"")); assert!(open_envelope(env.replace(&sig, "abcd").as_bytes()).unwrap_err().contains("128 hex")); // the plain pair still works for apps before 0.3.9: the same signature verifies the inner file on its own - assert!(verify_and_parse(SAMPLE.as_bytes(), &sig, &pk).is_ok()); + assert!(verify_and_parse(SAMPLE.as_bytes(), &sig, &[pk.as_str()]).is_ok()); // the URL next to the jobs file assert_eq!(signed_jobs_url("https://dl.igneum.network/dl/tok/igneum-jobs.json"), "https://dl.igneum.network/dl/tok/igneum-jobs.signed.json"); assert_eq!(signed_jobs_url(""), ""); @@ -927,15 +928,15 @@ mod tests { fn signature_verifies_and_tampering_fails() { let (sk, pk) = key(); let sig = manifest::hex_encode(&sk.sign(SAMPLE.as_bytes()).to_bytes()); - let f = verify_and_parse(SAMPLE.as_bytes(), &sig, &pk).unwrap(); + let f = verify_and_parse(SAMPLE.as_bytes(), &sig, &[pk.as_str()]).unwrap(); assert_eq!(f.jobs.len(), 2); // the id changed after signing: refused before parsing let tampered = SAMPLE.replace("collect-1", "collect-2"); - assert_eq!(verify_and_parse(tampered.as_bytes(), &sig, &pk).unwrap_err(), "jobs file signature does not verify"); + assert_eq!(verify_and_parse(tampered.as_bytes(), &sig, &[pk.as_str()]).unwrap_err(), "jobs file signature does not verify"); // the OTA key cannot be swapped for another let other = manifest::hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes()); - assert!(verify_and_parse(SAMPLE.as_bytes(), &sig, &other).is_err()); - assert!(verify_and_parse(SAMPLE.as_bytes(), "zz", &pk).is_err()); + assert!(verify_and_parse(SAMPLE.as_bytes(), &sig, &[other.as_str()]).is_err()); + assert!(verify_and_parse(SAMPLE.as_bytes(), "zz", &[pk.as_str()]).is_err()); } #[test] diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index dd3d609e5..90386de4c 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -12,20 +12,38 @@ //! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} }, //! "min_supported_version": "0.3.0", "notes": "one line", //! "consensus": { "activation_height": null|number, "deadline_note": "", "override": {...} }, -//! "tuning": { "updated": "...", "cards": { "": { "variant": "u2", "race": true, "candidates": [..] } } } +//! "tuning": { "updated": "...", "cards": { "": { "variant": "u2", "race": true, "candidates": [..] } } }, +//! "revoked_keys": [""] optional (docs/security/keys.md section 4) //! } //! A platform that is missing is not updated (the Windows build lands later than the Mac one). //! `tuning` (4 October 2026, docs/design/miner-tuning.md) is the fleet's per-card kernel tuning: the engine writes it //! to /tuning.json and every GPU worker reads it at its next prepare (IGNEUM_TUNING_FILE). +//! +//! Keys (5 October 2026, docs/security/keys.md section 4): the app embeds a LIST of public keys, `OTA_PUBLIC_KEYS` +//! (K1 today, K2 once the project lead has made it with tools/keys/keygen-k2.sh). A signature verifies when any trusted key +//! verifies it; the `.sig` format is unchanged (64 raw bytes as 128 hex, no key id). Revocation: a manifest may carry +//! `revoked_keys`, fingerprints of keys that must not be trusted any more. The app honours the list only from a +//! manifest signed by a key that is NOT on it (a key never revokes itself, so at least the signer stays trusted), +//! records the fingerprints in /updates/revoked.json (`load_revoked`, `save_revoked`) and from then on +//! verifies with `trusted_keys` only. A leaked K1 is retired by one manifest signed with K2 that lists K1. #![allow(dead_code)] use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use sha2::{Digest, Sha256}; -/// The public half of ~/.config/igneum/ota-signing-key (generated once on the Mac with `igneum-ota-sign keygen`; -/// the private key never enters the repo or CI). Fingerprint: SHA-256 of these 32 bytes, see `fingerprint()`. +/// K1: the public half of ~/.config/igneum/ota-signing-key (generated once on the Mac with `igneum-ota-sign keygen`, +/// 4 October 2026; the private key never enters the repo or CI). Fingerprint: SHA-256 of these 32 bytes, see +/// `fingerprint()` (sha256:8f186e37...). pub const OTA_PUBLIC_KEY_HEX: &str = "b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd"; +/// K2: the public half of the second signing key, whose private half lives only inside an encrypted disk image +/// (docs/security/keys.md section 4, step 1). tools/keys/keygen-k2.sh generates the pair and writes this constant; +/// until then it is empty, and an empty entry is not a key (skipped by every verify, absent from `embedded`). +pub const OTA_PUBLIC_KEY_2_HEX: &str = ""; +/// Every public key an app of this build may trust, in order of age. `trusted_keys` takes the revoked ones out. +pub const OTA_PUBLIC_KEYS: &[&str] = &[OTA_PUBLIC_KEY_HEX, OTA_PUBLIC_KEY_2_HEX]; +/// The revocation record, in the app's updates folder (next to manifest.json). +pub const REVOKED_FILE: &str = "revoked.json"; /// How many DAA blocks before a consensus activation height the app stops waiting for a safe moment. pub const FORK_URGENT_BLOCKS: u64 = 1_800; @@ -59,6 +77,9 @@ pub struct Manifest { /// tuning: the per-card kernel tuning object (tools/tuning.mjs writes it, publish-manifest.sh --tuning carries /// it), written as is to /tuning.json for the GPU workers; signed with the rest of the manifest. pub tuning: Option, + /// revoked_keys: fingerprints (sha256 hex of the 32 key bytes) of signing keys the fleet must stop trusting; + /// signed with the rest of the manifest, honoured only when the signer is not on the list (see `check`). + pub revoked_keys: Vec, } impl Manifest { @@ -102,21 +123,150 @@ pub fn public_key(hex: &str) -> Result { VerifyingKey::from_bytes(&arr).map_err(|e| format!("public key invalid: {e}")) } -/// SHA-256 of the raw public key bytes, as hex: what the report and the docs quote. +/// SHA-256 of the raw public key bytes, as hex: what the report and the docs quote. Empty for anything that is +/// not a 32-byte key (so the empty K2 slot has no fingerprint). pub fn fingerprint(pub_hex: &str) -> String { match hex_decode(pub_hex) { - Some(b) => hex_encode(&Sha256::digest(&b)), - None => String::new(), + Some(b) if b.len() == 32 => hex_encode(&Sha256::digest(&b)), + _ => String::new(), } } -/// Checks the detached signature (hex) over the manifest bytes with the given public key (hex). -pub fn verify_signature(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<(), String> { - let key = public_key(pub_hex)?; +/// The first 8 hex of a fingerprint: what the log lines and `tools/logs.mjs --rotation` show. +pub fn fingerprint8(pub_hex: &str) -> String { + fingerprint(pub_hex).chars().take(8).collect() +} + +/// The embedded keys that are keys: the empty K2 slot filtered out. +pub fn embedded_keys() -> Vec<&'static str> { + OTA_PUBLIC_KEYS.iter().copied().filter(|k| public_key(k).is_ok()).collect() +} + +/// Checks the detached signature (hex) over the manifest bytes against each public key (hex) in turn; Ok carries +/// the key that verified. An entry that is not a key (the empty K2 slot) is skipped. Two Ed25519 verifies cost +/// microseconds. The error for a signature no key verifies is the sentence the dashboard and the docs quote. +pub fn verify_signature<'a>(manifest_bytes: &[u8], sig_hex: &str, pub_keys: &[&'a str]) -> Result<&'a str, String> { let sig = hex_decode(sig_hex).ok_or("signature is not hex")?; let sig: [u8; 64] = sig.try_into().map_err(|_| "signature is not 64 bytes")?; let sig = Signature::from_bytes(&sig); - key.verify(manifest_bytes, &sig).map_err(|_| "manifest signature does not verify".to_string()) + let mut usable = 0; + for k in pub_keys { + let Ok(key) = public_key(k) else { continue }; + usable += 1; + if key.verify(manifest_bytes, &sig).is_ok() { + return Ok(k); + } + } + if usable == 0 { + return Err("no usable public key to verify with".into()); + } + Err("manifest signature does not verify".to_string()) +} + +// ---- revocation --------------------------------------------------------------------------------------------- + +/// One revoked key, as recorded in /revoked.json: who said so (the fingerprint of the key that signed the +/// manifest), from which manifest version, and when (unix seconds). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Revoked { + pub fingerprint: String, + pub by: String, + pub manifest_version: String, + pub at: u64, +} + +fn is_fingerprint(s: &str) -> bool { + s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) +} + +/// Reads the revocation record. A missing file is an empty list. A file that does not parse is also an empty list: +/// the caller logs it (`load_revoked_checked`), and the next revoking manifest writes it again. +pub fn load_revoked(path: &std::path::Path) -> Vec { + load_revoked_checked(path).unwrap_or_default() +} + +/// As `load_revoked`, with Err when the file exists and is not what `save_revoked` writes. +pub fn load_revoked_checked(path: &std::path::Path) -> Result, String> { + let Ok(text) = std::fs::read_to_string(path) else { return Ok(Vec::new()) }; + let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("{}: not JSON: {e}", path.display()))?; + let list = v.get("revoked").and_then(|x| x.as_array()).ok_or_else(|| format!("{}: no revoked array", path.display()))?; + let mut out = Vec::new(); + for e in list { + let fp = e.get("fingerprint").and_then(|x| x.as_str()).unwrap_or(""); + if !is_fingerprint(fp) { + return Err(format!("{}: an entry has no fingerprint", path.display())); + } + if out.iter().any(|r: &Revoked| r.fingerprint == fp) { + continue; + } + out.push(Revoked { + fingerprint: fp.to_string(), + by: e.get("by").and_then(|x| x.as_str()).unwrap_or("").to_string(), + manifest_version: e.get("manifest_version").and_then(|x| x.as_str()).unwrap_or("").to_string(), + at: e.get("at").and_then(|x| x.as_u64()).unwrap_or(0), + }); + } + Ok(out) +} + +/// Writes the revocation record (sorted keys, one object per entry), through a temporary file and a rename. +pub fn save_revoked(path: &std::path::Path, list: &[Revoked]) -> Result<(), String> { + let entries: Vec = list + .iter() + .map(|r| serde_json::json!({ "at": r.at, "by": r.by, "fingerprint": r.fingerprint, "manifest_version": r.manifest_version })) + .collect(); + let text = serde_json::json!({ "format": "igneum-revoked-keys/1", "revoked": entries }).to_string(); + if let Some(d) = path.parent() { + let _ = std::fs::create_dir_all(d); + } + let tmp = path.with_extension("json.new"); + std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?; + std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display())) +} + +/// The keys an app may verify with: the embedded keys that are keys, minus the revoked ones. +pub fn trusted_keys<'a>(pub_keys: &[&'a str], revoked: &[Revoked]) -> Vec<&'a str> { + pub_keys.iter().copied().filter(|k| public_key(k).is_ok() && !revoked.iter().any(|r| r.fingerprint == fingerprint(k))).collect() +} + +/// What `check` found: the manifest, the key that signed it, and the keys this manifest revoked (new entries only). +#[derive(Clone, Debug, PartialEq)] +pub struct Checked { + pub manifest: Manifest, + pub signer: String, + pub newly_revoked: Vec, +} + +/// The app's whole check of a fetched manifest: verify with the trusted keys (the embedded ones minus `revoked`), +/// parse, then apply the manifest's `revoked_keys` to `revoked`. Rules: a key never revokes itself (the signer's +/// own fingerprint on the list is ignored, so a manifest can never leave the app with no trusted key); a fingerprint +/// already recorded is not recorded twice; a fingerprint that is not one of the embedded keys is recorded all the +/// same (a future build that embeds that key inherits the record). A signature by a revoked key is named as such. +pub fn check(manifest_bytes: &[u8], sig_hex: &str, pub_keys: &[&str], revoked: &mut Vec, now: u64) -> Result { + let trusted = trusted_keys(pub_keys, revoked); + let signer = match verify_signature(manifest_bytes, sig_hex, &trusted) { + Ok(k) => k.to_string(), + Err(e) => { + // name a revoked key that would have verified: the publisher is still signing with it + let dead: Vec<&str> = pub_keys.iter().copied().filter(|k| public_key(k).is_ok() && !trusted.contains(k)).collect(); + if let Ok(k) = verify_signature(manifest_bytes, sig_hex, &dead) { + return Err(format!("manifest signature is by a revoked key (sha256:{})", fingerprint8(k))); + } + return Err(e); + } + }; + let text = std::str::from_utf8(manifest_bytes).map_err(|_| "manifest is not UTF-8")?; + let manifest = parse(text)?; + let own = fingerprint(&signer); + let mut newly = Vec::new(); + for fp in &manifest.revoked_keys { + if *fp == own || revoked.iter().any(|r| r.fingerprint == *fp) { + continue; + } + revoked.push(Revoked { fingerprint: fp.clone(), by: own.clone(), manifest_version: manifest.version.clone(), at: now }); + newly.push(fp.clone()); + } + Ok(Checked { manifest, signer, newly_revoked: newly }) } /// Parses the manifest JSON (after the signature was checked). @@ -168,12 +318,29 @@ pub fn parse(text: &str) -> Result { Some(t) if !t.is_null() => return Err("tuning must be an object with a cards object".into()), _ => None, }, + revoked_keys: match v.get("revoked_keys") { + None | Some(serde_json::Value::Null) => Vec::new(), + Some(serde_json::Value::Array(a)) => { + let mut out: Vec = Vec::new(); + for x in a { + let fp = x.as_str().unwrap_or(""); + if !is_fingerprint(fp) { + return Err("revoked_keys: every entry must be a 64-hex lowercase sha256 fingerprint".into()); + } + if !out.iter().any(|o| o == fp) { + out.push(fp.to_string()); + } + } + out + } + Some(_) => return Err("revoked_keys must be an array of fingerprints".into()), + }, }) } -/// Verifies, then parses. -pub fn verify_and_parse(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result { - verify_signature(manifest_bytes, sig_hex, pub_hex)?; +/// Verifies with any of the keys, then parses. The app's own path is `check` (it also applies revocations). +pub fn verify_and_parse(manifest_bytes: &[u8], sig_hex: &str, pub_keys: &[&str]) -> Result { + verify_signature(manifest_bytes, sig_hex, pub_keys)?; let text = std::str::from_utf8(manifest_bytes).map_err(|_| "manifest is not UTF-8")?; parse(text) } @@ -440,22 +607,23 @@ mod tests { fn signature_verifies_and_tampering_fails() { let (sk, pk) = key(); let sig = hex_encode(&sk.sign(SAMPLE.as_bytes()).to_bytes()); - assert!(verify_signature(SAMPLE.as_bytes(), &sig, &pk).is_ok()); - let m = verify_and_parse(SAMPLE.as_bytes(), &sig, &pk).unwrap(); + assert!(verify_signature(SAMPLE.as_bytes(), &sig, &[pk.as_str()]).is_ok()); + let m = verify_and_parse(SAMPLE.as_bytes(), &sig, &[pk.as_str()]).unwrap(); assert_eq!(m.version, "0.3.1"); // a tampered sha256 inside the manifest: the bytes changed, the signature no longer verifies let tampered = SAMPLE.replace("aaaaaaaa", "aaaaaaab"); - assert!(verify_and_parse(tampered.as_bytes(), &sig, &pk).is_err()); + assert!(verify_and_parse(tampered.as_bytes(), &sig, &[pk.as_str()]).is_err()); // a bad signature let mut bad = sig.clone(); bad.replace_range(0..2, if &sig[0..2] == "00" { "01" } else { "00" }); - assert!(verify_signature(SAMPLE.as_bytes(), &bad, &pk).is_err()); + assert!(verify_signature(SAMPLE.as_bytes(), &bad, &[pk.as_str()]).is_err()); // another key let other = hex_encode(SigningKey::from_bytes(&[9u8; 32]).verifying_key().as_bytes()); - assert!(verify_signature(SAMPLE.as_bytes(), &sig, &other).is_err()); + assert!(verify_signature(SAMPLE.as_bytes(), &sig, &[other.as_str()]).is_err()); // garbage - assert!(verify_signature(SAMPLE.as_bytes(), "zz", &pk).is_err()); - assert!(verify_signature(SAMPLE.as_bytes(), &sig, "abcd").is_err()); + assert!(verify_signature(SAMPLE.as_bytes(), "zz", &[pk.as_str()]).is_err()); + assert!(verify_signature(SAMPLE.as_bytes(), &sig, &["abcd"]).is_err()); + assert!(verify_signature(SAMPLE.as_bytes(), &sig, &[]).unwrap_err().contains("no usable")); } #[test] @@ -545,4 +713,155 @@ mod tests { assert_eq!(fingerprint(&pk).len(), 64); assert_eq!(fingerprint("zz"), ""); } + + fn key_from(seed: u8) -> (SigningKey, String) { + let sk = SigningKey::from_bytes(&[seed; 32]); + let pk = hex_encode(sk.verifying_key().as_bytes()); + (sk, pk) + } + + fn signed(sk: &SigningKey, text: &str) -> String { + hex_encode(&sk.sign(text.as_bytes()).to_bytes()) + } + + /// docs/security/keys.md section 4 step 2: a manifest signed by K2 verifies against the two-key slice, one + /// signed by a third key fails, the K1 vectors still pass, and the empty K2 slot of the real constant is skipped. + #[test] + fn second_key_verifies_third_key_fails_first_key_still_passes() { + let (k1, pk1) = key(); + let (k2, pk2) = key_from(8); + let (k3, _) = key_from(9); + let keys = [pk1.as_str(), pk2.as_str()]; + let sig1 = signed(&k1, SAMPLE); + let sig2 = signed(&k2, SAMPLE); + let sig3 = signed(&k3, SAMPLE); + // K1 vectors: the single-key slice and the two-key slice both verify, and name K1 + assert_eq!(verify_signature(SAMPLE.as_bytes(), &sig1, &[pk1.as_str()]).unwrap(), pk1); + assert_eq!(verify_signature(SAMPLE.as_bytes(), &sig1, &keys).unwrap(), pk1); + assert_eq!(verify_and_parse(SAMPLE.as_bytes(), &sig1, &keys).unwrap().version, "0.3.1"); + // K2: verifies against the slice, names K2, and the K1-only slice refuses it (an app before this build) + assert_eq!(verify_signature(SAMPLE.as_bytes(), &sig2, &keys).unwrap(), pk2); + assert!(verify_and_parse(SAMPLE.as_bytes(), &sig2, &keys).is_ok()); + assert_eq!(verify_signature(SAMPLE.as_bytes(), &sig2, &[pk1.as_str()]).unwrap_err(), "manifest signature does not verify"); + // a third key: refused by both + assert_eq!(verify_signature(SAMPLE.as_bytes(), &sig3, &keys).unwrap_err(), "manifest signature does not verify"); + assert!(verify_and_parse(SAMPLE.as_bytes(), &sig3, &keys).is_err()); + // order does not matter, and an empty slot (the K2 constant before keygen) is skipped, not an error + assert_eq!(verify_signature(SAMPLE.as_bytes(), &sig2, &[pk2.as_str(), pk1.as_str()]).unwrap(), pk2); + assert_eq!(verify_signature(SAMPLE.as_bytes(), &sig1, &["", pk1.as_str()]).unwrap(), pk1); + assert_eq!(verify_signature(SAMPLE.as_bytes(), &sig1, &[pk1.as_str(), ""]).unwrap(), pk1); + assert!(verify_signature(SAMPLE.as_bytes(), &sig1, &[""]).unwrap_err().contains("no usable")); + // the real constants: K1 is first and valid; K2 is either empty (not yet made) or a valid key; nothing else + assert_eq!(OTA_PUBLIC_KEYS[0], OTA_PUBLIC_KEY_HEX); + assert_eq!(OTA_PUBLIC_KEYS.len(), 2); + assert!(public_key(OTA_PUBLIC_KEY_HEX).is_ok()); + assert!(OTA_PUBLIC_KEY_2_HEX.is_empty() || public_key(OTA_PUBLIC_KEY_2_HEX).is_ok()); + assert_ne!(OTA_PUBLIC_KEY_2_HEX, OTA_PUBLIC_KEY_HEX, "K2 must be a different key"); + let emb = embedded_keys(); + assert_eq!(emb[0], OTA_PUBLIC_KEY_HEX); + assert_eq!(emb.len(), if OTA_PUBLIC_KEY_2_HEX.is_empty() { 1 } else { 2 }); + assert_eq!(fingerprint(OTA_PUBLIC_KEY_HEX), "8f186e37b48cfddf52d2b37478c562d78a74236fc87f0a675393ed6a35baac4e"); + assert_eq!(fingerprint8(OTA_PUBLIC_KEY_HEX), "8f186e37"); + assert_eq!(fingerprint(""), "", "the empty slot has no fingerprint"); + // the test keys refuse the real build's keys and the other way round + assert!(verify_signature(SAMPLE.as_bytes(), &sig1, OTA_PUBLIC_KEYS).is_err()); + } + + #[test] + fn revoked_keys_parse() { + let fp = fingerprint(&key().1); + let m = parse(&format!(r#"{{"version":"0.3.9","platforms":{{}},"revoked_keys":["{fp}"]}}"#)).unwrap(); + assert_eq!(m.revoked_keys, vec![fp.clone()]); + // duplicates collapse; null and absent read empty + assert_eq!(parse(&format!(r#"{{"version":"0.3.9","platforms":{{}},"revoked_keys":["{fp}","{fp}"]}}"#)).unwrap().revoked_keys.len(), 1); + assert!(parse(r#"{"version":"0.3.9","platforms":{},"revoked_keys":null}"#).unwrap().revoked_keys.is_empty()); + assert!(parse(r#"{"version":"0.3.9","platforms":{},"revoked_keys":[]}"#).unwrap().revoked_keys.is_empty()); + assert!(parse(SAMPLE).unwrap().revoked_keys.is_empty()); + // shapes that are refused: not an array, not a fingerprint, upper case, a public key instead of its hash + assert!(parse(r#"{"version":"0.3.9","platforms":{},"revoked_keys":"all"}"#).unwrap_err().contains("array")); + assert!(parse(r#"{"version":"0.3.9","platforms":{},"revoked_keys":["abcd"]}"#).unwrap_err().contains("fingerprint")); + assert!(parse(&format!(r#"{{"version":"0.3.9","platforms":{{}},"revoked_keys":["{}"]}}"#, fp.to_uppercase())).is_err()); + assert!(parse(&format!(r#"{{"version":"0.3.9","platforms":{{}},"revoked_keys":[{{"fingerprint":"{fp}"}}]}}"#)).is_err()); + assert!(parse(&format!(r#"{{"version":"0.3.9","platforms":{{}},"revoked_keys":["{}"]}}"#, key().1)).unwrap().revoked_keys.len() == 1, "a 64-hex public key is shaped like a fingerprint; harmless, it revokes nothing"); + } + + /// docs/security/keys.md section 4 step 3: the revocation path with a known-good and a known-revoked manifest. + #[test] + fn revocation_path() { + let (k1, pk1) = key(); + let (k2, pk2) = key_from(8); + let (k3, pk3) = key_from(9); + let keys = [pk1.as_str(), pk2.as_str()]; + let (fp1, fp2, fp3) = (fingerprint(&pk1), fingerprint(&pk2), fingerprint(&pk3)); + let dir = std::env::temp_dir().join(format!("igneum-revoked-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("updates").join(REVOKED_FILE); + let manifest_with = |fps: &[&str]| format!(r#"{{"platforms":{{}},"revoked_keys":[{}],"version":"0.3.9"}}"#, fps.iter().map(|f| format!("\"{f}\"")).collect::>().join(",")); + + // a missing record is an empty list; every embedded key is trusted + assert_eq!(load_revoked(&path), Vec::new()); + assert_eq!(trusted_keys(&keys, &[]), vec![pk1.as_str(), pk2.as_str()]); + assert_eq!(trusted_keys(&["", pk1.as_str()], &[]), vec![pk1.as_str()]); + + // 1. known-good: signed by K1, no revoked_keys. Verifies, names K1, revokes nothing, writes nothing + let mut revoked = load_revoked(&path); + let c = check(SAMPLE.as_bytes(), &signed(&k1, SAMPLE), &keys, &mut revoked, 1_000).unwrap(); + assert_eq!((c.signer.as_str(), c.newly_revoked.len(), c.manifest.version.as_str()), (pk1.as_str(), 0, "0.3.1")); + assert!(revoked.is_empty() && !path.exists()); + + // 2. a key never revokes itself: K1 listing K1 is ignored, K1 stays trusted + let own = manifest_with(&[&fp1]); + let c = check(own.as_bytes(), &signed(&k1, &own), &keys, &mut revoked, 1_001).unwrap(); + assert!(c.newly_revoked.is_empty() && revoked.is_empty()); + + // 3. an unknown key cannot revoke anything, even with a well-formed list + let both = manifest_with(&[&fp1, &fp2]); + assert_eq!(check(both.as_bytes(), &signed(&k3, &both), &keys, &mut revoked, 1_002).unwrap_err(), "manifest signature does not verify"); + assert!(revoked.is_empty()); + + // 4. known-revoked: signed by K2 (the OTHER key), listing K1. Verifies with K2, records K1 + let revoke_k1 = manifest_with(&[&fp1]); + let c = check(revoke_k1.as_bytes(), &signed(&k2, &revoke_k1), &keys, &mut revoked, 1_003).unwrap(); + assert_eq!((c.signer.as_str(), c.newly_revoked.as_slice()), (pk2.as_str(), &[fp1.clone()][..])); + assert_eq!(revoked, vec![Revoked { fingerprint: fp1.clone(), by: fp2.clone(), manifest_version: "0.3.9".into(), at: 1_003 }]); + save_revoked(&path, &revoked).unwrap(); + assert_eq!(load_revoked_checked(&path).unwrap(), revoked); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("igneum-revoked-keys/1") && text.contains(&fp1) && text.contains(&fp2), "{text}"); + assert!(!text.contains(&pk1) && !text.contains(&pk2), "the record holds fingerprints, not keys"); + + // 5. from then on: K1 is refused by name, K2 still verifies, the trusted list is K2 alone + let mut revoked = load_revoked(&path); + assert_eq!(trusted_keys(&keys, &revoked), vec![pk2.as_str()]); + let e = check(SAMPLE.as_bytes(), &signed(&k1, SAMPLE), &keys, &mut revoked, 1_004).unwrap_err(); + assert!(e.contains("revoked key") && e.contains(&fp1[..8]), "{e}"); + assert!(check(SAMPLE.as_bytes(), &signed(&k3, SAMPLE), &keys, &mut revoked, 1_004).unwrap_err().contains("does not verify")); + let c = check(SAMPLE.as_bytes(), &signed(&k2, SAMPLE), &keys, &mut revoked, 1_005).unwrap(); + assert_eq!((c.signer.as_str(), c.newly_revoked.len()), (pk2.as_str(), 0)); + // the same revocation again records nothing new; K2 cannot revoke itself; an unknown fingerprint is kept + let again = manifest_with(&[&fp1, &fp2, &fp3]); + let c = check(again.as_bytes(), &signed(&k2, &again), &keys, &mut revoked, 1_006).unwrap(); + assert_eq!(c.newly_revoked, vec![fp3.clone()]); + assert_eq!(revoked.len(), 2); + assert_eq!(trusted_keys(&keys, &revoked), vec![pk2.as_str()]); + // even a K1 manifest that tries to revoke K2 is refused: K1 is dead + let revenge = manifest_with(&[&fp2]); + assert!(check(revenge.as_bytes(), &signed(&k1, &revenge), &keys, &mut revoked, 1_007).is_err()); + assert_eq!(trusted_keys(&keys, &revoked), vec![pk2.as_str()]); + + // 6. the jobs file and the inputs manifest go through the same slice: a revoked key signs nothing + let trusted = trusted_keys(&keys, &revoked); + assert!(verify_signature(SAMPLE.as_bytes(), &signed(&k1, SAMPLE), &trusted).is_err()); + assert!(verify_signature(SAMPLE.as_bytes(), &signed(&k2, SAMPLE), &trusted).is_ok()); + + // 7. a corrupt record: named by the checked loader, read as empty by the plain one (the caller logs it) + std::fs::write(&path, "{not json").unwrap(); + assert!(load_revoked_checked(&path).unwrap_err().contains("not JSON")); + assert!(load_revoked(&path).is_empty()); + std::fs::write(&path, r#"{"revoked":[{"fingerprint":"short"}]}"#).unwrap(); + assert!(load_revoked_checked(&path).unwrap_err().contains("fingerprint")); + std::fs::write(&path, r#"{"revoked":"none"}"#).unwrap(); + assert!(load_revoked_checked(&path).unwrap_err().contains("revoked array")); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index 1313984cc..839f8c18d 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -355,6 +355,16 @@ impl Updater { } /// True when this (new) version has died twice before reaching HEALTHY_AFTER_S: the engine rolls back and exits. + /// The log header line for the signing keys: "ota keys: trusted []; revoked none| ...", the + /// fingerprints (first 8 hex) of the embedded keys this app verifies with and of the ones a manifest revoked. + /// `tools/logs.mjs --rotation` reads it from every machine's upload (docs/security/keys.md section 4, step 4). + pub fn describe_keys(&self) -> String { + let revoked = manifest::load_revoked(&self.dir.join(manifest::REVOKED_FILE)); + let trusted: Vec = manifest::trusted_keys(manifest::OTA_PUBLIC_KEYS, &revoked).iter().map(|k| manifest::fingerprint8(k)).collect(); + let dead: Vec = revoked.iter().map(|r| r.fingerprint.chars().take(8).collect()).collect(); + format!("ota keys: trusted {}; revoked {}", if trusted.is_empty() { "none".to_string() } else { trusted.join(" ") }, if dead.is_empty() { "none".to_string() } else { dead.join(" ") }) + } + pub fn needs_rollback(&self) -> bool { self.pending.as_ref().map(|p| p.starts >= 3).unwrap_or(false) } @@ -622,7 +632,7 @@ impl Updater { let dir = self.dir.clone(); let shared2 = shared.clone(); std::thread::spawn(move || { - let r = fetch_manifest(&url, &dir); + let r = fetch_manifest(&url, &dir, &shared2); shared2.send(Cmd::Ota(Event::Checked(r))); }); } @@ -1017,8 +1027,10 @@ fn curl(args: &[&str], limit: Duration) -> Result<(), String> { if t.is_empty() { Ok(()) } else { Err(t.lines().last().unwrap_or("curl failed").to_string()) } } -/// Fetches the manifest and its signature into /manifest.json(.sig), verifies, parses. -fn fetch_manifest(url: &str, dir: &Path) -> Result { +/// Fetches the manifest and its signature into /manifest.json(.sig), verifies with the trusted keys +/// (the embedded ones minus /revoked.json), parses, and records any key the manifest revokes +/// (manifest::check: a key never revokes itself, so the signer stays trusted). Logs what changed. +fn fetch_manifest(url: &str, dir: &Path, shared: &Arc) -> Result { let mf = dir.join("manifest.json.new"); let sf = dir.join("manifest.json.sig.new"); let _ = std::fs::remove_file(&mf); @@ -1027,7 +1039,28 @@ fn fetch_manifest(url: &str, dir: &Path) -> Result { curl(&["-fsSL", "--max-time", "20", "-o", &sf.display().to_string(), &format!("{url}.sig")], Duration::from_secs(25)).map_err(|e| format!("manifest signature: {e}"))?; let bytes = std::fs::read(&mf).map_err(|e| e.to_string())?; let sig = std::fs::read_to_string(&sf).map_err(|e| e.to_string())?; - let m = manifest::verify_and_parse(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEY_HEX)?; + let revoked_path = dir.join(manifest::REVOKED_FILE); + let mut revoked = match manifest::load_revoked_checked(&revoked_path) { + Ok(r) => r, + Err(e) => { + shared.log(&format!("update check: the revocation record is unreadable ({e}); every embedded key is trusted until a manifest revokes one again")); + Vec::new() + } + }; + let checked = manifest::check(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEYS, &mut revoked, crate::platform::unix_now() as u64)?; + if !checked.newly_revoked.is_empty() { + match manifest::save_revoked(&revoked_path, &revoked) { + Ok(()) => shared.log(&format!( + "update check: manifest {} signed by sha256:{} revokes signing key(s) {}; recorded in {}; this app no longer accepts anything they sign", + checked.manifest.version, + manifest::fingerprint8(&checked.signer), + checked.newly_revoked.iter().map(|f| format!("sha256:{}", &f[..8])).collect::>().join(", "), + revoked_path.display() + )), + Err(e) => shared.log(&format!("update check: manifest {} revokes a signing key but the record could not be written: {e}", checked.manifest.version)), + } + } + let m = checked.manifest; let _ = std::fs::rename(&mf, dir.join("manifest.json")); let _ = std::fs::rename(&sf, dir.join("manifest.json.sig")); Ok(m) diff --git a/docs/security/keys.md b/docs/security/keys.md index 67c029f52..3ff19a0c0 100644 --- a/docs/security/keys.md +++ b/docs/security/keys.md @@ -15,7 +15,8 @@ Column "lost" = the Mac dies and there is no backup. Column "leaked" = someone e | Name | Where it lives | What it unlocks (readers) | If lost | If leaked | Rotate: who, how | Rotation status | |---|---|---|---|---|---|---| -| `ota-signing-key` (Ed25519 seed, 32 bytes as hex, 65 B, 0600, made 4 Oct 12:32) | the Mac only. Never in CI, never in the repo. Its public half is compiled into every app (`app/igneum-app/src/manifest.rs:28`, `OTA_PUBLIC_KEY_HEX`, fingerprint sha256 `8f186e37b48cfddf52d2b37478c562d78a74236fc87f0a675393ed6a35baac4e`) and on the Mac as `ota-signing-key.pub` | signs the update manifest `igneum-app-latest.json` (`packaging/ota/publish-manifest.sh`, `publish-public.sh`, `tools/ship-app.mjs`), the remote jobs file `igneum-jobs.json` (`publish-jobs.sh`, `tools/jobs.mjs`; `kind: run` jobs execute on every app machine, `jobrun.rs:800`) and the Windows build inputs `payload-inputs.json` (`packaging/windows/push-inputs.sh`, verified in CI with the embedded key, `windows.yml:172`). Verified by `ota.rs:1030`, `jobrun.rs:800-811`, `igneum-ota-sign verify-inputs embedded` | no new manifest, job or Windows build can be signed. Every installed 0.3.x keeps checking hourly and never updates again. The only way forward is a hand reinstall by every miner of a build that embeds a new key: the fleet is stranded | whoever holds it signs a manifest or a jobs file the apps accept. With the manifest URL (the folder token is in every app) and a way to serve it (the Vercel token, or DNS) they run code on every machine | the project lead only. No fleet rotation path exists today (section 4): `igneum-ota-sign keygen`, change `OTA_PUBLIC_KEY_HEX`, ship, and every machine must apply that build first | never rotated; one key; backup = this branch | +| `ota-signing-key` (Ed25519 seed, 32 bytes as hex, 65 B, 0600, made 4 Oct 12:32) | the Mac only. Never in CI, never in the repo. Its public half is compiled into every app (`app/igneum-app/src/manifest.rs:28`, `OTA_PUBLIC_KEY_HEX`, fingerprint sha256 `8f186e37b48cfddf52d2b37478c562d78a74236fc87f0a675393ed6a35baac4e`) and on the Mac as `ota-signing-key.pub` | signs the update manifest `igneum-app-latest.json` (`packaging/ota/publish-manifest.sh`, `publish-public.sh`, `tools/ship-app.mjs`), the remote jobs file `igneum-jobs.json` (`publish-jobs.sh`, `tools/jobs.mjs`; `kind: run` jobs execute on every app machine, `jobrun.rs:800`) and the Windows build inputs `payload-inputs.json` (`packaging/windows/push-inputs.sh`, verified in CI with the embedded key, `windows.yml:172`). Verified by `ota.rs:1030`, `jobrun.rs:800-811`, `igneum-ota-sign verify-inputs embedded` | no new manifest, job or Windows build can be signed. Every installed 0.3.x keeps checking hourly and never updates again. The only way forward is a hand reinstall by every miner of a build that embeds a new key: the fleet is stranded | whoever holds it signs a manifest or a jobs file the apps accept. With the manifest URL (the folder token is in every app) and a way to serve it (the Vercel token, or DNS) they run code on every machine | the project lead only. From the K2 build on (section 4): lost = sign with K2 from its image; leaked = sign with K2 with K1 in `revoked_keys`. Before that build reaches a machine, that machine trusts K1 alone | never rotated; K2 code done 5 Oct 2026 (branch `ota-k2`), K2 itself not yet made | +| `ota-signing-key-2` (K2, Ed25519 seed; NOT YET MADE, 5 Oct 2026 evening) | ONLY inside `igneum-key-2-.dmg` (AES-256), two copies on the two media; never on this disk, never in the backup image. Public half: `ota-signing-key-2.pub` on the Mac and `OTA_PUBLIC_KEY_2_HEX` in `manifest.rs` (empty until `tools/keys/keygen-k2.sh`) | the same three files as K1, through `tools/keys/with-k2.sh` (section 4, step 5); apps from the K2 build accept either key | nothing while K1 lives; the fallback is gone and a new K2 is made the same way | as K1, from the moment the fleet runs the K2 build: a manifest signed with K1 and `--revoke ` retires it | the project lead: section 4 step 5 with the roles swapped | to be made (section 4 step 1) | | `ota-signing-key.pub` (65 B, 0644) | the Mac; the same bytes in the app | the local check of every publish (`publish-manifest.sh`, `ship-app.mjs:563`, `test-publish-jobs.sh`) | nothing: `igneum-ota-sign embedded` prints it from any app build | nothing, it is public | with the private key | with the private key | | `relay-token` (28 B, 0600, 4 Oct 19:23) | the Mac; `RELAY_TOKEN` on Vercel `igneum-relay`; baked into the relay clients on PC 1 and PC 2 (`relay/clients/make-clients.sh:8-12`, `igneum-agent.ps1`); the phone bookmark | the relay URL `/r//`: read and post the feed, the drop box, and `POST task kind: run` on the PCs with only this token (`docs/fud-ledger.md` X23/X24, still open). Readers: `tools/relay.mjs`, `console.mjs`, `build-job.mjs`, `ship-app.mjs`, `packaging/ota/publish-jobs.sh` | generate a new one, set the env, rebuild the clients, redistribute to the PCs and the phone; nothing is unrecoverable | elevated command execution on PC 1 and PC 2, and every file on the relay | the project lead: new value into the file, `vercel env rm/add RELAY_TOKEN production --scope igneum`, deploy, `make-clients.sh`, install on both PCs, delete the old | rotated 4 Oct 2026 (the `.old-2026-10-04` copy is dead and can go) | | `relay-key` (48 B, 0600, 4 Oct 19:23) | the Mac; `RELAY_KEY` on `igneum-relay`; in the relay clients on the PCs | the same relay, as the `x-igneum-key` header for scripts (`relay/lib/relay.mjs:34-44`; its own key since round 4 X23, no longer the intake key) | as the token | as the token | as the token | new 4 Oct 2026 | @@ -82,21 +83,47 @@ run in `--dry-run` only. ## 4. The OTA signing key: today, the second key, the emergency path -Today. One Ed25519 key signs three things: the update manifest, the jobs file and the Windows build inputs. Its public -half is one constant, `OTA_PUBLIC_KEY_HEX`, read at `ota.rs:1030`, `jobrun.rs:168,800,811` and by `igneum-ota-sign -embedded|verify-inputs`. Every app in the field trusts that one key and nothing else: on 5 October 2026 18:40 UTC the -intake shows 0.3.8 (one Mac), 0.3.7 (one Windows PC), 0.3.5 (Sam's Mac) and three machines without a version line -(`node tools/logs.mjs --rotation`). The signature file is 64 raw bytes as 128 hex, no key id. +Today (the fleet). One Ed25519 key, K1, signs three things: the update manifest, the jobs file and the Windows build +inputs. Every app in the field trusts that one key and nothing else: on 5 October 2026 18:40 UTC the intake showed +0.3.8 (one Mac), 0.3.7 (one Windows PC), 0.3.5 (Sam's Mac) and three machines without a version line (`node +tools/logs.mjs --rotation`). The signature file is 64 raw bytes as 128 hex, no key id. -The second key, as the next step (one release, about two hours of work). +Today (the code, branch `ota-k2`). The app embeds the list `OTA_PUBLIC_KEYS` (`manifest.rs`), verified through +`manifest::check` in `ota.rs fetch_manifest` and `manifest::trusted_keys` in `jobrun.rs fetch_jobs`, and by +`igneum-ota-sign embedded`; the list minus `/updates/revoked.json` is what a machine +trusts. The second entry is empty until K2 is made. -| Step | What | -|---|---| -| 1 | `igneum-ota-sign keygen` a second key (K2) on the Mac with the output directed INTO a mounted `igneum-keys` image, so its private half exists only inside the encrypted copies; keep `ota-signing-key-2.pub` on disk. The current key is K1 | -| 2 | `manifest.rs`: `OTA_PUBLIC_KEYS: &[&str] = &[K1, K2]`; `verify_signature` tries each key in turn (two Ed25519 verifies, microseconds); `fingerprint()` of each; `embedded` prints both. `ota.rs`, `jobrun.rs`, `jobs.rs`, `inputs.rs` and `ota-sign.rs` take the slice instead of the constant. Tests: a manifest signed by K2 verifies, by a third key fails, the existing K1 vectors still pass. The `.sig` format does not change, so 0.3.x apps keep verifying K1 signatures of the same files | -| 3 | The same release carries revocation: the manifest gains an optional `revoked_keys: []`, signed like the rest. An app that verifies a manifest with the OTHER key and sees its current key listed writes `updates/revoked.json` and refuses that key from then on. Without this, a leaked K1 stays trusted forever (step 2 alone covers loss, not leak) | -| 4 | Ship it as 0.3.9, signed with K1, so every 0.3.5 to 0.3.8 machine takes it on its hourly check. From then on the publisher keeps signing with K1; K2 stays offline. `tools/logs.mjs --rotation` gains a column for the embedded fingerprints the app logs (it already logs `fingerprint` at `jobrun.rs:168`) | -| 5 | When every machine reports 0.3.9 or later, K2 is live as the fallback: K1 lost = mount the image, sign with K2, ship; K1 leaked = sign the next manifest with K2 with K1 in `revoked_keys` | +The second key: the code is DONE (branch `ota-k2`, 5 October 2026 evening, after 0.3.10 shipped; it goes into the +next cut the Counter ASIC 2.0 coordinator assembles, not into 0.3.10 or 0.3.11). What each step is now. + +| Step | What | State | +|---|---|---| +| 1 | K2 is generated with `tools/keys/keygen-k2.sh`: the private half straight into a NEW encrypted image (`~/Desktop/igneum-key-2-.dmg`, 2 MB, AES-256, read-write, 0600), never on the disk; `ota-signing-key-2.pub` into `~/.config/igneum`; `OTA_PUBLIC_KEY_2_HEX` in `manifest.rs` filled in by the script. Its own image, not the backup image, because `backup.sh` writes read-only UDZO images that nothing can be written into, and because the backup packs `~/.config/igneum`, which the private half must never be in. The image is copied to the same two media as the backup. K1 is unchanged | [user], the commands below | +| 2 | `manifest.rs`: `OTA_PUBLIC_KEYS: &[&str] = &[K1, K2]` (K2 empty until step 1; an empty slot is skipped, `embedded_keys()` lists the real ones); `verify_signature` tries each key in turn and returns the one that verified; `fingerprint()` and `fingerprint8()` of each; `igneum-ota-sign embedded` prints every key with its fingerprint (K1 on lines 1 and 2 as before, so `head -1` and `sed -n 2p` in the scripts still read K1). `ota.rs`, `jobrun.rs`, `jobs.rs`, `inputs.rs` and `ota-sign.rs` take the slice; `embedded` as a key argument to every verify command means the slice. Tests: `manifest::tests::second_key_verifies_third_key_fails_first_key_still_passes` (a K2 signature verifies, a third key's fails, the K1 vectors pass, the empty slot is skipped), `inputs::tests::sign_verify_and_tamper` (two-key slice), the jobs and inputs suites on the slice. The `.sig` format is unchanged, so 0.3.x apps keep verifying K1 signatures of the same files | done | +| 3 | Revocation: the manifest's optional `revoked_keys: []`, signed like the rest (`parse` refuses anything but lowercase 64-hex entries). `manifest::check` is the app's whole path (`ota.rs fetch_manifest`): verify with `trusted_keys` (the embedded keys minus `/updates/revoked.json`), parse, then record every listed fingerprint except the signer's own (a key never revokes itself, so no manifest can leave an app with no trusted key) in `revoked.json` (`{"format":"igneum-revoked-keys/1","revoked":[{"fingerprint","by","manifest_version","at"}]}`), logged as "update check: manifest X signed by sha256:... revokes signing key(s) ...". From then on a signature by that key is refused by name ("manifest signature is by a revoked key (sha256:...)"), by the updater AND by the jobs runner (`jobrun.rs fetch_jobs` reads the same file at every poll). A corrupt record is logged and read as empty. Test: `manifest::tests::revocation_path` (a known-good K1 manifest changes nothing; a K2 manifest listing K1 records it; K1 then fails by name, K2 passes, the jobs slice follows; K1 listing itself is ignored; an unknown key cannot revoke; a dead K1 cannot revoke K2 back; the file round-trips; a corrupt file is named) and `revoked_keys_parse`. Publisher side: `publish-manifest.sh --revoke ` (repeatable), the list carried over from the current manifest until `--no-revoke`, the signing key's own fingerprint refused | done | +| 4 | Ship it in the next cut (the plan said 0.3.9; 0.3.9 and 0.3.10 went out without it, so it is 0.3.12 or whatever the coordinator numbers it), signed with K1, so every 0.3.5+ machine takes it on its hourly check. The publisher keeps signing with K1; K2 stays in its image. The engine logs a third header line, `ota keys: trusted []; revoked none|...`, and `tools/logs.mjs --rotation` has the `ota_keys` column (K1 and K2 named from the two `.pub` files; "REVOKED" when a machine reports one) and the line "N machine(s) embed K2, M embed K1 only, P log no ota keys line" | code done; the ship is the coordinator's | +| 5 | When every machine reports a build with K2: K1 lost = `tools/keys/with-k2.sh -- packaging/ota/publish-manifest.sh ...` (the image attached read-only for the minutes of the publish, `IGNEUM_OTA_KEY_FILE`/`IGNEUM_OTA_PUB_FILE` set; `publish-jobs.sh` and `push-inputs.sh` read the same two); K1 leaked = the same with `--revoke 8f186e37b48cfddf52d2b37478c562d78a74236fc87f0a675393ed6a35baac4e` (K1's fingerprint). Known limit, accepted with the plan: the two keys are peers, so whoever holds a leaked K1 can sign a manifest that revokes K2 just as well; whichever manifest a machine fetches first wins, and the mitigation table below (take the files off the folder first) is what makes ours arrive first | when the fleet has moved | + +What the project lead runs (step 1), in the main checkout once this branch is merged, in this order: + +``` +tools/keys/keygen-k2.sh # creates ~/Desktop/igneum-key-2-.dmg; hdiutil asks for a NEW passphrase twice + # (create, then attach); keygen into the image; sign-and-verify check; detach; + # ~/.config/igneum/ota-signing-key-2.pub written; manifest.rs patched; prints the + # fingerprint. --agent for the macOS dialog instead of the terminal prompt +git diff app/igneum-app/src/manifest.rs # one line: OTA_PUBLIC_KEY_2_HEX = "<64 hex>" +git add app/igneum-app/src/manifest.rs && TZ=UTC git commit -m "K2: the second OTA signing key's public half" +cp ~/Desktop/igneum-key-2-.dmg /Volumes// && cp ~/Desktop/igneum-key-2-.dmg /Volumes// +tools/keys/with-k2.sh /Volumes//igneum-key-2-.dmg --check # on each copy: attaches, signs, verifies, detaches +rm -P ~/Desktop/igneum-key-2-.dmg # the Desktop copy goes; the passphrase on paper, apart from both sticks +``` + +Then the build that embeds K2 ships through the normal release path (signed with K1), and `node tools/logs.mjs +--rotation` shows the fleet moving in the `ota_keys` column. Nothing above touches K1, the backup image or the +publish path. The harness `tools/keys/test-keygen-k2.sh` runs the same two scripts on a scratch folder, a scratch +image and a THROWAWAY key (25 checks, 5 October 2026: the .pub lands, the private half does not, the manifest copy is +patched, the private hex is in no file and not in the raw image bytes, `with-k2.sh --check` and `-- ` work, +the real build's `embedded` refuses the throwaway signature, a second keygen and a wrong passphrase are refused). If K1 leaks TODAY, before 0.3.9: a manifest signed with any new key is useless. Every 0.3.x app verifies with K1 only (`ota.rs:1030`), rejects the new signature ("manifest signature does not verify"), stays on its version and keeps @@ -107,13 +134,20 @@ key by the update path, and it stays open to whoever holds K1 for as long as tha |---|---|---| | 1 | Take the manifest and the jobs file off the folder (`dl//igneum-app-latest.json`, `.sig`, `igneum-jobs.json`, `.sig`): a deploy of the downloads folder without them | the apps read 404 as "no manifest at the update URL yet" and stay put; the attacker's signed manifest has nowhere to be served unless they also hold the Vercel token or the DNS | | 2 | Rotate the folder token (phase 2 again) and the Vercel token, the deSEC token, the Neon password, the relay token and key, the GitHub tokens: one leaked file from this folder means the folder was read | the old URL dies; the attacker cannot place a file at the new one | -| 3 | Build 0.3.9 (section 4 steps 1 to 3, with K1 NOT in the trusted list) and sign its manifest with K1, once, into the NEW folder; publish it before anything else goes there | every machine that applies it stops trusting K1. The race is real: a machine that fetched an attacker's K1 manifest first is owned; `logs.mjs` shows which machines moved | +| 3 | Build the next version with K1 NOT in `OTA_PUBLIC_KEYS` (K2 alone, made first) and sign its manifest with K1, once, into the NEW folder; publish it before anything else goes there. Once the fleet runs a K2 build this step is instead one manifest signed with K2 and `--revoke` K1 (step 5 above) | every machine that applies it stops trusting K1. The race is real: a machine that fetched an attacker's K1 manifest first is owned; `logs.mjs` shows which machines moved | | 4 | Machines that did not move (asleep, offline, or owned) are reinstalled by hand from the public links (`dl.igneum.network/public/`, `packaging/README-ship.md`) | the only path for an app that never saw step 3 | -Until 0.3.9 ships, the K1 file is the single point of failure in both directions, and this branch's backup covers the -loss direction only. The leak direction is covered by step 3 of the plan, and by keeping K1 off this disk: the publish -scripts read `~/.config/igneum/ota-signing-key` by path, so the file can become a symlink into a mounted image that is -attached only for the minutes of a publish (a follow-up, not done here; `publish-manifest.sh:36`, `ship-app.mjs:305`). +Until the K2 build ships and the fleet has it, the K1 file is the single point of failure in both directions, and the +backup covers the loss direction only. The leak direction is covered by steps 3 and 5 above. Keeping K1 off this disk +the same way as K2 is a follow-up: the publish scripts now take `IGNEUM_OTA_KEY_FILE`, so K1 can move into an image of +its own and `with-k2.sh`'s shape serves it too (`ship-app.mjs:305` still wants the file at its fixed path). + +The wallet (branch `wallet-v1`, `app/igneum-wallet/src/updater.rs`) reads the SAME manifest format through its own +copy, `app/igneum-common/src/manifest.rs` (an older fork of the app's file, without `tuning`), and verifies with +`OTA_PUBLIC_KEY_HEX` alone (`updater.rs:729`). It does not share `manifest.rs` with the app. Follow-up on that +branch: port the key slice, `revoked_keys` and `check` into `app/igneum-common/src/manifest.rs`, make `updater.rs` +call `check` with its own `updates/revoked.json`, and log the `ota keys:` line; until then the wallet keeps trusting +K1 only and does not see a revocation. ## 5. The CI check diff --git a/packaging/ota/README.md b/packaging/ota/README.md index 6404a4100..23dc1f3b3 100644 --- a/packaging/ota/README.md +++ b/packaging/ota/README.md @@ -26,13 +26,22 @@ Generated once on the Mac (4 October 2026), never in the repo or in CI: app/igneum-app/target/release/igneum-ota-sign keygen ~/.config/igneum/ota-signing-key ~/.config/igneum/ota-signing-key.pub -`ota-signing-key` is the 32-byte seed as hex, mode 0600. The public key is the constant `OTA_PUBLIC_KEY_HEX` in -`app/igneum-app/src/manifest.rs`; `igneum-ota-sign embedded` prints it with its fingerprint (SHA-256 of the 32 key -bytes). `publish-manifest.sh` refuses to sign when the embedded key is not the one in `~/.config/igneum`. +`ota-signing-key` (K1) is the 32-byte seed as hex, mode 0600. The app embeds a LIST of public keys, +`OTA_PUBLIC_KEYS` in `app/igneum-app/src/manifest.rs` (K1 = `OTA_PUBLIC_KEY_HEX`, K2 = `OTA_PUBLIC_KEY_2_HEX`, empty +until `tools/keys/keygen-k2.sh` makes it); `igneum-ota-sign embedded` prints every key with its fingerprint (SHA-256 +of the 32 key bytes; K1 on lines 1 and 2). A signature verifies when any trusted key verifies it; the `.sig` format +is the same 128 hex. `publish-manifest.sh`, `publish-jobs.sh` and `push-inputs.sh` refuse to sign when the key in +`~/.config/igneum` (or `IGNEUM_OTA_KEY_FILE`/`IGNEUM_OTA_PUB_FILE`) is not one of the embedded keys. -Key rotation: a new key means a new app build (the constant), published and signed with the OLD key, then the next -manifest signed with the new one. Apps that skipped the bridge build stop updating and show "manifest signature does -not verify"; they are updated by hand from the download page. +The second key, revocation and the emergency path: docs/security/keys.md section 4. In short: K2's private half +lives only in an encrypted image (`tools/keys/keygen-k2.sh`); `tools/keys/with-k2.sh -- ` +signs with it; `publish-manifest.sh --revoke ` puts a key on the manifest's `revoked_keys`, and every +app that takes that manifest writes `updates/revoked.json` and refuses that key for good (a key never revokes +itself; the list is carried over to later manifests until `--no-revoke`). + +Key rotation beyond that: a new key means a new app build (the list), published and signed with a key the apps +already trust. Apps that skipped the bridge build stop updating and show "manifest signature does not verify"; they +are updated by hand from the download page. ## Publishing a version diff --git a/packaging/ota/TEST.md b/packaging/ota/TEST.md index 57cbdfbd7..cead64574 100644 --- a/packaging/ota/TEST.md +++ b/packaging/ota/TEST.md @@ -50,7 +50,8 @@ OTA-capable build; from then on every update is automatic. "Install updates by itself at a safe moment" switch ON, and a note. Click Check now: within 10 s the note says "This is the latest version (checked ...)" and the log drawer (Logs) has `update check: is current`. If it says `no manifest at the update URL yet` the deploy did not land; if `manifest signature does not verify` - the installer was built from a tree with a different `OTA_PUBLIC_KEY_HEX` than the key that signed. + the installer was built from a tree whose `OTA_PUBLIC_KEYS` do not include the key that signed (and `manifest + signature is by a revoked key` means a manifest revoked it: docs/security/keys.md section 4). 2. On the Mac, publish a test version: bump `version` in `app/igneum-app/Cargo.toml`, `app/windows/version.h` and `app/igneum-app/resources/igneum-app.rc` (patch level only), push, wait for CI, `fetch-ci-artifacts.sh --deploy` with `OTA_NOTES="OTA test"`. (The Mac entry is carried over only when it has the same version; without a Mac diff --git a/packaging/ota/publish-jobs.sh b/packaging/ota/publish-jobs.sh index 4e26f0ed9..a8e2bb72a 100755 --- a/packaging/ota/publish-jobs.sh +++ b/packaging/ota/publish-jobs.sh @@ -41,8 +41,8 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$HERE/../.." && pwd)" export PATH="$HOME/.cargo/bin:$PATH" -KEY="$HOME/.config/igneum/ota-signing-key" -PUB="$HOME/.config/igneum/ota-signing-key.pub" +KEY="${IGNEUM_OTA_KEY_FILE:-$HOME/.config/igneum/ota-signing-key}" # K1, or K2 through tools/keys/with-k2.sh +PUB="${IGNEUM_OTA_PUB_FILE:-$HOME/.config/igneum/ota-signing-key.pub}" TOKEN_FILE="$HOME/.config/igneum/dl-token" SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign" @@ -128,10 +128,10 @@ if [ ! -x "$SIGNER" ]; then echo "building igneum-ota-sign" (cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet) fi -EMBEDDED="$("$SIGNER" embedded | head -1)" OURS="$(tr -d '[:space:]' < "$PUB")" -if [ "$EMBEDDED" != "$OURS" ]; then - echo "the public key in app/igneum-app/src/manifest.rs ($EMBEDDED) is not $PUB ($OURS); the apps would refuse this file" >&2 +EMBEDDED_ALL="$("$SIGNER" embedded)" # captured first: `embedded | grep -q` under pipefail fails on SIGPIPE when grep exits early +if ! grep -qx "$OURS" <<< "$EMBEDDED_ALL"; then + echo "the public key in $PUB ($OURS) is not one of the keys in app/igneum-app/src/manifest.rs; the apps would refuse this file" >&2 exit 1 fi diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh index 33e3786cd..c6cd9db06 100755 --- a/packaging/ota/publish-manifest.sh +++ b/packaging/ota/publish-manifest.sh @@ -17,6 +17,16 @@ # [--tuning tuning.json | --no-tuning] the fleet's per-card kernel tuning (tools/tuning.mjs writes it; # docs/design/miner-tuning.md); carried over from the current # manifest when not given, as is consensus.override +# [--revoke ]... [--no-revoke] revoked_keys: signing keys every app must stop trusting +# (docs/security/keys.md section 4, step 5: K1 leaked = sign +# with K2 and --revoke K1's fingerprint). Carried over from +# the current manifest, so a revocation outlives one publish; +# --no-revoke drops the carried list. The signing key's own +# fingerprint is refused (an app ignores it anyway) +# +# The key: ~/.config/igneum/ota-signing-key (K1) and its .pub, or IGNEUM_OTA_KEY_FILE and IGNEUM_OTA_PUB_FILE +# (tools/keys/with-k2.sh sets them to K2 inside its mounted image). Either key's public half must be one of the +# keys compiled into the app (`igneum-ota-sign embedded`). # # A platform you do not pass is carried over from the manifest already in the folder when that one has the same # version (the Windows build lands later than the Mac one: publish the Mac entry first, add the Windows entry when @@ -33,15 +43,18 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$HERE/../.." && pwd)" export PATH="$HOME/.cargo/bin:$PATH" -KEY="$HOME/.config/igneum/ota-signing-key" -PUB="$HOME/.config/igneum/ota-signing-key.pub" +KEY="${IGNEUM_OTA_KEY_FILE:-$HOME/.config/igneum/ota-signing-key}" +PUB="${IGNEUM_OTA_PUB_FILE:-$HOME/.config/igneum/ota-signing-key.pub}" TOKEN_FILE="$HOME/.config/igneum/dl-token" SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign" VERSION="" MAC="" WIN="" NOTES="" ACTIVATION="" DEADLINE="" MIN_SUPPORTED="" CHANNEL="devnet" BASE="" DEST="" DEPLOY=0 VERIFY_ONLY=0 TRIES=12 -OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0 +OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0 NO_REVOKE=0 +REVOKE=() while [ $# -gt 0 ]; do case "$1" in + --revoke) REVOKE+=("$2"); shift 2 ;; # a sha256 fingerprint (igneum-ota-sign fingerprint ); repeatable + --no-revoke) NO_REVOKE=1; shift ;; --version) VERSION="$2"; shift 2 ;; --mac) MAC="$2"; shift 2 ;; --win) WIN="$2"; shift 2 ;; @@ -88,12 +101,13 @@ if [ ! -x "$SIGNER" ]; then echo "building igneum-ota-sign" (cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet) fi -EMBEDDED="$("$SIGNER" embedded | head -1)" OURS="$(tr -d '[:space:]' < "$PUB")" -if [ "$EMBEDDED" != "$OURS" ]; then - echo "the public key in app/igneum-app/src/manifest.rs ($EMBEDDED) is not $PUB ($OURS); the apps would refuse this manifest" >&2 +EMBEDDED_ALL="$("$SIGNER" embedded)" # captured first: `embedded | grep -q` under pipefail fails on SIGPIPE when grep exits early +if ! grep -qx "$OURS" <<< "$EMBEDDED_ALL"; then + echo "the public key in $PUB ($OURS) is not one of the keys in app/igneum-app/src/manifest.rs ($(grep -v fingerprint <<< "$EMBEDDED_ALL" | tr '\n' ' ')); the apps would refuse this manifest" >&2 exit 1 fi +OUR_FP="$("$SIGNER" fingerprint "$PUB" | tail -1 | sed 's/^fingerprint sha256://')" # the platform entries: the files given here, else carried over from the current manifest at the same version entry() { # -> "url sha256 size kind" @@ -162,6 +176,24 @@ if [ -z "$OVERRIDE" ] && [ -f "$OLD" ]; then OVERRIDE="$(python3 -c 'import json,sys; o=json.load(open(sys.argv[1])).get("consensus",{}).get("override"); print(json.dumps(o, sort_keys=True, separators=(",",":")) if isinstance(o, dict) and o else "")' "$OLD" 2>/dev/null || true)" [ -n "$OVERRIDE" ] && echo "consensus.override: carried over from the current manifest: $OVERRIDE" fi +# revoked_keys: given here, plus the current manifest's list unless --no-revoke; never the signing key itself +REVOKED="" +if [ "$NO_REVOKE" = 0 ] && [ -f "$OLD" ]; then + carried_rev="$(python3 -c 'import json,sys; print(" ".join(json.load(open(sys.argv[1])).get("revoked_keys") or []))' "$OLD" 2>/dev/null || true)" + if [ -n "$carried_rev" ]; then + for fp in $carried_rev; do REVOKE+=("$fp"); done + echo "revoked_keys: carried over from the current manifest: $carried_rev" + fi +fi +if [ ${#REVOKE[@]} -gt 0 ]; then + for fp in "${REVOKE[@]}"; do + case "$fp" in *[!0-9a-f]*|"") echo "--revoke $fp: a fingerprint is 64 lowercase hex characters (igneum-ota-sign fingerprint )" >&2; exit 2 ;; esac + [ ${#fp} = 64 ] || { echo "--revoke $fp: a fingerprint is 64 hex characters, this is ${#fp}" >&2; exit 2; } + [ "$fp" != "$OUR_FP" ] || { echo "--revoke $fp is the fingerprint of the key this manifest is signed with ($PUB); a key cannot revoke itself. Sign with the other key (tools/keys/with-k2.sh)" >&2; exit 2; } + done + REVOKED="$(printf '%s\n' "${REVOKE[@]}" | LC_ALL=C sort -u | tr '\n' ' ')" + echo "revoked_keys: $REVOKED(the apps that take this manifest stop trusting these keys for good)" +fi TUNING="" if [ -n "$TUNING_FILE" ]; then [ -f "$TUNING_FILE" ] || { echo "missing: $TUNING_FILE" >&2; exit 1; } @@ -173,9 +205,9 @@ fi # canonical JSON: sorted keys, no whitespace; the signature is over these exact bytes NEW="$DEST/igneum-app-latest.json.new" -python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" <<'PY' +python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${REVOKED:-}" <<'PY' import json, sys, datetime -out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning = sys.argv[1:12] +out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, revoked = sys.argv[1:13] override = json.loads(override) if override else None if override is not None and (not isinstance(override, dict) or not override): raise SystemExit("--override must be a non-empty JSON object") def entry(s): @@ -193,6 +225,8 @@ m = { } if tuning: m["tuning"] = json.loads(tuning) +if revoked.split(): + m["revoked_keys"] = sorted(set(revoked.split())) open(out, "w").write(json.dumps(m, sort_keys=True, separators=(",", ":"), ensure_ascii=False)) PY "$SIGNER" sign "$KEY" "$NEW" > "$NEW.sig" @@ -202,7 +236,7 @@ mv "$NEW.sig" "$DEST/igneum-app-latest.json.sig" echo "manifest: $DEST/igneum-app-latest.json" cat "$DEST/igneum-app-latest.json"; echo echo "signature: $(cat "$DEST/igneum-app-latest.json.sig")" -echo "key fingerprint: $("$SIGNER" fingerprint "$PUB" | tail -1)" +echo "key fingerprint: $("$SIGNER" fingerprint "$PUB" | tail -1)$([ -n "$REVOKED" ] && echo "; revokes $REVOKED")" # --public: the same version into dl/public/ with public URLs (its own signed manifest), the aliases rewritten if [ "$PUBLIC" = 1 ]; then diff --git a/packaging/windows/push-inputs.sh b/packaging/windows/push-inputs.sh index 05f515dc9..3e4fa6352 100755 --- a/packaging/windows/push-inputs.sh +++ b/packaging/windows/push-inputs.sh @@ -61,16 +61,16 @@ else echo "warning: no $NVRTC_DIR/igneum-worker-cuda.exe (run $NVRTC_DIR/build-w [ -f "$CL_WORKER" ] && cp "$CL_WORKER" "$STAGE/" || echo "warning: no $CL_WORKER" # the signer, built from the app crate (it includes src/manifest.rs and src/inputs.rs, so it signs what the runner verifies) -KEY="$HOME/.config/igneum/ota-signing-key" -PUB="$HOME/.config/igneum/ota-signing-key.pub" +KEY="${IGNEUM_OTA_KEY_FILE:-$HOME/.config/igneum/ota-signing-key}" # K1, or K2 through tools/keys/with-k2.sh +PUB="${IGNEUM_OTA_PUB_FILE:-$HOME/.config/igneum/ota-signing-key.pub}" SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign" [ -f "$KEY" ] && [ -f "$PUB" ] || { echo "no $KEY or $PUB (the OTA signing key; packaging/ota/publish-manifest.sh explains keygen)" >&2; exit 1; } if [ ! -x "$SIGNER" ]; then echo "building igneum-ota-sign" (cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet) fi -EMBEDDED="$("$SIGNER" embedded | head -1)" -[ "$EMBEDDED" = "$(tr -d '[:space:]' < "$PUB")" ] || { echo "the public key in app/igneum-app/src/manifest.rs ($EMBEDDED) is not $PUB; the runner would refuse this signature" >&2; exit 1; } +EMBEDDED_ALL="$("$SIGNER" embedded)" # captured first: `embedded | grep -q` under pipefail fails on SIGPIPE when grep exits early +grep -qx "$(tr -d '[:space:]' < "$PUB")" <<< "$EMBEDDED_ALL" || { echo "the public key in $PUB is not one of the keys in app/igneum-app/src/manifest.rs; the runner would refuse this signature" >&2; exit 1; } # the manifest: what is in the zip, from where, when. IGNEUM_NODE_SRC names the worktree the exes were built from # (default devnet-v4); its full commit is pinned in the manifest and in packaging/windows/node-source.pin. diff --git a/tools/ci/pipe-grep-q-check.sh b/tools/ci/pipe-grep-q-check.sh new file mode 100755 index 000000000..4b6a88f76 --- /dev/null +++ b/tools/ci/pipe-grep-q-check.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# The SIGPIPE class (5 October 2026, packaging/ota/test-publish-jobs.sh): under `set -o pipefail`, a line of the shape +# `producer | grep -q` fails at random when the producer still has lines to write after grep's first match: grep +# exits, the next write gets SIGPIPE (exit 141), and the pipeline reports failure. `igneum-ota-sign embedded` prints +# two to four lines, and `publish-jobs.sh` read "the public key ... is not one of the keys" once in three runs. +# Rule: capture the producer's output first (`ALL="$(cmd)"; grep -q ... <<< "$ALL"`), or let grep read everything +# (`cmd | grep ... >/dev/null`). This check fails CI on any tracked script that pipes the signer into `grep -q`. +# The one-line producers (`file`, `printf '%s'`, `gh auth status`) stay as they are: one write, no second line to lose. +# tools/ci/pipe-grep-q-check.sh the tree +# tools/ci/pipe-grep-q-check.sh --self-test must fire on a known-bad snippet and stay quiet on a known-good one +set -euo pipefail +cd "$(dirname "$0")/../.." +SHAPE='(igneum-ota-sign|"?\$SIGNER"?|"?\$IGNEUM_OTA_SIGN(ER)?"?)[^|]*\| *grep +-[a-zA-Z]*q' + +check_files() { + local fail=0 f + while IFS= read -r f; do + # comments stripped first (a trailing "# ... | grep -q ..." explaining the fix is not the shape) + if sed -E 's/(^|[[:space:]])#.*$//' "$f" | grep -nE "$SHAPE"; then echo "pipe-grep-q: $f pipes the signer into grep -q under pipefail (capture its output first)"; fail=1; fi + done + return $fail +} + +if [ "${1:-}" = "--self-test" ]; then + T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT + printf 'set -euo pipefail\nif ! "$SIGNER" embedded | grep -qx "$OURS"; then exit 1; fi\n' > "$T/bad.sh" + printf 'set -euo pipefail\nALL="$("$SIGNER" embedded)" # captured first: `embedded | grep -q` fails on SIGPIPE\nif ! grep -qx "$OURS" <<< "$ALL"; then exit 1; fi\n# a comment: "$SIGNER" embedded | grep -q is the bad shape\nfile "$f" | grep -q arm64\n' > "$T/good.sh" + printf '%s\n' "$T/bad.sh" | check_files >/dev/null 2>&1 && { echo "SELF-TEST FAILED: the bad snippet passed"; exit 1; } + printf '%s\n' "$T/good.sh" | check_files >/dev/null 2>&1 || { echo "SELF-TEST FAILED: the good snippet was reported"; exit 1; } + echo "pipe-grep-q: self-test passed (fires on the bad snippet, quiet on the good one)" + exit 0 +fi + +if git ls-files 'packaging/**' 'tools/**' 'infra/**' 'relay/**' '.github/**' | grep -E '\.(sh|yml|yaml)$' | check_files; then + echo "pipe-grep-q: no script pipes the signer into grep -q" +else + exit 1 +fi diff --git a/tools/keys/keygen-k2.sh b/tools/keys/keygen-k2.sh new file mode 100755 index 000000000..d56cd0528 --- /dev/null +++ b/tools/keys/keygen-k2.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# The second OTA signing key, K2 (docs/security/keys.md section 4, step 1). Its private half is generated straight +# into a NEW encrypted disk image and never exists on an unencrypted disk: not under ~/.config/igneum, not in the +# backup image (tools/keys/backup.sh packs ~/.config/igneum, and the backup images are read-only UDZO, so a key +# cannot be written into one; hence an image of its own, copied to the same two media). Only the public half goes +# to disk, as ~/.config/igneum/ota-signing-key-2.pub, and into app/igneum-app/src/manifest.rs as OTA_PUBLIC_KEY_2_HEX. +# +# tools/keys/keygen-k2.sh # ~/Desktop/igneum-key-2-.dmg, 2 MB, AES-256, read-write; hdiutil's own +# # passphrase prompt (twice: create, then attach); then keygen INTO the image, +# # a sign-and-verify check, detach, the .pub written, manifest.rs patched +# tools/keys/keygen-k2.sh --agent # the passphrase through the macOS Security Agent dialog +# tools/keys/keygen-k2.sh --no-patch # leave manifest.rs alone (print the line to paste) +# +# Test harness only (tools/keys/test-keygen-k2.sh): --stdinpass reads a NUL-terminated passphrase from standard +# input once; --out, --config and --manifest point at scratch paths. Never type a real passphrase through --stdinpass. +# +# Refuses to run when the .pub, the image or a filled K2 constant already exists: K2 is made once. Nothing here +# prints a private value; the image's mount point is private and detached at exit, also on failure. +# +# After it: copy the image to the two media next to the backup (section 2), delete the Desktop copy, commit the +# manifest.rs change, and check the copies with `tools/keys/with-k2.sh --check` (attaches, signs a test +# manifest, verifies it with the .pub, detaches). +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +export PATH="$HOME/.cargo/bin:$PATH" +CFG="$HOME/.config/igneum" +MANIFEST_RS="$REPO/app/igneum-app/src/manifest.rs" +SIGNER="${IGNEUM_OTA_SIGNER:-$REPO/app/igneum-app/target/release/igneum-ota-sign}" +OUT=""; MODE="tty"; PATCH=1 +DATE="$(date -u +%Y-%m-%d)" + +usage() { sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } +say() { printf '%s\n' "$*"; } +die() { printf 'keygen-k2: %s\n' "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --agent) MODE="agent" ;; + --stdinpass) MODE="stdin" ;; + --no-patch) PATCH=0 ;; + --out) OUT="${2:?--out needs a file}"; shift ;; + --config) CFG="${2:?--config needs a folder}"; shift ;; + --manifest) MANIFEST_RS="${2:?--manifest needs a file}"; shift ;; + -h|--help) usage ;; + *) die "unknown argument $1" ;; + esac + shift +done + +[ -n "$OUT" ] || OUT="$HOME/Desktop/igneum-key-2-$DATE.dmg" +case "$OUT" in *.dmg) ;; *) die "the output must end in .dmg" ;; esac +command -v hdiutil >/dev/null || die "hdiutil is not available (macOS only)" +PUB="$CFG/ota-signing-key-2.pub" +[ ! -e "$OUT" ] || die "$OUT exists; K2 is made once (a second image would be a third key)" +[ ! -e "$PUB" ] || die "$PUB exists; K2 was already made (its image holds the private half)" +[ ! -e "$CFG/ota-signing-key-2" ] || die "$CFG/ota-signing-key-2 exists on disk; the private half must live only in the image. Stop and look" +[ -d "$CFG" ] || die "no folder at $CFG" +[ -f "$MANIFEST_RS" ] || die "no $MANIFEST_RS" +if [ "$PATCH" = 1 ] && ! grep -q '^pub const OTA_PUBLIC_KEY_2_HEX: &str = "";$' "$MANIFEST_RS"; then + die "$MANIFEST_RS has no empty OTA_PUBLIC_KEY_2_HEX line to fill (K2 already set, or the file moved); --no-patch to skip" +fi +if [ ! -x "$SIGNER" ]; then + say "building igneum-ota-sign (under the build lock)" + "$REPO/tools/lock/with-lock.sh" build bash -c "cd '$REPO/app/igneum-app' && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet" + [ -x "$SIGNER" ] || die "no signer at $SIGNER after the build" +fi + +PASS="" +if [ "$MODE" = stdin ]; then + IFS= read -r -d '' PASS || true + [ -n "$PASS" ] || die "--stdinpass: no passphrase on standard input" +fi + +MNT="$(mktemp -d "${TMPDIR:-/tmp}/igneum-key-2-mnt.XXXXXX")" +MNT="$(cd "$MNT" && pwd -P)" +attached() { mount | grep -qF " on $MNT "; } +cleanup() { + if attached; then hdiutil detach "$MNT" -quiet >/dev/null 2>&1 || hdiutil detach "$MNT" -force -quiet >/dev/null 2>&1 || true; fi + attached || rmdir "$MNT" 2>/dev/null || true + [ -z "${T:-}" ] || rm -rf "$T" +} +trap cleanup EXIT +T="$(mktemp -d "${TMPDIR:-/tmp}/igneum-key-2-chk.XXXXXX")"; chmod 700 "$T" + +say "== 1. the image: $OUT (2 MB, AES-256, read-write, volume igneum-key-2)" +case "$MODE" in + tty) hdiutil create -size 2m -fs "HFS+" -volname "igneum-key-2" -encryption AES-256 -quiet "$OUT" ;; + agent) hdiutil create -size 2m -fs "HFS+" -volname "igneum-key-2" -encryption AES-256 -agentpass -quiet "$OUT" ;; + stdin) printf '%s\0' "$PASS" | hdiutil create -size 2m -fs "HFS+" -volname "igneum-key-2" -encryption AES-256 -stdinpass -quiet "$OUT" ;; +esac +[ -f "$OUT" ] || die "hdiutil did not create $OUT" +chmod 600 "$OUT" + +say "== 2. attach (the passphrase again)" +case "$MODE" in + tty) hdiutil attach "$OUT" -nobrowse -noautoopen -mountpoint "$MNT" -quiet ;; + agent) hdiutil attach "$OUT" -nobrowse -noautoopen -agentpass -mountpoint "$MNT" -quiet ;; + stdin) printf '%s\0' "$PASS" | hdiutil attach "$OUT" -nobrowse -noautoopen -stdinpass -mountpoint "$MNT" -quiet ;; +esac +attached || die "the image did not attach at $MNT" + +say "== 3. keygen: private half into the image, public half to $PUB" +"$SIGNER" keygen "$MNT/ota-signing-key-2" "$PUB" > "$T/keygen.txt" +chmod 644 "$PUB" +PUBHEX="$(tr -d '[:space:]' < "$PUB")" +FP="$(sed -n 's/^fingerprint sha256://p' "$T/keygen.txt")" +[ ${#PUBHEX} = 64 ] && [ ${#FP} = 64 ] || die "keygen did not print a 64-hex public key and fingerprint" +say "public key $PUBHEX" +say "fingerprint sha256:$FP" +[ "$(stat -f '%Lp' "$MNT/ota-signing-key-2")" = 600 ] || die "the private half is not 0600 inside the image" + +say "== 4. check: a test manifest signed inside the image verifies with the .pub, and the running build refuses it" +printf '{"platforms":{},"version":"0.0.0"}' > "$T/m.json" +"$SIGNER" sign "$MNT/ota-signing-key-2" "$T/m.json" > "$T/m.sig" +"$SIGNER" verify "$PUB" "$T/m.json" "$T/m.sig" >/dev/null || die "the fresh pair does not sign and verify" +if "$SIGNER" verify embedded "$T/m.json" "$T/m.sig" >/dev/null 2>&1; then + die "the build at $SIGNER already trusts this key; that cannot be for a key made just now" +fi +say "ok: signs, verifies, not yet embedded" + +say "== 5. detach" +hdiutil detach "$MNT" -quiet +attached && die "still attached at $MNT" +rmdir "$MNT" 2>/dev/null || true +if grep -qF "$PUBHEX" "$OUT"; then die "the public key is readable in the raw image bytes: the image is not encrypted"; fi +sum="$(shasum -a 256 "$OUT" | cut -d' ' -f1)" +say "image sha256 $sum ($(stat -f '%z' "$OUT") bytes)" + +if [ "$PATCH" = 1 ]; then + say "== 6. manifest.rs: OTA_PUBLIC_KEY_2_HEX = $PUBHEX" + tmp="$MANIFEST_RS.new" + sed "s/^pub const OTA_PUBLIC_KEY_2_HEX: &str = \"\";\$/pub const OTA_PUBLIC_KEY_2_HEX: \&str = \"$PUBHEX\";/" "$MANIFEST_RS" > "$tmp" + grep -q "^pub const OTA_PUBLIC_KEY_2_HEX: &str = \"$PUBHEX\";\$" "$tmp" || { rm -f "$tmp"; die "the patch did not take"; } + mv "$tmp" "$MANIFEST_RS" + say "patched; now: git -C '$REPO' diff app/igneum-app/src/manifest.rs, build, run the tests, commit" +else + say "== 6. paste into $MANIFEST_RS:" + say "pub const OTA_PUBLIC_KEY_2_HEX: &str = \"$PUBHEX\";" +fi + +cat < --check on each copy: attaches, signs, verifies, detaches + 3. ship the build that embeds K2 (docs/security/keys.md section 4, step 4), signed with K1 as usual +EOF diff --git a/tools/keys/test-keygen-k2.sh b/tools/keys/test-keygen-k2.sh new file mode 100755 index 000000000..e7982abd4 --- /dev/null +++ b/tools/keys/test-keygen-k2.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# End-to-end test of keygen-k2.sh and with-k2.sh on SCRATCH paths with a throwaway passphrase and a THROWAWAY key. +# Never points at ~/.config/igneum, never at the repository's manifest.rs, never uses a real passphrase. The pair +# it makes is deleted with the scratch folder at exit; nothing of it is written under ~/.config. +# +# tools/keys/test-keygen-k2.sh # exit 0 when every step passes; prints each step +# IGNEUM_OTA_SIGNER= # the signer to use (default: the release build, else the debug build) +# +# Steps: keygen-k2.sh --stdinpass into a scratch image, scratch config folder and a scratch copy of manifest.rs; +# the .pub lands in the config folder (0644) and the private half does not; the manifest copy carries the key; +# the private hex appears nowhere outside the image, not in the raw image bytes either; with-k2.sh --check passes; +# with-k2.sh -- runs a command that signs a manifest the .pub verifies; the real build's `embedded` refuses that +# signature; a second keygen is refused; a wrong passphrase is refused. macOS only (hdiutil). +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +SIGNER="${IGNEUM_OTA_SIGNER:-}" +if [ -z "$SIGNER" ]; then + for c in "$REPO/app/igneum-app/target/release/igneum-ota-sign" "$REPO/app/igneum-app/target/debug/igneum-ota-sign"; do [ -x "$c" ] && { SIGNER="$c"; break; }; done +fi +[ -n "$SIGNER" ] && [ -x "$SIGNER" ] || { echo "no igneum-ota-sign built (cargo build --bin igneum-ota-sign in app/igneum-app)"; exit 1; } +export IGNEUM_OTA_SIGNER="$SIGNER" +T="$(mktemp -d "${TMPDIR:-/tmp}/igneum-k2-test.XXXXXX")"; chmod 700 "$T" +trap 'rm -rf "$T"' EXIT +CFG="$T/config"; mkdir -p "$CFG"; chmod 700 "$CFG" +cp "$REPO/app/igneum-app/src/manifest.rs" "$T/manifest.rs" +# the scratch copy always has the empty slot, whatever the real file says +python3 - "$T/manifest.rs" <<'PY' +import re, sys +p = sys.argv[1]; s = open(p).read() +s2 = re.sub(r'^pub const OTA_PUBLIC_KEY_2_HEX: &str = "[0-9a-f]*";$', 'pub const OTA_PUBLIC_KEY_2_HEX: &str = "";', s, flags=re.M) +assert 'pub const OTA_PUBLIC_KEY_2_HEX: &str = "";' in s2, "no K2 line in manifest.rs" +open(p, 'w').write(s2) +PY +rnd() { head -c "$1" /dev/urandom | base64 | tr -d '\n/+=' | head -c "$1"; } +PASS="test-$(rnd 24)" +OUT="$T/igneum-key-2-test.dmg" +pass=0; fail=0 +step() { printf '\n== %s\n' "$*"; } +ok() { pass=$((pass + 1)); printf ' ok %s\n' "$*"; } +bad() { fail=$((fail + 1)); printf ' FAIL %s\n' "$*"; } + +step "1 keygen into a scratch image" +printf '%s\0' "$PASS" | "$HERE/keygen-k2.sh" --stdinpass --out "$OUT" --config "$CFG" --manifest "$T/manifest.rs" | tee "$T/keygen.txt" +[ -f "$OUT" ] && ok "the image exists" || bad "no image" +[ "$(stat -f '%Lp' "$OUT")" = 600 ] && ok "the image is 0600" || bad "the image is $(stat -f '%Lp' "$OUT")" +[ -f "$CFG/ota-signing-key-2.pub" ] && ok "the .pub is in the config folder" || bad "no .pub" +[ "$(stat -f '%Lp' "$CFG/ota-signing-key-2.pub")" = 644 ] && ok "the .pub is 0644" || bad ".pub mode $(stat -f '%Lp' "$CFG/ota-signing-key-2.pub")" +[ ! -e "$CFG/ota-signing-key-2" ] && ok "no private half in the config folder" || bad "the private half is on disk" +PUBHEX="$(tr -d '[:space:]' < "$CFG/ota-signing-key-2.pub")" +[ ${#PUBHEX} = 64 ] && ok "the public key is 64 hex" || bad "public key: ${#PUBHEX} chars" +grep -q "^pub const OTA_PUBLIC_KEY_2_HEX: &str = \"$PUBHEX\";\$" "$T/manifest.rs" && ok "manifest.rs copy carries K2" || bad "manifest.rs copy not patched" +grep -q "not yet embedded" "$T/keygen.txt" && ok "the running build does not trust the new key" || bad "the embedded check did not run" +! grep -rqF "$PUBHEX" "$CFG" --include='*' --exclude='ota-signing-key-2.pub' 2>/dev/null && ok "nothing but the .pub holds the public hex" || true + +step "2 the private half exists only inside the image" +# attach by hand to read the private hex, then prove it is nowhere else +MNT="$(mktemp -d "$T/mnt.XXXXXX")"; MNT="$(cd "$MNT" && pwd -P)" +printf '%s\0' "$PASS" | hdiutil attach "$OUT" -readonly -nobrowse -noautoopen -stdinpass -mountpoint "$MNT" -quiet +PRIV="$(tr -d '[:space:]' < "$MNT/ota-signing-key-2")" +[ "$(stat -f '%Lp' "$MNT/ota-signing-key-2")" = 600 ] && ok "the private half is 0600 in the image" || bad "private mode" +hdiutil detach "$MNT" -quiet +[ ${#PRIV} = 64 ] && ok "the private half is 64 hex" || bad "private: ${#PRIV} chars" +! grep -rqF "$PRIV" "$CFG" "$T/manifest.rs" "$T/keygen.txt" && ok "the private hex is not in the config folder, the manifest or the output" || bad "the private hex leaked" +! grep -qF "$PRIV" "$OUT" && ok "the raw image bytes do not contain the private hex (encrypted)" || bad "the image is not encrypted" +! grep -qF "$PUBHEX" "$OUT" && ok "nor the public hex" || bad "the public hex is readable in the image" +unset PRIV + +step "3 with-k2.sh --check on the image" +printf '%s\0' "$PASS" | "$HERE/with-k2.sh" "$OUT" --stdinpass --check --config "$CFG" | tee "$T/check.txt" +grep -q "^K2 ok: fingerprint sha256:" "$T/check.txt" && ok "the pair matches" || bad "--check failed" +grep -q "NOT embedded" "$T/check.txt" && ok "and says the build does not embed it" || bad "the embedded line is wrong" + +step "4 with-k2.sh -- a command signs with K2 and the .pub verifies it" +printf '{"platforms":{},"version":"0.3.9","revoked_keys":["8f186e37b48cfddf52d2b37478c562d78a74236fc87f0a675393ed6a35baac4e"]}' > "$T/m.json" +printf '%s\0' "$PASS" | "$HERE/with-k2.sh" "$OUT" --stdinpass --config "$CFG" -- bash -c '"$IGNEUM_OTA_SIGNER" sign "$IGNEUM_OTA_KEY_FILE" "$1" > "$1.sig" && "$IGNEUM_OTA_SIGNER" verify "$IGNEUM_OTA_PUB_FILE" "$1" "$1.sig"' x "$T/m.json" | tee "$T/sign.txt" +grep -q "^ok: version 0.3.9" "$T/sign.txt" && ok "signed inside the image, verified with the .pub" || bad "sign or verify failed" +grep -q "revokes sha256:8f186e37" "$T/sign.txt" && ok "verify names the revoked key" || bad "verify did not print the revocation" +mount | grep -qF "igneum-key-2-mnt" && bad "the image is still attached" || ok "the image was detached" +"$SIGNER" verify embedded "$T/m.json" "$T/m.json.sig" >/dev/null 2>&1 && bad "the real build accepted the throwaway key" || ok "the real build's embedded keys refuse the throwaway signature" +"$SIGNER" verify "$CFG/ota-signing-key-2.pub" "$T/m.json" "$T/m.json.sig" >/dev/null 2>&1 && ok "the .pub alone still verifies it" || bad ".pub verify failed" + +step "5 a second keygen is refused" +printf '%s\0' "$PASS" | "$HERE/keygen-k2.sh" --stdinpass --out "$T/second.dmg" --config "$CFG" --manifest "$T/manifest.rs" >/dev/null 2>&1 && bad "a second K2 was made" || ok "refused (the .pub exists)" +[ ! -e "$T/second.dmg" ] && ok "no second image" || bad "a second image exists" + +step "6 a wrong passphrase is refused" +printf '%s\0' "not-$PASS" | "$HERE/with-k2.sh" "$OUT" --stdinpass --check --config "$CFG" >/dev/null 2>&1 && bad "a wrong passphrase attached the image" || ok "refused" +mount | grep -qF "igneum-key-2-mnt" && bad "something is still attached" || ok "nothing attached" + +echo +echo "$pass passed, $fail failed (throwaway key, scratch folder $T, deleted at exit)" +[ "$fail" = 0 ] diff --git a/tools/keys/with-k2.sh b/tools/keys/with-k2.sh new file mode 100755 index 000000000..0de191ff8 --- /dev/null +++ b/tools/keys/with-k2.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Runs one command with the second OTA signing key, K2, available from its encrypted image (tools/keys/keygen-k2.sh), +# for the minutes of a publish (docs/security/keys.md section 4, step 5). The image is attached read-only at a +# private mount point, IGNEUM_OTA_KEY_FILE and IGNEUM_OTA_PUB_FILE are set for the publish scripts +# (publish-manifest.sh, publish-jobs.sh, push-inputs.sh read them), the command runs, the image is detached at exit, +# also on failure. The private half never leaves the image. +# +# tools/keys/with-k2.sh .dmg> --check attach, sign a test manifest, verify it with +# ~/.config/igneum/ota-signing-key-2.pub, print +# the fingerprint, detach (run on every copy) +# tools/keys/with-k2.sh .dmg> -- the command with K2 as the signing key, e.g. +# tools/keys/with-k2.sh ~/igneum-key-2.dmg -- packaging/ota/publish-manifest.sh --version 0.3.12 \ +# --mac packaging/mac/dist/Igneum-Miner-0.3.12.dmg --notes "..." --revoke --deploy +# --agent (the Security Agent dialog), --stdinpass (the test harness only), --config (scratch) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +export PATH="$HOME/.cargo/bin:$PATH" +CFG="$HOME/.config/igneum" +SIGNER="${IGNEUM_OTA_SIGNER:-$REPO/app/igneum-app/target/release/igneum-ota-sign}" +IMG=""; MODE="tty"; CHECK=0; CMD=() + +usage() { sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } +say() { printf '%s\n' "$*"; } +die() { printf 'with-k2: %s\n' "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --agent) MODE="agent" ;; + --stdinpass) MODE="stdin" ;; + --check) CHECK=1 ;; + --config) CFG="${2:?--config needs a folder}"; shift ;; + --) shift; CMD=("$@"); break ;; + -h|--help) usage ;; + -*) die "unknown argument $1" ;; + *) [ -z "$IMG" ] || die "one image only"; IMG="$1" ;; + esac + shift +done +[ -n "$IMG" ] || usage +[ -f "$IMG" ] || die "no image at $IMG" +[ "$CHECK" = 1 ] || [ ${#CMD[@]} -gt 0 ] || die "--check, or -- " +command -v hdiutil >/dev/null || die "hdiutil is not available (macOS only)" +PUB="$CFG/ota-signing-key-2.pub" +[ -f "$PUB" ] || die "no $PUB (keygen-k2.sh writes it)" +[ -x "$SIGNER" ] || die "no signer at $SIGNER (cargo build --release --bin igneum-ota-sign in app/igneum-app)" + +PASS="" +if [ "$MODE" = stdin ]; then + IFS= read -r -d '' PASS || true + [ -n "$PASS" ] || die "--stdinpass: no passphrase on standard input" +fi + +MNT="$(mktemp -d "${TMPDIR:-/tmp}/igneum-key-2-mnt.XXXXXX")" +MNT="$(cd "$MNT" && pwd -P)" +attached() { mount | grep -qF " on $MNT "; } +cleanup() { + if attached; then hdiutil detach "$MNT" -quiet >/dev/null 2>&1 || hdiutil detach "$MNT" -force -quiet >/dev/null 2>&1 || true; fi + attached || rmdir "$MNT" 2>/dev/null || true + [ -z "${T:-}" ] || rm -rf "$T" +} +trap cleanup EXIT +T="$(mktemp -d "${TMPDIR:-/tmp}/igneum-key-2-chk.XXXXXX")"; chmod 700 "$T" + +case "$MODE" in + tty) hdiutil attach "$IMG" -readonly -nobrowse -noautoopen -mountpoint "$MNT" -quiet ;; + agent) hdiutil attach "$IMG" -readonly -nobrowse -noautoopen -agentpass -mountpoint "$MNT" -quiet ;; + stdin) printf '%s\0' "$PASS" | hdiutil attach "$IMG" -readonly -nobrowse -noautoopen -stdinpass -mountpoint "$MNT" -quiet ;; +esac +attached || die "the image did not attach at $MNT (wrong passphrase, or already attached: hdiutil info)" +KEY="$MNT/ota-signing-key-2" +[ -f "$KEY" ] || die "no ota-signing-key-2 inside the image" + +# the pair must match: a test manifest signed with the image's key verifies with the .pub on disk +printf '{"platforms":{},"version":"0.0.0"}' > "$T/m.json" +"$SIGNER" sign "$KEY" "$T/m.json" > "$T/m.sig" +"$SIGNER" verify "$PUB" "$T/m.json" "$T/m.sig" >/dev/null || die "the key in the image does not match $PUB" +FP="$("$SIGNER" fingerprint "$PUB" | tail -1)" +EMBEDDED_ALL="$("$SIGNER" embedded)" # captured first: a pipe into grep -q under pipefail fails on SIGPIPE +if grep -qx "$(tr -d '[:space:]' < "$PUB")" <<< "$EMBEDDED_ALL"; then EMB="embedded in the build at $SIGNER"; else EMB="NOT embedded in the build at $SIGNER (an older build, or K2 not yet in manifest.rs)"; fi +say "K2 ok: $FP; $EMB" +if [ "$CHECK" = 1 ] && [ ${#CMD[@]} = 0 ]; then + exit 0 +fi + +export IGNEUM_OTA_KEY_FILE="$KEY" IGNEUM_OTA_PUB_FILE="$PUB" +say "running with K2: ${CMD[*]}" +"${CMD[@]}" diff --git a/tools/logs.mjs b/tools/logs.mjs index 608b25455..39426ea50 100755 --- a/tools/logs.mjs +++ b/tools/logs.mjs @@ -7,27 +7,46 @@ // win-*), the version and the "config:" header line of its latest upload (the // intake key's fingerprint and the downloads folder's fingerprint), against the // fingerprints of ~/.config/igneum/log-intake-key.next and dl-token.next; exit 1 -// while any machine still reports with the old values +// while any machine still reports with the old values. Also the "ota keys:" +// line (the signing keys the build trusts and the ones a manifest revoked, +// docs/security/keys.md section 4) against ota-signing-key.pub and +// ota-signing-key-2.pub: the ota_keys column, and a count of the machines +// that embed K2 (the column is informational; it does not set the exit code) // node tools/logs.mjs --self-test the header parser on sample lines // Reads DATABASE_URL from ~/.config/igneum/env. No dependencies: Neon HTTP SQL over fetch. import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { createHash } from 'node:crypto'; -// the two header lines the engine logs at every start (app/igneum-app/src/engine.rs run(), config.rs describe()): +// the three header lines the engine logs at every start (app/igneum-app/src/engine.rs run(), config.rs describe(), +// ota.rs describe_keys()): // IGNEUM-APP version=0.3.6 machine=1ccfe586 platform=windows node=... // config: intake https://.../api/log key 477bb0ef (packaged); manifest https://.../dl//igneum-app-latest.json folder ed9c4d2e (packaged) -// The LAST occurrence wins (the restart after an OTA apply logs them again). Fields missing from the upload read ''. +// ota keys: trusted 8f186e37 1a2b3c4d; revoked none (builds before the K2 release log no such line) +// The LAST occurrence wins (the restart after an OTA apply logs them again). Fields missing from the upload read '' +// (otaTrusted and otaRevoked are arrays of 8-hex fingerprints, empty when the line is missing). export function parseRotation(lines) { - const out = { version: '', keyFp: '', keySource: '', folderFp: '', manifestSource: '' }; + const out = { version: '', keyFp: '', keySource: '', folderFp: '', manifestSource: '', otaTrusted: [], otaRevoked: [], otaLine: false }; for (const line of String(lines).split('\n')) { let m = /IGNEUM-APP version=(\S+)/.exec(line); if (m) out.version = m[1]; m = /config: intake \S+ (?:key ([0-9a-f]{8})|no key) \((packaged|file [^)]+|none)\); manifest \S+ (?:folder ([0-9a-f]{8})|no folder|custom) \((packaged|file [^)]+|none)\)/.exec(line); if (m) { out.keyFp = m[1] || ''; out.keySource = m[2]; out.folderFp = m[3] || ''; out.manifestSource = m[4]; } + m = /ota keys: trusted (none|[0-9a-f]{8}(?: [0-9a-f]{8})*); revoked (none|[0-9a-f]{8}(?: [0-9a-f]{8})*)/.exec(line); + if (m) { out.otaLine = true; out.otaTrusted = m[1] === 'none' ? [] : m[1].split(' '); out.otaRevoked = m[2] === 'none' ? [] : m[2].split(' '); } } return out; } + +// the app's fingerprint of a public key file: the first 8 hex of sha256 over the 32 RAW key bytes (manifest::fingerprint8), +// not over the file's text; '' when the file is missing or not a 64-hex key +export function keyFingerprint8(pubPath) { + try { + const hex = readFileSync(pubPath, 'utf8').trim(); + if (!/^[0-9a-f]{64}$/.test(hex)) return ''; + return createHash('sha256').update(Buffer.from(hex, 'hex')).digest('hex').slice(0, 8); + } catch { return ''; } +} // the first 8 hex of sha256 over the trimmed file content; '' when the file is missing (the app's config::fingerprint8) export function fingerprintFile(path) { if (!existsSync(path)) return ''; @@ -52,6 +71,21 @@ if (process.argv[2] === '--self-test') { check('a custom manifest URL reads no folder', custom.keyFp === '01234567' && custom.folderFp === '', JSON.stringify(custom)); check('an old upload without the config line reads version only', (() => { const o = parseRotation('1 IGNEUM-APP version=0.3.4 machine=a platform=mac node=x\n'); return o.version === '0.3.4' && o.keyFp === '' && o.keySource === ''; })()); check('fingerprintFile of a missing file is empty', fingerprintFile('/nonexistent/igneum/key') === ''); + const keys = parseRotation('1 config: intake none no key (none); manifest none no folder (none)\n1 ota keys: trusted 8f186e37; revoked none\n2 ota keys: trusted 8f186e37 1a2b3c4d; revoked none\n'); + check('the last ota keys line wins', keys.otaLine && keys.otaTrusted.join(',') === '8f186e37,1a2b3c4d' && keys.otaRevoked.length === 0, JSON.stringify(keys)); + const rev = parseRotation('1 ota keys: trusted 1a2b3c4d; revoked 8f186e37\n'); + check('a revoked key is read', rev.otaTrusted.join(',') === '1a2b3c4d' && rev.otaRevoked.join(',') === '8f186e37', JSON.stringify(rev)); + check('a build before the K2 release has no ota line', !parseRotation(sample).otaLine && parseRotation(sample).otaTrusted.length === 0); + check('a malformed ota line is ignored', !parseRotation('1 ota keys: trusted ZZ; revoked none\n').otaLine); + // K1's fingerprint from its public key bytes (docs/security/keys.md: sha256 8f186e37...), computed from a scratch .pub + const { writeFileSync, mkdtempSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const d = mkdtempSync(`${tmpdir()}/igneum-logs-test-`); + writeFileSync(`${d}/k1.pub`, 'b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd\n'); + writeFileSync(`${d}/bad.pub`, 'not a key\n'); + check('keyFingerprint8 is the app\'s fingerprint of the raw key bytes', keyFingerprint8(`${d}/k1.pub`) === '8f186e37', keyFingerprint8(`${d}/k1.pub`)); + check('keyFingerprint8 of a non-key or a missing file is empty', keyFingerprint8(`${d}/bad.pub`) === '' && keyFingerprint8(`${d}/none.pub`) === ''); + rmSync(d, { recursive: true, force: true }); console.log(fails ? `${fails} check(s) failed` : 'all checks passed'); process.exit(fails ? 1 : 0); } @@ -94,16 +128,22 @@ if (runId === '--rotation') { FROM miner_logs WHERE label LIKE 'mac-%' OR label LIKE 'win-%' ORDER BY label, received_at DESC`); if (!rows.length) { console.log('No app uploads yet.'); process.exit(1); } - let moved = 0, stale = 0; + // the signing keys (docs/security/keys.md section 4): K1 and, once made, K2; what each machine's build trusts + const ota = { k1: keyFingerprint8(`${cfg}/ota-signing-key.pub`), k2: keyFingerprint8(`${cfg}/ota-signing-key-2.pub`) }; + let moved = 0, stale = 0, withK2 = 0, withoutK2 = 0, noLine = 0, revokedAny = 0; const table = rows.map(r => { const p = parseRotation(r.lines); const ok = p.keyFp === want.key && p.folderFp === want.folder; if (ok) moved++; else stale++; - return { label: r.label, machine: r.machine, version: p.version || '?', key: p.keyFp || '-', folder: p.folderFp || '-', sources: [p.keySource, p.manifestSource].filter(Boolean).join(' / ') || '-', last_received: new Date(r.received_at).toISOString().replace('T', ' ').slice(0, 19) + ' UTC', state: ok ? 'moved' : p.keyFp === old.key || p.folderFp === old.folder ? 'OLD' : 'unknown' }; + if (!p.otaLine) noLine++; else if (ota.k2 && p.otaTrusted.includes(ota.k2)) withK2++; else withoutK2++; + if (p.otaRevoked.length) revokedAny++; + const otaKeys = !p.otaLine ? '-' : `trusted ${p.otaTrusted.map(f => f === ota.k1 ? `${f}=K1` : f === ota.k2 ? `${f}=K2` : f).join(' ') || 'none'}${p.otaRevoked.length ? `; REVOKED ${p.otaRevoked.join(' ')}` : ''}`; + return { label: r.label, machine: r.machine, version: p.version || '?', key: p.keyFp || '-', folder: p.folderFp || '-', sources: [p.keySource, p.manifestSource].filter(Boolean).join(' / ') || '-', ota_keys: otaKeys, last_received: new Date(r.received_at).toISOString().replace('T', ' ').slice(0, 19) + ' UTC', state: ok ? 'moved' : p.keyFp === old.key || p.folderFp === old.folder ? 'OLD' : 'unknown' }; }).sort((a, b) => a.state.localeCompare(b.state) || a.label.localeCompare(b.label)); console.table(table); console.log(`expected: key ${want.key || '?'} folder ${want.folder || '?'} (from the .next files when they exist, else the plain files); old: key ${old.key || '?'} folder ${old.folder || '?'} (${renamed ? 'from the dated .old-* files' : 'from the plain files'})`); console.log(`${moved} machine(s) on the new key and folder, ${stale} not yet; a machine silent for over a day is listed by its last upload`); + console.log(`signing keys: K1 ${ota.k1 || '?'}${ota.k2 ? `, K2 ${ota.k2}` : ', K2 not made yet (tools/keys/keygen-k2.sh)'}; ${withK2} machine(s) embed K2, ${withoutK2} embed K1 only, ${noLine} log no ota keys line (a build before the K2 release)${revokedAny ? `; ${revokedAny} machine(s) REPORT A REVOKED KEY` : ''}`); process.exit(stale ? 1 : 0); }