diff --git a/app/igneum-app/Cargo.lock b/app/igneum-app/Cargo.lock index 3806753ef..e6c4992f9 100644 --- a/app/igneum-app/Cargo.lock +++ b/app/igneum-app/Cargo.lock @@ -66,6 +66,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "der" version = "0.7.10" @@ -98,6 +125,30 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -127,6 +178,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "generic-array" version = "0.14.9" @@ -164,11 +221,13 @@ dependencies = [ name = "igneum-app" version = "0.3.0" dependencies = [ + "ed25519-dalek", "getrandom", "k256", "libc", "serde", "serde_json", + "sha2", "sha3", ] @@ -254,6 +313,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "sec1" version = "0.7.3" @@ -268,6 +336,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -295,7 +369,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -311,6 +385,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha3" version = "0.10.9" @@ -346,6 +431,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.6" diff --git a/app/igneum-app/Cargo.toml b/app/igneum-app/Cargo.toml index b8fd8004e..6fdac7f1a 100644 --- a/app/igneum-app/Cargo.toml +++ b/app/igneum-app/Cargo.toml @@ -11,12 +11,20 @@ build = "build.rs" # Windows targets: links resources/igneum-app.rc (coin icon name = "igneum-app" path = "src/main.rs" +# the publisher's manifest signer (packaging/ota/publish-manifest.sh); built here, never shipped +[[bin]] +name = "igneum-ota-sign" +path = "src/bin/ota-sign.rs" + [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" k256 = { version = "0.13", default-features = false, features = ["arithmetic", "std"] } sha3 = { version = "0.10", default-features = false } getrandom = "0.2" +# over-the-air updates (src/manifest.rs, src/ota.rs): Ed25519 manifest signatures and sha256 of the downloads +ed25519-dalek = { version = "2", default-features = false, features = ["std"] } +sha2 = { version = "0.10", default-features = false } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/app/igneum-app/src/bin/ota-sign.rs b/app/igneum-app/src/bin/ota-sign.rs new file mode 100644 index 000000000..a8afc8aac --- /dev/null +++ b/app/igneum-app/src/bin/ota-sign.rs @@ -0,0 +1,95 @@ +//! igneum-ota-sign: the publisher's side of the update manifest. Built with the app (cargo build) but never shipped. +//! It includes src/manifest.rs as is, so the bytes it signs are verified by the same code the app runs. +//! +//! igneum-ota-sign keygen 32-byte seed as hex (0600) and the public key as hex +//! igneum-ota-sign sign prints the detached signature (128 hex) +//! igneum-ota-sign verify exit 0 when it verifies and parses +//! igneum-ota-sign embedded prints the public key compiled into the app and its fingerprint +//! igneum-ota-sign fingerprint +//! igneum-ota-sign sha256 the file's sha256 and size, for the manifest + +#[path = "../manifest.rs"] +mod manifest; + +use ed25519_dalek::{Signer, SigningKey}; +use std::path::Path; + +fn read_key_arg(a: &str) -> String { + if Path::new(a).is_file() { + std::fs::read_to_string(a).unwrap_or_default().trim().to_string() + } else { + a.trim().to_string() + } +} + +fn die(msg: &str) -> ! { + eprintln!("igneum-ota-sign: {msg}"); + std::process::exit(2) +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(|s| s.as_str()) { + Some("keygen") if args.len() == 3 => { + if Path::new(&args[1]).exists() { + die(&format!("{} exists; not overwriting a signing key", args[1])); + } + let mut seed = [0u8; 32]; + getrandom::getrandom(&mut seed).expect("os randomness"); + let sk = SigningKey::from_bytes(&seed); + let pk = manifest::hex_encode(sk.verifying_key().as_bytes()); + if let Some(d) = Path::new(&args[1]).parent() { + let _ = std::fs::create_dir_all(d); + } + std::fs::write(&args[1], format!("{}\n", manifest::hex_encode(&seed))).unwrap_or_else(|e| die(&e.to_string())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&args[1], std::fs::Permissions::from_mode(0o600)); + } + std::fs::write(&args[2], format!("{pk}\n")).unwrap_or_else(|e| die(&e.to_string())); + println!("public key {pk}"); + println!("fingerprint sha256:{}", manifest::fingerprint(&pk)); + } + Some("sign") if args.len() == 3 => { + let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex")); + let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes")); + let sk = SigningKey::from_bytes(&seed); + let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2]))); + let text = std::str::from_utf8(&bytes).unwrap_or_else(|_| die("manifest is not UTF-8")); + manifest::parse(text).unwrap_or_else(|e| die(&format!("refusing to sign: {e}"))); + println!("{}", manifest::hex_encode(&sk.sign(&bytes).to_bytes())); + } + Some("verify") if args.len() == 4 => { + let pk = read_key_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())), + 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)); + } + Some("fingerprint") if args.len() == 2 => { + let pk = read_key_arg(&args[1]); + if manifest::public_key(&pk).is_err() { + die("not a public key"); + } + println!("{pk}"); + println!("fingerprint sha256:{}", manifest::fingerprint(&pk)); + } + Some("sha256") if args.len() == 2 => { + let p = Path::new(&args[1]); + let sum = manifest::sha256_file(p).unwrap_or_else(|e| die(&format!("{}: {e}", args[1]))); + let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0); + println!("{sum} {size}"); + } + _ => { + eprintln!("usage: igneum-ota-sign keygen | sign | verify | embedded | fingerprint | sha256 "); + std::process::exit(2); + } + } +} diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 0c7741544..34faa9405 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -345,6 +345,7 @@ pub struct Engine { clock_https: Option<(f64, Instant)>, clock_next_https: Instant, clock_last_sample: Instant, + last_accepted: Option, last_settings_save: Instant, last_error_event: Instant, } @@ -402,6 +403,7 @@ impl Engine { clock_https: None, clock_next_https: now + Duration::from_secs(5), clock_last_sample: now, + last_accepted: None, last_settings_save: now, last_error_event: now - Duration::from_secs(600), } @@ -1084,8 +1086,9 @@ impl Engine { self.start_watch(); } // no reading in a while: the node is away + let accepted_recent = self.last_accepted.map(|t| now.duration_since(t) <= Duration::from_secs(60)).unwrap_or(false); if let Some(t) = self.node_last_reading { - if now.duration_since(t) > Duration::from_secs(45) { + if now.duration_since(t) > Duration::from_secs(45) && !accepted_recent { let mut st = self.st(); if st.node.state == "synced" { st.node.state = "syncing".into(); @@ -1349,22 +1352,20 @@ impl Engine { let tips = kv_u64(text, "tips").unwrap_or(0); let blue = kv_u64(text, "blue").unwrap_or(0); let difficulty = kv_f64(text, "difficulty").unwrap_or(0.0); - let flag = kv(text, "synced").map(|s| s.to_string()); + let flag = kv(text, "synced").map(|s| s == "true"); let was_synced = self.st().node.synced; - // the rule of today's launchers: the node's own flag when it carries one, else peers > 0, blocks caught up - // with the headers and the count moving (or stable for 60 s); a private test node with unsynced mining - // counts as synced once it answers - let synced = if self.shared.runtime.unsynced_mining && self.shared.runtime.peers.is_empty() { - true // a private test node answering its RPC (genesis alone reads as 0 blocks) - } else if let Some(f) = flag.as_deref() { - f == "true" && peers > 0 - } else if peers > 0 && blocks >= headers && blocks > 1 { - let stable = self.sync_stable_since.get_or_insert(now); - (self.sync_prev.map(|p| blocks > p).unwrap_or(false)) || now.duration_since(*stable) >= Duration::from_secs(60) + // Derived from every reading, never a one-shot transition (PC 2, 4 October 2026: the node caught up after a + // clock fix and mined at 118 MH/s while the card still said "syncing", because getInfo's flag stayed false). + let caught_up_moving = self.sync_prev.map(|p| blocks > p).unwrap_or(false); + if peers > 0 && headers <= blocks + 2 && blocks > 1 { + self.sync_stable_since.get_or_insert(now); } else { self.sync_stable_since = None; - false - }; + } + let stable_s = self.sync_stable_since.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(0.0); + let accepted_recent = self.last_accepted.map(|t| now.duration_since(t) <= Duration::from_secs(60)).unwrap_or(false); + let private = self.shared.runtime.unsynced_mining && self.shared.runtime.peers.is_empty(); + let synced = sync_decision(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private); self.sync_prev = Some(blocks); // the first clock source: local time against the latest block the peers produced, once blocks arrive if blocks > 0 && (peers > 0 || self.shared.runtime.peers.is_empty()) && now.duration_since(self.clock_last_sample) >= Duration::from_secs(9) { @@ -1392,8 +1393,10 @@ impl Engine { String::new() } else if peers == 0 { "waiting for a peer".into() - } else { + } else if headers > blocks + 2 { format!("{blocks} of {headers} blocks") + } else { + "caught up, waiting for the next block".into() }; } if synced && !was_synced { @@ -1439,6 +1442,16 @@ impl Engine { return; } if text.contains(" ACCEPTED block") { + self.last_accepted = Some(Instant::now()); + { + // the node accepted our block: it is at the tip, whatever its flag says + let mut st = self.st(); + if !st.node.synced { + st.node.synced = true; + st.node.state = "synced".into(); + st.node.message = String::new(); + } + } let unix = crate::platform::unix_now_f(); let total = { let mut s = self.shared.settings.lock().unwrap(); @@ -1629,6 +1642,26 @@ impl Engine { } } +/// One `igneum-miner watch` reading. +pub struct Reading { + pub blocks: u64, + pub headers: u64, + pub peers: u64, + /// getInfo's is_synced, when the line carries one + pub flag: Option, +} + +/// Is the node synced? Any of: a private test node; our block accepted in the last minute; the node's own flag; +/// caught up (headers within 2 of blocks, a peer, more than genesis) and the count moving or stable for 60 s. +/// A false flag never overrides caught-up-and-moving: getInfo said false on PC 2 while it mined (4 October 2026). +pub fn sync_decision(r: &Reading, moving: bool, stable_s: f64, accepted_recent: bool, private: bool) -> bool { + if private || accepted_recent || r.flag == Some(true) { + return true; + } + let caught_up = r.peers > 0 && r.headers <= r.blocks + 2 && r.blocks > 1; + caught_up && (moving || stable_s >= 60.0) +} + /// "...block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000" -> at least 63 s behind. fn behind_from_warning(text: &str) -> Option { let nums: Vec = text.split(|c: char| !c.is_ascii_digit()).filter(|s| s.len() >= 12).filter_map(|s| s.parse::().ok()).collect(); @@ -1641,6 +1674,39 @@ fn behind_from_warning(text: &str) -> Option { #[cfg(test)] mod tests { + use super::{sync_decision, Reading}; + + fn r(blocks: u64, headers: u64, peers: u64, flag: Option) -> Reading { + Reading { blocks, headers, peers, flag } + } + + /// PC 2, 4 October 2026: syncing, then every relayed block refused (clock), then the catch-up burst, then steady. + #[test] + fn sync_state_machine() { + // syncing: headers run ahead of blocks + assert!(!sync_decision(&r(0, 500, 1, Some(false)), false, 0.0, false, false)); + // blocks refused for a slow clock: headers climb, blocks do not, nothing accepted + assert!(!sync_decision(&r(0, 600, 1, Some(false)), false, 0.0, false, false)); + assert!(!sync_decision(&r(0, 700, 1, Some(false)), false, 120.0, false, false)); + // the clock is fixed: the catch-up burst, blocks moving but still behind the headers + assert!(!sync_decision(&r(46, 700, 1, Some(false)), true, 0.0, false, false)); + assert!(!sync_decision(&r(400, 700, 1, Some(false)), true, 0.0, false, false)); + // caught up and moving: synced, whatever getInfo's flag says + assert!(sync_decision(&r(699, 700, 1, Some(false)), true, 0.0, false, false)); + assert!(sync_decision(&r(700, 700, 1, None), true, 0.0, false, false)); + // steady: one reading with the same count (a quiet 10 s) is still synced once stable for a minute + assert!(!sync_decision(&r(700, 700, 1, Some(false)), false, 10.0, false, false)); + assert!(sync_decision(&r(700, 700, 1, Some(false)), false, 60.0, false, false)); + // our block accepted in the last minute: proof of sync even with a stale reading + assert!(sync_decision(&r(0, 700, 1, Some(false)), false, 0.0, true, false)); + // the node's own flag + assert!(sync_decision(&r(650, 700, 1, Some(true)), false, 0.0, false, false)); + // no peer: never synced from the numbers alone + assert!(!sync_decision(&r(700, 700, 0, None), true, 100.0, false, false)); + // a private test node + assert!(sync_decision(&r(0, 0, 0, None), false, 0.0, false, true)); + } + #[test] fn node_warning() { let l = "2026-10-04 11:02:11.123+00:00 [WARN ] HandleRelayInvsFlow flow error: the block timestamp is too far into the future: block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000"; diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 2a743e5f4..e43fba434 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -19,6 +19,8 @@ mod platform; mod procs; mod server; mod state; +mod manifest; +mod ota; mod update; use std::io::{BufRead, Write}; diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs new file mode 100644 index 000000000..f27c4d8eb --- /dev/null +++ b/app/igneum-app/src/manifest.rs @@ -0,0 +1,422 @@ +//! The over-the-air update manifest: what the downloads host publishes as `igneum-app-latest.json` with a detached +//! Ed25519 signature next to it (`igneum-app-latest.json.sig`, 64 bytes as 128 hex characters). The signature is over +//! the exact bytes of the manifest file; the publisher (packaging/ota/publish-manifest.sh) writes the file in canonical +//! form (sorted keys, no whitespace) and the app verifies the bytes before it parses them. +//! +//! This module is self-contained (serde_json, ed25519-dalek, sha2 only), so the signer binary +//! (src/bin/ota-sign.rs) includes it with `#[path]` and signs with the very code that verifies. +//! +//! Manifest shape: +//! { +//! "version": "0.3.1", "published_at": "2026-10-04T13:00:00Z", "channel": "devnet", +//! "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": "" } +//! } +//! A platform that is missing is not updated (the Windows build lands later than the Mac one). + +#![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()`. +pub const OTA_PUBLIC_KEY_HEX: &str = "b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd"; + +/// 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; +/// No apply within this many seconds of an hourly program boundary. +pub const BOUNDARY_GUARD_S: i64 = 180; +/// A ready update that found no safe moment for this long is applied anyway (an unsynced node mines nothing). +pub const SAFE_MOMENT_PATIENCE_S: u64 = 6 * 3600; + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct PlatformEntry { + pub url: String, + pub sha256: String, + pub size: u64, + pub kind: String, // dmg | zip | inno-setup +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Manifest { + pub version: String, + pub published_at: String, + pub channel: String, + pub mac: Option, + pub windows: Option, + pub min_supported_version: String, + pub notes: String, + pub activation_height: Option, + pub deadline_note: String, +} + +impl Manifest { + pub fn platform(&self, name: &str) -> Option<&PlatformEntry> { + match name { + "mac" => self.mac.as_ref(), + "windows" => self.windows.as_ref(), + _ => None, + } + } + pub fn this_platform(&self) -> Option<&PlatformEntry> { + self.platform(platform_name()) + } +} + +pub fn platform_name() -> &'static str { + if cfg!(target_os = "macos") { + "mac" + } else if cfg!(windows) { + "windows" + } else { + "linux" + } +} + +pub fn hex_decode(s: &str) -> Option> { + let s = s.trim(); + if s.len() % 2 != 0 { + return None; + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() +} + +pub fn hex_encode(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +pub fn public_key(hex: &str) -> Result { + let bytes = hex_decode(hex).ok_or("public key is not hex")?; + let arr: [u8; 32] = bytes.try_into().map_err(|_| "public key is not 32 bytes")?; + 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. +pub fn fingerprint(pub_hex: &str) -> String { + match hex_decode(pub_hex) { + Some(b) => hex_encode(&Sha256::digest(&b)), + None => 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)?; + 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()) +} + +/// Parses the manifest JSON (after the signature was checked). +pub fn parse(text: &str) -> Result { + let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("manifest is not JSON: {e}"))?; + let s = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + let version = s(&v, "version"); + if parse_version(&version).is_none() { + return Err(format!("manifest version '{version}' is not a version")); + } + let entry = |name: &str| -> Result, String> { + let Some(p) = v.get("platforms").and_then(|p| p.get(name)) else { return Ok(None) }; + if p.is_null() { + return Ok(None); + } + let e = PlatformEntry { url: s(p, "url"), sha256: s(p, "sha256").to_ascii_lowercase(), size: p.get("size").and_then(|x| x.as_u64()).unwrap_or(0), kind: s(p, "kind") }; + if !e.url.starts_with("https://") { + return Err(format!("{name}: the url is not https")); + } + if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("{name}: sha256 is not 64 hex characters")); + } + if e.size == 0 { + return Err(format!("{name}: size is missing")); + } + if !["dmg", "zip", "inno-setup"].contains(&e.kind.as_str()) { + return Err(format!("{name}: kind '{}' is unknown", e.kind)); + } + Ok(Some(e)) + }; + let consensus = v.get("consensus").cloned().unwrap_or(serde_json::Value::Null); + Ok(Manifest { + version, + published_at: s(&v, "published_at"), + channel: s(&v, "channel"), + mac: entry("mac")?, + windows: entry("windows")?, + min_supported_version: s(&v, "min_supported_version"), + notes: s(&v, "notes"), + activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()), + deadline_note: s(&consensus, "deadline_note"), + }) +} + +/// Verifies, then parses. +pub fn verify_and_parse(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result { + verify_signature(manifest_bytes, sig_hex, pub_hex)?; + let text = std::str::from_utf8(manifest_bytes).map_err(|_| "manifest is not UTF-8")?; + parse(text) +} + +/// SHA-256 of a file, streamed, as hex. +pub fn sha256_file(path: &std::path::Path) -> std::io::Result { + use std::io::Read; + let mut f = std::fs::File::open(path)?; + let mut h = Sha256::new(); + let mut buf = vec![0u8; 1 << 20]; + loop { + let n = f.read(&mut buf)?; + if n == 0 { + break; + } + h.update(&buf[..n]); + } + Ok(hex_encode(&h.finalize())) +} + +// ---- versions ----------------------------------------------------------------------------------------------- + +/// major.minor.patch plus an optional pre-release tag ("0.4.0-rc1" sorts before "0.4.0"). A leading "v" is allowed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Version { + pub parts: [u64; 3], + pub pre: Option, +} + +pub fn parse_version(s: &str) -> Option { + let s = s.trim().trim_start_matches('v'); + if s.is_empty() { + return None; + } + let (num, pre) = match s.split_once('-') { + Some((n, p)) if !p.is_empty() => (n, Some(p.to_string())), + Some(_) => return None, + None => (s, None), + }; + let mut parts = [0u64; 3]; + let mut n = 0; + for p in num.split('.') { + if n >= 3 || p.is_empty() { + return None; + } + parts[n] = p.parse().ok()?; + n += 1; + } + if n == 0 { + return None; + } + Some(Version { parts, pre }) +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self.parts.cmp(&other.parts) { + std::cmp::Ordering::Equal => match (&self.pre, &other.pre) { + (None, None) => std::cmp::Ordering::Equal, + (None, Some(_)) => std::cmp::Ordering::Greater, + (Some(_), None) => std::cmp::Ordering::Less, + (Some(a), Some(b)) => a.cmp(b), + }, + o => o, + } + } +} + +/// True when `latest` is a newer version than `current`. Unparseable input is never newer. +pub fn newer(latest: &str, current: &str) -> bool { + match (parse_version(latest), parse_version(current)) { + (Some(a), Some(b)) => a > b, + _ => false, + } +} + +// ---- when to apply -------------------------------------------------------------------------------------------- + +/// What the engine knows when it asks whether now is a safe moment. +#[derive(Clone, Debug, Default)] +pub struct Moment { + pub node_synced: bool, + /// DAA blocks (about seconds) to the next hourly program boundary; None when the node has not said. + pub boundary_eta_s: Option, + /// A worker is starting, exporting a pack or being built: let it finish. + pub miner_busy: bool, + /// How long the update has been ready and waiting. + pub ready_for_s: u64, + /// A consensus activation is close, or this version is below min_supported_version: now beats later. + pub urgent: bool, +} + +/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows. +pub fn safe_to_apply(m: &Moment) -> Result<(), String> { + if m.urgent { + return Ok(()); + } + if m.ready_for_s >= SAFE_MOMENT_PATIENCE_S { + return Ok(()); + } + if !m.node_synced { + return Err("waiting for the node to sync".into()); + } + if let Some(eta) = m.boundary_eta_s { + if (0..=BOUNDARY_GUARD_S).contains(&eta) { + return Err(format!("hourly program boundary in {} s; installing after it", eta.max(1))); + } + } + if m.miner_busy { + return Err("a worker is starting; installing once it runs".into()); + } + Ok(()) +} + +/// A consensus activation is within FORK_URGENT_BLOCKS of the node's DAA score (and the node has a score). +pub fn fork_is_close(activation_height: Option, daa: u64) -> bool { + match activation_height { + Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h, + _ => false, + } +} + +/// `current` is older than the manifest's min_supported_version. +pub fn unsupported(m: &Manifest, current: &str) -> bool { + !m.min_supported_version.is_empty() && newer(&m.min_supported_version, current) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + const SAMPLE: &str = r#"{"channel":"devnet","consensus":{"activation_height":120000,"deadline_note":"difficulty v2"},"min_supported_version":"0.3.0","notes":"difficulty v2 at height 120000","platforms":{"mac":{"kind":"dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":20588331,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-0.3.1.dmg"},"windows":{"kind":"inno-setup","sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","size":43978429,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-Setup-0.3.1.exe"}},"published_at":"2026-10-04T13:00:00Z","version":"0.3.1"}"#; + + fn key() -> (SigningKey, String) { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let pk = hex_encode(sk.verifying_key().as_bytes()); + (sk, pk) + } + + #[test] + fn parses_manifest() { + let m = parse(SAMPLE).unwrap(); + assert_eq!(m.version, "0.3.1"); + assert_eq!(m.channel, "devnet"); + assert_eq!(m.activation_height, Some(120000)); + assert_eq!(m.deadline_note, "difficulty v2"); + assert_eq!(m.min_supported_version, "0.3.0"); + let mac = m.mac.as_ref().unwrap(); + assert_eq!(mac.kind, "dmg"); + assert_eq!(mac.size, 20588331); + assert_eq!(m.windows.as_ref().unwrap().kind, "inno-setup"); + assert_eq!(m.platform("linux"), None); + } + + #[test] + fn missing_platform_is_none_and_bad_entries_fail() { + let m = parse(r#"{"version":"0.3.1","platforms":{"mac":null},"consensus":{"activation_height":null}}"#).unwrap(); + assert!(m.mac.is_none() && m.windows.is_none()); + assert_eq!(m.activation_height, None); + assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"http://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("https")); + assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("sha256")); + assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,"kind":"tar"}}}"#).unwrap_err().contains("kind")); + assert!(parse(r#"{"version":"latest"}"#).is_err()); + assert!(parse("not json").is_err()); + } + + #[test] + 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_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()); + // 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()); + // 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()); + // garbage + assert!(verify_signature(SAMPLE.as_bytes(), "zz", &pk).is_err()); + assert!(verify_signature(SAMPLE.as_bytes(), &sig, "abcd").is_err()); + } + + #[test] + fn sha256_of_file_is_checked_by_the_caller() { + let dir = std::env::temp_dir().join(format!("igneum-manifest-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("x.bin"); + std::fs::write(&f, b"abc").unwrap(); + assert_eq!(sha256_file(&f).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + std::fs::write(&f, b"abd").unwrap(); + assert_ne!(sha256_file(&f).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn versions() { + assert!(newer("0.3.1", "0.3.0")); + assert!(newer("0.4.0", "0.3.9")); + assert!(newer("1.0.0", "0.99.99")); + assert!(newer("v0.3.1", "0.3.0")); + assert!(!newer("0.3.0", "0.3.0")); + assert!(!newer("0.2.9", "0.3.0")); + assert!(!newer("0.3", "0.3.0")); + assert!(newer("0.3.1", "0.3")); + assert!(newer("0.3.1", "0.3.1-rc1")); + assert!(!newer("0.3.1-rc1", "0.3.1")); + assert!(newer("0.3.1-rc2", "0.3.1-rc1")); + assert!(!newer("", "0.3.0")); + assert!(!newer("latest", "0.3.0")); + assert!(!newer("0.3.1", "garbage")); + assert!(!newer("0.3.1-", "0.3.0")); + assert!(!newer("0.3.1.2", "0.3.0")); + } + + #[test] + fn safe_moments() { + let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false }; + assert!(safe_to_apply(&base).is_ok()); + assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync"); + assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s")); + assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err()); + assert!(safe_to_apply(&Moment { boundary_eta_s: Some(181), ..base.clone() }).is_ok()); + assert!(safe_to_apply(&Moment { boundary_eta_s: None, ..base.clone() }).is_ok()); + assert!(safe_to_apply(&Moment { miner_busy: true, ..base.clone() }).unwrap_err().contains("worker")); + // urgent beats every wait + assert!(safe_to_apply(&Moment { node_synced: false, boundary_eta_s: Some(5), miner_busy: true, urgent: true, ..base.clone() }).is_ok()); + // patience: an unsynced node for 6 h applies anyway + assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok()); + assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err()); + } + + #[test] + fn fork_closeness_and_support() { + assert!(!fork_is_close(None, 100_000)); + assert!(!fork_is_close(Some(120_000), 0)); + assert!(!fork_is_close(Some(120_000), 118_199)); + assert!(fork_is_close(Some(120_000), 118_200)); + assert!(fork_is_close(Some(120_000), 120_000)); + assert!(fork_is_close(Some(120_000), 130_000)); + let m = parse(SAMPLE).unwrap(); + assert!(!unsupported(&m, "0.3.0")); + assert!(unsupported(&m, "0.2.9")); + assert!(!unsupported(&parse(r#"{"version":"0.3.1"}"#).unwrap(), "0.0.1")); + } + + #[test] + fn fingerprint_is_sha256_of_key_bytes() { + let (_, pk) = key(); + assert_eq!(fingerprint(&pk).len(), 64); + assert_eq!(fingerprint("zz"), ""); + } +} diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs new file mode 100644 index 000000000..179adb7b4 --- /dev/null +++ b/app/igneum-app/src/ota.rs @@ -0,0 +1 @@ +//! placeholder diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 1f98b6171..ef6950618 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -272,7 +272,10 @@ $('d-blocks-sub').textContent = m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''); var nodeWord = n.state === 'synced' ? 'synced' : n.state === 'syncing' ? 'syncing' : n.state; $('d-node').textContent = nodeWord; - $('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + Math.round(Math.abs(s.clock.skew_s)) + ' s ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': fix it to mine' : n.state === 'synced' ? ('height ' + withCommas(n.blocks) + ', ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's')) : n.state === 'syncing' ? (n.message || (withCommas(n.blocks) + ' blocks')) : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s') : (n.message || ''); + // the live numbers always show, so a stuck label is visible as stuck + var live = 'height ' + withCommas(n.blocks) + (n.headers > n.blocks ? ' of ' + withCommas(n.headers) : '') + ' · daa ' + withCommas(n.daa) + ' · ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's'); + $('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + Math.round(Math.abs(s.clock.skew_s)) + ' s ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': fix it to mine' : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s · ' + live) : (n.state === 'syncing' && n.message ? n.message + ' · ' : '') + live; + $('d-node-sub').title = n.last_reading_age_s >= 0 ? 'last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : ''; var cell = $('d-node-cell'); cell.classList.toggle('ok', n.state === 'synced'); cell.classList.toggle('bad', n.state === 'failed' || n.state === 'restarting' || n.state === 'stopped' || (s.clock && s.clock.severity === 'block')); if (n.daa > 0 && p.eta_s >= 0) { $('d-eta').textContent = hms(p.eta_s); $('d-eta-sub').textContent = p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : ''); } else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = 'waiting for the node'; } diff --git a/docs/design/app-screens/mac-dashboard.png b/docs/design/app-screens/mac-dashboard.png index 2e319310e..4229305ef 100644 Binary files a/docs/design/app-screens/mac-dashboard.png and b/docs/design/app-screens/mac-dashboard.png differ