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:
parent
52d9a110be
commit
9062ed3e0c
19 changed files with 1027 additions and 132 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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\"";
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
38
tools/ci/pipe-grep-q-check.sh
Executable 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
152
tools/keys/keygen-k2.sh
Executable 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
93
tools/keys/test-keygen-k2.sh
Executable 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
88
tools/keys/with-k2.sh
Executable 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[@]}"
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue