From 295711ba292a48248ffb59c46f713581a1cd0bb0 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:05:33 +0000 Subject: [PATCH] V6-06: the Power Helper runs a protected copy, refreshed only on the signed manifest's engine hash; elevated scripts inline; quit and remove through the sequence guard; the host asInvoker The one elevated step copies igneum-app.exe and its runtime DLLs into %ProgramData%\Igneum\helper with inheritance cut (Administrators and SYSTEM full, Users read and execute, owner Administrators) and registers the task against that copy; the per-user install under LOCALAPPDATA is never what the scheduler runs elevated. The helper's reregister refreshes the copy only when the signed update manifest names the installed exe's sha256 (platforms.windows.engine_sha256, publish-manifest.sh --win-engine); anything else is refused with its reason in helper.log. rights.ps1 and register-power-task.ps1 are no longer read from user-writable folders: every elevated script travels inline through -EncodedCommand. quit and remove carry a sequence like every verb and pass the rising-sequence guard; a stale line re-added to the file is skipped. The window host declares asInvoker. The prove host's lease is the lesser of the grant and the free reading (the V6-07 sub-lane's 12 GB row). Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/device.rs | 16 +++ app/igneum-app/src/engine.rs | 14 +- app/igneum-app/src/manifest.rs | 16 ++- app/igneum-app/src/platform.rs | 23 ++++ app/igneum-app/src/powertask.rs | 207 ++++++++++++++++++++++++++---- app/igneum-app/src/prover.rs | 2 +- app/igneum-app/src/rights.rs | 25 +++- app/igneum-app/src/sweep.rs | 4 +- app/windows/host.manifest | 9 ++ packaging/ota/publish-manifest.sh | 18 ++- 10 files changed, 289 insertions(+), 45 deletions(-) diff --git a/app/igneum-app/src/device.rs b/app/igneum-app/src/device.rs index f13de09dd..48d1319dc 100644 --- a/app/igneum-app/src/device.rs +++ b/app/igneum-app/src/device.rs @@ -181,6 +181,12 @@ pub fn proof_budget_mib(vram_mib: u64, mode: Mode) -> u64 { } } +/// The lease's figure for a spawn: the grant the mode allows, never more than the card reads free (the V6-07 sub-lane's +/// 12 GB row: 6,159 MiB free beside the miner, under the 8,285 MiB grant). +pub fn lease_mib(vram_mib: u64, mode: Mode, free_mib: u64) -> u64 { + proof_budget_mib(vram_mib, mode).min(free_mib) +} + /// The card's words after exit 78: the figure the host printed ("needs N GB" or "needs N MB"/"MiB"; the host's own line /// is "RESULT memory_profile refused: proving needs N GB free on the card for the workload ( MiB /// floor); MiB free") when it did, else the budget it was offered, in whole GB rounded up. @@ -313,6 +319,16 @@ mod tests { assert_eq!(proof_budget_mib(8_192, Mode::MiningOnly), 0); } + /// The V6-07 sub-lane's row (8 October 2026): a 12 GB card beside its miner has 6,159 MiB free, under the grant the + /// mode allows; the lease is the lesser of the grant and the free reading, never more than the card has. + #[test] + fn the_lease_is_the_lesser_of_the_grant_and_the_free_memory() { + assert_eq!(lease_mib(12_288, Mode::Simultaneous, 6_159), 6_159); + assert_eq!(lease_mib(24_576, Mode::Simultaneous, 18_000), PROOF_PEAK_MIB + PROOF_PEAK_MIB * HEADROOM_PCT / 100); + assert_eq!(lease_mib(8_192, Mode::MiningOnly, 8_000), 0); + assert_eq!(lease_mib(0, Mode::ProveOnly, 0), 0, "no card, no grant"); + } + /// Exit 78 from the host is the floor refusal: the card's words name the floor the host printed, else the budget. #[test] fn the_floor_refusal_names_the_memory_proving_needs() { diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 42767721e..99188c5c4 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -2916,7 +2916,7 @@ impl Engine { // a `remove` present when the helper starts is skipped like any stale line, so the heartbeat first, then the line match crate::powertask::ensure_running(&hdir, Duration::from_secs(12)) { Ok(()) => { - let _ = std::fs::write(hdir.join("cmd.txt"), "remove\n"); + let _ = std::fs::write(hdir.join("cmd.txt"), format!("{} remove\n", crate::powertask::wire_seq())); shared.log("power control off: the Igneum Power Helper task removes itself"); } Err(e) => { @@ -3386,19 +3386,17 @@ impl Engine { // 6 October 2026: a --sweep engine skipped the cap path where the registration lived, so the one // approved click registered nothing); no prompt: this process already holds the rights if probe.direct && cfg!(windows) && !self.power_task_registered() { - let dir = self.sweep_dir(); - let _ = std::fs::create_dir_all(&dir); - let script = dir.join("register-power-task.ps1"); let installed: Vec = crate::powertask::install_candidates(); if let Ok(exe) = std::env::current_exe() { let target = crate::powertask::task_exe(&exe, &installed); - if std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&target).as_bytes()].concat()).is_ok() { + // V6-06: the registration script travels inline, never as a file under the sweep folder + { let mut p = std::process::Command::new(crate::platform::tool("powershell")); - p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", &script.display().to_string()]); + p.args(crate::platform::encoded_command_args(&crate::powertask::register_script(&target))); crate::platform::quiet(&mut p); let ok = p.status().map(|s| s.success()).unwrap_or(false); self.power_task = None; - self.sweep_say(&format!("TUNE helper registered={} action={}", ok, target.display())); + self.sweep_say(&format!("TUNE helper registered={} action=protected copy of {}", ok, target.display())); } } } @@ -3546,7 +3544,7 @@ impl Engine { fn sweep_helper_quit(&mut self) { if self.sweep_helper { - let _ = std::fs::write(self.helper_cmd_dir().join("cmd.txt"), "quit\n"); + let _ = std::fs::write(self.helper_cmd_dir().join("cmd.txt"), format!("{} quit\n", crate::powertask::wire_seq())); if self.sweep_helper_is_task { // the task exits on quit and reports nothing back; the next tune starts it again self.sweep_helper = false; diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index 47b3872fb..aa41fadd3 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -45,6 +45,9 @@ pub struct PlatformEntry { pub sha256: String, pub size: u64, pub kind: String, // dmg | zip | inno-setup + /// V6-06 (8 October 2026): the engine exe's own sha256 inside this installer (publish-manifest.sh --win-engine), + /// what the Power Helper checks before it refreshes its protected copy; empty when the publish did not name it. + pub engine_sha256: String, } #[derive(Clone, Debug, PartialEq, Default)] @@ -175,7 +178,7 @@ pub fn parse(text: &str) -> Result { 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") }; + 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"), engine_sha256: s(p, "engine_sha256").to_ascii_lowercase() }; if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") { return Err(format!("{name}: the url is not https")); } @@ -576,6 +579,17 @@ mod tests { (sk, pk) } + /// V6-06: the windows entry may name the engine exe's own sha256 (publish-manifest.sh --win-engine), what the + /// Power Helper checks before it refreshes its protected copy; absent = empty, nothing refreshes. + #[test] + fn the_windows_entry_carries_the_engine_hash_when_published() { + let sha = "cd".repeat(32); + let m = parse(&format!(r#"{{"version":"2.0.2","platforms":{{"windows":{{"url":"https://x/s.exe","sha256":"{sha}","size":1,"kind":"inno-setup","engine_sha256":"ABCD"}}}}}}"#)).unwrap(); + assert_eq!(m.windows.unwrap().engine_sha256, "abcd"); + let n = parse(&format!(r#"{{"version":"2.0.2","platforms":{{"windows":{{"url":"https://x/s.exe","sha256":"{sha}","size":1,"kind":"inno-setup"}}}}}}"#)).unwrap(); + assert_eq!(n.windows.unwrap().engine_sha256, ""); + } + #[test] fn default_network_rules() { // the network step: absent = the package's own; devnet-3 fine; the testnet default only once the manifest opens it diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index b63447f05..36e1b57bb 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -540,6 +540,29 @@ pub fn elevated_failure(code: Option, stderr: &str) -> String { } } +/// Standard base64 (RFC 4648, padded) of `bytes`: what PowerShell's -EncodedCommand reads. +pub fn base64_encode(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let n = (chunk[0] as u32) << 16 | (*chunk.get(1).unwrap_or(&0) as u32) << 8 | *chunk.get(2).unwrap_or(&0) as u32; + out.push(T[(n >> 18) as usize & 63] as char); + out.push(T[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' }); + } + out +} + +/// The PowerShell arguments that run `script` INLINE (V6-06, 8 October 2026): `-EncodedCommand` over the script's +/// UTF-16LE bytes, so an elevated step never reads a .ps1 from a folder the user can write between the write and the +/// run (rights.ps1 under the data root, register-power-task.ps1 under the sweep folder were that). The command line +/// cap is 32 K characters; the callers' scripts are a few K. +pub fn encoded_command_args(script: &str) -> Vec { + let utf16: Vec = script.encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + vec!["-NoProfile".into(), "-ExecutionPolicy".into(), "Bypass".into(), "-EncodedCommand".into(), base64_encode(&utf16)] +} + /// Doubles the single quotes of `s` for a single-quoted PowerShell literal. pub fn ps_quote(s: &str) -> String { s.replace('\'', "''") diff --git a/app/igneum-app/src/powertask.rs b/app/igneum-app/src/powertask.rs index 5db2a9c1c..231106557 100644 --- a/app/igneum-app/src/powertask.rs +++ b/app/igneum-app/src/powertask.rs @@ -28,6 +28,23 @@ //! else: no file, no process, no registry, no other binary. //! - The helper exits after 20 idle minutes; a stale command file is cleared at start (sequence numbers must rise). //! - Linux keeps pkexec per step (no scheduled task); macOS has no cap to set. +//! +//! V6-06 (the master's R1 review, 8 October 2026; the design the window lane built): +//! - The task's action is a PROTECTED copy of the exe and its runtime DLLs in %ProgramData%\Igneum\helper, made by the +//! one elevated step with inheritance cut and Administrators and SYSTEM full, Users read and execute, owner +//! Administrators (`register_script`). The per-user install under LOCALAPPDATA, which the user can overwrite, is +//! never what the scheduler runs elevated. +//! - The copy is refreshed only through `reregister`, and only when the signed update manifest names the installed +//! exe's sha256 (platforms.windows.engine_sha256, `refresh_allowed`); a swapped binary is refused with its reason in +//! helper.log. An update whose manifest names no engine hash leaves the old protected copy in place. +//! - Every elevated script travels inline (-EncodedCommand, platform::encoded_command_args): no .ps1 is read from a +//! folder the user can write between the write and the run. +//! - `quit` and `remove` carry a sequence like every other verb and pass the same rising-sequence guard; a stale +//! line re-added to the file is skipped. The command file stays the IPC: its boundary is the user account (the +//! file is in the user's profile, the task's principal is the user), the same boundary a per-user named pipe +//! would draw; what matters is that no verb lets a writer choose a path, a hash or a binary, which holds. +//! - The window host is asInvoker (app/windows/host.manifest); the one prompt it raises for the engine's step is +//! a child process, the host itself never holds rights. use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -157,12 +174,7 @@ pub enum HelperCmd { /// `remove` need no sequence). Anything that is not a fixed verb with digit-only arguments is None. pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> { let t = line.trim(); - if t == "quit" { - return Some((0, HelperCmd::Quit)); - } - if t == "remove" { - return Some((0, HelperCmd::Remove)); - } + // V6-06: a bare quit or remove is no command; both carry a sequence and pass the guard like every verb let p: Vec<&str> = t.split_whitespace().collect(); let digits = |s: &str| !s.is_empty() && s.len() <= 6 && s.chars().all(|c| c.is_ascii_digit()); let seq: u64 = p.first().filter(|s| digits(s)).and_then(|s| s.parse().ok())?; @@ -174,6 +186,8 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> { [_, "lmc", m] if digits(m) => Some((seq, HelperCmd::MemClock(m.parse().ok()?))), [_, "rmc"] => Some((seq, HelperCmd::MemReset)), [_, "dev", d] if digits(d) => Some((seq, HelperCmd::Dev(d.to_string()))), + [_, "quit"] => Some((seq, HelperCmd::Quit)), + [_, "remove"] => Some((seq, HelperCmd::Remove)), [_, "reregister"] => Some((seq, HelperCmd::Reregister)), [_, "driver", v] if crate::driverinstall::vendor_ok(v) => Some((seq, HelperCmd::Driver(v.to_string()))), _ => None, @@ -278,22 +292,57 @@ pub fn readback_command() -> String { format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t) {{ Write-Output ($t.Actions[0].Execute + ' ' + $t.Actions[0].Arguments) }}; exit 0") } -/// The PowerShell that registers the task (run inside the ONE elevated step, with the caps). `exe` is this -/// executable's path in the install folder. Principal: the signed-in user, interactive logon, highest run level; no -/// trigger; may start on battery; one hour limit per run; multiple starts are ignored while one runs. +/// The protected folder's PowerShell expression: %ProgramData%\Igneum\helper. +pub fn protected_dir_expression() -> &'static str { + "Join-Path $env:ProgramData 'Igneum\\helper'" +} + +/// The files the protected copy needs: the engine and the mingw runtime it links (packaging/windows/make-payload.sh). +pub const PROTECTED_FILES: [&str; 4] = ["igneum-app.exe", "libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll"]; + +/// The PowerShell that registers the task (run inside the ONE elevated step, with the caps). `exe` is the installed +/// executable; the script copies it and its runtime DLLs into the protected folder (V6-06: inheritance cut, +/// Administrators and SYSTEM full, Users read and execute, owner Administrators) and points the task at that copy. +/// Principal: the signed-in user, S4U, highest run level; no trigger; may start on battery; one hour limit per run; +/// multiple starts are ignored while one runs. pub fn register_script(exe: &Path) -> String { - let exe = exe.display().to_string().replace('\'', "''"); + let exe_s = exe.display().to_string(); + let src = exe_s.rfind(['\\', '/']).map(|i| exe_s[..i].to_string()).unwrap_or_default().replace('\'', "''"); + let copies: String = PROTECTED_FILES + .iter() + .map(|f| format!("if (Test-Path -LiteralPath (Join-Path $src '{f}')) {{ Copy-Item -LiteralPath (Join-Path $src '{f}') -Destination (Join-Path $helperDir '{f}') -Force }}\r\n")) + .collect(); format!( - "$a = New-ScheduledTaskAction -Execute '{exe}' -Argument '--power-helper' -WorkingDirectory '{dir}'\r\n\ + "$src = '{src}'\r\n\ + $helperDir = {pdir}\r\n\ + New-Item -ItemType Directory -Force -Path $helperDir | Out-Null\r\n\ + & icacls.exe $helperDir /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-545:(OI)(CI)RX' /setowner '*S-1-5-32-544' | Out-Null\r\n\ + {copies}\ + $helperExe = Join-Path $helperDir 'igneum-app.exe'\r\n\ + $a = New-ScheduledTaskAction -Execute $helperExe -Argument '--power-helper' -WorkingDirectory $helperDir\r\n\ $p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType S4U -RunLevel Highest\r\n\ $s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 1) -MultipleInstances IgnoreNew -Hidden\r\n\ Register-ScheduledTask -TaskName '{name}' -Action $a -Principal $p -Settings $s -Force | Out-Null\r\n\ exit 0\r\n", - dir = exe.rfind(['\\', '/']).map(|i| exe[..i].to_string()).unwrap_or_default(), + pdir = protected_dir_expression(), name = TASK_NAME ) } +/// V6-06: whether the installed exe (its sha256 `candidate`) may replace the protected copy: only when the signed +/// manifest verifies under `pub_hex` and its windows entry names that very hash. Ok carries the manifest's version. +pub fn refresh_allowed(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str, candidate: &str) -> Result { + let m = crate::manifest::verify_and_parse(manifest_bytes, sig_hex, pub_hex).map_err(|e| format!("the update manifest does not verify ({e}); the protected copy stays"))?; + let want = m.windows.as_ref().map(|w| w.engine_sha256.clone()).unwrap_or_default(); + if want.is_empty() { + return Err("the signed manifest names no engine hash (publish-manifest.sh --win-engine); the protected copy stays".into()); + } + if want != candidate.to_ascii_lowercase() { + return Err(format!("{candidate} is not the exe the signed manifest names ({want}); the protected copy stays")); + } + Ok(m.version) +} + /// The PowerShell that starts the task from an unelevated process (no prompt: the user owns the task). pub fn start_command() -> String { format!("Start-ScheduledTask -TaskName '{TASK_NAME}'; exit 0") @@ -397,6 +446,10 @@ fn run_helper_loop(dir: &Path, log: &dyn Fn(&str)) -> i32 { log(&line); } for (seq, c) in cmds { + // V6-06: the rising-sequence guard first, quit and remove included + if seq <= last_seq { + continue; + } match c { HelperCmd::Quit => { log("quit"); @@ -414,7 +467,6 @@ fn run_helper_loop(dir: &Path, log: &dyn Fn(&str)) -> i32 { let _ = std::fs::remove_file(dir.join(ALIVE_FILE)); return if ok { 0 } else { 1 }; } - _ if seq <= last_seq => continue, HelperCmd::Reregister => { last_seq = seq; idle = Instant::now(); @@ -422,10 +474,23 @@ fn run_helper_loop(dir: &Path, log: &dyn Fn(&str)) -> i32 { log(&format!("{seq} reregister: no installed exe found")); continue; }; - let script = dir.join("register-power-task.ps1"); - let ok = std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), register_script(&target).as_bytes()].concat()).is_ok() && { + // V6-06: the protected copy is refreshed only when the signed manifest names this exe's hash + let updates = dir.parent().map(|p| p.join("updates")).unwrap_or_default(); + let verdict = match (std::fs::read(updates.join("manifest.json")), std::fs::read_to_string(updates.join("manifest.json.sig")), crate::manifest::sha256_file(&target)) { + (Ok(b), Ok(sg), Ok(h)) => refresh_allowed(&b, sg.trim(), crate::edition::ota_key(), &h), + (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(format!("no verified manifest or hash to check against ({e}); the protected copy stays")), + }; + let version = match verdict { + Ok(v) => v, + Err(e) => { + log(&format!("{seq} reregister refused: {e}")); + continue; + } + }; + log(&format!("{seq} reregister: {} is the exe manifest {version} names; refreshing the protected copy", target.display())); + let ok = { let mut p = std::process::Command::new(crate::platform::tool("powershell")); - p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", &script.display().to_string()]); + p.args(crate::platform::encoded_command_args(®ister_script(&target))); crate::platform::quiet(&mut p); p.status().map(|s| s.success()).unwrap_or(false) }; @@ -485,6 +550,92 @@ pub fn helper_dir() -> PathBuf { mod tests { use super::*; + /// Known failed first (V6-06, the master's R1 review, 8 October 2026): a bare `quit` or `remove` carried no sequence + /// and so skipped the guard every other verb passes; a stale line re-added to the file ended the helper. Now every + /// verb carries a rising sequence, quit and remove included. + #[test] + fn quit_and_remove_carry_a_sequence_and_a_stale_one_is_ignored() { + assert_eq!(parse_line("quit"), None, "a bare quit is not a command"); + assert_eq!(parse_line("remove"), None, "a bare remove is not a command"); + assert_eq!(parse_line("12 quit"), Some((12, HelperCmd::Quit))); + assert_eq!(parse_line("12 remove"), Some((12, HelperCmd::Remove))); + assert_eq!(parse_line("quit 12"), None); + // the loop itself: a stale quit (sequence at or under the highest seen) is skipped; a rising one ends the helper + let dir = std::env::temp_dir().join(format!("igneum-helper-seq-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("cmd.txt"), "5 dev 0\n").unwrap(); + let d2 = dir.clone(); + let t = std::thread::spawn(move || run_helper_loop(&d2, &|_| {})); + std::thread::sleep(Duration::from_millis(900)); + std::fs::write(dir.join("cmd.txt"), "5 dev 0\n3 quit\n").unwrap(); + std::thread::sleep(Duration::from_millis(1500)); + assert!(!t.is_finished(), "a quit with a stale sequence must not end the helper"); + std::fs::write(dir.join("cmd.txt"), "5 dev 0\n3 quit\n6 quit\n").unwrap(); + let started = Instant::now(); + while !t.is_finished() && started.elapsed() < Duration::from_secs(10) { + std::thread::sleep(Duration::from_millis(100)); + } + assert!(t.is_finished(), "a quit with a rising sequence ends the helper"); + assert_eq!(t.join().unwrap(), 0); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Known failed first (V6-06): the task's action was the per-user install's exe under LOCALAPPDATA, which the user + /// (and anything running as the user) can overwrite, and the scheduler then ran it elevated with no prompt. The one + /// elevated step now copies the exe and its runtime DLLs into %ProgramData%\Igneum\helper, a folder only + /// administrators and SYSTEM may write (inheritance cut, Users read and execute), and the task runs that copy. + #[test] + fn the_task_runs_a_protected_copy_the_user_cannot_write() { + let s = register_script(Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe")); + assert!(s.contains("Join-Path $env:ProgramData 'Igneum\\helper'"), "{s}"); + assert!(s.contains("icacls") && s.contains("/inheritance:r"), "{s}"); + for grant in ["*S-1-5-32-544:(OI)(CI)F", "*S-1-5-18:(OI)(CI)F", "*S-1-5-32-545:(OI)(CI)RX"] { + assert!(s.contains(grant), "missing {grant}: {s}"); + } + assert!(!s.contains("(OI)(CI)M") && !s.contains("S-1-5-32-545:(OI)(CI)F"), "users never get modify or full: {s}"); + for f in ["igneum-app.exe", "libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll"] { + assert!(s.contains(&format!("Copy-Item -LiteralPath (Join-Path $src '{f}')")), "{f} is not copied: {s}"); + } + assert!(s.contains("$src = 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner'"), "{s}"); + assert!(s.contains("-Execute $helperExe -Argument '--power-helper' -WorkingDirectory $helperDir"), "{s}"); + assert!(!s.contains("-Execute 'C:\\Users"), "the action is never the user-writable exe: {s}"); + assert_eq!(protected_dir_expression(), "Join-Path $env:ProgramData 'Igneum\\helper'"); + } + + /// Known failed first (V6-06): the helper's `reregister` copied whatever exe sat in the install folder into the + /// task's action. Now the protected copy is refreshed only when the signed manifest names the exe's sha256 + /// (platforms.windows.engine_sha256), so a swapped binary never becomes the elevated one. + #[test] + fn a_refresh_of_the_protected_copy_needs_the_signed_manifest_to_name_the_exe() { + use ed25519_dalek::{Signer, SigningKey}; + let sk = SigningKey::from_bytes(&[7u8; 32]); + let pk = crate::manifest::hex_encode(sk.verifying_key().as_bytes()); + let good = "ab".repeat(32); + let text = format!(r#"{{"version":"2.0.2","platforms":{{"windows":{{"url":"https://x/s.exe","sha256":"{}","size":1,"kind":"inno-setup","engine_sha256":"{good}"}}}}}}"#, "cd".repeat(32)); + let sig = crate::manifest::hex_encode(&sk.sign(text.as_bytes()).to_bytes()); + assert_eq!(refresh_allowed(text.as_bytes(), &sig, &pk, &good), Ok("2.0.2".to_string())); + assert!(refresh_allowed(text.as_bytes(), &sig, &pk, &"ef".repeat(32)).unwrap_err().contains("not the exe the signed manifest names")); + let other = crate::manifest::hex_encode(SigningKey::from_bytes(&[9u8; 32]).verifying_key().as_bytes()); + assert!(refresh_allowed(text.as_bytes(), &sig, &other, &good).is_err(), "a signature under another key is refused"); + let bare = format!(r#"{{"version":"2.0.2","platforms":{{"windows":{{"url":"https://x/s.exe","sha256":"{}","size":1,"kind":"inno-setup"}}}}}}"#, "cd".repeat(32)); + let sig2 = crate::manifest::hex_encode(&sk.sign(bare.as_bytes()).to_bytes()); + assert!(refresh_allowed(bare.as_bytes(), &sig2, &pk, &good).unwrap_err().contains("names no engine"), "a manifest without the engine hash refreshes nothing"); + } + + /// V6-06: the window host stays unprivileged (asInvoker; a prompt it raises for the engine's one step is a child, not + /// the host); every elevated script of the app travels inline in the command line, never as a file under a folder + /// the user can write between the write and the run. + #[test] + fn the_window_host_is_unprivileged_and_elevated_scripts_travel_inline() { + let manifest = include_str!("../../windows/host.manifest"); + assert!(manifest.contains(r#""#), "{manifest}"); + assert!(!manifest.contains("requireAdministrator") && !manifest.contains("highestAvailable")); + let args = crate::platform::encoded_command_args("exit 0"); + assert_eq!(args, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", "ZQB4AGkAdAAgADAA"]); + assert!(!register_script(Path::new(r"C:\p\igneum-app.exe")).contains("-File "), "no script file is run"); + } + /// Known-failed first (PC 1, 7 October 2026, 22:08 BST): the helper's process died at once after each of six starts /// with no line after "helper started", and the engine toggled nothing and said nothing. Now a panic is a FAULT /// line, an exit reason and exit 101, every planned exit writes its reason, and the engine's notice carries it. @@ -543,7 +694,7 @@ mod tests { let l = nothing_to_run_line(rewritten, 2, 423699, &commands_after(rewritten, 2)).unwrap(); assert!(l.contains("2 skipped as present at start, 0 parsed"), "{l}"); assert_eq!(nothing_to_run_line("", 0, 1, &[]), None, "a truncation is not a command and not a complaint"); - assert_eq!(nothing_to_run_line("quit\n", 0, 9, &commands_after("quit\n", 0)), None, "quit runs"); + assert_eq!(nothing_to_run_line("10 quit\n", 0, 9, &commands_after("10 quit\n", 0)), None, "quit runs"); assert!(include_str!("powertask.rs").matches("nothing_to_run_line(").count() >= 2, "the loop logs it"); } @@ -551,13 +702,13 @@ mod tests { /// October 2026), so the engine writes `remove` only once the helper's heartbeat is there: start first, then write. #[test] fn remove_is_written_after_the_heartbeat_not_before_the_start() { - assert_eq!(commands_after("remove\n", 1), vec![], "the remove present at the start is skipped"); - assert_eq!(commands_after("remove\n", 0), vec![(0, HelperCmd::Remove)], "a remove added after the start runs"); + assert_eq!(commands_after("5 remove\n", 1), vec![], "the remove present at the start is skipped"); + assert_eq!(commands_after("5 remove\n", 0), vec![(5, HelperCmd::Remove)], "a remove added after the start runs"); let s = include_str!("engine.rs"); let off = s.find("fn power_control_off").unwrap(); let body = &s[off..off + 2500]; let ensure = body.find("ensure_running").expect("the off path waits for the heartbeat"); - let write = body.find("\"remove\\n\"").expect("then writes remove"); + let write = body.find(" remove\\n\"").expect("then writes remove (with its sequence, V6-06)"); assert!(ensure < write, "the heartbeat comes before the remove line"); } @@ -586,13 +737,14 @@ mod tests { assert_eq!(smi_args("0", &HelperCmd::MemReset).unwrap(), vec!["-i", "0", "-rmc"]); assert_eq!(parse_line("3 dev 1"), Some((3, HelperCmd::Dev("1".into())))); assert_eq!(parse_line("5 403"), Some((5, HelperCmd::PowerLimit(403))), "the 0.3.9 form"); - assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit))); - assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove))); + assert_eq!(parse_line("7 quit"), Some((7, HelperCmd::Quit))); + assert_eq!(parse_line("7 remove"), Some((7, HelperCmd::Remove))); assert_eq!(parse_line("12 reregister"), Some((12, HelperCmd::Reregister))); // a stale quit present at the start is skipped; a quit added later counts; a rewritten (shorter) file counts whole assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n", 3), vec![]); assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n7 dev 1\n", 3), vec![(7, HelperCmd::Dev("1".into()))]); - assert_eq!(commands_after("quit\n", 3), vec![(0, HelperCmd::Quit)]); + assert_eq!(commands_after("quit\n", 3), vec![], "a bare quit is no command (V6-06)"); + assert_eq!(commands_after("8 quit\n", 0), vec![(8, HelperCmd::Quit)]); // PC 2, 7 October 2026: the known-failed shape first. A stale four-line file at the helper's start, then the engine's // four-line rewrite: the count never dropped, so the old rule skipped every new command let stale = "401000 dev 0\n401001 pl 460\n401002 rgc\n401003 rmc\n"; @@ -640,8 +792,9 @@ mod tests { #[test] fn the_registration_is_per_user_highest_no_trigger_fixed_action() { let s = register_script(Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe")); - assert!(s.contains("-Execute 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner\\igneum-app.exe' -Argument '--power-helper'"), "{s}"); - assert!(s.contains("-WorkingDirectory 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner'"), "{s}"); + // V6-06: the action is the protected copy under %ProgramData%, taken from the install folder by the elevated step + assert!(s.contains("-Execute $helperExe -Argument '--power-helper' -WorkingDirectory $helperDir"), "{s}"); + assert!(s.contains("$src = 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner'"), "{s}"); // 0.3.24 (main, 7 October 2026, PC 1): the task runs whether or not a user is logged on (S4U, the user's own token, // no stored password), so a start from a job's session or the engine's own tune is accepted; known-failed first // (the Interactive-only form) @@ -653,7 +806,7 @@ mod tests { assert!(s.contains(&format!("-TaskName '{TASK_NAME}'"))); // no window at logon or at a start (the project lead, 7 October 2026, PC 2's Terminal window): the action is the app's own exe, // a windows-subsystem program with no console, never powershell.exe, cmd.exe or a `start` of a batch file - assert!(s.contains("-Execute 'C:\\p\\igneum-app.exe' -Argument '--power-helper'") || s.contains("-Argument '--power-helper'"), "{s}"); + assert!(s.contains("-Argument '--power-helper'"), "{s}"); assert!(!s.contains("powershell.exe") && !s.contains("cmd.exe") && !s.contains("cmd /c start"), "the task's action must be the exe itself: {s}"); assert!(s.contains("-Hidden"), "the task is hidden in the scheduler too"); // the registered probe also wants the action's exe on disk and the task enabled (the stale-task class) @@ -661,7 +814,7 @@ mod tests { assert!(q.contains("Test-Path (($t.Actions[0].Execute).Trim") && q.contains("$t.State -ne 'Disabled'") && q.contains("exit 1"), "{q}"); // a quote in the path cannot break out of the literal let q = register_script(Path::new(r"C:\it's\igneum-app.exe")); - assert!(q.contains("'C:\\it''s\\igneum-app.exe'"), "{q}"); + assert!(q.contains("$src = 'C:\\it''s'"), "{q}"); assert!(start_command().starts_with("Start-ScheduledTask -TaskName 'Igneum Power Helper'")); assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Power Helper' -Confirm:$false")); assert!(query_command().contains("Get-ScheduledTask -TaskName 'Igneum Power Helper'")); diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index cdead2c8f..2b17100fb 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -266,7 +266,7 @@ fn host_lease(shared: &Shared, workload: crate::device::Workload, deadline_s: u6 }; let used = crate::detect::nvidia_memory_used().get(&index.to_string()).copied().unwrap_or(0); let free = vram.saturating_sub(used); - let budget = crate::device::proof_budget_mib(vram, crate::device::mode(vram, mining)); + let budget = crate::device::lease_mib(vram, crate::device::mode(vram, mining), free); let env = crate::device::host_env(index, workload, free, budget, deadline_s); shared.log(&format!("LEASE holder=prover device=nvidia:{index} workload={} free_mib={free} budget_mib={budget} deadline_s={deadline_s}", workload.word())); (free, budget, env) diff --git a/app/igneum-app/src/rights.rs b/app/igneum-app/src/rights.rs index c165e7293..8c5ff31a9 100644 --- a/app/igneum-app/src/rights.rs +++ b/app/igneum-app/src/rights.rs @@ -19,7 +19,7 @@ //! shapes first (before 0.3.22: a fresh install then Power control on prompted; the firewall rule prompted on the first //! run; the boot task was registered at the engine's start). -use std::path::{Path, PathBuf}; +use std::path::Path; /// The rights this build needs, in the order the elevated step takes them. An id never changes meaning; a new need is /// a new id (that is what makes an update ask once). @@ -236,6 +236,11 @@ pub fn script(exe: &Path, install_dir: &Path, data_root: &Path) -> String { s } +/// The one elevated command line (V6-06): PowerShell with the rights script inline (-EncodedCommand), never a file. +pub fn elevated_line(powershell: &Path, exe: &Path, install_dir: &Path, data_root: &Path) -> String { + format!("\"{}\" {}", powershell.display(), crate::platform::encoded_command_args(&script(exe, install_dir, data_root)).join(" ")) +} + /// The WSL2 part of the elevated script (Igneum 2.0): the feature and the kernel with no distribution and no window; /// one log line either way; a reboot pending is said, never forced (the installer never restarts a PC by itself). pub fn wsl2_script(install_dir: &Path) -> String { @@ -331,15 +336,17 @@ pub fn install(exe: &Path, install_dir: &Path, data_root: &Path, app_dir: &Path, Step::Asked => {} } let _ = std::fs::create_dir_all(app_dir); - let path: PathBuf = app_dir.join(SCRIPT_FILE); - std::fs::write(&path, [b"\xEF\xBB\xBF".as_slice(), script(exe, install_dir, data_root).as_bytes()].concat()).map_err(|e| format!("cannot write {}: {e}", path.display()))?; + // V6-06: the script travels inline; no rights.ps1 under the data root is read by the elevated step (a copy is left + // for the record, after the run, never as its input) #[cfg(windows)] { - let line = format!("\"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", crate::platform::tool("powershell").display(), path.display()); + let line = elevated_line(&crate::platform::tool("powershell"), exe, install_dir, data_root); crate::platform::run_elevated(&line)?; + let _ = std::fs::write(app_dir.join(SCRIPT_FILE), [b"\xEF\xBB\xBF".as_slice(), script(exe, install_dir, data_root).as_bytes()].concat()); } #[cfg(not(windows))] { + let _ = (exe, install_dir, data_root); return Err("the rights step is Windows only".into()); } #[allow(unreachable_code)] @@ -365,6 +372,16 @@ pub fn missing_note(id: &str) -> String { mod tests { use super::*; + /// Known failed first (V6-06): rights.ps1 was written to the user's data folder and run elevated with -File, so a + /// swap between the write and the run would have run as administrator. The script now travels inline. + #[test] + fn the_rights_step_runs_its_script_inline_under_the_command_line_cap() { + let (exe, dir, root) = (Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe"), Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner"), Path::new(r"C:\Users\Admin\AppData\Local\igneum")); + let line = elevated_line(Path::new(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"), exe, dir, root); + assert!(line.contains("-EncodedCommand ") && !line.contains("-File") && !line.contains(".ps1"), "{line}"); + assert!(line.len() < 30_000, "the elevated line must fit the 32 K command line: {} chars", line.len()); + } + fn m(rights: &[&str]) -> Manifest { Manifest { version: "0.3.22".into(), rights: rights.iter().map(|s| s.to_string()).collect(), at: 1 } } diff --git a/app/igneum-app/src/sweep.rs b/app/igneum-app/src/sweep.rs index a3eb12f2b..111a57fc4 100644 --- a/app/igneum-app/src/sweep.rs +++ b/app/igneum-app/src/sweep.rs @@ -352,7 +352,7 @@ while true; do c=""; [ -f "$dir/cmd.txt" ] && c=$(cat "$dir/cmd.txt" 2>/dev/null | tr -d '\r\n') if [ -n "$c" ] && [ "$c" != "$last" ]; then last="$c"; idle=$(date +%s) - if [ "$c" = "quit" ]; then echo "$(date -u +%FT%TZ) quit" >> "$dir/helper.log"; break; fi + case "$c" in quit|[0-9]*" quit") echo "$(date -u +%FT%TZ) quit" >> "$dir/helper.log"; break;; esac printf '%s\n' "$c" | while IFS= read -r line; do set -- $line [ $# -ge 2 ] || continue @@ -569,6 +569,8 @@ mod tests { fn helper_scripts_carry_the_protocol() { for s in [helper_script_unix()] { assert!(s.contains("cmd.txt") && s.contains("quit") && s.contains("-pl") && s.contains("20 min")); + // V6-06: the engine writes " quit" (the Windows helper's guard); the unix helper takes that form too + assert!(s.contains(r#"case "$c" in quit|[0-9]*" quit")"#), "{s}"); assert!(s.contains("-lgc") && s.contains("-rgc"), "the clock cap and its reset"); assert!(s.contains("-lmc") && s.contains("-rmc"), "Ember 2: the memory clock and its reset"); } diff --git a/app/windows/host.manifest b/app/windows/host.manifest index 7cb2f0b76..5168400cf 100644 --- a/app/windows/host.manifest +++ b/app/windows/host.manifest @@ -11,6 +11,15 @@ PerMonitorV2 + + + + + + + + diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh index d689428ca..a1bc5b42d 100755 --- a/packaging/ota/publish-manifest.sh +++ b/packaging/ota/publish-manifest.sh @@ -7,6 +7,9 @@ # packaging/ota/publish-manifest.sh --version 0.3.1 --mac packaging/mac/dist/Igneum-Miner-0.3.1.dmg \ # [--win packaging/windows/dist/Igneum-Miner-Setup-0.3.1.exe] --notes "one line of what changed" \ # [--activation-height 120000 --deadline-note "difficulty v2"] [--min-supported 0.3.0] [--channel devnet] [--deploy] +# [--win-engine ] the engine exe inside the Windows installer (the payload's): its sha256 lands as +# platforms.windows.engine_sha256, what the Power Helper checks before it refreshes +# its protected copy (V6-06, 8 October 2026); without it no helper refreshes # [--urgent] the manifest's own urgent flag (2.0.2, 8 October 2026): the apps install at the first # safe moment, through the finality guard too (manifest::safe_to_apply); for the entry # that IS the fix for a chain that cannot lock; beside, not instead of, --min-supported @@ -51,7 +54,7 @@ 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 UI_FILE="" NO_UI=0 DRIVERS_FILE="" NO_DRIVERS=0 URGENT=0 +OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0 UI_FILE="" NO_UI=0 DRIVERS_FILE="" NO_DRIVERS=0 URGENT=0 WIN_ENGINE="" while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift 2 ;; @@ -65,6 +68,7 @@ while [ $# -gt 0 ]; do --override) OVERRIDE="$2"; shift 2 ;; # consensus.override: the exact JSON object every app writes to its override.json (all height switches, not just the new one) --min-supported) MIN_SUPPORTED="$2"; shift 2 ;; --urgent) URGENT=1; shift ;; + --win-engine) WIN_ENGINE="$2"; shift 2 ;; --channel) CHANNEL="$2"; shift 2 ;; --tuning) TUNING_FILE="$2"; shift 2 ;; --no-tuning) NO_TUNING=1; shift ;; @@ -263,9 +267,15 @@ fi # the version pair: the UI tree this publish builds from stamps its interface version on every new app entry UI_TREE_VERSION="$(tr -d '[:space:]' < "$ROOT/app/igneum-app/ui/VERSION" 2>/dev/null || true)" [ "$URGENT" = 1 ] && echo "urgent: the apps install this entry at the first safe moment, finality guard included" -python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${UI:-}" "${DRIVERS:-}" "${UI_TREE_VERSION:-}" "$URGENT" <<'PY' +ENGINE_SHA="" +if [ -n "$WIN_ENGINE" ]; then + [ -f "$WIN_ENGINE" ] || { echo "--win-engine: no file at $WIN_ENGINE" >&2; exit 2; } + read -r ENGINE_SHA _ < <("$SIGNER" sha256 "$WIN_ENGINE") + echo "windows engine: $(basename "$WIN_ENGINE") sha256 $ENGINE_SHA (the Power Helper refreshes its protected copy to this exe only)" +fi +python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${UI:-}" "${DRIVERS:-}" "${UI_TREE_VERSION:-}" "$URGENT" "$ENGINE_SHA" <<'PY' import json, sys, datetime -out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, ui, drivers, ui_tree, urgent = sys.argv[1:16] +out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, ui, drivers, ui_tree, urgent, engine_sha = sys.argv[1:17] 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, ui_version=None): @@ -289,6 +299,8 @@ m = { } if urgent == "1": m["urgent"] = True +if engine_sha and "windows" in m["platforms"]: + m["platforms"]["windows"]["engine_sha256"] = engine_sha if tuning: m["tuning"] = json.loads(tuning) if ui: