From 78b9fab46bbb801b8ecd4adb4ec5bee7a81a6967 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:12:09 +0000 Subject: [PATCH] Igneum Wallet 0.1.2: Touch ID (unlock, every send, the backup, idle lock), Windows Hello written untested; the coin and the chain line on the balance card; the version in the header and Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window host owns the prompt and the secret (app/mac/Biometric.swift): LAPolicy.deviceOwnerAuthenticationWithBiometrics with "Use password" as the fallback button (never the device password), the wallet's password sealed to a Secure Enclave key made with .biometryCurrentSet (the Keychain refuses biometric access controls under the ad hoc signature, -34018, measured) in /wallet/biometric.json; a fingerprint change invalidates it. The engine owns the gate (igneum-common/src/biometric.rs): a nonce per action, read by the host with its token (the HOST line on stdout, X-Igneum-Host on host-only calls), confirmed after the prompt, taken once within 30 s and bound to the exact quote; /api/send refuses without it while enrolled; /api/reveal with a nonce reads the unlocked key in memory; the password never goes through the page (enrolment parks it under a one-time token the host takes). Idle lock after 5 minutes without window activity (setting, default on). A password change or a wallet removal deletes the sealed file. Reason lines in our voice ("Unlock your wallet", "Send 1.5 IGN to 0x7E5F…5Bdf", "Show your recovery words"); the page shows its own ember line after every prompt. Windows: app/windows/biometric.h (UserConsentVerifier through IUserConsentVerifierInterop, DPAPI), wired into wallet-host.cpp and BUILD-WALLET-APP.bat, not yet compiled on a PC. Hosts gain a @main entry so Biometric.swift compiles alongside; build-wallet-dmg.sh links LocalAuthentication. Balance card: the coin at 56 px, "0" (or the balance) as soon as the node answers, "reading the chain, N of M blocks" under it while the history scans. Version: v0.1.2 in the brand band, "Igneum Wallet 0.1.2 · up to date" in Settings. Unit tests: the gate (7, igneum-common), the wallet's 17 still green. README: the flows, the threat model, what was verified on this Mac (enrol and unlock through the real prompt) and what was not. Co-Authored-By: Claude Fable 5.1 --- app/igneum-common/src/biometric.rs | 352 ++++++++++++++++++++++++++ app/igneum-common/src/http.rs | 8 +- app/igneum-common/src/lib.rs | 3 + app/igneum-wallet/Cargo.lock | 2 +- app/igneum-wallet/Cargo.toml | 2 +- app/igneum-wallet/README.md | 105 ++++++++ app/igneum-wallet/src/engine.rs | 224 ++++++++++++++++- app/igneum-wallet/src/main.rs | 15 +- app/igneum-wallet/src/server.rs | 59 ++++- app/igneum-wallet/src/state.rs | 3 + app/igneum-wallet/ui/app.css | 18 ++ app/igneum-wallet/ui/app.js | 173 ++++++++++++- app/igneum-wallet/ui/index.html | 40 ++- app/mac/Biometric.swift | 328 ++++++++++++++++++++++++ app/mac/IgneumWallet.swift | 86 +++++-- app/windows/BUILD-WALLET-APP.bat | 6 +- app/windows/biometric.h | 377 ++++++++++++++++++++++++++++ app/windows/wallet-host.cpp | 29 +++ app/windows/wallet-version.h | 4 +- packaging/mac/build-wallet-dmg.sh | 5 +- packaging/windows/Igneum-Wallet.iss | 2 +- 21 files changed, 1778 insertions(+), 63 deletions(-) create mode 100644 app/igneum-common/src/biometric.rs create mode 100644 app/mac/Biometric.swift create mode 100644 app/windows/biometric.h diff --git a/app/igneum-common/src/biometric.rs b/app/igneum-common/src/biometric.rs new file mode 100644 index 000000000..df08b41fe --- /dev/null +++ b/app/igneum-common/src/biometric.rs @@ -0,0 +1,352 @@ +//! The biometric gate, the part that needs no Touch ID and no Windows Hello: challenges the engine issues, the host +//! confirms after the prompt, and the action consumes once. The window host (macOS: app/mac/Biometric.swift; +//! Windows: the WebView2 host) holds the secret; this module only decides whether a confirmation is fresh. +//! +//! The flow for a gated action: +//! 1. the window asks the engine for a challenge: `Gate::issue(purpose, bound, reason)` gives a nonce; the reason +//! (at most 80 characters) is what the prompt shows, built by the engine so the window cannot word it; +//! 2. the window hands the nonce to the host; the host reads the reason from the engine (with the host token, which +//! only the host knows: the engine printed it on its stdout), shows the prompt, and on success posts +//! `Gate::confirm(nonce)`; +//! 3. the window calls the action with the nonce; the engine runs `Gate::take(nonce, purpose, bound)`: confirmed +//! within CHALLENGE_TTL_S, the same purpose and the same binding (the quote for a send, the new address for an +//! address change), never used before. One nonce, one action. +//! +//! Enrolment (the wallet): the window posts the password to the engine, which checks it and keeps it under a one-time +//! token for ENROL_TTL_S; the host takes it with the token after the prompt, seals it and writes the file. The +//! password reaches the host over 127.0.0.1 only, never through the page. + +use serde::Serialize; +use std::time::{Duration, Instant}; + +/// A confirmation is fresh for this long after the prompt succeeded. +pub const CHALLENGE_TTL_S: u64 = 30; +/// An unconfirmed challenge waits for the prompt this long (the confirm screen can sit open). +pub const CHALLENGE_WAIT_S: u64 = 180; +/// The enrolment token's life. +pub const ENROL_TTL_S: u64 = 120; +/// The prompt's reason string: at most this many characters (the hard clip); the strings the engines build stay +/// under 60, in our voice, no trailing full stop ("Unlock your wallet", "Send 1.5 IGN to 0x7F45…C126"). +pub const REASON_MAX: usize = 80; +/// The idle lock (the wallet): minutes without the window reporting activity before the key is zeroed. +pub const IDLE_LOCK_MIN: u64 = 5; + +/// What `/api/state` carries under `biometric`. +#[derive(Clone, Serialize, Default)] +pub struct BiometricState { + /// the host said the prompt can be shown (Touch ID set up, Windows Hello configured) + pub available: bool, + /// touchid | hello | "" (no host yet) + pub kind: String, + /// the sealed file exists: the gated actions need the prompt + pub enrolled: bool, + /// the host's word for why it is unavailable, in the window's wording + pub message: String, + /// the last prompt's outcome: ok | cancelled | failed | locked | invalidated | unavailable | "" + pub last_result: String, + pub last_op: String, + pub last_at: f64, + /// the wallet: lock after IDLE_LOCK_MIN minutes idle (setting, on by default once enrolled) + pub idle_lock: bool, + pub idle_lock_min: u64, + /// set when the password changed under an enrolment: the sealed password is stale and was removed + pub needs_enrol: bool, +} + +pub struct Challenge { + pub nonce: String, + pub purpose: String, + pub bound: String, + pub reason: String, + issued: Instant, + confirmed: Option, + used: bool, +} + +#[derive(Default)] +pub struct Gate { + challenges: Vec, + enrol: Option<(String, String, Instant)>, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum GateError { + Unknown, + Expired, + NotConfirmed, + Stale, + Used, + Mismatch, +} + +impl GateError { + /// The window's wording. `what` is "Touch ID" or "Windows Hello". + pub fn text(&self, what: &str) -> String { + match self { + GateError::Unknown => format!("confirm with {what} first"), + GateError::Expired => format!("the {what} request timed out; try again"), + GateError::NotConfirmed => format!("{what} did not confirm this; try again"), + GateError::Stale => format!("the {what} confirmation is older than {CHALLENGE_TTL_S} s; confirm again"), + GateError::Used => format!("that {what} confirmation was already used; confirm again"), + GateError::Mismatch => format!("the {what} confirmation was for something else; confirm again"), + } + } +} + +fn random_hex(n: usize) -> String { + let mut raw = vec![0u8; n]; + getrandom::getrandom(&mut raw).expect("os randomness"); + crate::keys::hex(&raw) +} + +/// Cuts a reason to REASON_MAX characters (not bytes), with a trailing ellipsis when it was longer. +pub fn clip_reason(s: &str) -> String { + let count = s.chars().count(); + if count <= REASON_MAX { + return s.to_string(); + } + let mut out: String = s.chars().take(REASON_MAX - 1).collect(); + out.push('…'); + out +} + +/// The prompt's line for a send: the amount (the caller gives it to 4 decimals) and the checksum address as its +/// first 6 and last 4 characters: "Send 1.5 IGN to 0x7F45…C126". +pub fn send_reason(amount_ign: &str, display_to: &str) -> String { + let short = if display_to.len() > 10 { format!("{}…{}", &display_to[..6], &display_to[display_to.len() - 4..]) } else { display_to.to_string() }; + clip_reason(&format!("Send {amount_ign} IGN to {short}")) +} + +impl Gate { + pub fn new() -> Gate { + Gate::default() + } + + fn prune(&mut self, now: Instant) { + // used nonces stay until their window ends, so a replay is answered "used", not "unknown" + self.challenges.retain(|c| now.duration_since(c.issued) < Duration::from_secs(CHALLENGE_WAIT_S + CHALLENGE_TTL_S)); + if let Some((_, _, t)) = &self.enrol { + if now.duration_since(*t) >= Duration::from_secs(ENROL_TTL_S) { + self.enrol = None; + } + } + } + + pub fn issue(&mut self, purpose: &str, bound: &str, reason: &str, now: Instant) -> String { + self.prune(now); + let nonce = random_hex(16); + self.challenges.push(Challenge { nonce: nonce.clone(), purpose: purpose.into(), bound: bound.into(), reason: clip_reason(reason), issued: now, confirmed: None, used: false }); + nonce + } + + /// For the host: what the prompt says for this nonce. + pub fn reason_of(&self, nonce: &str) -> Option<(String, String)> { + self.challenges.iter().find(|c| c.nonce == nonce && !c.used).map(|c| (c.purpose.clone(), c.reason.clone())) + } + + /// The host says the prompt succeeded for this nonce. + pub fn confirm(&mut self, nonce: &str, now: Instant) -> Result<(), GateError> { + self.prune(now); + let c = self.challenges.iter_mut().find(|c| c.nonce == nonce).ok_or(GateError::Unknown)?; + if c.used { + return Err(GateError::Used); + } + if now.duration_since(c.issued) >= Duration::from_secs(CHALLENGE_WAIT_S) { + return Err(GateError::Expired); + } + c.confirmed = Some(now); + Ok(()) + } + + /// The action runs: fresh, the same purpose and binding, once. + pub fn take(&mut self, nonce: &str, purpose: &str, bound: &str, now: Instant) -> Result<(), GateError> { + let c = self.challenges.iter_mut().find(|c| c.nonce == nonce).ok_or(GateError::Unknown)?; + if c.used { + return Err(GateError::Used); + } + let Some(t) = c.confirmed else { + return Err(if now.duration_since(c.issued) >= Duration::from_secs(CHALLENGE_WAIT_S) { GateError::Expired } else { GateError::NotConfirmed }); + }; + if c.purpose != purpose || c.bound != bound { + return Err(GateError::Mismatch); + } + if now.duration_since(t) >= Duration::from_secs(CHALLENGE_TTL_S) { + c.used = true; + return Err(GateError::Stale); + } + c.used = true; + Ok(()) + } + + /// Enrolment: the engine keeps the checked secret under a one-time token. + pub fn enrol_begin(&mut self, secret: &str, now: Instant) -> String { + let token = random_hex(16); + self.enrol = Some((token.clone(), secret.to_string(), now)); + token + } + + /// The host takes the secret with the token, once. + pub fn enrol_take(&mut self, token: &str, now: Instant) -> Option { + self.prune(now); + match self.enrol.take() { + Some((t, s, _)) if constant_eq(&t, token) => Some(s), + Some(other) => { + // a wrong token does not burn the pending enrolment + self.enrol = Some(other); + None + } + None => None, + } + } + + pub fn enrol_pending(&self) -> bool { + self.enrol.is_some() + } + + pub fn enrol_cancel(&mut self) { + self.enrol = None; + } + + #[cfg(test)] + fn len(&self) -> usize { + self.challenges.len() + } +} + +/// Equal strings, compared in constant time over the longer length. +pub fn constant_eq(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + let mut diff = (a.len() ^ b.len()) as u8; + for i in 0..a.len().max(b.len()) { + diff |= a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0); + } + diff == 0 +} + +/// A fingerprint of a send quote, so the confirmation binds to what the window showed. +pub fn send_binding(to: &str, value: &str, tx_nonce: u64, chain_id: u64) -> String { + format!("send:{}:{}:{}:{}", to.to_ascii_lowercase(), value, tx_nonce, chain_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t(base: Instant, s: u64) -> Instant { + base + Duration::from_secs(s) + } + + #[test] + fn confirm_then_take_once() { + let mut g = Gate::new(); + let now = Instant::now(); + let n = g.issue("send", "send:0xab:1:0:7", "Send 1 IGN to 0xab", now); + assert_eq!(g.reason_of(&n), Some(("send".into(), "Send 1 IGN to 0xab".into()))); + // not confirmed yet + assert_eq!(g.take(&n, "send", "send:0xab:1:0:7", t(now, 1)), Err(GateError::NotConfirmed)); + g.confirm(&n, t(now, 2)).unwrap(); + // wrong binding, wrong purpose + assert_eq!(g.take(&n, "send", "send:0xcd:1:0:7", t(now, 3)), Err(GateError::Mismatch)); + assert_eq!(g.take(&n, "reveal", "send:0xab:1:0:7", t(now, 3)), Err(GateError::Mismatch)); + // the right one, once + assert_eq!(g.take(&n, "send", "send:0xab:1:0:7", t(now, 3)), Ok(())); + assert_eq!(g.take(&n, "send", "send:0xab:1:0:7", t(now, 4)), Err(GateError::Used)); + assert_eq!(g.confirm(&n, t(now, 4)), Err(GateError::Used)); + assert!(g.reason_of(&n).is_none()); + } + + #[test] + fn replay_and_expiry() { + let mut g = Gate::new(); + let now = Instant::now(); + assert_eq!(g.take("nope", "send", "", now), Err(GateError::Unknown)); + assert_eq!(g.confirm("nope", now), Err(GateError::Unknown)); + // a confirmation older than the TTL is stale and burns the nonce + let n = g.issue("reveal", "", "Show the words", now); + g.confirm(&n, t(now, 1)).unwrap(); + assert_eq!(g.take(&n, "reveal", "", t(now, 1 + CHALLENGE_TTL_S)), Err(GateError::Stale)); + assert_eq!(g.take(&n, "reveal", "", t(now, 2)), Err(GateError::Used)); + // a challenge nobody confirmed within the wait expires + let n2 = g.issue("reveal", "", "Show the words", now); + assert_eq!(g.confirm(&n2, t(now, CHALLENGE_WAIT_S)), Err(GateError::Expired)); + assert_eq!(g.take(&n2, "reveal", "", t(now, CHALLENGE_WAIT_S)), Err(GateError::Expired)); + // pruned away after wait + ttl + let _ = g.issue("reveal", "", "x", t(now, CHALLENGE_WAIT_S + CHALLENGE_TTL_S + 1)); + assert_eq!(g.len(), 1); + // and a used nonce still answers "used" inside its window + let n4 = g.issue("send", "b", "r", t(now, 1000)); + g.confirm(&n4, t(now, 1001)).unwrap(); + assert_eq!(g.take(&n4, "send", "b", t(now, 1002)), Ok(())); + assert_eq!(g.confirm(&n4, t(now, 1003)), Err(GateError::Used)); + // a fresh confirmation at the edge still works + let n3 = g.issue("send", "b", "r", now); + g.confirm(&n3, t(now, CHALLENGE_WAIT_S - 1)).unwrap(); + assert_eq!(g.take(&n3, "send", "b", t(now, CHALLENGE_WAIT_S - 1 + CHALLENGE_TTL_S - 1)), Ok(())); + } + + #[test] + fn nonces_are_distinct_and_hex() { + let mut g = Gate::new(); + let now = Instant::now(); + let a = g.issue("send", "", "r", now); + let b = g.issue("send", "", "r", now); + assert_ne!(a, b); + assert_eq!(a.len(), 32); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn enrol_token_one_time_and_expiry() { + let mut g = Gate::new(); + let now = Instant::now(); + let tok = g.enrol_begin("correct horse", now); + assert!(g.enrol_pending()); + // a wrong token leaves the pending enrolment in place + assert_eq!(g.enrol_take("wrong", t(now, 1)), None); + assert!(g.enrol_pending()); + assert_eq!(g.enrol_take(&tok, t(now, 1)).as_deref(), Some("correct horse")); + assert_eq!(g.enrol_take(&tok, t(now, 1)), None); + assert!(!g.enrol_pending()); + // expiry + let tok2 = g.enrol_begin("p", now); + assert_eq!(g.enrol_take(&tok2, t(now, ENROL_TTL_S)), None); + // cancel + let tok3 = g.enrol_begin("p", now); + g.enrol_cancel(); + assert_eq!(g.enrol_take(&tok3, t(now, 1)), None); + } + + #[test] + fn reasons_are_clipped_to_eighty() { + let long = "x".repeat(200); + assert_eq!(clip_reason(&long).chars().count(), REASON_MAX); + assert_eq!(clip_reason("short"), "short"); + let r = send_reason("1.5", "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"); + assert_eq!(r, "Send 1.5 IGN to 0x7E5F…5Bdf"); + assert!(r.chars().count() < 60); + // a long amount still fits under 60 + let r2 = send_reason("123456789.1234", "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"); + assert_eq!(r2, "Send 123456789.1234 IGN to 0x7E5F…5Bdf"); + assert!(r2.chars().count() < 60); + // absurd amount: still clipped at 80 characters + let r3 = send_reason(&"9".repeat(90), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"); + assert_eq!(r3.chars().count(), REASON_MAX); + } + + #[test] + fn constant_eq_and_binding() { + assert!(constant_eq("abc", "abc")); + assert!(!constant_eq("abc", "abd")); + assert!(!constant_eq("abc", "abcd")); + assert!(!constant_eq("", "a")); + assert_eq!(send_binding("0xAB", "5", 3, 7), "send:0xab:5:3:7"); + } + + #[test] + fn state_defaults() { + let s = BiometricState::default(); + assert!(!s.available && !s.enrolled && s.kind.is_empty() && s.last_result.is_empty()); + let v = serde_json::to_value(&s).unwrap(); + assert_eq!(v["idle_lock_min"], 0); + } +} diff --git a/app/igneum-common/src/http.rs b/app/igneum-common/src/http.rs index a5df92efb..08c4a23c5 100644 --- a/app/igneum-common/src/http.rs +++ b/app/igneum-common/src/http.rs @@ -13,6 +13,8 @@ pub struct Req { pub origin: Option, pub sec_fetch_site: Option, pub host: Option, + /// X-Igneum-Host: the window host's token (the engine printed it on stdout; the page never sees it) + pub host_token: Option, } pub fn read_request(stream: &mut TcpStream) -> Option { @@ -24,7 +26,7 @@ pub fn read_request(stream: &mut TcpStream) -> Option { let method = parts.next()?.to_string(); let target = parts.next()?.to_string(); let mut content_length = 0usize; - let (mut origin, mut sec_fetch_site, mut host) = (None, None, None); + let (mut origin, mut sec_fetch_site, mut host, mut host_token) = (None, None, None, None); loop { let mut h = String::new(); reader.read_line(&mut h).ok()?; @@ -42,6 +44,8 @@ pub fn read_request(stream: &mut TcpStream) -> Option { sec_fetch_site = Some(v.to_ascii_lowercase()); } else if k.eq_ignore_ascii_case("host") { host = Some(v.to_string()); + } else if k.eq_ignore_ascii_case("x-igneum-host") { + host_token = Some(v.to_string()); } } } @@ -56,7 +60,7 @@ pub fn read_request(stream: &mut TcpStream) -> Option { Some((p, q)) => (p.to_string(), q.to_string()), None => (target, String::new()), }; - Some(Req { method, path, query, body, origin, sec_fetch_site, host }) + Some(Req { method, path, query, body, origin, sec_fetch_site, host, host_token }) } /// R4.3.7: a mutating request must come from the dashboard itself. A browser sends Sec-Fetch-Site (same-origin for diff --git a/app/igneum-common/src/lib.rs b/app/igneum-common/src/lib.rs index 91adeefe0..3433476c2 100644 --- a/app/igneum-common/src/lib.rs +++ b/app/igneum-common/src/lib.rs @@ -13,7 +13,10 @@ //! small JSON-over-HTTP client for a node's Ethereum RPC on 127.0.0.1 //! - `run`: a command with a time limit //! - `config`: the packager's `igneum-app.json` and the per-install machine id +//! - `biometric`: the Touch ID / Windows Hello gate logic (nonces, one-time enrolment token, state); the prompt and +//! the sealed secret live in the window hosts +pub mod biometric; pub mod config; pub mod fetch; pub mod http; diff --git a/app/igneum-wallet/Cargo.lock b/app/igneum-wallet/Cargo.lock index c519e31b8..c87ebe423 100644 --- a/app/igneum-wallet/Cargo.lock +++ b/app/igneum-wallet/Cargo.lock @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "igneum-wallet" -version = "0.1.1" +version = "0.1.2" dependencies = [ "argon2", "bip32", diff --git a/app/igneum-wallet/Cargo.toml b/app/igneum-wallet/Cargo.toml index 811eeb3fc..190bfb891 100644 --- a/app/igneum-wallet/Cargo.toml +++ b/app/igneum-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-wallet" -version = "0.1.1" +version = "0.1.2" edition = "2021" description = "Igneum Wallet engine: keeps the key encrypted, signs in Rust, reads a node, verifies finality certificates itself, and serves the window on 127.0.0.1" license = "MIT" diff --git a/app/igneum-wallet/README.md b/app/igneum-wallet/README.md index 5d740e2e6..b3186dc92 100644 --- a/app/igneum-wallet/README.md +++ b/app/igneum-wallet/README.md @@ -11,6 +11,111 @@ packaging: `packaging/mac/build-wallet-dmg.sh`, `packaging/windows/Igneum-Wallet |---|---|---| | 0.1.0 | 4 Oct 2026 | first DMG; built before `/coin.png` existed, so the coin on the home screen is blank; updates download and offer "Open the download" only | | 0.1.1 | 5 Oct 2026 | coin served from `brand/igneum-coin-1024.png`; over-the-air updates v2 (unattended install) | +| 0.1.2 | 5 Oct 2026 | Touch ID (macOS) and Windows Hello (Windows, untested): unlock, confirm sends, show the backup, idle lock; the coin and the "reading the chain" line on the balance card; the version in the header and in Settings | + +## Touch ID and Windows Hello (src/engine.rs, igneum-common/src/biometric.rs, app/mac/Biometric.swift, app/windows/biometric.h) + +What it gates once "Use Touch ID" is on (Settings): unlocking at start and after the idle lock, every send, and +showing the words or the key. The password still works for all three. Nothing else asks for a finger. + +| Piece | Where | What it does | +|---|---|---| +| the gate | `igneum-common/src/biometric.rs` | nonces the engine issues, the host confirms and the action consumes once; the one-time enrolment token; the state in `/api/state` | +| the engine | `src/engine.rs`, `src/server.rs` | `/api/biometric/*`; `/api/send` refuses without a confirmed nonce for that quote while enrolled; `/api/reveal` with a nonce reads the unlocked key in memory; the idle lock; the `HOST {...}` line on stdout | +| the Mac host | `app/mac/Biometric.swift` | the `biometric` script message handler; `LAPolicy.deviceOwnerAuthenticationWithBiometrics`; the Secure Enclave seal | +| the Windows host | `app/windows/biometric.h` | the `window.chrome.webview` bridge; `UserConsentVerifier` through `IUserConsentVerifierInterop`; DPAPI | +| the page | `ui/app.js` | the fingerprint controls on the unlock, send and Settings screens; the activity ping for the idle lock | + +The prompt's lines, worded by the engine or the host, never by the page, in our voice, under 60 characters, no +trailing full stop: + +| Prompt | Line | +|---|---| +| unlock | Unlock your wallet | +| send | Send 1.5 IGN to 0x7E5F…5Bdf (the amount to 4 decimals; the address as its first 6 and last 4 characters) | +| backup and export | Show your recovery words | +| turn on | Turn on Touch ID for your wallet | + +The prompt's buttons: "Use password" (the fallback button; the policy is biometrics only, so it never reaches the +device password: the window asks for the wallet's own password) and "Cancel". After the system prompt the page shows +its own line with the fingerprint glyph in ember: "Touch ID confirmed, unlocking", "Touch ID cancelled, enter your +password", "Touch ID did not match, try again or enter your password", and so on (`bioLine` in `ui/app.js`). The +prompt's title and icon are the bundled app's ("Igneum Wallet", the mark): the window host is the bundle's own +executable, so the system attributes the prompt to it; a bare engine or a test binary shows its own name instead. +macOS composes the sentence itself ("Igneum Wallet is trying to unlock your wallet."), so the Mac host lowercases +the line's first letter at display time; Windows Hello shows the line on its own, with its capital. + +The flow for a send: the quote (`/api/send/quote`) comes with `confirm_nonce` and `confirm_reason`. The page hands the nonce to the host; the host +reads the reason from the engine with its host token, shows the prompt with that line, and on success posts +`/api/biometric/confirm`. The page then calls `/api/send` with the quote and the nonce; the engine checks the nonce +was confirmed within 30 s, for this exact quote (address, value, transaction nonce, chain id), and not used before. +Unlock: the host shows the prompt, unseals the password and posts it to `/api/unlock` itself. The backup: a +`reveal` challenge, the prompt, then `/api/reveal` with the nonce; the words come from the key already unlocked in +memory, so the password is neither typed nor stored for it. + +The host token: the engine prints `HOST {"token": ..., "biometric_file": ...}` on its stdout, which only the window +host reads. The host sends it as `X-Igneum-Host` on the calls only it may make (status, report, confirm, taking the +parked password at enrolment, recording the enrolment). The page cannot confirm its own challenges. + +Enrolment: the page posts the password to `/api/biometric/enrol/begin`; the engine checks it against the vault and +parks it under a one-time token for 120 s; the host shows the prompt ("Turn on Touch ID for Igneum Wallet"), takes +the password with the token (once), seals it and writes the file, then posts `/api/biometric/enrolled`. A password +change deletes the sealed file and Settings asks to turn Touch ID on again. Removing the wallet deletes it too. + +The idle lock: with Touch ID on and "Lock after 5 minutes idle" on (the default), the engine zeroes the key after 5 +minutes without the window reporting input (mouse, keys; `/api/activity` every 20 s while there is some), never +during a send or with a confirm screen open. The next unlock is the prompt again, or the password. + +### What is stored, and where (macOS) + +The packaging signs the app ad hoc. Under an ad hoc signature macOS refuses every Keychain item that carries a +biometric access control, on the login keychain and the data-protection keychain alike (`errSecMissingEntitlement`, +-34018: `keychain-access-groups` needs a team signature; measured 5 October 2026 on this Mac with a 40-line probe). +A Secure Enclave key with the same control is allowed, so the password is sealed to one instead: + +- enrol: a P-256 key is made in the Secure Enclave with `SecAccessControl(.privateKeyUsage, .biometryCurrentSet)`; + an ephemeral P-256 key agrees a shared secret with its public half (no prompt), HKDF-SHA256 derives an AES-GCM key + and the password is sealed. `~/Library/Application Support/Igneum/wallet/biometric.json` (0600) holds the Secure + Enclave key's wrapped form, the ephemeral public key and the box. +- unlock or confirm: the host evaluates `LAPolicy.deviceOwnerAuthenticationWithBiometrics` (fallback button "Use + password", which only closes the prompt with `LAError.userFallback`; the device password is never offered), then + uses the Secure Enclave key under that context. The key agreement runs on every confirm + too, so a changed fingerprint set is caught on a send, not only on an unlock. +- `.biometryCurrentSet`: a fingerprint added or removed in System Settings makes the Secure Enclave refuse the key. + The host reports "invalidated"; the window says to use the password and turn Touch ID on again. + +Windows: the password is sealed with DPAPI (`CryptProtectData`, current user, `CRYPTPROTECT_UI_FORBIDDEN`) only after +a `UserConsentVerifier` success and written to `%LOCALAPPDATA%\igneum\wallet\biometric.json`. DPAPI has no +biometric binding of its own: the host unseals only after Hello verified. + +### Threat model + +| | Touch ID protects | Touch ID does not protect | +|---|---|---| +| Casual access at an unlocked Mac (someone at the keyboard while the wallet is locked) | yes: no finger, no unlock, no send, no words; the idle lock closes the window within 5 minutes of nobody being there | | +| A copy of `vault.json` taken off the machine | yes, as before: Argon2id + XChaCha20-Poly1305 under the password; the sealed file is useless off this Mac's Secure Enclave | | +| A copy of `biometric.json` by another user on this Mac | yes: 0600, and the Secure Enclave key is bound to this device and the current fingerprint set | | +| A root attacker, or malware running as the signed-in user | | no: it can read the wallet's memory while unlocked, patch the app, or drive the window; the sealed file is as strong as the user's macOS login and Secure Enclave, not stronger | +| Someone who knows the password | | no: the password works everywhere Touch ID does, by design | +| A fingerprint added to this Mac by someone with the macOS password | | the Secure Enclave key dies (`.biometryCurrentSet`), so the new finger cannot unlock; the person with the macOS password could still enrol again, which needs the wallet password | +| The page (ui/) or a cross-site page in the browser | yes: the host token is never in the page; confirmations are host-only; `/api/send` binds the nonce to the quote; the same-origin guard stays | | + +Windows (untested): DPAPI protects against other accounts and offline copies, not against code running as the user; +the same table applies with "Windows Hello" and "the Windows account". + +### Verified (5 October 2026) + +- Unit tests: `cargo test biometric` in `app/igneum-common` (confirm/take once, replay, expiry, stale, mismatch, + the enrolment token, reason clipping, the constant-time compare, the binding). +- The Swift host compiles with `Biometric.swift` and links LocalAuthentication; the DMG builds. +- The probe: `SecItemAdd` with `.biometryCurrentSet` fails with -34018 under the ad hoc signature; a non-permanent + Secure Enclave key with the same control is created, exported (`dataRepresentation`, 569 bytes), re-imported, and + the ephemeral key agreement seals a box without a prompt. + +### Untested + +- The Windows path: `biometric.h` was written on a Mac; the first compile is BUILD-WALLET-APP.bat on the runner. +- See the report for whether the Touch ID prompt was exercised end to end on this Mac (a finger is needed). ## Over-the-air updates (src/updater.rs, igneum-common/src/{fetch,ota}.rs) diff --git a/app/igneum-wallet/src/engine.rs b/app/igneum-wallet/src/engine.rs index 829980ba0..a9ba0aae0 100644 --- a/app/igneum-wallet/src/engine.rs +++ b/app/igneum-wallet/src/engine.rs @@ -7,6 +7,7 @@ use crate::history::{Entry, History}; use crate::node::{Grpc, OwnNode, Source}; use crate::state::{Rings, State}; use crate::vault::{self, Secret}; +use igneum_common::biometric::{self, BiometricState, Gate, GateError}; use igneum_common::config::Packaged; use igneum_common::keys; use serde::{Deserialize, Serialize}; @@ -28,13 +29,17 @@ pub struct Settings { /// the last block the window scrolled to; display only #[serde(default)] pub display_name: String, + /// lock after IDLE_LOCK_MIN minutes without the window reporting activity; only acts while Touch ID or Windows + /// Hello is enrolled (the password still works) + #[serde(default = "yes")] + pub idle_lock: bool, } fn yes() -> bool { true } impl Default for Settings { fn default() -> Settings { - Settings { auto_update: true, display_name: String::new() } + Settings { auto_update: true, display_name: String::new(), idle_lock: true } } } impl Settings { @@ -58,6 +63,8 @@ pub struct Paths { pub vault: PathBuf, pub settings: PathBuf, pub miner_wallet: PathBuf, + /// the sealed password (macOS: a Secure Enclave key blob + AES-GCM box; Windows: DPAPI), written by the window host + pub biometric: PathBuf, } pub enum Cmd { @@ -114,6 +121,12 @@ pub struct Shared { sends: std::sync::atomic::AtomicU32, /// when the last quote was given: a confirm screen may be open for QUOTE_HOLDS_S after it last_quote: Mutex>, + /// the window host's token (printed on stdout as HOST {...}; the page never sees it): the host's calls carry it + pub host_token: String, + /// the biometric gate: challenges, confirmations, the one-time enrolment token + gate: Mutex, + /// the last time the window reported a person at it (the idle lock) + last_activity: Mutex, } /// Counts one /api/send from entry to exit, whatever the outcome. @@ -125,7 +138,7 @@ impl Drop for SendGuard<'_> { } impl Shared { - pub fn new(token: String, paths: Paths, packaged: Packaged, settings: Settings, machine_id: String, cmd_tx: Sender, engine_log: Option, log_path: PathBuf) -> Shared { + pub fn new(token: String, host_token: String, paths: Paths, packaged: Packaged, settings: Settings, machine_id: String, cmd_tx: Sender, engine_log: Option, log_path: PathBuf) -> Shared { let mut st = State { version: VERSION.into(), ..Default::default() }; let v = vault::load(&paths.vault); st.has_vault = v.is_some(); @@ -149,6 +162,8 @@ impl Shared { st.machine_id = machine_id.clone(); st.host = igneum_common::platform::host_label(); st.log_dir = paths.log_dir.display().to_string(); + st.app_dir = paths.app_dir.display().to_string(); + st.biometric = BiometricState { enrolled: biometric_file_present(&paths.biometric), idle_lock: settings.idle_lock, idle_lock_min: biometric::IDLE_LOCK_MIN, ..Default::default() }; st.update.status = if packaged.update_manifest.is_empty() { "off".into() } else { "unknown".into() }; Shared { token, @@ -171,6 +186,9 @@ impl Shared { history: Mutex::new(None), sends: std::sync::atomic::AtomicU32::new(0), last_quote: Mutex::new(None), + host_token, + gate: Mutex::new(Gate::new()), + last_activity: Mutex::new(Instant::now()), } } @@ -328,7 +346,7 @@ impl Shared { Ok(json!({ "ok": true, "address": address, "display": keys::checksum(&address), "source": source })) } - pub fn unlock(&self, password: &str) -> Result { + pub fn unlock(&self, password: &str, via_host: bool) -> Result { let v = vault::load(&self.paths.vault).ok_or("no wallet on this machine")?; let s = vault::open(&v, password)?; let d = crate::hd::parse_private_key(&s.private_key)?; @@ -341,7 +359,8 @@ impl Shared { st.unlocked = true; st.phase = "home".into(); } - self.log("unlocked"); + self.touch_activity(); + self.log(if via_host { "unlocked with the biometric prompt" } else { "unlocked with the password" }); self.send(Cmd::Refresh); Ok(json!({ "ok": true })) } @@ -367,6 +386,17 @@ impl Shared { Ok(json!({ "ok": true, "words": s.mnemonic.as_ref().map(|w| w.split(' ').collect::>()), "private_key": s.private_key, "address": v.address, "display": keys::checksum(&v.address) })) } + /// The backup sheet after a biometric confirmation: the words from the unlocked key in memory (the password is + /// not asked and not stored anywhere the engine can read). + pub fn reveal_confirmed(&self, nonce: &str) -> Result { + self.take_nonce(nonce, "reveal", "")?; + let v = vault::load(&self.paths.vault).ok_or("no wallet")?; + let guard = self.secret.lock().unwrap(); + let s = guard.as_ref().ok_or("unlock first")?; + self.log(&format!("the backup was shown in the window (after {})", self.what())); + Ok(json!({ "ok": true, "words": s.mnemonic.as_ref().map(|w| w.split(' ').collect::>()), "private_key": s.private_key, "address": v.address, "display": keys::checksum(&v.address) })) + } + pub fn mark_backed_up(&self) -> Result { let mut v = vault::load(&self.paths.vault).ok_or("no wallet")?; v.backed_up = true; @@ -383,6 +413,14 @@ impl Shared { nv.created = v.created; vault::save(&self.paths.vault, &nv)?; self.log("password changed"); + if self.biometric_remove_file() { + let what = self.what(); + let mut st = self.state.lock().unwrap(); + st.biometric.enrolled = false; + st.biometric.needs_enrol = true; + drop(st); + self.event("info", &format!("{what} was turned off: the sealed password is stale; turn it on again in Settings")); + } Ok(json!({ "ok": true })) } @@ -392,7 +430,10 @@ impl Shared { vault::open(&v, password)?; self.lock(); std::fs::remove_file(&self.paths.vault).map_err(|e| e.to_string())?; + self.biometric_remove_file(); let mut st = self.state.lock().unwrap(); + st.biometric.enrolled = false; + st.biometric.needs_enrol = false; st.has_vault = false; st.phase = "welcome".into(); st.address.clear(); @@ -463,14 +504,20 @@ impl Shared { Ok(Quote { to, value: value.to_string(), gas, base_fee: base.to_string(), tip: tip.to_string(), max_fee: max_fee.to_string(), fee_max: fee_max.to_string(), total_max: total.to_string(), chain_id, nonce }) } - /// Signs and sends what the window confirmed (the quote it was shown, verbatim). - pub fn send_tx(&self, q: &Quote) -> Result { + /// Signs and sends what the window confirmed (the quote it was shown, verbatim). With Touch ID or Windows Hello + /// enrolled, `nonce` must be a confirmation the host posted for this very quote within CHALLENGE_TTL_S. + pub fn send_tx(&self, q: &Quote, nonce: Option<&str>) -> Result { self.sends.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let _guard = SendGuard(self); let to = q.to.to_ascii_lowercase(); if !keys::valid_address(&to) || to == "0x0000000000000000000000000000000000000000" { return Err("refused: bad or zero address".into()); } + if self.enrolled() { + let bound = biometric::send_binding(&to, &q.value, q.nonce, q.chain_id); + self.take_nonce(nonce.unwrap_or(""), "send", &bound).map_err(|e| format!("refused: {e}"))?; + } + self.touch_activity(); let value: u128 = q.value.parse().map_err(|_| "bad value")?; let max_fee: u128 = q.max_fee.parse().map_err(|_| "bad fee")?; let tip: u128 = q.tip.parse().map_err(|_| "bad tip")?; @@ -525,6 +572,167 @@ impl Shared { self.state.lock().unwrap().settings.start_at_login = on; Ok(json!({ "ok": true })) } + + // ---- Touch ID / Windows Hello (igneum_common::biometric; the prompt and the sealed password live in the host) ---- + + /// "Touch ID" or "Windows Hello", for messages. + pub fn what(&self) -> String { + let k = self.state.lock().unwrap().biometric.kind.clone(); + match k.as_str() { + "hello" => "Windows Hello".into(), + "touchid" => "Touch ID".into(), + _ if cfg!(windows) => "Windows Hello".into(), + _ => "Touch ID".into(), + } + } + pub fn enrolled(&self) -> bool { + self.state.lock().unwrap().biometric.enrolled + } + /// The host's token on a request, in constant time. + pub fn host_ok(&self, given: Option<&str>) -> bool { + !self.host_token.is_empty() && given.map(|g| biometric::constant_eq(g, &self.host_token)).unwrap_or(false) + } + fn biometric_remove_file(&self) -> bool { + if self.paths.biometric.exists() { + let _ = std::fs::remove_file(&self.paths.biometric); + true + } else { + false + } + } + fn take_nonce(&self, nonce: &str, purpose: &str, bound: &str) -> Result<(), String> { + let r = self.gate.lock().unwrap().take(nonce, purpose, bound, Instant::now()); + r.map_err(|e: GateError| e.text(&self.what())) + } + /// The host reports what it can do, once at start (with its token). + pub fn biometric_status(&self, available: bool, kind: &str, message: &str) -> Result { + let mut st = self.state.lock().unwrap(); + st.biometric.available = available; + st.biometric.kind = kind.to_string(); + st.biometric.message = message.to_string(); + drop(st); + self.log(&format!("biometric host: {} {}{}", kind, if available { "available" } else { "unavailable" }, if message.is_empty() { String::new() } else { format!(" ({message})") })); + Ok(json!({ "ok": true })) + } + /// The host reports a prompt's outcome. + pub fn biometric_report(&self, op: &str, ok: bool, code: &str, message: &str) -> Result { + let mut st = self.state.lock().unwrap(); + st.biometric.last_result = if ok { "ok".into() } else if code.is_empty() { "failed".into() } else { code.to_string() }; + st.biometric.last_op = op.to_string(); + st.biometric.last_at = igneum_common::platform::unix_now_f(); + drop(st); + self.log(&format!("{} {op}: {}{}", self.what(), if ok { "ok" } else { code }, if message.is_empty() { String::new() } else { format!(" ({message})") })); + Ok(json!({ "ok": true })) + } + /// The window asks for a challenge (reveal); send challenges come with the quote. + pub fn challenge(&self, purpose: &str) -> Result { + if !self.unlocked() { + return Err("unlock first".into()); + } + let reason = match purpose { + "reveal" => "Show your recovery words", + _ => return Err("purpose is reveal".into()), + }; + let nonce = self.gate.lock().unwrap().issue(purpose, "", reason, Instant::now()); + Ok(json!({ "ok": true, "nonce": nonce, "reason": reason, "purpose": purpose })) + } + pub fn challenge_for_send(&self, q: &Quote, amount_ign: &str) -> (String, String) { + let reason = biometric::send_reason(amount_ign, &keys::checksum(&q.to)); + let bound = biometric::send_binding(&q.to, &q.value, q.nonce, q.chain_id); + let nonce = self.gate.lock().unwrap().issue("send", &bound, &reason, Instant::now()); + (nonce, reason) + } + /// The host reads what the prompt must say. + pub fn challenge_reason(&self, nonce: &str) -> Result { + let (purpose, reason) = self.gate.lock().unwrap().reason_of(nonce).ok_or("unknown or used challenge")?; + Ok(json!({ "ok": true, "purpose": purpose, "reason": reason })) + } + /// The host confirms: the prompt succeeded for this nonce. + pub fn confirm(&self, nonce: &str) -> Result { + self.gate.lock().unwrap().confirm(nonce, Instant::now()).map_err(|e| e.text(&self.what()))?; + self.touch_activity(); + Ok(json!({ "ok": true })) + } + /// Enrolment, step 1 (the window): the password is checked and parked under a one-time token for the host. + pub fn enrol_begin(&self, password: &str) -> Result { + if !self.state.lock().unwrap().biometric.available { + return Err(format!("{} is not available on this machine", self.what())); + } + let v = vault::load(&self.paths.vault).ok_or("no wallet")?; + vault::open(&v, password)?; + let token = self.gate.lock().unwrap().enrol_begin(password, Instant::now()); + self.log("enrolment started: waiting for the host's prompt"); + Ok(json!({ "ok": true, "token": token })) + } + /// Enrolment, step 2 (the host, after the prompt): the password, once. + pub fn enrol_take(&self, token: &str) -> Result { + let p = self.gate.lock().unwrap().enrol_take(token, Instant::now()).ok_or("no enrolment is waiting (it may have timed out: start again)")?; + Ok(json!({ "ok": true, "secret": p })) + } + /// Enrolment, step 3 (the host): the sealed file is written; the engine checks it is there. + pub fn enrolled_set(&self, kind: &str) -> Result { + if !biometric_file_present(&self.paths.biometric) { + return Err("the sealed file was not written".into()); + } + igneum_common::platform::lock_permissions(&self.paths.biometric, false); + let mut st = self.state.lock().unwrap(); + st.biometric.enrolled = true; + st.biometric.needs_enrol = false; + if !kind.is_empty() { + st.biometric.kind = kind.to_string(); + } + drop(st); + self.touch_activity(); + self.event("ok", &format!("{} is on: it unlocks the wallet, confirms sends and shows the backup", self.what())); + Ok(json!({ "ok": true })) + } + pub fn enrol_cancel(&self) -> Result { + self.gate.lock().unwrap().enrol_cancel(); + Ok(json!({ "ok": true })) + } + /// Settings > turn off: the sealed file goes; the password is the only way in again. + pub fn biometric_remove(&self) -> Result { + self.biometric_remove_file(); + let mut st = self.state.lock().unwrap(); + st.biometric.enrolled = false; + st.biometric.needs_enrol = false; + drop(st); + self.event("info", &format!("{} is off", self.what())); + Ok(json!({ "ok": true })) + } + pub fn set_idle_lock(&self, on: bool) -> Result { + { + let mut s = self.settings.lock().unwrap(); + s.idle_lock = on; + s.save(&self.paths.settings); + } + self.state.lock().unwrap().biometric.idle_lock = on; + Ok(json!({ "ok": true })) + } + /// The window reports a person at it (mouse, keys), every few seconds while active. + pub fn touch_activity(&self) { + *self.last_activity.lock().unwrap() = Instant::now(); + } + /// The idle lock: enrolled, the setting on, unlocked, nothing being sent, and IDLE_LOCK_MIN minutes without + /// activity. Only the engine thread calls this. + pub fn idle_lock_due(&self) -> bool { + if !self.unlocked() || self.send_in_flight() || self.creating() { + return false; + } + let st = self.state.lock().unwrap(); + if !(st.biometric.enrolled && st.biometric.idle_lock) { + return false; + } + drop(st); + // IGNEUM_WALLET_IDLE_LOCK_S: tests only, a shorter period + let secs = std::env::var("IGNEUM_WALLET_IDLE_LOCK_S").ok().and_then(|v| v.parse().ok()).unwrap_or(biometric::IDLE_LOCK_MIN * 60); + self.last_activity.lock().unwrap().elapsed() >= Duration::from_secs(secs) + } +} + +/// The sealed file counts when it parses as JSON with a `kind` (the host writes it whole, then tells the engine). +pub fn biometric_file_present(p: &std::path::Path) -> bool { + std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str::(&t).ok()).map(|v| v.get("kind").and_then(|k| k.as_str()).map(|k| !k.is_empty()).unwrap_or(false)).unwrap_or(false) } // ---- the engine thread ------------------------------------------------------------------------------------------ @@ -610,6 +818,10 @@ impl Engine { self.last_scan = Instant::now(); self.scan(); } + if self.shared.idle_lock_due() { + self.shared.lock(); + self.shared.event("info", &format!("locked after {} minutes idle", biometric::IDLE_LOCK_MIN)); + } let ctx = crate::updater::Ctx { send_in_flight: self.shared.send_in_flight() || !self.watch.is_empty(), creating: self.shared.creating() }; if let Some(crate::updater::Action::Apply) = self.updater.tick(&self.shared, &ctx) { if self.apply_update() { diff --git a/app/igneum-wallet/src/main.rs b/app/igneum-wallet/src/main.rs index a434b9730..e37b4a52a 100644 --- a/app/igneum-wallet/src/main.rs +++ b/app/igneum-wallet/src/main.rs @@ -1,13 +1,15 @@ //! Igneum Wallet engine. Keeps the key, signs, reads a node, verifies finality certificates, and serves the window on //! 127.0.0.1:/t//. The window host (macOS: app/mac/IgneumWallet.swift, Windows: the WebView2 //! host) starts it with --wrapper, reads `URL ...` and `STATE {...}` lines from its stdout and writes `quit` on its -//! stdin. Without a host (--open) the window opens in the default browser. +//! stdin. Without a host (--open) the window opens in the default browser. A host also reads one `HOST {...}` line: +//! its own token (sent as X-Igneum-Host on the calls only it may make: the biometric confirmations) and the path of +//! the sealed-password file it writes for Touch ID or Windows Hello. //! //! igneum-wallet [--wrapper | --open | --no-open | --launch] [--print-url] //! //! Environment (tests): IGNEUM_APP_DATA, IGNEUM_APP_LOGS, IGNEUM_APP_BIN, IGNEUM_WALLET_GRPC_PORT, IGNEUM_WALLET_EVM_PORT, //! IGNEUM_WALLET_NO_NODE, IGNEUM_WALLET_NETWORK, IGNEUM_WALLET_DEVNET_SUFFIX, IGNEUM_WALLET_PEERS, -//! IGNEUM_WALLET_NODE_DIR, IGNEUM_WALLET_OVERRIDE_PARAMS, IGNEUM_WALLET_UPDATE_MANIFEST. +//! IGNEUM_WALLET_NODE_DIR, IGNEUM_WALLET_OVERRIDE_PARAMS, IGNEUM_WALLET_UPDATE_MANIFEST, IGNEUM_WALLET_IDLE_LOCK_S. #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] mod engine; @@ -59,6 +61,7 @@ fn main() { vault: app_dir.join("vault.json"), settings: app_dir.join("settings.json"), miner_wallet: root.join(igneum_common::MINER.data_sub).join("wallet.json"), + biometric: app_dir.join("biometric.json"), app_dir: app_dir.clone(), log_dir: log_dir.clone(), }; @@ -66,10 +69,12 @@ fn main() { let settings = engine::Settings::load(&paths.settings); let machine_id = igneum_common::config::machine_id(&app_dir); let token = igneum_common::http::new_token(); + let host_token = igneum_common::http::new_token(); let stamp_file = log_dir.join(format!("wallet-{}.log", stamp_now())); let engine_log = std::fs::File::create(&stamp_file).ok(); let (tx, rx) = channel(); - let shared = Arc::new(engine::Shared::new(token.clone(), paths, packaged, settings, machine_id, tx, engine_log, stamp_file.clone())); + let biometric_file = paths.biometric.clone(); + let shared = Arc::new(engine::Shared::new(token.clone(), host_token.clone(), paths, packaged, settings, machine_id, tx, engine_log, stamp_file.clone())); let port = match server::start(shared.clone()) { Ok(p) => p, Err(e) => { @@ -87,6 +92,10 @@ fn main() { shared.log(&format!("window listening on 127.0.0.1:{port} (the URL with its token is in wallet.url; log {})", stamp_file.display())); if wrapper || args.iter().any(|a| a == "--print-url") { println!("URL {url}"); + if wrapper { + // the host alone reads stdout: its token and where the sealed password goes + println!("HOST {}", serde_json::json!({ "token": host_token, "biometric_file": biometric_file.display().to_string() })); + } let _ = std::io::stdout().flush(); } if !no_open { diff --git a/app/igneum-wallet/src/server.rs b/app/igneum-wallet/src/server.rs index 14d617786..fd08312b2 100644 --- a/app/igneum-wallet/src/server.rs +++ b/app/igneum-wallet/src/server.rs @@ -62,6 +62,18 @@ fn handle(mut stream: TcpStream, shared: Arc) { let lines = shared.rings.lock().unwrap().since(after, limit); json_resp(&mut stream, 200, json!({ "lines": lines })); } + // the host reads a challenge's reason with its token (the page never needs this: it has the reason already) + ("GET", "/api/biometric/challenge") => { + if !shared.host_ok(req.host_token.as_deref()) { + json_resp(&mut stream, 403, json!({ "ok": false, "error": "host token" })); + return; + } + let nonce = query_param(&req.query, "nonce").unwrap_or_default(); + match shared.challenge_reason(&nonce) { + Ok(v) => json_resp(&mut stream, 200, v), + Err(e) => json_resp(&mut stream, 400, json!({ "ok": false, "error": e })), + } + } ("GET", p) if p.starts_with("/api/tx/") => { let hash = p.trim_start_matches("/api/tx/").to_string(); match shared.tx_detail(&hash) { @@ -75,7 +87,12 @@ fn handle(mut stream: TcpStream, shared: Arc) { return; } let body: Value = if req.body.is_empty() { json!({}) } else { serde_json::from_slice(&req.body).unwrap_or(json!({})) }; - match api_post(&shared, p, body) { + let from_host = shared.host_ok(req.host_token.as_deref()); + if HOST_ONLY.contains(&p) && !from_host { + json_resp(&mut stream, 403, json!({ "ok": false, "error": "only the window host may call this" })); + return; + } + match api_post(&shared, p, body, from_host) { Ok(v) => json_resp(&mut stream, 200, v), Err(e) => json_resp(&mut stream, 400, json!({ "ok": false, "error": e })), } @@ -84,8 +101,13 @@ fn handle(mut stream: TcpStream, shared: Arc) { } } -fn api_post(shared: &Arc, path: &str, body: Value) -> Result { +/// Calls that carry a biometric result or take the parked password: the host's token (X-Igneum-Host) is required, +/// so the page cannot confirm its own challenges. +const HOST_ONLY: &[&str] = &["/api/biometric/status", "/api/biometric/report", "/api/biometric/confirm", "/api/biometric/enrol/take", "/api/biometric/enrolled"]; + +fn api_post(shared: &Arc, path: &str, body: Value, from_host: bool) -> Result { let s = |k: &str| body.get(k).and_then(|v| v.as_str()).map(|v| v.to_string()); + let b = |k: &str| body.get(k).and_then(|v| v.as_bool()); match path { "/api/create" => shared.create_begin(&s("password").ok_or("password missing")?), "/api/create/confirm" => { @@ -94,12 +116,15 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result shared.import(&s("mode").unwrap_or_default(), &s("data").unwrap_or_default(), &s("password").ok_or("password missing")?), - "/api/unlock" => shared.unlock(&s("password").ok_or("password missing")?), + "/api/unlock" => shared.unlock(&s("password").ok_or("password missing")?, from_host), "/api/lock" => { shared.lock(); Ok(json!({ "ok": true })) } - "/api/reveal" => shared.reveal(&s("password").ok_or("password missing")?), + "/api/reveal" => match s("nonce") { + Some(n) => shared.reveal_confirmed(&n), + None => shared.reveal(&s("password").ok_or("password missing")?), + }, "/api/backed-up" => shared.mark_backed_up(), "/api/password" => shared.change_password(&s("old").ok_or("old missing")?, &s("new").ok_or("new missing")?), "/api/remove" => shared.remove(&s("password").ok_or("password missing")?), @@ -121,18 +146,40 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result().unwrap_or(0) * 1_000_000_000, 3)); v["tip_gwei"] = json!(crate::evm::ign(q.tip.parse::().unwrap_or(0) * 1_000_000_000, 3)); v["display_to"] = json!(igneum_common::keys::checksum(&q.to)); + // the biometric challenge for this quote: the prompt's line and the nonce the host confirms + let (nonce, reason) = shared.challenge_for_send(&q, &crate::evm::ign(q.value.parse().unwrap_or(0), 4)); + v["confirm_nonce"] = json!(nonce); + v["confirm_reason"] = json!(reason); + v["confirm_needed"] = json!(shared.enrolled()); Ok(v) } "/api/send" => { let q: crate::engine::Quote = serde_json::from_value(body.get("quote").cloned().ok_or("quote missing")?).map_err(|e| format!("quote: {e}"))?; - shared.send_tx(&q) + shared.send_tx(&q, s("nonce").as_deref()) } "/api/settings" => { - if let Some(on) = body.get("start_at_login").and_then(|v| v.as_bool()) { + if let Some(on) = b("start_at_login") { shared.set_start_at_login(on)?; } + if let Some(on) = b("idle_lock") { + shared.set_idle_lock(on)?; + } Ok(json!({ "ok": true })) } + // ---- Touch ID / Windows Hello: the page's side and the host's side (HOST_ONLY) ---- + "/api/activity" => { + shared.touch_activity(); + Ok(json!({ "ok": true })) + } + "/api/biometric/challenge" => shared.challenge(&s("purpose").unwrap_or_default()), + "/api/biometric/enrol/begin" => shared.enrol_begin(&s("password").ok_or("password missing")?), + "/api/biometric/enrol/cancel" => shared.enrol_cancel(), + "/api/biometric/remove" => shared.biometric_remove(), + "/api/biometric/status" => shared.biometric_status(b("available").unwrap_or(false), &s("kind").unwrap_or_default(), &s("message").unwrap_or_default()), + "/api/biometric/report" => shared.biometric_report(&s("op").unwrap_or_default(), b("ok").unwrap_or(false), &s("code").unwrap_or_default(), &s("message").unwrap_or_default()), + "/api/biometric/confirm" => shared.confirm(&s("nonce").ok_or("nonce missing")?), + "/api/biometric/enrol/take" => shared.enrol_take(&s("token").ok_or("token missing")?), + "/api/biometric/enrolled" => shared.enrolled_set(&s("kind").unwrap_or_default()), "/api/refresh" => { shared.send(Cmd::Refresh); Ok(json!({ "ok": true })) diff --git a/app/igneum-wallet/src/state.rs b/app/igneum-wallet/src/state.rs index 165a91fca..5ba6bfe9e 100644 --- a/app/igneum-wallet/src/state.rs +++ b/app/igneum-wallet/src/state.rs @@ -103,6 +103,8 @@ pub struct State { pub scanned_to: Option, pub update: UpdateState, pub settings: SettingsState, + /// Touch ID / Windows Hello (igneum_common::biometric; the prompt lives in the window host) + pub biometric: igneum_common::biometric::BiometricState, pub events: Vec, pub miner_wallet_file: String, pub miner_wallet_present: bool, @@ -111,6 +113,7 @@ pub struct State { pub machine_id: String, pub host: String, pub log_dir: String, + pub app_dir: String, pub uptime_s: u64, pub now: f64, pub quitting: bool, diff --git a/app/igneum-wallet/ui/app.css b/app/igneum-wallet/ui/app.css index a931efb3d..b3d9db29b 100644 --- a/app/igneum-wallet/ui/app.css +++ b/app/igneum-wallet/ui/app.css @@ -401,3 +401,21 @@ body{user-select:text;-webkit-user-select:text} .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bone);color:var(--obsidian);font-family:var(--mono);font-size:13px;padding:10px 16px;border-radius:999px;z-index:30} .step .card{margin-top:12px} #view-settings .step{max-width:760px} + +/* 5 October 2026: the version in the brand band, the coin on the balance card, Touch ID / Windows Hello controls */ +.brand .ver{font-size:11px;letter-spacing:.08em;color:var(--ash);margin-left:10px;align-self:center} +.balance-row{display:flex;align-items:center;gap:18px} +.coin.small{width:56px;height:56px;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))} +.reading{font-size:12px;color:var(--ash);letter-spacing:.04em;margin-top:8px} +.version-row{font-size:12px;color:var(--ash);letter-spacing:.06em;margin-top:-6px} +.version-row b{color:var(--ink-2);font-weight:500} +.btn.fp svg{flex:0 0 auto} +.bio-row{display:flex;flex-direction:column;align-items:center;gap:10px;margin-top:6px} +.bio-row .note{text-align:center;max-width:46ch} +.unlock-dim .unlock{opacity:.7} +#send-go .fp-ico[hidden]{display:none} +.bio-state{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;letter-spacing:.04em;color:var(--ember);min-height:18px} +.bio-state.ok{color:var(--molten)} +.bio-state.wait{color:var(--ash)} +.fp-glyph{flex:0 0 auto;color:var(--ember)} +.err .bio-state{font-family:var(--mono)} diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js index a3fc74455..e4f40a607 100644 --- a/app/igneum-wallet/ui/app.js +++ b/app/igneum-wallet/ui/app.js @@ -13,6 +13,63 @@ const post = (path, body = {}) => api(path, body); let state = null, quote = null, sentHash = null, txHash = null, lastPhase = null; if (location.search.includes('host=mac')) document.body.classList.add('mac'); +// ---- Touch ID / Windows Hello: the window host's bridge (app/mac/Biometric.swift; app/windows/wallet-host.cpp) ---- +// The page posts {id, op, ...}; the host answers window.__igneumBiometric(id, {ok, code, message}). The secret never +// comes this way: the host posts the password to the engine itself, and confirmations are nonces the engine issued. +const bio = (() => { + const pending = new Map(); let seq = 0; + const mac = !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.biometric); + const win = !!(window.chrome && window.chrome.webview); + window.__igneumBiometric = (id, r) => { const p = pending.get(id); if (p) { pending.delete(id); p(r || { ok: false, code: 'bad', message: 'no answer' }); } }; + if (win) window.chrome.webview.addEventListener('message', ev => { let d = ev.data; if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { return; } } if (d && d.id != null) window.__igneumBiometric(d.id, d); }); + return { + host: mac ? 'mac' : win ? 'windows' : null, + busy: false, + call(op, params = {}) { + return new Promise(res => { + if (!this.host) return res({ ok: false, code: 'nohost', message: what() + ' needs the Igneum Wallet app window.' }); + const id = ++seq; pending.set(id, res); this.busy = true; + const m = Object.assign({ id, op }, params); + if (mac) window.webkit.messageHandlers.biometric.postMessage(m); else window.chrome.webview.postMessage(JSON.stringify(m)); + setTimeout(() => { if (pending.has(id)) { pending.delete(id); res({ ok: false, code: 'timeout', message: what() + ' did not answer.' }); } }, 180000); + }).then(r => { this.busy = false; return r; }); + } + }; +})(); +function what() { const k = state && state.biometric && state.biometric.kind; return k === 'hello' || (!k && /Win/.test(navigator.platform)) ? 'Windows Hello' : 'Touch ID'; } +// the page's own line after the system prompt: the glyph in ember and what happened, in our words +const FP = ''; +function bioLine(r, okText) { + if (!r) return ''; + if (r.ok) return `${FP}${esc(what() + ' ' + (okText || 'confirmed'))}`; + const w = what(), c = r.code; + let t; + if (c === 'cancelled' || c === 'fallback') t = w + ' cancelled, enter your password'; + else if (c === 'failed') t = w + ' did not match, try again or enter your password'; + else if (c === 'locked') t = w + ' is locked, enter your password'; + else if (c === 'invalidated') t = w + ' was turned off (a fingerprint changed), enter your password and turn it on again in Settings'; + else if (c === 'not_set_up' || c === 'unavailable') t = r.message || (w + ' is not set up on this Mac'); + else if (c === 'nohost') t = w + ' needs the Igneum Wallet app window'; + else t = r.message || (w + ' did not succeed'); + return `${FP}${esc(t)}`; +} +function setLine(el, html) { el.innerHTML = html; } +function bioEnrolled() { return !!(state && state.biometric && state.biometric.enrolled); } +let promptPending = false, lastPrompt = 0; +async function unlockWithTouch() { + if (bio.busy) return; + lastPrompt = Date.now(); + $('unlock-touch').disabled = true; setLine($('unlock-bio-note'), `${FP}${esc('Waiting for ' + what())}`); + const r = await bio.call('unlock'); + $('unlock-touch').disabled = false; + setLine($('unlock-bio-note'), bioLine(r, 'confirmed, unlocking')); + if (r.ok) await poll(); else $('unlock-pw').focus(); +} +// the idle lock: the window tells the engine a person is here, every 20 s while there is input +let active = false; +['mousemove', 'keydown', 'mousedown', 'wheel', 'touchstart'].forEach(e => document.addEventListener(e, () => { active = true; }, { passive: true })); +setInterval(() => { if (active && state && state.phase === 'home') { active = false; post('/api/activity').catch(() => {}); } }, 20000); + function toast(text) { const t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(t._h); t._h = setTimeout(() => { t.hidden = true; }, 1800); } function copy(text, what) { navigator.clipboard.writeText(text).then(() => toast((what || 'copied') + ' to the clipboard'), () => toast('could not copy')); } function ign(wei, places = 6) { @@ -34,8 +91,9 @@ function render() { const s = state; const phase = s.phase; const inFlow = ['create', 'import'].includes(document.body.dataset.phase); - if (!inFlow || phase === 'home') { if (lastPhase !== phase) { setPhase(phase); if (phase === 'home') setView('overview'); } } + if (!inFlow || phase === 'home') { if (lastPhase !== phase) { setPhase(phase); if (phase === 'home') setView('overview'); if (phase === 'unlock') promptPending = true; } } lastPhase = phase; + $('ver').textContent = 'v' + s.version; $('btn-settings').hidden = phase !== 'home'; $('btn-lock').hidden = phase !== 'home'; // pill @@ -48,10 +106,20 @@ function render() { $('pill-text').textContent = pillText; $('pill').className = pillCls; $('welcome-eyebrow').textContent = `${s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network} · nothing is bought or sold`; renderUpdate(s); - // unlock + // unlock: the fingerprint button when enrolled and the app window is the host; the password form stays $('unlock-address').textContent = s.display; + const b = s.biometric || {}; + const bioHere = b.enrolled && !!bio.host; + $('unlock-bio').hidden = !bioHere; + $('unlock-touch-text').textContent = 'Unlock with ' + what(); + if (phase === 'unlock' && bioHere && promptPending && !document.hidden && !bio.busy) { promptPending = false; unlockWithTouch(); } // home - $('balance').textContent = s.balance_known ? s.balance : '…'; + $('balance').textContent = s.balance_known ? s.balance : '0'; + $('balance').classList.toggle('dim', !s.balance_known); + const note = $('balance-note'); + if (s.scanning) { note.textContent = `reading the chain, ${(s.scanned_to == null ? 0 : s.scanned_to).toLocaleString()} of ${n.block.toLocaleString()} blocks`; note.hidden = false; } + else if (!s.balance_known) { note.textContent = n.state === 'ok' ? 'reading the balance' : 'waiting for a node'; note.hidden = false; } + else note.hidden = true; $('home-address').textContent = s.display; $('backup-note').hidden = s.backed_up; kv($('node-kv'), [ @@ -73,6 +141,7 @@ function render() { renderHistory(s.history); // settings $('start-login').checked = !!s.settings.start_at_login; + renderBio(s); kv($('settings-node-kv'), [['source', esc(n.source)], ['ethereum rpc', esc(n.evm || 'none')], ['grpc', esc(n.grpc || 'none')], ['chain id', n.chain_id || '…'], ['network', esc(s.settings.network)], ['public rpc', esc(s.public_rpc || 'none in this build')]]); kv($('machine-kv'), [['machine', esc(s.machine_id.slice(0, 8))], ['version', esc(s.version)], ['logs', esc(s.log_dir)], ['miner key file', s.miner_wallet_present ? 'present' : 'none']]); $('seg-miner').disabled = !s.miner_wallet_present; @@ -168,14 +237,25 @@ $('send-quote').onclick = async () => { $('c-fee').textContent = `${quote.fee_max_ign} IGN`; $('c-total').textContent = `${quote.total_max_ign} IGN`; $('c-fee-note').textContent = `Fee = gas × price. Gas ${quote.gas.toLocaleString()}. Price = base fee ${quote.base_fee_gwei} gwei (burned by the network) + tip ${quote.tip_gwei} gwei (to the miner), capped at ${ign(quote.max_fee, 0) === '0' ? (Number(quote.max_fee) / 1e9).toFixed(3) + ' gwei' : ign(quote.max_fee) + ' IGN'} per gas; what is not used comes back.`; + const needs = !!quote.confirm_needed && !!bio.host; + $('send-go-text').textContent = needs ? 'Confirm with ' + what() : 'Send now'; + $('send-go').querySelector('.fp-ico').hidden = !needs; $('send-form').hidden = true; $('send-confirm').hidden = false; $('confirm-send-err').textContent = ''; + if (quote.confirm_needed && !bio.host) $('confirm-send-err').textContent = what() + ' is on for this wallet, and only the Igneum Wallet app window can show it.'; } catch (e) { $('send-err').textContent = e.message; } }; $('send-go').onclick = async () => { $('confirm-send-err').textContent = ''; $('send-go').disabled = true; try { - const { ok, value_ign, fee_max_ign, total_max_ign, base_fee_gwei, tip_gwei, display_to, ...q } = quote; - const r = await post('/api/send', { quote: q }); + const { ok, value_ign, fee_max_ign, total_max_ign, base_fee_gwei, tip_gwei, display_to, confirm_nonce, confirm_reason, confirm_needed, ...q } = quote; + if (confirm_needed) { + // the prompt shows the engine's own line (amount and address); the host confirms the nonce to the engine + setLine($('send-bio-line'), `${FP}${esc('Waiting for ' + what())}`); + const c = await bio.call('confirm', { nonce: confirm_nonce }); + setLine($('send-bio-line'), bioLine(c, 'confirmed, sending')); + if (!c.ok) { $('send-go').disabled = false; return; } + } + const r = await post('/api/send', { quote: q, nonce: confirm_nonce }); sentHash = r.hash; $('sent-hash').textContent = r.hash; $('send-confirm').hidden = true; $('send-done').hidden = false; $('send-to').value = ''; $('send-amount').value = ''; await poll(); @@ -223,6 +303,82 @@ $('backup-show').onclick = async () => { } catch (e) { $('backup-err').textContent = e.message; } }; $('backup-hide').onclick = () => { $('backup-out').hidden = true; $('backup-words').innerHTML = ''; $('backup-key').textContent = ''; }; +// the backup and the export after a confirmation: the words come from the unlocked key, no password typed +async function revealWithTouch(errEl) { + errEl.textContent = ''; + const c = await post('/api/biometric/challenge', { purpose: 'reveal' }); + setLine(errEl, `${FP}${esc('Waiting for ' + what())}`); + const h = await bio.call('confirm', { nonce: c.nonce }); + setLine(errEl, bioLine(h, 'confirmed')); + if (!h.ok) return null; + return post('/api/reveal', { nonce: c.nonce }); +} +$('backup-touch').onclick = async () => { + try { + const r = await revealWithTouch($('backup-err')); + if (!r) return; + $('backup-words').innerHTML = (r.words || []).map(w => `
  • ${esc(w)}
  • `).join(''); + $('backup-key').textContent = r.private_key; $('backup-out').hidden = false; + if (!state.backed_up) await post('/api/backed-up'); + } catch (e) { $('backup-err').textContent = e.message; } +}; +$('export-touch').onclick = async () => { + try { const r = await revealWithTouch($('export-err')); if (!r) return; $('export-key').textContent = r.private_key; $('export-out').hidden = false; } + catch (e) { $('export-err').textContent = e.message; } +}; +// ---- Touch ID / Windows Hello in Settings: enrol (password once, then the prompt), the idle lock, turn off ---- +function versionLine(s) { + const u = s.update || {}, v = 'Igneum Wallet ' + s.version; + let tail; + switch (u.status) { + case 'current': tail = 'up to date'; break; + case 'available': case 'downloading': tail = u.version + ' downloading'; break; + case 'staging': case 'ready': case 'deferred': case 'manual': tail = u.version + ' downloaded'; break; + case 'applying': tail = 'installing ' + u.version; break; + case 'checking': tail = 'checking for updates'; break; + case 'off': tail = 'updates off in this build'; break; + case 'error': tail = 'update check failed'; break; + default: tail = 'not checked yet'; + } + return `${esc(v)} · ${esc(tail)}`; +} +function renderBio(s) { + const b = s.biometric || {}, w = what(); + $('version-row').innerHTML = versionLine(s); + $('bio-title').textContent = w; + $('bio-enrol-text').textContent = 'Turn on ' + w; + $('idle-lock-text').textContent = `Lock after ${b.idle_lock_min || 5} minutes idle`; + $('backup-touch').hidden = !(b.enrolled && bio.host); $('backup-touch').querySelector('span').textContent = 'Show with ' + w; + $('export-touch').hidden = !(b.enrolled && bio.host); $('export-touch').querySelector('span').textContent = 'Show with ' + w; + $('backup-note-text').textContent = b.enrolled ? `Show the 24 words (or the key) again, with ${w} or the password.` : 'Show the 24 words (or the key) again. Needs the password.'; + $('bio-on').hidden = !b.enrolled; $('bio-off').hidden = b.enrolled; + if (document.activeElement !== $('idle-lock')) $('idle-lock').checked = !!b.idle_lock; + let off = ''; + if (!bio.host) off = w + ' needs the Igneum Wallet app window; this page is open in a browser.'; + else if (!b.available) off = b.message || (w === 'Touch ID' ? 'Touch ID is not set up on this Mac.' : 'Windows Hello is not set up on this PC.'); + $('bio-off-note').textContent = off; + $('bio-enrol').disabled = !!off; $('bio-pw').disabled = !!off; + $('bio-on-note').textContent = b.needs_enrol ? '' : (b.last_result && b.last_result !== 'ok' && b.last_op ? `last ${b.last_op}: ${b.last_result}` : ''); + if (b.needs_enrol && !$('bio-err').textContent) $('bio-err').textContent = `The password changed, so ${w} was turned off. Turn it on again here.`; +} +$('bio-enrol').onclick = async () => { + $('bio-err').textContent = ''; + const pw = $('bio-pw').value; + if (!pw) return $('bio-err').textContent = 'type the password first'; + $('bio-enrol').disabled = true; + try { + const r = await post('/api/biometric/enrol/begin', { password: pw }); + setLine($('bio-err'), `${FP}${esc('Waiting for ' + what())}`); + const h = await bio.call('enrol', { token: r.token }); + setLine($('bio-err'), bioLine(h, 'is on')); + if (!h.ok) { post('/api/biometric/enrol/cancel').catch(() => {}); $('bio-enrol').disabled = false; return; } + $('bio-pw').value = ''; toast(what() + ' is on'); await poll(); + } catch (e) { $('bio-err').textContent = e.message; } + $('bio-enrol').disabled = false; +}; +$('bio-remove').onclick = async () => { try { await post('/api/biometric/remove'); $('bio-err').textContent = ''; toast(what() + ' is off'); await poll(); } catch (e) { $('bio-err').textContent = e.message; } }; +$('idle-lock').onchange = async ev => { try { await post('/api/settings', { idle_lock: ev.target.checked }); } catch (e) { toast(e.message); } }; +$('unlock-touch').onclick = unlockWithTouch; $('pw-change').onclick = async () => { $('pw-err').textContent = ''; try { await post('/api/password', { old: $('pw-old').value, new: $('pw-new').value }); $('pw-old').value = ''; $('pw-new').value = ''; toast('password changed'); } @@ -307,7 +463,12 @@ $('remove-go').onclick = async () => { try { await post('/api/remove', { password: $('remove-pw').value }); $('remove-pw').value = ''; await poll(); } catch (e) { $('remove-err').textContent = e.message; } }; -document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); }); +document.addEventListener('visibilitychange', () => { + if (document.hidden) return; + poll(); + // the window came back (menu bar, Dock): the prompt once more on the unlock screen, not within 10 s of the last + if (state && state.phase === 'unlock' && bioEnrolled() && bio.host && Date.now() - lastPrompt > 10000) promptPending = true; +}); new MutationObserver(() => { if (document.body.dataset.view === 'settings') renderNetwork(); }).observe(document.body, { attributes: true, attributeFilter: ['data-view'] }); poll(); diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html index e0bbbb9ec..428729f7d 100644 --- a/app/igneum-wallet/ui/index.html +++ b/app/igneum-wallet/ui/index.html @@ -9,11 +9,12 @@ +
    - IGNEUMWALLET + IGNEUMWALLET
    starting
    @@ -109,9 +110,13 @@

    Unlock

    +
    - +
    Forgot it? Only the 24 words or the key open this wallet again: Settings is locked too.
    @@ -124,7 +129,13 @@
    balance
    -
     IGN
    +
    + +
    +
    0IGN
    + +
    +
    @@ -167,7 +178,8 @@

    -
    +
    +