From 81a1337d5608321637e27f4fd303b8571e4ef2df Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:52:34 +0000 Subject: [PATCH] jobs: one signed object (igneum-jobs.signed.json) so a file and a signature from two deployments can never pair The 13:19:41Z refusal on PC 2: fetch_jobs took igneum-jobs.json and .sig in two requests while the edge was still serving the previous deployment for one of them. The signer wraps the verified pair into one object and reads it back; the app fetches that object (the pair only when none is published); publish-jobs.sh writes and mirrors all three files and verifies every folder after the deploy; tools/jobs.mjs reads the envelope. Tests: jobs.rs signed_envelope_binds_file_and_signature, packaging/ota/test-publish-jobs.sh (24 checks). Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/bin/ota-sign.rs | 22 +++++- app/igneum-app/src/jobrun.rs | 47 +++++++++---- app/igneum-app/src/jobs.rs | 103 ++++++++++++++++++++++++++++ docs/plans/release-0.3.6.md | 2 +- docs/plans/release-0.3.8.md | 2 +- packaging/ota/publish-jobs.sh | 86 +++++++++++++++++------ packaging/ota/test-publish-jobs.sh | 105 +++++++++++++++++++++++++++++ tools/jobs.mjs | 26 +++++-- tools/ship-app.mjs | 2 +- 9 files changed, 350 insertions(+), 45 deletions(-) create mode 100755 packaging/ota/test-publish-jobs.sh diff --git a/app/igneum-app/src/bin/ota-sign.rs b/app/igneum-app/src/bin/ota-sign.rs index 436121a93..32c26df46 100644 --- a/app/igneum-app/src/bin/ota-sign.rs +++ b/app/igneum-app/src/bin/ota-sign.rs @@ -9,6 +9,9 @@ //! igneum-ota-sign sha256 the file's sha256 and size, for the manifest //! igneum-ota-sign sign-jobs the remote-jobs file (src/jobs.rs), same key //! igneum-ota-sign verify-jobs +//! igneum-ota-sign envelope-jobs prints igneum-jobs.signed.json: +//! the file and its signature in ONE object (src/jobs.rs), refused when the pair does not verify +//! igneum-ota-sign verify-signed-jobs //! igneum-ota-sign sign-inputs the Windows build inputs (src/inputs.rs), same key //! igneum-ota-sign verify-inputs //! [--zip ] [--dir ] [--node-commit <40 hex>] @@ -122,6 +125,23 @@ fn main() { Err(e) => die(&e), } } + Some("envelope-jobs") 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]))); + 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}"))); + println!("{env}"); + } + Some("verify-signed-jobs") if args.len() == 3 => { + let pk = read_key_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) { + Ok(f) => println!("ok: {} job(s), published {}, file and signature in one object", f.jobs.len(), f.published_at), + Err(e) => die(&e), + } + } Some("sign-inputs") 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")); @@ -169,7 +189,7 @@ fn main() { println!("ok: inputs built {} from node commit {} ({}); checked: {}", m.built_at, m.node_source_commit, m.node_source_branch, checked.join(", ")); } _ => { - eprintln!("usage: igneum-ota-sign keygen | sign | verify | embedded | fingerprint | sha256 | sign-jobs | verify-jobs | sign-inputs | verify-inputs [--zip z] [--dir d] [--node-commit c]"); + eprintln!("usage: igneum-ota-sign keygen | sign | verify | embedded | fingerprint | sha256 | sign-jobs | verify-jobs | envelope-jobs | verify-signed-jobs | sign-inputs | verify-inputs [--zip z] [--dir d] [--node-commit c]"); std::process::exit(2); } } diff --git a/app/igneum-app/src/jobrun.rs b/app/igneum-app/src/jobrun.rs index 1d669f1cc..bc833a21e 100644 --- a/app/igneum-app/src/jobrun.rs +++ b/app/igneum-app/src/jobrun.rs @@ -1,9 +1,9 @@ //! The remote-job runner (the model is src/jobs.rs). Driven from the engine's tick like the updater: //! poll (40 s after start, then every 2 minutes) and, since 0.3.6, a wake: one thread long-polls the relay's public //! /wake and the engine fetches the moment publish-jobs.sh records a new jobs-file stamp (seconds, not minutes; -//! backoff 5, 15, 60 s while the relay is unreachable, the 2-minute poll carries on). The fetch: igneum-jobs.json -//! and its .sig from the folder of the -//! update manifest, verify with the OTA public key, parse, keep the jobs this machine has not run that target it +//! backoff 5, 15, 60 s while the relay is unreachable, the 2-minute poll carries on). The fetch: the signed +//! envelope igneum-jobs.signed.json (0.3.9; the pair igneum-jobs.json + .sig when no envelope is published) from +//! the folder of the update manifest, verify with the OTA public key, parse, keep the jobs this machine has not run that target it //! (machine id, platform, requirements probed here: wsl, wsl-prover, nvidia) //! -> run them one at a time, in file order; each id at most once (jobs-state.json, written before the run) //! -> kinds: run (a script, optionally elevated, optionally with the miners stopped first), fetch (a file by @@ -779,19 +779,42 @@ fn curl(args: &[&str], limit: Duration) -> Result<(), String> { if code == Some(0) { Ok(()) } else { Err(if t.is_empty() { format!("curl exit {code:?}") } else { t.lines().last().unwrap_or("curl failed").to_string() }) } } -/// The jobs file and its signature, verified; jobs of a kind this version does not know come back as skipped ids. +/// The jobs file, verified; jobs of a kind this version does not know come back as skipped ids. +/// +/// One request: the signed envelope `igneum-jobs.signed.json` (jobs.rs, 0.3.9) carries the file and its +/// signature together, so the two can never come from two deployments (5 October 2026, 13:19:41Z: this function +/// 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> { let jf = dir.join("jobs.json.new"); let sf = dir.join("jobs.json.sig.new"); - let _ = std::fs::remove_file(&jf); - let _ = std::fs::remove_file(&sf); - curl(&["-fsSL", "--max-time", "20", "-o", &jf.display().to_string(), url], Duration::from_secs(25)).map_err(|e| if e.contains("404") { "no jobs file published".to_string() } else { format!("jobs file: {e}") })?; - curl(&["-fsSL", "--max-time", "20", "-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 (f, skipped) = jobs::verify_and_parse_lenient(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEY_HEX)?; + let ef = dir.join("jobs.signed.json.new"); + for f in [&jf, &sf, &ef] { + let _ = std::fs::remove_file(f); + } + let signed_url = jobs::signed_jobs_url(url); + 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 _ = std::fs::write(&jf, &inner); + let _ = std::fs::rename(&ef, dir.join("jobs.signed.json")); + (f, skipped) + } + Err(e) if e.contains("404") => { + // no envelope published: the pair, two requests (a publisher before 0.3.9) + curl(&["-fsSL", "--max-time", "20", "-H", "Cache-Control: no-cache", "-o", &jf.display().to_string(), url], Duration::from_secs(25)).map_err(|e| if e.contains("404") { "no jobs file published".to_string() } else { format!("jobs file: {e}") })?; + 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 _ = std::fs::rename(&sf, dir.join("jobs.json.sig")); + r + } + Err(e) => return Err(format!("signed jobs file: {e}")), + }; let _ = std::fs::rename(&jf, dir.join("jobs.json")); - let _ = std::fs::rename(&sf, dir.join("jobs.json.sig")); Ok((f, skipped)) } diff --git a/app/igneum-app/src/jobs.rs b/app/igneum-app/src/jobs.rs index 143726e8d..e54805bf9 100644 --- a/app/igneum-app/src/jobs.rs +++ b/app/igneum-app/src/jobs.rs @@ -50,6 +50,15 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; pub const JOBS_FILE: &str = "igneum-jobs.json"; +/// The signed envelope (0.3.9): ONE file that carries the jobs file and its signature together, so an app never +/// pairs a file with a signature from another deployment. 5 October 2026, 13:19:41Z: PC 2's 0.3.7 app fetched +/// `igneum-jobs.json` and then `igneum-jobs.json.sig` in two requests while a deploy was landing on the edge, got +/// a pair that did not belong together and logged "jobs file signature does not verify"; the identical bytes +/// re-signed verified four minutes later. The pair stays published for apps before 0.3.9. +/// Shape: `{"file":"","format":"igneum-jobs-signed-1","sig":""}`; +/// the signature is over the bytes of `file`, so the same key and the same signer sign both forms. +pub const JOBS_SIGNED_FILE: &str = "igneum-jobs.signed.json"; +pub const JOBS_SIGNED_FORMAT: &str = "igneum-jobs-signed-1"; pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark", "build"]; /// Requirements the engine knows how to probe (src/jobrun.rs). An unknown requirement is never satisfied. pub const KNOWN_REQUIRES: &[&str] = &["wsl", "wsl-prover", "nvidia"]; @@ -130,6 +139,58 @@ impl Job { } } +/// The signed envelope sits next to the jobs file: same folder, fixed name (`/igneum-jobs.signed.json`). +pub fn signed_jobs_url(jobs_url: &str) -> String { + let u = jobs_url.trim(); + if u.is_empty() { + return String::new(); + } + match u.rfind('/') { + Some(i) => format!("{}/{}", &u[..i], JOBS_SIGNED_FILE), + None => String::new(), + } +} + +/// The envelope text for a jobs file and its detached signature: what publish-jobs.sh writes next to the pair +/// (through `igneum-ota-sign envelope-jobs`). The signature is checked here, so a pair that does not belong +/// together is never wrapped. Keys in sorted order, no whitespace, as the jobs file itself. +pub fn signed_envelope(file: &[u8], sig_hex: &str, pub_hex: &str) -> Result { + manifest::verify_signature(file, sig_hex.trim(), pub_hex).map_err(|_| "jobs file signature does not verify; not wrapping it".to_string())?; + 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())) +} + +/// Opens the envelope: the jobs file bytes and the signature hex, both as strings in one JSON object. Nothing is +/// verified here; `verify_and_parse_signed*` do that over the exact inner bytes. +pub fn open_envelope(bytes: &[u8]) -> Result<(Vec, String), String> { + let text = std::str::from_utf8(bytes).map_err(|_| "signed jobs file is not UTF-8")?; + let v: Value = serde_json::from_str(text).map_err(|e| format!("signed jobs file is not JSON: {e}"))?; + let format = v.get("format").and_then(|x| x.as_str()).unwrap_or(""); + if format != JOBS_SIGNED_FORMAT { + return Err(format!("signed jobs file: format '{format}' is not {JOBS_SIGNED_FORMAT}")); + } + let file = v.get("file").and_then(|x| x.as_str()).ok_or("signed jobs file has no \"file\" string")?; + let sig = v.get("sig").and_then(|x| x.as_str()).ok_or("signed jobs file has no \"sig\" string")?.trim(); + if sig.len() != 128 || !sig.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("signed jobs file: sig is not 128 hex characters".into()); + } + Ok((file.as_bytes().to_vec(), sig.to_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 { + let (file, sig) = open_envelope(bytes)?; + verify_and_parse(&file, &sig, pub_hex) +} + +/// The runner's check of an envelope: as `verify_and_parse_lenient` over the inner pair. Also returns the inner +/// file bytes, which the runner keeps on disk as jobs.json for the dashboard. +pub fn verify_and_parse_signed_lenient(bytes: &[u8], pub_hex: &str) -> Result<(JobsFile, Vec, Vec), String> { + let (file, sig) = open_envelope(bytes)?; + let (f, skipped) = verify_and_parse_lenient(&file, &sig, pub_hex)?; + Ok((f, skipped, file)) +} + /// The jobs file sits next to the update manifest: same folder, fixed name. pub fn jobs_url_from_manifest(manifest_url: &str) -> String { let u = manifest_url.trim(); @@ -820,6 +881,48 @@ mod tests { assert_eq!(parse_lenient(SAMPLE).unwrap().1.len(), 0); } + /// The envelope: one object, the file text and its signature together. A file with the signature of another + /// file (the 13:19:41Z pair) is refused at wrapping time and at reading time; a tampered inner text is refused; + /// a missing field, another format and a short sig are named. + #[test] + 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(); + 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(); + assert_eq!(f.jobs.len(), 2); + let (f2, skipped, inner) = verify_and_parse_signed_lenient(env.as_bytes(), &pk).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"); + // 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"); + // 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"); + // 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()); + // 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()); + // 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(""), ""); + } + #[test] fn signature_verifies_and_tampering_fails() { let (sk, pk) = key(); diff --git a/docs/plans/release-0.3.6.md b/docs/plans/release-0.3.6.md index 6797d9a04..a60f083f4 100644 --- a/docs/plans/release-0.3.6.md +++ b/docs/plans/release-0.3.6.md @@ -652,7 +652,7 @@ of which the update-now job's reach on the 0.3.5 PCs (10-minute poll) was 9 min | Item | State | |---|---| | Why the PC-built Windows node dies at start even with matching DLLs (section 9) | 0.3.8: reproduce under WSL (wine) or on PC 2 in a scratch run; GCC 13 posix vs GCC 16; `-static` and rocksdb's thread model. Until then the Windows node is the Mac cross-build | -| `publish-jobs.sh --deploy` writes one folder | mirror to the NEXT folder while the rotation lasts (done by hand at 10:22Z) | +| `publish-jobs.sh --deploy` writes one folder; PC 2's 0.3.7 app refused one jobs file (`jobs file signature does not verify`, 13:19:41Z, release-0.3.8 plan section 11) | ANSWERED (housekeeping, 5 October 2026, commit on branch `housekeeping`). Cause: the app fetched `igneum-jobs.json` and then `igneum-jobs.json.sig` in two requests (`jobrun.rs` `fetch_jobs`), and the edge serves the previous deployment for some seconds after a deploy, per object (the same afternoon a publish needed 4 live-check tries, 15 s, before the edge served the new file). Two requests a moment apart can therefore return a file from one deployment and a signature from the other: a pair that does not belong together, which the key correctly refuses. The mirror step (bdde87a) was not the cause: it copies after the signer's read-back and one deploy ships both folders. Fix: ONE object, `igneum-jobs.signed.json` (`{"file":"","format":"igneum-jobs-signed-1","sig":""}`), made by the signer (`igneum-ota-sign envelope-jobs`, which refuses a pair that does not verify) and read back by it (`verify-signed-jobs`) before anything moves into place; the app fetches that one object (0.3.9 `fetch_jobs`, `Cache-Control: no-cache`, the pair only when no envelope is published); `publish-jobs.sh` writes all three files, mirrors all three, and after a deploy verifies the envelope and the pair in EVERY folder it wrote (`verify_live` walks `folders()`); `tools/jobs.mjs` reads the envelope; `ship-app.mjs` carries it. Tests: `jobs.rs` `signed_envelope_binds_file_and_signature` (round trip, a file with another publish's signature refused at wrapping and at reading with the words the app logs, a tampered inner byte, another key, each shape error named; 27 signer tests pass), `packaging/ota/test-publish-jobs.sh` (24 checks with the real key in a `--dest` folder: the three files, the envelope's text IS the plain file byte for byte, the stale pair refused by the signer and as a hand-made envelope, `sign` rewrites all three). Until the 0.3.9 apps are out, a refusal of this kind is harmless: the next poll (2 minutes) or wake fetches a consistent pair | | The app marks "update complete" on its own health | it should wait for the node's first DAA score, so a dead node rolls back (the 0.3.6 PCs looped for 20 minutes) | | Two finality tests under the five-package parallel suite | test isolation (per-process PoW cache directory); a clean `kaspa-consensus`-alone run on 2b6d23ef still owed | | igneumd not reproducible across PC 1 and PC 2 (8f) | unverified why | diff --git a/docs/plans/release-0.3.8.md b/docs/plans/release-0.3.8.md index 6e6cda0e6..e81ac54a2 100644 --- a/docs/plans/release-0.3.8.md +++ b/docs/plans/release-0.3.8.md @@ -192,7 +192,7 @@ WSL host, so it reports `command` and trusts: not changed by this release); PC 3 |---|---| | PC 37ba0461 | silent from 13:52:20Z (before the update-now job); on 0.3.7; takes 0.3.8 on its next check (hourly slot minute 57) or when the job reaches it | | Sam's Mac 3a9bf309 | silent since 09:41Z, on 0.3.5; the same | -| PC 2's 0.3.7 app refused the jobs file once (`jobs file signature does not verify`, 13:19:41Z) | the identical bytes re-signed verified four minutes later; the signer, the key and `jobs.rs` are unchanged since 0.3.7. Unexplained; if it recurs, diff the fetched bytes on the PC against the published file (a collect job on `jobs/` would show them) | +| PC 2's 0.3.7 app refused the jobs file once (`jobs file signature does not verify`, 13:19:41Z) | EXPLAINED and closed (housekeeping, 5 October 2026): the app fetched the file and its signature in two requests while the edge was still serving the previous deployment for one of the two objects (a publish that afternoon needed 15 s before the edge served the new file), so the pair did not belong together. From 0.3.9 the app fetches `igneum-jobs.signed.json`, one object carrying the file text and the signature, and the publisher verifies every folder after the deploy. Details: release-0.3.6 plan, section 10 | | The pool was not observed empty before the Mac's host changed (section 7, step 2) | inferred from the 23 minutes between the last record and the restart; the watch that should have reported it treated a connection error as "not empty" and said nothing: the next watch of this kind prints every error line | | The package gate's execute half | skipped (`SKIP_GATE=1` on instruction); the native half ran on the DMG's host; PC 2's CUDA build proved three shards within two minutes, which is the stronger check | | PC 2's prover crates rebuilt in 5.65 s | plausible (three small crates, every dependency cached) and the installed host answers `--mode id`, which a 0.3.7 host cannot; not verified by a clean build | diff --git a/packaging/ota/publish-jobs.sh b/packaging/ota/publish-jobs.sh index 7b4515dfd..4e26f0ed9 100755 --- a/packaging/ota/publish-jobs.sh +++ b/packaging/ota/publish-jobs.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Publishes signed remote jobs for the Igneum Miner apps: igneum-jobs.json (canonical JSON, sorted keys, no -# whitespace) and its detached Ed25519 signature igneum-jobs.json.sig, next to the update manifest in the downloads -# folder (dl//), signed on this Mac with the OTA key ~/.config/igneum/ota-signing-key. Every app holds a +# whitespace), its detached Ed25519 signature igneum-jobs.json.sig, and igneum-jobs.signed.json, ONE object that +# carries the file text and the signature together (the 0.3.9 apps fetch that one; 5 October 2026, 13:19:41Z: PC 2 +# fetched the file and the signature in two requests across a deploy and refused the pair), next to the update +# manifest in the downloads folder (dl//), signed on this Mac with the OTA key ~/.config/igneum/ota-signing-key. Every app holds a # long-poll on the relay's public /wake (relay/api/wake.mjs) and fetches the file the moment --deploy records the new # stamp there (0.3.6, app/igneum-app/src/jobrun.rs; a poll every 2 minutes is the fallback), verifies it with the # public key compiled into src/manifest.rs, runs each job that targets it ONCE per id, and reports to the log intake @@ -27,7 +29,8 @@ # packaging/ota/publish-jobs.sh list what is published (expired jobs marked) # packaging/ota/publish-jobs.sh remove [--deploy] # packaging/ota/publish-jobs.sh sign [--deploy] re-sign the file as it is (expired jobs dropped) -# packaging/ota/publish-jobs.sh verify [--tries N] check the live file against the local one (reachable, identical, verifies) +# packaging/ota/publish-jobs.sh verify [--tries N] check the live files against the local ones in every folder they +# were written to (reachable, identical, verify) # # Jobs already in the file stay (expired ones are dropped on every write). A machine runs an id once: to run the # same thing again, add it again (a new id is generated from the kind and the time unless --id is given). @@ -118,6 +121,7 @@ fi [ -n "$BASE" ] || BASE="https://dl.igneum.network/dl/$TOKEN" BASE="${BASE%/}" JOBS="$DEST/igneum-jobs.json" +SIGNED="$DEST/igneum-jobs.signed.json" command -v python3 >/dev/null || { echo "python3 is needed for the canonical JSON" >&2; exit 1; } if [ ! -x "$SIGNER" ]; then @@ -131,28 +135,60 @@ if [ "$EMBEDDED" != "$OURS" ]; then exit 1 fi -# Checks the live jobs file against the local one: reachable, byte-identical, signature verifies. Retries, because the -# edge serves the previous deployment for some seconds after a deploy (4 October 2026: a deploy that had succeeded was +# The folders the signed files are written to: the token's folder, and the next token's folder while a rotation +# runs (the mirror below). Each entry is " "; the live check runs on every one. +mirror_folder() { # prints the next folder's path when the mirror applies, else nothing + local nt + [ -n "$DLSITE" ] && [ -f "$HOME/.config/igneum/dl-token.next" ] || return 0 + nt="$(tr -d '[:space:]' < "$HOME/.config/igneum/dl-token.next")" + [ -n "$nt" ] && [ -d "$DLSITE/dl/$nt" ] && [ "$nt" != "$TOKEN" ] || return 0 + echo "$DLSITE/dl/$nt" +} +folders() { + echo "$BASE $DEST" + local nf + nf="$(mirror_folder)" + [ -n "$nf" ] && echo "https://dl.igneum.network/dl/$(basename "$nf") $nf" + return 0 +} + +# Checks the live files against the local ones in every folder: the envelope (one object, what the 0.3.9 apps read) +# reachable, byte-identical and verifying, and the plain pair identical (the older apps). Retries, because the edge +# serves the previous deployment for some seconds after a deploy (4 October 2026: a deploy that had succeeded was # reported as "not reachable, differs from the local one, or does not verify" by the one check made the moment the CLI -# returned). Each failure names the condition that failed. -verify_live() { # (5 s apart); 0 = verified, 1 = not, with the reason on stderr - local tries="${1:-12}" t=0 verdict="" tmp +# returned). Each failure names the folder and the condition that failed. +verify_live_one() { # ; 0 = verified, 1 = not, with the reason on stderr + local base="$1" local_dir="$2" tries="${3:-12}" t=0 verdict="" tmp shown + shown="${base//$TOKEN/}"; [ -n "$NEXT_TOKEN_SHOWN" ] && shown="${shown//$NEXT_TOKEN_SHOWN/}" tmp="$(mktemp -d)" while [ "$t" -lt "$tries" ]; do t=$((t + 1)); verdict="" - if ! curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp/j.json" "$BASE/igneum-jobs.json"; then verdict="is not reachable" - elif ! curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp/j.sig" "$BASE/igneum-jobs.json.sig"; then verdict="has no reachable signature" - elif ! cmp -s "$tmp/j.json" "$JOBS"; then verdict="differs from the local one (live $(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("published_at", "?"))' "$tmp/j.json" 2>/dev/null || echo unreadable), local $(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("published_at", "?"))' "$JOBS" 2>/dev/null || echo unreadable))" - elif ! "$SIGNER" verify-jobs "$PUB" "$tmp/j.json" "$tmp/j.sig" >/dev/null 2>&1; then verdict="does not verify against $PUB" + if ! curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp/j.signed" "$base/igneum-jobs.signed.json"; then verdict="is not reachable (the envelope)" + elif ! cmp -s "$tmp/j.signed" "$local_dir/igneum-jobs.signed.json"; then verdict="differs from the local one (the envelope)" + elif ! "$SIGNER" verify-signed-jobs "$PUB" "$tmp/j.signed" >/dev/null 2>&1; then verdict="does not verify against $PUB (the envelope)" + elif ! curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp/j.json" "$base/igneum-jobs.json"; then verdict="is not reachable (the plain file)" + elif ! curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp/j.sig" "$base/igneum-jobs.json.sig"; then verdict="has no reachable signature (the plain pair)" + elif ! cmp -s "$tmp/j.json" "$local_dir/igneum-jobs.json"; then verdict="differs from the local one (the plain file; live $(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("published_at", "?"))' "$tmp/j.json" 2>/dev/null || echo unreadable), local $(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("published_at", "?"))' "$local_dir/igneum-jobs.json" 2>/dev/null || echo unreadable))" + elif ! cmp -s "$tmp/j.sig" "$local_dir/igneum-jobs.json.sig"; then verdict="differs from the local one (the plain signature)" + elif ! "$SIGNER" verify-jobs "$PUB" "$tmp/j.json" "$tmp/j.sig" >/dev/null 2>&1; then verdict="does not verify against $PUB (the plain pair)" fi [ -z "$verdict" ] && break [ "$t" -lt "$tries" ] && sleep 5 done rm -rf "$tmp" - if [ -z "$verdict" ]; then echo "live jobs file verified at ${BASE//$TOKEN/}/igneum-jobs.json (try $t of $tries)"; return 0; fi - echo "the live jobs file $verdict after $t tr$([ "$t" = 1 ] && echo y || echo ies) ($(( (t - 1) * 5 )) s); check the deploy output, then: $0 verify" >&2 + if [ -z "$verdict" ]; then echo "live jobs files verified at $shown/igneum-jobs.signed.json and the plain pair (try $t of $tries)"; return 0; fi + echo "the live jobs file at $shown $verdict after $t tr$([ "$t" = 1 ] && echo y || echo ies) ($(( (t - 1) * 5 )) s); check the deploy output, then: $0 verify" >&2 return 1 } +verify_live() { # ; every folder the files were written to must verify + local tries="${1:-12}" rc=0 base dir + NEXT_TOKEN_SHOWN="$(basename "$(mirror_folder)" 2>/dev/null)"; [ "$NEXT_TOKEN_SHOWN" = "." ] && NEXT_TOKEN_SHOWN="" + while read -r base dir; do + [ -n "$base" ] || continue + verify_live_one "$base" "$dir" "$tries" || rc=1 + done < <(folders) + return $rc +} # After a verified deploy: records the new jobs-file stamp on the relay (relay/api/wake.mjs), where every app holds a # long-poll and fetches the file the moment the stamp moves (0.3.6). The stamp is published_at plus 8 hex of the @@ -186,6 +222,7 @@ fi if [ "$CMD" = list ]; then [ -f "$JOBS" ] || { echo "no jobs file in $DEST"; exit 0; } "$SIGNER" verify-jobs "$PUB" "$JOBS" "$JOBS.sig" || { echo "the file in $DEST does not verify; run: $0 sign" >&2; exit 1; } + if [ -f "$SIGNED" ]; then "$SIGNER" verify-signed-jobs "$PUB" "$SIGNED" >/dev/null || { echo "the envelope in $DEST does not verify; run: $0 sign" >&2; exit 1; }; else echo "(no igneum-jobs.signed.json yet; the next write makes one)"; fi python3 - "$JOBS" <<'PY' import json, sys, datetime f = json.load(open(sys.argv[1])) @@ -330,16 +367,21 @@ open(out, "w").write(json.dumps(f, sort_keys=True, separators=(",", ":"), ensure PY "$SIGNER" sign-jobs "$KEY" "$NEW" > "$NEW.sig" "$SIGNER" verify-jobs "$PUB" "$NEW" "$NEW.sig" +# the envelope is made from the signed pair by the signer, which refuses a pair that does not verify, and is read +# back with the app's own code before anything moves into place +"$SIGNER" envelope-jobs "$PUB" "$NEW" "$NEW.sig" > "$NEW.signed" +"$SIGNER" verify-signed-jobs "$PUB" "$NEW.signed" >/dev/null mv "$NEW" "$JOBS" mv "$NEW.sig" "$JOBS.sig" -echo "jobs file: $JOBS ($(wc -c < "$JOBS" | tr -d ' ') bytes)" -# Rotation phase 2 (5 October 2026): apps built with the next token read the next folder, so the signed file and its -# signature go to both folders while both exist (the 0.3.6 apps missed a job published to the old folder only). -if [ -n "$DLSITE" ] && [ -f "$HOME/.config/igneum/dl-token.next" ]; then - NEXT_TOKEN="$(tr -d '[:space:]' < "$HOME/.config/igneum/dl-token.next")" - if [ -n "$NEXT_TOKEN" ] && [ -d "$DLSITE/dl/$NEXT_TOKEN" ] && [ "$NEXT_TOKEN" != "$TOKEN" ]; then - cp "$JOBS" "$JOBS.sig" "$DLSITE/dl/$NEXT_TOKEN/" && echo "jobs file mirrored to the next folder" - fi +mv "$NEW.signed" "$SIGNED" +echo "jobs file: $JOBS ($(wc -c < "$JOBS" | tr -d ' ') bytes), signature, and the envelope $(basename "$SIGNED") ($(wc -c < "$SIGNED" | tr -d ' ') bytes)" +# Rotation phase 2 (5 October 2026): apps built with the next token read the next folder, so the three signed +# files go to both folders while both exist (the 0.3.6 apps missed a job published to the old folder only). The +# copy happens after the signer's read-back, so the mirror never carries a half-written set; the live check after +# the deploy covers both folders. +NEXT_FOLDER="$(mirror_folder)" +if [ -n "$NEXT_FOLDER" ]; then + cp "$JOBS" "$JOBS.sig" "$SIGNED" "$NEXT_FOLDER/" && echo "jobs file, signature and envelope mirrored to the next folder" fi if [ "$DEPLOY" = 1 ]; then diff --git a/packaging/ota/test-publish-jobs.sh b/packaging/ota/test-publish-jobs.sh new file mode 100755 index 000000000..d3fbc9686 --- /dev/null +++ b/packaging/ota/test-publish-jobs.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Mac-side test of the jobs publisher's signed envelope (5 October 2026, the 13:19:41Z refusal on PC 2): +# +# packaging/ota/test-publish-jobs.sh +# +# What it proves, in a temporary folder with --dest (nothing deployed, the downloads folder untouched): one `add` +# writes igneum-jobs.json, its .sig and igneum-jobs.signed.json; the envelope's inner text IS the plain file byte for +# byte and its sig IS the plain signature; the signer reads the envelope back; a second `add` (a new publish) gives +# a new envelope; an envelope made by hand from the FIRST file and the SECOND signature (the stale pair an edge can +# serve across a deploy) is REFUSED by the signer with the words the app logs; a tampered inner byte is refused; +# `sign` rewrites all three consistently; and the real OTA key is the key the app embeds. +# +# A check is trusted only once it has fired on a known-good and a known-bad case (standing rule, 4 October 2026), so +# every negative case here must FAIL for the run to pass. Needs ~/.config/igneum/ota-signing-key (the publisher's own +# precondition) and the signer from this tree. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +SIGNER="${IGNEUM_OTA_SIGN:-$ROOT/app/igneum-app/target/release/igneum-ota-sign}" +PUB="$HOME/.config/igneum/ota-signing-key.pub" +[ -x "$SIGNER" ] || { echo "no $SIGNER: build it first (cd app/igneum-app && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign)" >&2; exit 1; } +[ -f "$PUB" ] || { echo "no $PUB" >&2; exit 1; } + +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +umask 077 +pass=0; fail=0 +ok() { pass=$((pass + 1)); echo " ok $1"; } +bad() { fail=$((fail + 1)); echo " FAIL $1"; } +expect_ok() { local what="$1"; shift; if "$@" > "$T/out" 2>&1; then ok "$what"; else bad "$what: $(tail -1 "$T/out")"; fi; } +expect_fail() { local what="$1"; shift; if "$@" > "$T/out" 2>&1; then bad "$what: accepted, must refuse"; else ok "$what: refused ($(tail -1 "$T/out" | cut -c1-110))"; fi; } + +D="$T/folder" +printf 'Write-Output "hello"\n' > "$T/s.ps1" +PUBLISH="$HERE/publish-jobs.sh" + +echo "== one publish writes the pair and the envelope" +expect_ok "add --dest" "$PUBLISH" add --kind run --target 1ccfe586 --script "$T/s.ps1" --id test-one --dest "$D" --base-url https://example.invalid/dl/t +for f in igneum-jobs.json igneum-jobs.json.sig igneum-jobs.signed.json; do + if [ -s "$D/$f" ]; then ok "$f written"; else bad "$f missing"; fi +done +expect_ok "the plain pair verifies" "$SIGNER" verify-jobs "$PUB" "$D/igneum-jobs.json" "$D/igneum-jobs.json.sig" +expect_ok "the envelope verifies" "$SIGNER" verify-signed-jobs "$PUB" "$D/igneum-jobs.signed.json" +python3 - "$D" > "$T/inner.txt" <<'PY' +import json, sys +d = sys.argv[1] +e = json.load(open(d + "/igneum-jobs.signed.json")) +plain = open(d + "/igneum-jobs.json", "rb").read() +sig = open(d + "/igneum-jobs.json.sig").read().strip() +print("format", e.get("format")) +print("inner-identical", e.get("file", "").encode() == plain) +print("sig-identical", e.get("sig") == sig) +print("one-line", b"\n" not in open(d + "/igneum-jobs.signed.json", "rb").read().rstrip(b"\n")) +PY +grep -q "^format igneum-jobs-signed-1$" "$T/inner.txt" && ok "envelope format igneum-jobs-signed-1" || bad "envelope format: $(grep ^format "$T/inner.txt")" +grep -q "^inner-identical True$" "$T/inner.txt" && ok "the envelope's file text is the plain file, byte for byte" || bad "the envelope's inner text differs from the plain file" +grep -q "^sig-identical True$" "$T/inner.txt" && ok "the envelope's sig is the plain signature" || bad "the envelope's sig differs from the plain signature" +grep -q "^one-line True$" "$T/inner.txt" && ok "the envelope is one line" || bad "the envelope spans lines" +cp "$D/igneum-jobs.json" "$T/first.json"; cp "$D/igneum-jobs.json.sig" "$T/first.sig"; cp "$D/igneum-jobs.signed.json" "$T/first.signed" + +echo "== a second publish: new file, new signature, new envelope" +sleep 1 +expect_ok "add a second job" "$PUBLISH" add --kind run --target 1ccfe586 --script "$T/s.ps1" --id test-two --dest "$D" --base-url https://example.invalid/dl/t +if ! cmp -s "$T/first.json" "$D/igneum-jobs.json" && ! cmp -s "$T/first.sig" "$D/igneum-jobs.json.sig" && ! cmp -s "$T/first.signed" "$D/igneum-jobs.signed.json"; then ok "all three files changed"; else bad "a file did not change on the second publish"; fi +expect_ok "the second envelope verifies" "$SIGNER" verify-signed-jobs "$PUB" "$D/igneum-jobs.signed.json" + +echo "== the stale pair: the first file with the second signature (what two requests across a deploy can return)" +expect_fail "the plain pair, mixed" "$SIGNER" verify-jobs "$PUB" "$T/first.json" "$D/igneum-jobs.json.sig" +expect_fail "the signer refuses to wrap the mixed pair" "$SIGNER" envelope-jobs "$PUB" "$T/first.json" "$D/igneum-jobs.json.sig" +python3 - "$T/first.json" "$D/igneum-jobs.json.sig" "$T/mixed.signed" <<'PY' +import json, sys +f, s, out = sys.argv[1:4] +open(out, "w").write(json.dumps({"file": open(f).read(), "format": "igneum-jobs-signed-1", "sig": open(s).read().strip()}, sort_keys=True, separators=(",", ":"))) +PY +expect_fail "a hand-made envelope of the mixed pair" "$SIGNER" verify-signed-jobs "$PUB" "$T/mixed.signed" +grep -q "jobs file signature does not verify" "$T/out" && ok "refused with the words the app logs" || bad "another reason: $(tail -1 "$T/out")" +python3 - "$D/igneum-jobs.signed.json" "$T/tampered.signed" <<'PY' +import json, sys +e = json.load(open(sys.argv[1])); e["file"] = e["file"].replace("test-two", "test-tw0") +open(sys.argv[2], "w").write(json.dumps(e, sort_keys=True, separators=(",", ":"))) +PY +expect_fail "a tampered inner byte" "$SIGNER" verify-signed-jobs "$PUB" "$T/tampered.signed" +sed 's/igneum-jobs-signed-1/igneum-jobs-signed-9/' "$D/igneum-jobs.signed.json" > "$T/format.signed" +expect_fail "another format" "$SIGNER" verify-signed-jobs "$PUB" "$T/format.signed" + +echo "== sign rewrites all three" +expect_ok "sign --dest" "$PUBLISH" sign --dest "$D" --base-url https://example.invalid/dl/t +expect_ok "the re-signed envelope verifies" "$SIGNER" verify-signed-jobs "$PUB" "$D/igneum-jobs.signed.json" +python3 - "$D" > "$T/inner2.txt" <<'PY' +import json, sys +d = sys.argv[1] +e = json.load(open(d + "/igneum-jobs.signed.json")) +print("inner-identical", e.get("file", "").encode() == open(d + "/igneum-jobs.json", "rb").read()) +print("sig-identical", e.get("sig") == open(d + "/igneum-jobs.json.sig").read().strip()) +PY +grep -q "^inner-identical True$" "$T/inner2.txt" && grep -q "^sig-identical True$" "$T/inner2.txt" && ok "after sign: the envelope still holds the plain file and its signature" || bad "after sign: the envelope and the pair differ" +expect_ok "list reads the folder" "$PUBLISH" list --dest "$D" + +echo "== the key" +EMB="$("$SIGNER" embedded | head -1)" +[ "$EMB" = "$(tr -d '[:space:]' < "$PUB")" ] && ok "the embedded key is the Mac's OTA public key" || bad "the embedded key is not $PUB" + +echo +echo "$pass passed, $fail failed" +[ "$fail" = 0 ] diff --git a/tools/jobs.mjs b/tools/jobs.mjs index 5b1fadc55..ec9a846d1 100755 --- a/tools/jobs.mjs +++ b/tools/jobs.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node // Mac side of the remote jobs (app/igneum-app/src/jobs.rs, published by packaging/ota/publish-jobs.sh). -// node tools/jobs.mjs the published jobs file: fetched from the downloads host, signature checked +// node tools/jobs.mjs the published jobs file: the signed envelope (or the pair) fetched from the +// downloads host, signature checked // node tools/jobs.mjs status per machine, from the log intake: the latest job run and its SUMMARY line, // plus the woken latency (job started_at minus the publish that added it, // from relay_wake, written by publish-jobs.sh --deploy since 0.3.6) @@ -50,11 +51,22 @@ if (!a) { const tok = cfg('dl-token'); if (!tok) { console.error('no ~/.config/igneum/dl-token'); process.exit(1); } const base = `https://dl.igneum.network/dl/${tok}`; - const r = await fetch(`${base}/igneum-jobs.json`); - if (r.status === 404) { console.log('no jobs file published'); process.exit(0); } - if (!r.ok) { console.error(`jobs file: http ${r.status}`); process.exit(1); } - const bytes = Buffer.from(await r.arrayBuffer()); - const sig = (await (await fetch(`${base}/igneum-jobs.json.sig`)).text()).trim(); + // the envelope first (one object: file text and signature together, what the 0.3.9 apps read), the pair when + // no envelope is published yet + let bytes, sig, form = 'envelope'; + const e = await fetch(`${base}/igneum-jobs.signed.json`, { headers: { 'Cache-Control': 'no-cache' } }); + if (e.ok) { + const env = JSON.parse(await e.text()); + if (env.format !== 'igneum-jobs-signed-1' || typeof env.file !== 'string' || typeof env.sig !== 'string') { console.error('the signed jobs file has another shape'); process.exit(1); } + bytes = Buffer.from(env.file, 'utf8'); sig = env.sig.trim(); + } else { + form = 'pair'; + const r = await fetch(`${base}/igneum-jobs.json`, { headers: { 'Cache-Control': 'no-cache' } }); + if (r.status === 404) { console.log('no jobs file published'); process.exit(0); } + if (!r.ok) { console.error(`jobs file: http ${r.status}`); process.exit(1); } + bytes = Buffer.from(await r.arrayBuffer()); + sig = (await (await fetch(`${base}/igneum-jobs.json.sig`)).text()).trim(); + } const pub = cfg('ota-signing-key.pub'); let verified = 'not checked (no ~/.config/igneum/ota-signing-key.pub)'; if (pub) { @@ -62,7 +74,7 @@ if (!a) { verified = verify(null, bytes, key, Buffer.from(sig, 'hex')) ? 'signature OK' : 'SIGNATURE DOES NOT VERIFY (the apps refuse this file)'; } const f = JSON.parse(bytes.toString('utf8')); - console.log(`${base.replace(tok, '')}/igneum-jobs.json: published ${f.published_at}, ${f.jobs.length} job(s), ${verified}`); + console.log(`${base.replace(tok, '')}/${form === 'envelope' ? 'igneum-jobs.signed.json' : 'igneum-jobs.json (no envelope published)'}: published ${f.published_at}, ${f.jobs.length} job(s), ${verified}`); const now = Date.now(); for (const j of f.jobs) { const exp = Date.parse(j.expires_at); diff --git a/tools/ship-app.mjs b/tools/ship-app.mjs index b47e997b4..350df3663 100644 --- a/tools/ship-app.mjs +++ b/tools/ship-app.mjs @@ -196,7 +196,7 @@ const DEST_NEXT = BOTH && DLSITE && TOKEN_NEXT ? join(DLSITE, 'dl', TOKEN_NEXT) const BASE_NEXT = `https://dl.igneum.network/dl/${TOKEN_NEXT}`; // the folder-level files the apps and the CI read next to the manifest (jobs, the CI's inputs, the CI record, the // WSL2 prover zip): mirrored into the NEXT folder when present in the current one -const FOLDER_FILES = ['igneum-jobs.json', 'igneum-jobs.json.sig', 'payload-inputs.zip', 'payload-inputs.json', 'payload-inputs.sha256', 'igneum-windows-ci.json', 'igneum-prove-wsl2.zip']; +const FOLDER_FILES = ['igneum-jobs.json', 'igneum-jobs.json.sig', 'igneum-jobs.signed.json', 'payload-inputs.zip', 'payload-inputs.json', 'payload-inputs.sha256', 'igneum-windows-ci.json', 'igneum-prove-wsl2.zip']; const DMG_NAME = `Igneum-Miner-${VERSION}.dmg`; const SETUP_NAME = `Igneum-Miner-Setup-${VERSION}.exe`; const ZIP_NAME = 'igneum-windows-app.zip';