OTA: the second signing key (K2) with revocation, docs/security/keys.md section 4 steps 2 and 3

manifest.rs: OTA_PUBLIC_KEYS = [K1, K2] (K2 empty until tools/keys/keygen-k2.sh fills it; an empty slot is skipped),
verify_signature over the slice returning the key that verified, fingerprint8, the optional signed revoked_keys
list, the revoked.json record (load, save, trusted_keys) and check(): a key never revokes itself, a revoked key is
refused by name. ota.rs fetch_manifest goes through check() and writes <updates>/revoked.json; jobrun.rs verifies the
jobs file with the same trusted keys at every poll; jobs.rs, inputs.rs and ota-sign.rs take the slice; `embedded`
prints every key (K1 on lines 1 and 2 as before) and is accepted as the key argument of every verify command; the
engine logs "ota keys: trusted ...; revoked ..." at start and tools/logs.mjs --rotation shows it as the ota_keys
column with a K2 count.

Publisher side: publish-manifest.sh --revoke <fingerprint> (carried over until --no-revoke, the signer's own refused),
IGNEUM_OTA_KEY_FILE/IGNEUM_OTA_PUB_FILE in the three signing scripts, the embedded check accepts any embedded key.
tools/keys/keygen-k2.sh makes K2 straight into a new AES-256 image (private half never on disk), writes the .pub and
patches manifest.rs; tools/keys/with-k2.sh runs one publish with K2 from the image; tools/keys/test-keygen-k2.sh
proves both on a scratch folder with a throwaway key (25 checks).

Tests: manifest::tests second_key_verifies_third_key_fails_first_key_still_passes, revoked_keys_parse,
revocation_path; inputs sign_verify_and_tamper on the two-key slice; 79 igneum-app and 30 igneum-ota-sign unit tests
pass on the Mac; test-inputs-signing.sh 16, test-publish-jobs.sh 24, logs.mjs --self-test 12, no-secrets 0 hits.

Class fix: `"$SIGNER" embedded | grep -q` under pipefail failed on SIGPIPE once in three runs (grep exits at the
first match, the signer still has lines to write); the output is captured first in all four places, and
tools/ci/pipe-grep-q-check.sh (self-tested, wired into ci.yml) fails CI when the shape comes back.

K2 itself is not made here: the exact commands for the project lead are in docs/security/keys.md section 4. The wallet
(wallet-v1, app/igneum-common/src/manifest.rs, its own copy) is listed there as the follow-up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 20:29:23 +00:00
parent 52d9a110be
commit 9062ed3e0c
19 changed files with 1027 additions and 132 deletions

View file

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

View file

@ -3,8 +3,11 @@
//!
//! igneum-ota-sign keygen <private-key-file> <public-key-file> 32-byte seed as hex (0600) and the public key as hex
//! igneum-ota-sign sign <private-key-file> <manifest.json> prints the detached signature (128 hex)
//! igneum-ota-sign verify <public-key-file|hex> <manifest.json> <sig-file> 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 <public-key-file|hex|embedded> <manifest.json> <sig-file> 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 <public-key-file|hex>
//! igneum-ota-sign sha256 <file> the file's sha256 and size, for the manifest
//! igneum-ota-sign sign-jobs <private-key-file> <igneum-jobs.json> 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<String> {
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::<Vec<_>>().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<String> = vec![format!("signature by {}", manifest::fingerprint(&pk))];
let mut checked: Vec<String> = 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 <priv> <pub> | sign <priv> <manifest.json> | verify <pub> <manifest.json> <sig> | embedded | fingerprint <pub> | sha256 <file> | sign-jobs <priv> <jobs.json> | verify-jobs <pub> <jobs.json> <sig> | envelope-jobs <pub> <jobs.json> <sig> | verify-signed-jobs <pub> <jobs.signed.json> | sign-inputs <priv> <payload-inputs.json> | verify-inputs <pub|embedded> <payload-inputs.json> <sig> [--zip z] [--dir d] [--node-commit c]");
eprintln!("usage: igneum-ota-sign keygen <priv> <pub> | sign <priv> <manifest.json> | verify <pub|embedded> <manifest.json> <sig> | embedded | fingerprint <pub> | sha256 <file> | sign-jobs <priv> <jobs.json> | verify-jobs <pub|embedded> <jobs.json> <sig> | envelope-jobs <pub|embedded> <jobs.json> <sig> | verify-signed-jobs <pub|embedded> <jobs.signed.json> | sign-inputs <priv> <payload-inputs.json> | verify-inputs <pub|embedded> <payload-inputs.json> <sig> [--zip z] [--dir d] [--node-commit c] (<pub> = a key file or hex; embedded = every key compiled into the app)");
std::process::exit(2);
}
}

View file

@ -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()));

View file

@ -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<InputsManifest, String> {
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<InputsManifest, String> {
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<InputsManifest, String> {
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]

View file

@ -128,6 +128,8 @@ pub struct Jobs {
active: Option<Active>,
needs_logged: std::collections::HashSet<String>,
fingerprint: String,
/// <app dir>/updates/revoked.json: the keys a signed manifest revoked (manifest.rs); read at every fetch
revoked_path: PathBuf,
wake: Arc<WakeCtl>,
/// 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<String> = 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>), String> {
fn fetch_jobs(url: &str, dir: &Path, revoked_path: &Path) -> Result<(jobs::JobsFile, Vec<String>), 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<String>), 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<String>), 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<String>), 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::<Vec<_>>().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\"";

View file

@ -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<String, String> {
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<String, String> {
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<u8>, 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<JobsFile, String> {
pub fn verify_and_parse_signed(bytes: &[u8], pub_keys: &[&str]) -> Result<JobsFile, String> {
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<String>, Vec<u8>), String> {
pub fn verify_and_parse_signed_lenient(bytes: &[u8], pub_keys: &[&str]) -> Result<(JobsFile, Vec<String>, Vec<u8>), 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<JobsFile, String> {
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<JobsFile, 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(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>), 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>), 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]

View file

@ -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": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } }
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } },
//! "revoked_keys": ["<sha256 fingerprint of an embedded public key>"] 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 <app data>/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 <app data>/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 <app data>/tuning.json for the GPU workers; signed with the rest of the manifest.
pub tuning: Option<serde_json::Value>,
/// 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<String>,
}
impl Manifest {
@ -102,21 +123,150 @@ pub fn public_key(hex: &str) -> Result<VerifyingKey, String> {
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 <updates>/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<Revoked> {
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<Vec<Revoked>, 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<serde_json::Value> = 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<String>,
}
/// 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<Revoked>, now: u64) -> Result<Checked, String> {
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<Manifest, String> {
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<String> = 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<Manifest, String> {
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<Manifest, String> {
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::<Vec<_>>().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);
}
}

View file

@ -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 <fp8> [<fp8>]; revoked none|<fp8> ...", 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<String> = manifest::trusted_keys(manifest::OTA_PUBLIC_KEYS, &revoked).iter().map(|k| manifest::fingerprint8(k)).collect();
let dead: Vec<String> = 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 <updates>/manifest.json(.sig), verifies, parses.
fn fetch_manifest(url: &str, dir: &Path) -> Result<Manifest, String> {
/// Fetches the manifest and its signature into <updates>/manifest.json(.sig), verifies with the trusted keys
/// (the embedded ones minus <updates>/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<Shared>) -> Result<Manifest, String> {
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<Manifest, String> {
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::<Vec<_>>().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)

View file

@ -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-<date>.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 <K2 fp>` 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/<token>/`: 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 <verify command> embedded`; the list minus `<app data>/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: [<fingerprint>]`, 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-<date>.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: [<sha256 fingerprint>]`, 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 `<app data>/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 <fingerprint>` (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 <fp8> [<fp8>]; revoked none|<fp8>...`, 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 <image> -- 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-<date>.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-<date>.dmg /Volumes/<stick 1>/ && cp ~/Desktop/igneum-key-2-<date>.dmg /Volumes/<stick 2>/
tools/keys/with-k2.sh /Volumes/<stick 1>/igneum-key-2-<date>.dmg --check # on each copy: attaches, signs, verifies, detaches
rm -P ~/Desktop/igneum-key-2-<date>.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 `-- <command>` 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/<token>/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

View file

@ -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 <image> -- <publish command>`
signs with it; `publish-manifest.sh --revoke <fingerprint>` 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

View file

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

View file

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

View file

@ -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 <sha256 fingerprint>]... [--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 <pub>); 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() { # <file> -> "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 <pub>)" >&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

View file

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

38
tools/ci/pipe-grep-q-check.sh Executable file
View file

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

152
tools/keys/keygen-k2.sh Executable file
View file

@ -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-<YYYY-MM-DD>.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 <copy> --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 <<EOF
K2 made. What exists where:
private half only inside $OUT (passphrase: yours, on paper, apart from the media)
public half $PUB (0644) and manifest.rs
fingerprint sha256:$FP (the value tools/logs.mjs --rotation shows as ${FP:0:8})
Next:
1. copy $OUT to the two media that hold the backup image; delete the Desktop copy (rm -P)
2. tools/keys/with-k2.sh <copy> --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

93
tools/keys/test-keygen-k2.sh Executable file
View file

@ -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=<path> # 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 ]

88
tools/keys/with-k2.sh Executable file
View file

@ -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 <igneum-key-2-<date>.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 <igneum-key-2-<date>.dmg> -- <command...> 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 <K1 fingerprint> --deploy
# --agent (the Security Agent dialog), --stdinpass (the test harness only), --config <dir> (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...>"
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[@]}"

View file

@ -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/<token>/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);
}