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

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 <data>/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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 09:12:09 +00:00
parent 31af76c564
commit 78b9fab46b
21 changed files with 1778 additions and 63 deletions

View file

@ -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<Instant>,
used: bool,
}
#[derive(Default)]
pub struct Gate {
challenges: Vec<Challenge>,
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<String> {
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);
}
}

View file

@ -13,6 +13,8 @@ pub struct Req {
pub origin: Option<String>,
pub sec_fetch_site: Option<String>,
pub host: Option<String>,
/// X-Igneum-Host: the window host's token (the engine printed it on stdout; the page never sees it)
pub host_token: Option<String>,
}
pub fn read_request(stream: &mut TcpStream) -> Option<Req> {
@ -24,7 +26,7 @@ pub fn read_request(stream: &mut TcpStream) -> Option<Req> {
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<Req> {
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<Req> {
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

View file

@ -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;

View file

@ -1940,7 +1940,7 @@ dependencies = [
[[package]]
name = "igneum-wallet"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"argon2",
"bip32",

View file

@ -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"

View file

@ -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)

View file

@ -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<Option<Instant>>,
/// 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<Gate>,
/// the last time the window reported a person at it (the idle lock)
last_activity: Mutex<Instant>,
}
/// 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<Cmd>, engine_log: Option<std::fs::File>, log_path: PathBuf) -> Shared {
pub fn new(token: String, host_token: String, paths: Paths, packaged: Packaged, settings: Settings, machine_id: String, cmd_tx: Sender<Cmd>, engine_log: Option<std::fs::File>, 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<Value, String> {
pub fn unlock(&self, password: &str, via_host: bool) -> Result<Value, String> {
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::<Vec<_>>()), "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<Value, String> {
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::<Vec<_>>()), "private_key": s.private_key, "address": v.address, "display": keys::checksum(&v.address) }))
}
pub fn mark_backed_up(&self) -> Result<Value, String> {
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<Value, String> {
/// 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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
{
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::<Value>(&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() {

View file

@ -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:<random port>/t/<token>/. 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 {

View file

@ -62,6 +62,18 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
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<Shared>) {
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<Shared>) {
}
}
fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, String> {
/// 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<Shared>, path: &str, body: Value, from_host: bool) -> Result<Value, String> {
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<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.create_confirm(&checks)
}
"/api/import" => 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<Shared>, path: &str, body: Value) -> Result<Value, Stri
v["base_fee_gwei"] = json!(crate::evm::ign(q.base_fee.parse::<u128>().unwrap_or(0) * 1_000_000_000, 3));
v["tip_gwei"] = json!(crate::evm::ign(q.tip.parse::<u128>().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 }))

View file

@ -103,6 +103,8 @@ pub struct State {
pub scanned_to: Option<u64>,
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<Event>,
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,

View file

@ -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)}

View file

@ -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 = '<svg class="fp-glyph" width="16" height="16" aria-hidden="true"><use href="#i-fp"></use></svg>';
function bioLine(r, okText) {
if (!r) return '';
if (r.ok) return `<span class="bio-state ok">${FP}${esc(what() + ' ' + (okText || 'confirmed'))}</span>`;
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 `<span class="bio-state">${FP}${esc(t)}</span>`;
}
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'), `<span class="bio-state wait">${FP}${esc('Waiting for ' + what())}</span>`);
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'), `<span class="bio-state wait">${FP}${esc('Waiting for ' + what())}</span>`);
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, `<span class="bio-state wait">${FP}${esc('Waiting for ' + what())}</span>`);
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 => `<li>${esc(w)}</li>`).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 `<b>${esc(v)}</b> · ${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'), `<span class="bio-state wait">${FP}${esc('Waiting for ' + what())}</span>`);
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();

View file

@ -9,11 +9,12 @@
<link rel="stylesheet" href="app.css">
</head>
<body data-phase="welcome" data-view="overview">
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><symbol id="i-fp" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.8 7.7A9.2 9.2 0 0 1 12 4.1c1.5 0 2.9.3 4.1.9"/><path d="M5.2 16.6A12.5 12.5 0 0 1 4.6 12a7.4 7.4 0 0 1 14.8 0v4"/><path d="M8.5 20a9 9 0 0 1-1.3-4.7V12a4.8 4.8 0 0 1 9.6 0v1.5"/><path d="M12 11a1 1 0 0 1 1 1v2a6 6 0 0 1-1 3.3"/><path d="M15.6 20a10 10 0 0 0 .9-2.6"/></g></symbol></svg>
<header class="top">
<div class="brand">
<img src="mark.svg" width="30" height="30" alt="">
<span class="word">IGNEUM</span><span class="miner">WALLET</span>
<span class="word">IGNEUM</span><span class="miner">WALLET</span><span class="ver mono" id="ver"></span>
</div>
<div class="top-right">
<div class="pill" id="pill"><span class="dot"></span><span id="pill-text">starting</span></div>
@ -109,9 +110,13 @@
<div class="coin-wrap"><img class="coin" src="coin.png" width="148" height="148" alt=""></div>
<h2>Unlock</h2>
<p class="lead mono" id="unlock-address"></p>
<div class="bio-row" id="unlock-bio" hidden>
<button class="btn primary big fp" id="unlock-touch"><svg width="22" height="22"><use href="#i-fp"></use></svg><span id="unlock-touch-text">Unlock with Touch ID</span></button>
<div class="note" id="unlock-bio-note"></div>
</div>
<form id="unlock-form" class="unlock">
<input type="password" id="unlock-pw" placeholder="Password" autocomplete="current-password" autofocus>
<button class="btn primary big" type="submit">Unlock</button>
<button class="btn primary big" type="submit" id="unlock-submit">Unlock</button>
</form>
<div class="err" id="unlock-err"></div>
<div class="seedline">Forgot it? Only the 24 words or the key open this wallet again: Settings is locked too.</div>
@ -124,7 +129,13 @@
<div class="view" id="view-overview">
<div class="balance-card">
<div class="eyebrow">balance</div>
<div class="balance"><span id="balance">&nbsp;</span><span class="unit">IGN</span></div>
<div class="balance-row">
<img class="coin small" src="coin.png" width="56" height="56" alt="">
<div>
<div class="balance"><span id="balance">0</span><span class="unit">IGN</span></div>
<div class="mono reading" id="balance-note" hidden></div>
</div>
</div>
<div class="addr-row"><span class="mono" id="home-address"></span><button class="btn tiny" id="copy-address">Copy</button></div>
<div class="actions">
<button class="btn primary big" id="go-send">Send</button>
@ -167,7 +178,8 @@
</div>
<p class="note" id="c-fee-note"></p>
<div class="err" id="confirm-send-err"></div>
<div class="cta"><button class="btn primary big" id="send-go">Send now</button><button class="btn ghost" id="send-edit">Edit</button></div>
<div class="note" id="send-bio-line"></div>
<div class="cta"><button class="btn primary big" id="send-go"><svg class="fp-ico" width="20" height="20" hidden><use href="#i-fp"></use></svg><span id="send-go-text">Send now</span></button><button class="btn ghost" id="send-edit">Edit</button></div>
</div>
<div id="send-done" hidden>
<h3>Sent</h3>
@ -203,10 +215,24 @@
<div class="step">
<div class="eyebrow ember">settings</div>
<h2>Settings</h2>
<div class="version-row mono" id="version-row"></div>
<div class="card" id="bio-card"><h3 id="bio-title">Touch ID</h3>
<p class="note" id="bio-note">Unlock the wallet, confirm each send and show the backup with a fingerprint. The password still works everywhere.</p>
<div class="err" id="bio-err"></div>
<div id="bio-off">
<div class="inline"><input type="password" id="bio-pw" placeholder="Password" autocomplete="current-password"><button class="btn small primary fp" id="bio-enrol"><svg width="16" height="16"><use href="#i-fp"></use></svg><span id="bio-enrol-text">Turn on Touch ID</span></button></div>
<p class="note small" id="bio-off-note"></p>
</div>
<div id="bio-on" hidden>
<label class="switch"><input type="checkbox" id="idle-lock"><span id="idle-lock-text">Lock after 5 minutes idle</span></label>
<div class="inline"><button class="btn small" id="bio-remove">Turn off</button><span class="note" id="bio-on-note"></span></div>
</div>
</div>
<div class="card"><h3>Backup</h3>
<p class="note">Show the 24 words (or the key) again. Needs the password.</p>
<div class="inline"><input type="password" id="backup-pw" placeholder="Password" autocomplete="current-password"><button class="btn small" id="backup-show">Show</button></div>
<p class="note" id="backup-note-text">Show the 24 words (or the key) again. Needs the password.</p>
<div class="inline"><input type="password" id="backup-pw" placeholder="Password" autocomplete="current-password"><button class="btn small" id="backup-show">Show</button><button class="btn small fp" id="backup-touch" hidden><svg width="16" height="16"><use href="#i-fp"></use></svg><span>Show with Touch ID</span></button></div>
<div class="err" id="backup-err"></div>
<div id="backup-out" hidden>
<ol class="words small" id="backup-words"></ol>
@ -230,7 +256,7 @@
<div class="warn-box">
<b>Export the private key</b>
<p class="note">A copied key can be stolen from the clipboard, a screenshot or a notes app. Paste it straight into MetaMask and clear the clipboard.</p>
<div class="inline"><input type="password" id="export-pw" placeholder="Password" autocomplete="current-password"><button class="btn small danger" id="export-show">Show the key</button></div>
<div class="inline"><input type="password" id="export-pw" placeholder="Password" autocomplete="current-password"><button class="btn small danger" id="export-show">Show the key</button><button class="btn small danger fp" id="export-touch" hidden><svg width="16" height="16"><use href="#i-fp"></use></svg><span>Show with Touch ID</span></button></div>
<div class="err" id="export-err"></div>
<div id="export-out" hidden><p class="mono small" id="export-key"></p><button class="btn tiny" id="export-copy">Copy key</button> <button class="btn tiny ghost" id="export-hide">Hide</button></div>
</div>

328
app/mac/Biometric.swift Normal file
View file

@ -0,0 +1,328 @@
// Touch ID for the Igneum window hosts (IgneumWallet.swift, IgneumMiner.swift), compiled into each with
// swiftc ... IgneumWallet.swift Biometric.swift -framework Cocoa -framework WebKit -framework LocalAuthentication
// 5 October 2026.
//
// The page in the WKWebView posts {id, op, ...} to the "biometric" script message handler; the host answers with
// window.__igneumBiometric(id, {ok, code, message, ...}). Ops: status, enrol, unlock (wallet), confirm.
//
// What is stored, and where. The packaging signs the apps ad hoc, and under an ad hoc signature macOS refuses any
// Keychain item with a biometric access control (errSecMissingEntitlement, -34018, on the login keychain and the
// data-protection keychain alike: keychain-access-groups needs a team signature). A Secure Enclave key with the same
// control is allowed, so the secret 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 makes an AES-GCM key
// and the secret (the wallet's password; a fixed marker for the miner) is sealed. The file the engine named
// (<data root>/<app>/biometric.json, user-only permissions) holds the Secure Enclave key's wrapped form, the
// ephemeral public key and the box. The wrapped key is useless off this Mac's Secure Enclave.
// - unlock or confirm: the host evaluates LAPolicy.deviceOwnerAuthenticationWithBiometrics (no fallback button: the
// wallet's own password is the fallback, in the window), then uses the Secure Enclave key under that context.
// .biometryCurrentSet means a fingerprint added or removed in System Settings makes the key unusable: the host
// reports "invalidated" and the window asks for the password and a fresh enrolment.
// The secret never goes through the page: on unlock the host posts it to the engine on 127.0.0.1 with the engine's
// URL token; the engine's host token (X-Igneum-Host, read from the engine's stdout) marks the calls only the host
// may make (the confirmations, taking the parked password at enrolment).
import Foundation
import LocalAuthentication
import CryptoKit
import Security
final class Biometric {
let product: String
/// the prompt's fallback button: "Use password" for the wallet (the window then asks for it), "" for the miner
let fallbackTitle: String
/// the enrolment prompt's line: "Turn on Touch ID for your wallet" / "... for the miner"
let enrolReason: String
private(set) var engineURL = ""
private(set) var hostToken = ""
private(set) var file: URL?
private let queue = DispatchQueue(label: "network.igneum.biometric")
private let salt = Data("igneum-biometric-v1".utf8)
private let minerMarker = "igneum-biometric-marker-v1"
init(product: String, fallbackTitle: String, enrolReason: String) {
self.product = product
self.fallbackTitle = fallbackTitle
self.enrolReason = enrolReason
}
/// From the engine's HOST line. Reports availability to the engine at once.
func configure(engineURL: String, hostToken: String, file: String) {
self.engineURL = engineURL
self.hostToken = hostToken
self.file = file.isEmpty ? nil : URL(fileURLWithPath: file)
let s = status()
queue.async {
_ = self.post("api/biometric/status", ["available": s.available, "kind": "touchid", "message": s.message])
}
}
// ---- availability ----
func status() -> (available: Bool, message: String) {
let ctx = LAContext()
var err: NSError?
if ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) {
if ctx.biometryType != .touchID { return (false, "This Mac has no Touch ID sensor.") }
return (true, "")
}
return (false, Biometric.text(for: err, op: "status").message)
}
// ---- the ops, one at a time on the queue ----
func handle(_ msg: [String: Any], reply: @escaping ([String: Any]) -> Void) {
let op = msg["op"] as? String ?? ""
queue.async {
let r: [String: Any]
switch op {
case "status":
let s = self.status()
r = ["ok": true, "available": s.available, "kind": "touchid", "message": s.message, "enrolled": self.sealedFileExists()]
case "enrol": r = self.enrol(token: msg["token"] as? String)
case "unlock": r = self.unlock()
case "confirm": r = self.confirm(nonce: msg["nonce"] as? String ?? "")
default: r = ["ok": false, "code": "bad_op", "message": "unknown op \(op)"]
}
if op != "status" {
_ = self.post("api/biometric/report", ["op": op, "ok": r["ok"] as? Bool ?? false, "code": r["code"] as? String ?? "", "message": r["message"] as? String ?? ""])
}
DispatchQueue.main.async { reply(r) }
}
}
private func enrol(token: String?) -> [String: Any] {
guard let file = file else { return fail("unavailable", "The engine has not named the sealed file yet.") }
let s = status()
if !s.available { return fail("unavailable", s.message) }
let ctx = context()
if let e = evaluate(ctx, reason: enrolReason) { return e }
// the secret: the wallet's password from the engine (one-time token), or the miner's marker
var secret: Data
if let t = token, !t.isEmpty {
let r = post("api/biometric/enrol/take", ["token": t])
guard r.ok, let p = r.json["secret"] as? String else { return fail("engine", r.json["error"] as? String ?? "the engine did not hand over the password") }
secret = Data(p.utf8)
} else {
secret = Data(minerMarker.utf8)
}
defer { secret.resetBytes(in: 0..<secret.count) }
do {
var acErr: Unmanaged<CFError>?
guard let ac = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .biometryCurrentSet], &acErr) else {
return fail("secure_enclave", "The access control could not be made: \(acErr?.takeRetainedValue().localizedDescription ?? "unknown")")
}
let key = try SecureEnclave.P256.KeyAgreement.PrivateKey(accessControl: ac, authenticationContext: ctx)
let eph = P256.KeyAgreement.PrivateKey()
let shared = try eph.sharedSecretFromKeyAgreement(with: key.publicKey)
let sym = shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(product.utf8), outputByteCount: 32)
let box = try AES.GCM.seal(secret, using: sym)
guard let combined = box.combined else { return fail("seal", "The box has no combined form.") }
let doc: [String: Any] = ["version": 1, "kind": "touchid", "product": product, "created": Int(Date().timeIntervalSince1970),
"key": key.dataRepresentation.base64EncodedString(), "eph": eph.publicKey.rawRepresentation.base64EncodedString(), "box": combined.base64EncodedString()]
let data = try JSONSerialization.data(withJSONObject: doc, options: [.sortedKeys])
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: file, options: [.atomic])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path)
} catch {
return fail("secure_enclave", "The Secure Enclave refused: \(error.localizedDescription)")
}
let r = post("api/biometric/enrolled", ["kind": "touchid"])
if !r.ok { return fail("engine", r.json["error"] as? String ?? "the engine did not record the enrolment") }
return ["ok": true]
}
/// Wallet: Touch ID, then the password out of the box and into the engine; never to the page.
private func unlock() -> [String: Any] {
guard sealedFileExists() else { return fail("not_enrolled", "Touch ID is not turned on for this wallet.") }
let ctx = context()
if let e = evaluate(ctx, reason: "Unlock your wallet") { return e }
switch open(ctx) {
case .failure(let e): return e.dict
case .success(var secret):
defer { secret.resetBytes(in: 0..<secret.count) }
guard let p = String(data: secret, encoding: .utf8) else { return fail("seal", "The sealed password did not decode.") }
let r = post("api/unlock", ["password": p])
if !r.ok { return fail("engine", r.json["error"] as? String ?? "the engine did not unlock") }
return ["ok": true]
}
}
/// The engine's challenge: its reason on the prompt, then the confirmation with the host token. The Secure
/// Enclave key is exercised too, so a changed fingerprint set is caught here as well.
private func confirm(nonce: String) -> [String: Any] {
guard !nonce.isEmpty else { return fail("bad_nonce", "No challenge.") }
let c = get("api/biometric/challenge?nonce=\(nonce)")
guard c.ok, let reason = c.json["reason"] as? String else { return fail("engine", c.json["error"] as? String ?? "the engine does not know this challenge") }
let ctx = context()
if let e = evaluate(ctx, reason: reason) { return e }
if sealedFileExists() {
if case .failure(let e) = agree(ctx) { return e.dict }
}
let r = post("api/biometric/confirm", ["nonce": nonce])
if !r.ok { return fail("engine", r.json["error"] as? String ?? "the engine refused the confirmation") }
return ["ok": true]
}
// ---- Touch ID ----
func context() -> LAContext {
let ctx = LAContext()
// the policy is biometrics only, so the fallback button never reaches the device password: "Use password"
// (the wallet) returns LAError.userFallback and the window asks for the wallet's own password; the miner
// has no password, so no button
ctx.localizedFallbackTitle = fallbackTitle
ctx.localizedCancelTitle = "Cancel"
return ctx
}
/// nil on success, else the reply for the page.
private func evaluate(_ ctx: LAContext, reason: String) -> [String: Any]? {
var err: NSError?
guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) else {
let t = Biometric.text(for: err, op: "evaluate")
return fail(t.code, t.message)
}
// macOS composes the sentence itself: "Igneum Wallet is trying to <reason>." (the bundled app's name and icon
// are the title), so the line starts lowercase here; the engine's canonical lines keep their capital for
// Windows Hello, which shows the line on its own
let line = String(reason.prefix(80))
let shown = line.prefix(1).lowercased() + line.dropFirst()
let sem = DispatchSemaphore(value: 0)
var ok = false
var failure: Error?
ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: shown) { success, error in
ok = success
failure = error
sem.signal()
}
sem.wait()
if ok { return nil }
let t = Biometric.text(for: failure as NSError?, op: "evaluate")
return fail(t.code, t.message)
}
static func text(for err: NSError?, op: String) -> (code: String, message: String) {
guard let e = err else { return ("failed", "Touch ID did not succeed.") }
if e.domain == LAErrorDomain, let la = LAError.Code(rawValue: e.code) {
switch la {
case .userCancel, .systemCancel, .appCancel: return ("cancelled", "Touch ID was cancelled.")
case .authenticationFailed: return ("failed", "Touch ID did not match.")
case .biometryLockout: return ("locked", "Touch ID is locked after too many tries. Use the password; Touch ID comes back after the Mac is unlocked with its password.")
case .biometryNotEnrolled: return ("not_set_up", "Touch ID is not set up on this Mac. Add a fingerprint in System Settings > Touch ID & Password.")
case .biometryNotAvailable, .passcodeNotSet: return ("unavailable", "Touch ID is not available on this Mac.")
case .userFallback: return ("fallback", "Enter your password.")
default: break
}
}
return ("failed", "Touch ID did not succeed: \(e.localizedDescription)")
}
// ---- the sealed file ----
func sealedFileExists() -> Bool {
guard let f = file else { return false }
return FileManager.default.fileExists(atPath: f.path)
}
private struct Sealed {
let key: SecureEnclave.P256.KeyAgreement.PrivateKey
let eph: P256.KeyAgreement.PublicKey
let box: AES.GCM.SealedBox
}
private func load(_ ctx: LAContext) -> Result<Sealed, Reply> {
guard let f = file, let data = try? Data(contentsOf: f), let doc = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let k = doc["key"] as? String, let e = doc["eph"] as? String, let b = doc["box"] as? String,
let kd = Data(base64Encoded: k), let ed = Data(base64Encoded: e), let bd = Data(base64Encoded: b) else {
return .failure(failure("not_enrolled", "The sealed file is missing or unreadable. Turn Touch ID on again in Settings."))
}
do {
let key = try SecureEnclave.P256.KeyAgreement.PrivateKey(dataRepresentation: kd, authenticationContext: ctx)
let eph = try P256.KeyAgreement.PublicKey(rawRepresentation: ed)
let box = try AES.GCM.SealedBox(combined: bd)
return .success(Sealed(key: key, eph: eph, box: box))
} catch {
return .failure(failure("invalidated", "Touch ID no longer opens this: the sealed key is not usable (\(error.localizedDescription)). Use the password, then turn Touch ID on again in Settings."))
}
}
/// The key agreement under the authenticated context: the Secure Enclave refuses it when the fingerprint set
/// changed since enrolment (.biometryCurrentSet).
private func agree(_ ctx: LAContext) -> Result<SymmetricKey, Reply> {
switch load(ctx) {
case .failure(let e): return .failure(e)
case .success(let s):
do {
let shared = try s.key.sharedSecretFromKeyAgreement(with: s.eph)
return .success(shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(product.utf8), outputByteCount: 32))
} catch {
return .failure(failure("invalidated", "Touch ID no longer opens this: a fingerprint was added or removed since it was turned on. Use the password, then turn Touch ID on again in Settings."))
}
}
}
private func open(_ ctx: LAContext) -> Result<Data, Reply> {
switch load(ctx) {
case .failure(let e): return .failure(e)
case .success(let s):
do {
let shared = try s.key.sharedSecretFromKeyAgreement(with: s.eph)
let sym = shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(product.utf8), outputByteCount: 32)
return .success(try AES.GCM.open(s.box, using: sym))
} catch {
return .failure(failure("invalidated", "Touch ID no longer opens this: a fingerprint was added or removed since it was turned on. Use the password, then turn Touch ID on again in Settings."))
}
}
}
// ---- the engine on 127.0.0.1 ----
/// A reply for the page that is also an Error, so Result can carry it.
struct Reply: Error {
let dict: [String: Any]
}
private func fail(_ code: String, _ message: String) -> [String: Any] {
["ok": false, "code": code, "message": message]
}
private func failure(_ code: String, _ message: String) -> Reply {
Reply(dict: fail(code, message))
}
private struct Answer {
let ok: Bool
let json: [String: Any]
}
private func request(_ path: String, method: String, body: [String: Any]?) -> Answer {
guard !engineURL.isEmpty, let url = URL(string: engineURL + path) else { return Answer(ok: false, json: ["error": "no engine URL"]) }
var req = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
req.httpMethod = method
req.setValue(hostToken, forHTTPHeaderField: "X-Igneum-Host")
if let b = body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: b)
}
let sem = DispatchSemaphore(value: 0)
var out = Answer(ok: false, json: ["error": "no answer from the engine"])
let task = URLSession.shared.dataTask(with: req) { data, resp, error in
defer { sem.signal() }
if let e = error { out = Answer(ok: false, json: ["error": e.localizedDescription]); return }
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
let j = data.flatMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] } ?? [:]
out = Answer(ok: code == 200 && (j["ok"] as? Bool ?? true), json: j)
}
task.resume()
sem.wait()
return out
}
private func post(_ path: String, _ body: [String: Any]) -> Answer {
request(path, method: "POST", body: body)
}
private func get(_ path: String) -> Answer {
request(path, method: "GET", body: nil)
}
}

View file

@ -1,10 +1,11 @@
// Igneum Wallet for macOS: the window around the wallet engine. A copy of IgneumMiner.swift with the names, the
// bundle and the menu changed (no pause; a Lock item instead). Compiled by packaging/mac/build-wallet-dmg.sh with
// swiftc -O -target arm64-apple-macos11 -o "Igneum Wallet" IgneumWallet.swift -framework Cocoa -framework WebKit
// It starts Contents/MacOS/igneum-wallet --wrapper, reads "URL ..." and "STATE {...}" lines from its stdout, shows the
// URL in a WKWebView, keeps a menu-bar item with the state, and on quit writes "quit" to the engine's stdin and waits
// for its "EXIT" line (the bundled node stops first when one runs). Closing the window hides it; the app lives on in
// the menu bar and the Dock. 4 October 2026.
// swiftc -O -target arm64-apple-macos11 -o "Igneum Wallet" IgneumWallet.swift Biometric.swift -framework Cocoa -framework WebKit -framework LocalAuthentication
// It starts Contents/MacOS/igneum-wallet --wrapper, reads "URL ...", "HOST {...}" and "STATE {...}" lines from its
// stdout, shows the URL in a WKWebView, keeps a menu-bar item with the state, and on quit writes "quit" to the engine's
// stdin and waits for its "EXIT" line (the bundled node stops first when one runs). Closing the window hides it; the
// app lives on in the menu bar and the Dock. 4 October 2026. Touch ID (Biometric.swift, the "biometric" script
// message handler): 5 October 2026.
//
// Snapshot mode, used to make the design screenshots without a browser:
// "Igneum Wallet" --snapshot <out.png> --url <window url> [--size 1120x780]
@ -44,7 +45,9 @@ final class DragStrip: NSView {
override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil }
}
final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate, WKScriptMessageHandler {
let bio = Biometric(product: "Igneum Wallet", fallbackTitle: "Use password", enrolReason: "Turn on Touch ID for your wallet")
var enginePort = ""
var window: NSWindow!
var web: WKWebView!
var engine: Process?
@ -72,6 +75,12 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
buildWindow()
if let p = snapshotPath, let u = snapshotURL, let url = URL(string: u) {
self.url = url
enginePort = url.port.map(String.init) ?? ""
// design screenshots and tests: IGNEUM_HOST_LINE (the engine's HOST json) wires the Touch ID bridge to a
// scratch engine, IGNEUM_PROBE runs JS in the page, IGNEUM_SNAPSHOT_DELAY (s) waits before the capture
if let h = ProcessInfo.processInfo.environment["IGNEUM_HOST_LINE"], let d = h.data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
bio.configure(engineURL: u.replacingOccurrences(of: "?host=mac", with: ""), hostToken: o["token"] as? String ?? "", file: o["biometric_file"] as? String ?? "")
}
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
web.load(URLRequest(url: url))
@ -123,7 +132,8 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
window.isReleasedWhenClosed = false
window.appearance = NSAppearance(named: .darkAqua)
let conf = WKWebViewConfiguration()
web = WKWebView(frame: window.contentView!.bounds, configuration: conf)
conf.userContentController.add(self, name: "biometric") // the page's Touch ID bridge (Biometric.swift)
web = WKWebView(frame: window.contentView!.bounds, configuration: conf)
web.autoresizingMask = [.width, .height]
web.navigationDelegate = self
web.uiDelegate = self
@ -205,9 +215,15 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
let u = String(line.dropFirst(4)).trimmingCharacters(in: .whitespaces)
if let url = URL(string: u + "?host=mac") {
self.url = url
enginePort = url.port.map(String.init) ?? ""
placeholder.isHidden = true
web.load(URLRequest(url: url))
}
} else if line.hasPrefix("HOST ") {
// the host token and the sealed file's path: the page never sees this line
if let d = String(line.dropFirst(5)).data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
bio.configure(engineURL: self.url?.absoluteString.replacingOccurrences(of: "?host=mac", with: "") ?? "", hostToken: o["token"] as? String ?? "", file: o["biometric_file"] as? String ?? "")
}
} else if line.hasPrefix("STATE ") {
applyState(String(line.dropFirst(6)))
} else if line.hasPrefix("FATAL ") {
@ -297,6 +313,18 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
return false
}
// ---- Touch ID: the page posts {id, op, ...}; the answer goes back as window.__igneumBiometric(id, {...}) ----
func userContentController(_ ucc: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == "biometric", let body = message.body as? [String: Any], let id = body["id"] as? Int else { return }
// only the engine's own page, same origin as the URL the engine printed
let origin = message.frameInfo.securityOrigin
guard origin.host == "127.0.0.1", String(origin.port) == enginePort else { return }
bio.handle(body) { result in
guard let d = try? JSONSerialization.data(withJSONObject: result), let j = String(data: d, encoding: .utf8) else { return }
self.web.evaluateJavaScript("window.__igneumBiometric && window.__igneumBiometric(\(id), \(j))", completionHandler: nil)
}
}
// ---- links: anything off 127.0.0.1 opens in the default browser ----
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let u = action.request.url, let scheme = u.scheme, scheme.hasPrefix("http"), u.host != "127.0.0.1" {
@ -325,6 +353,11 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] {
web.evaluateJavaScript(js) { r, e in print("probe:", r ?? "nil", e?.localizedDescription ?? "") }
}
let delay = Double(ProcessInfo.processInfo.environment["IGNEUM_SNAPSHOT_DELAY"] ?? "") ?? 0
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { self.capture(to: path) }
}
func capture(to path: String) {
let conf = WKSnapshotConfiguration()
conf.rect = web.bounds
web.takeSnapshot(with: conf) { image, error in
@ -338,23 +371,30 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
}
}
let app = NSApplication.shared
let delegate = App()
var args = Array(CommandLine.arguments.dropFirst())
var i = 0
while i < args.count {
switch args[i] {
case "--snapshot": if i + 1 < args.count { delegate.snapshotPath = args[i + 1]; i += 1 }
case "--url": if i + 1 < args.count { delegate.snapshotURL = args[i + 1]; i += 1 }
case "--size":
if i + 1 < args.count {
let parts = args[i + 1].split(separator: "x").compactMap { Double($0) }
if parts.count == 2 { delegate.snapshotSize = NSSize(width: parts[0], height: parts[1]) }
// The entry point. With Biometric.swift compiled alongside, top-level statements are not allowed here (only in a
// main.swift), so the launch lives in a @main type.
@main
struct Main {
static func main() {
let app = NSApplication.shared
let delegate = App()
let args = Array(CommandLine.arguments.dropFirst())
var i = 0
while i < args.count {
switch args[i] {
case "--snapshot": if i + 1 < args.count { delegate.snapshotPath = args[i + 1]; i += 1 }
case "--url": if i + 1 < args.count { delegate.snapshotURL = args[i + 1]; i += 1 }
case "--size":
if i + 1 < args.count {
let parts = args[i + 1].split(separator: "x").compactMap { Double($0) }
if parts.count == 2 { delegate.snapshotSize = NSSize(width: parts[0], height: parts[1]) }
i += 1
}
default: break
}
i += 1
}
default: break
app.delegate = delegate
app.run()
}
i += 1
}
app.delegate = delegate
app.run()

View file

@ -1,7 +1,7 @@
@echo off
rem Builds "Igneum Wallet.exe" (the WebView2 window host) on a Windows PC. Double-click this file.
rem Needs Visual Studio with the MSVC v143 x64 component (cl.exe, rc.exe) and the internet on the first run
rem (the WebView2 SDK comes from NuGet). The output lands in dist\ and, when the extracted payload folder
rem Needs Visual Studio with the MSVC v143 x64 component (cl.exe, rc.exe), the Windows SDK's C++/WinRT headers
rem (Windows Hello, biometric.h) and the internet on the first run (the WebView2 SDK comes from NuGet). The output lands in dist\ and, when the extracted payload folder
rem (igneum-windows-app, with igneum-wallet.exe) is next to this folder or its parent, is copied into it, so
rem packaging\windows\BUILD-INSTALLER.bat ships it. Without this exe the installer still works: the Start Menu entry
rem runs igneum-wallet.exe --launch, which opens the dashboard in the default browser.
@ -46,7 +46,7 @@ rc.exe /nologo /i "%ART%" /fo build\host.res wallet-host.rc || (pause & exit /b
echo [build] cl
cl.exe /nologo /O2 /MT /EHsc /W3 /std:c++17 /DUNICODE /D_UNICODE /I "build\webview2\build\native\include" /Fo"build\\" wallet-host.cpp build\host.res ^
/link /SUBSYSTEM:WINDOWS /OUT:"dist\Igneum Wallet.exe" "build\webview2\build\native\x64\WebView2LoaderStatic.lib" ^
user32.lib shell32.lib ole32.lib advapi32.lib gdi32.lib version.lib shlwapi.lib || (pause & exit /b 1)
user32.lib shell32.lib ole32.lib advapi32.lib gdi32.lib version.lib shlwapi.lib windowsapp.lib crypt32.lib winhttp.lib || (pause & exit /b 1)
echo [build] done: dist\Igneum Wallet.exe
rem ---- 5. into the payload, when it is here ----

377
app/windows/biometric.h Normal file
View file

@ -0,0 +1,377 @@
// Windows Hello for the Igneum window hosts (wallet-host.cpp, host.cpp): the WebView2 side of what
// app/mac/Biometric.swift does with Touch ID. 5 October 2026. UNTESTED on a PC at the time of writing (written on a
// Mac): BUILD-WALLET-APP.bat / BUILD-APP.bat on the GitHub runner are the first compile.
//
// The page posts a JSON string {id, op, nonce?, token?} with window.chrome.webview.postMessage; the host answers with
// PostWebMessageAsJson({id, ok, code, message, ...}) and the page's listener routes it to window.__igneumBiometric.
// Ops: status, enrol, unlock (wallet), confirm.
//
// The prompt is Windows.Security.Credentials.UI.UserConsentVerifier, through IUserConsentVerifierInterop so a Win32
// window can own it (RequestVerificationForWindowAsync). What is stored: the wallet's password, sealed with DPAPI
// (CryptProtectData, current user, CRYPTPROTECT_UI_FORBIDDEN) only after a Hello success, in the file the engine
// named on its HOST line (%LOCALAPPDATA%\igneum\wallet\biometric.json); the miner seals a fixed marker. DPAPI is as
// strong as the Windows account: anything running as this user can unseal it, which is why the host only unseals
// after Hello verified, and why the threat model in app/igneum-wallet/README.md says what this does and does not
// protect. The secret never goes through the page: the host posts it to the engine on 127.0.0.1 with the engine's
// URL token; X-Igneum-Host (the token from the HOST line) marks the calls only the host may make.
//
// Needs: C++/WinRT headers (the Windows SDK's cppwinrt include, on INCLUDE after vcvarsall), windowsapp.lib,
// crypt32.lib, winhttp.lib. C++17.
#pragma once
#include <windows.h>
#include <wincrypt.h>
#include <winhttp.h>
#include <winrt/base.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Security.Credentials.UI.h>
#include <UserConsentVerifierInterop.h>
#include <string>
#include <vector>
#include <thread>
#include <functional>
namespace igbio {
using winrt::Windows::Security::Credentials::UI::UserConsentVerifier;
using winrt::Windows::Security::Credentials::UI::UserConsentVerifierAvailability;
using winrt::Windows::Security::Credentials::UI::UserConsentVerificationResult;
struct Config {
std::wstring product; // L"Igneum Wallet" | L"Igneum Miner"
std::wstring enrolReason; // L"Turn on Windows Hello for your wallet" | L"... for the miner"
std::wstring engineURL; // http://127.0.0.1:<port>/t/<token>/
std::string hostToken; // from the HOST line
std::wstring file; // the sealed file's path from the HOST line
HWND hwnd = nullptr; // the window that owns the prompt
};
static Config g_cfg;
static const char* MARKER = "igneum-biometric-marker-v1";
// ---- small JSON helpers (flat objects, string values) ----
static std::string jsonEscape(const std::string& s) {
std::string o;
for (unsigned char c : s) {
switch (c) {
case '"': o += "\\\""; break;
case '\\': o += "\\\\"; break;
case '\n': o += "\\n"; break;
case '\r': o += "\\r"; break;
case '\t': o += "\\t"; break;
default:
if (c < 0x20) { char b[8]; sprintf_s(b, "\\u%04x", c); o += b; } else o += (char)c;
}
}
return o;
}
// The value of "key" in a flat JSON object: a string (escapes decoded), a number, or a bool as text.
static std::string jsonGet(const std::string& j, const char* key) {
std::string k = std::string("\"") + key + "\"";
size_t i = j.find(k);
if (i == std::string::npos) return "";
i = j.find(':', i + k.size());
if (i == std::string::npos) return "";
i++;
while (i < j.size() && (j[i] == ' ' || j[i] == '\t')) i++;
if (i < j.size() && j[i] == '"') {
std::string o;
for (i++; i < j.size() && j[i] != '"'; i++) {
if (j[i] == '\\' && i + 1 < j.size()) {
char e = j[++i];
if (e == 'n') o += '\n';
else if (e == 'r') o += '\r';
else if (e == 't') o += '\t';
else if (e == 'u' && i + 4 < j.size()) {
unsigned v = strtoul(j.substr(i + 1, 4).c_str(), nullptr, 16);
i += 4;
if (v < 0x80) o += (char)v;
else if (v < 0x800) { o += (char)(0xC0 | (v >> 6)); o += (char)(0x80 | (v & 0x3F)); }
else { o += (char)(0xE0 | (v >> 12)); o += (char)(0x80 | ((v >> 6) & 0x3F)); o += (char)(0x80 | (v & 0x3F)); }
} else o += e;
} else o += j[i];
}
return o;
}
size_t e = i;
while (e < j.size() && j[e] != ',' && j[e] != '}') e++;
std::string v = j.substr(i, e - i);
while (!v.empty() && (v.back() == ' ' || v.back() == '\r' || v.back() == '\n')) v.pop_back();
return v;
}
static std::wstring widen8(const std::string& s) {
if (s.empty()) return L"";
int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0);
std::wstring w(n, 0);
MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), &w[0], n);
return w;
}
static std::string narrow8(const std::wstring& w) {
if (w.empty()) return "";
int n = WideCharToMultiByte(CP_UTF8, 0, w.data(), (int)w.size(), nullptr, 0, nullptr, nullptr);
std::string s(n, 0);
WideCharToMultiByte(CP_UTF8, 0, w.data(), (int)w.size(), &s[0], n, nullptr, nullptr);
return s;
}
static std::string b64(const std::vector<BYTE>& d) {
DWORD n = 0;
CryptBinaryToStringA(d.data(), (DWORD)d.size(), CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, nullptr, &n);
std::string o(n, 0);
CryptBinaryToStringA(d.data(), (DWORD)d.size(), CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, &o[0], &n);
while (!o.empty() && o.back() == 0) o.pop_back();
return o;
}
static std::vector<BYTE> unb64(const std::string& s) {
DWORD n = 0;
if (!CryptStringToBinaryA(s.c_str(), (DWORD)s.size(), CRYPT_STRING_BASE64, nullptr, &n, nullptr, nullptr)) return {};
std::vector<BYTE> o(n);
if (!CryptStringToBinaryA(s.c_str(), (DWORD)s.size(), CRYPT_STRING_BASE64, o.data(), &n, nullptr, nullptr)) return {};
o.resize(n);
return o;
}
static std::string reply(bool ok, const std::string& code, const std::string& message) {
return std::string("{\"ok\":") + (ok ? "true" : "false") + ",\"code\":\"" + jsonEscape(code) + "\",\"message\":\"" + jsonEscape(message) + "\"}";
}
// ---- the engine on 127.0.0.1 (WinHTTP) ----
struct Answer { bool ok; int status; std::string body; };
static Answer call(const std::wstring& method, const std::string& path, const std::string& body) {
Answer a = { false, 0, "" };
URL_COMPONENTS uc = { sizeof(uc) };
wchar_t host[64] = {}, upath[1024] = {};
uc.lpszHostName = host; uc.dwHostNameLength = 64;
uc.lpszUrlPath = upath; uc.dwUrlPathLength = 1024;
std::wstring full = g_cfg.engineURL + widen8(path);
if (!WinHttpCrackUrl(full.c_str(), 0, 0, &uc)) { a.body = "{\"error\":\"bad engine url\"}"; return a; }
HINTERNET s = WinHttpOpen(L"IgneumHost/1", WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
if (!s) { a.body = "{\"error\":\"winhttp\"}"; return a; }
WinHttpSetTimeouts(s, 5000, 5000, 15000, 15000);
HINTERNET c = WinHttpConnect(s, host, uc.nPort, 0);
HINTERNET r = c ? WinHttpOpenRequest(c, method.c_str(), upath, nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, 0) : nullptr;
if (r) {
std::wstring hdr = L"X-Igneum-Host: " + widen8(g_cfg.hostToken) + L"\r\nContent-Type: application/json\r\n";
if (WinHttpSendRequest(r, hdr.c_str(), (DWORD)-1, body.empty() ? WINHTTP_NO_REQUEST_DATA : (LPVOID)body.data(), (DWORD)body.size(), (DWORD)body.size(), 0) && WinHttpReceiveResponse(r, nullptr)) {
DWORD st = 0, n = sizeof(st);
WinHttpQueryHeaders(r, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &st, &n, WINHTTP_NO_HEADER_INDEX);
a.status = (int)st;
DWORD avail = 0;
while (WinHttpQueryDataAvailable(r, &avail) && avail > 0) {
std::string chunk(avail, 0);
DWORD got = 0;
if (!WinHttpReadData(r, &chunk[0], avail, &got)) break;
a.body.append(chunk, 0, got);
}
a.ok = st == 200 && jsonGet(a.body, "ok") != "false";
} else a.body = "{\"error\":\"no answer from the engine\"}";
}
if (r) WinHttpCloseHandle(r);
if (c) WinHttpCloseHandle(c);
WinHttpCloseHandle(s);
return a;
}
static Answer post(const std::string& path, const std::string& body) { return call(L"POST", path, body); }
static Answer get(const std::string& path) { return call(L"GET", path, ""); }
// ---- Windows Hello ----
static std::string availabilityText(UserConsentVerifierAvailability a, bool* available) {
*available = false;
switch (a) {
case UserConsentVerifierAvailability::Available: *available = true; return "";
case UserConsentVerifierAvailability::DeviceNotPresent: return "Windows Hello has no fingerprint or face sensor on this PC.";
case UserConsentVerifierAvailability::NotConfiguredForUser: return "Windows Hello is not set up on this PC. Set it up in Settings > Accounts > Sign-in options.";
case UserConsentVerifierAvailability::DisabledByPolicy: return "Windows Hello is disabled by policy on this PC.";
case UserConsentVerifierAvailability::DeviceBusy: return "The Windows Hello sensor is busy.";
default: return "Windows Hello is not available on this PC.";
}
}
static std::string status(bool* available) {
try {
auto a = UserConsentVerifier::CheckAvailabilityAsync().get();
return availabilityText(a, available);
} catch (...) {
*available = false;
return "Windows Hello could not be checked on this PC.";
}
}
// The prompt. Returns "" on success, else the reply JSON for the page.
static std::string verify(const std::wstring& reason) {
try {
auto factory = winrt::get_activation_factory<UserConsentVerifier, IUserConsentVerifierInterop>();
winrt::Windows::Foundation::IAsyncOperation<UserConsentVerificationResult> op{ nullptr };
winrt::hstring msg(reason.substr(0, 80));
winrt::check_hresult(factory->RequestVerificationForWindowAsync(g_cfg.hwnd, static_cast<HSTRING>(winrt::get_abi(msg)),
winrt::guid_of<winrt::Windows::Foundation::IAsyncOperation<UserConsentVerificationResult>>(), winrt::put_abi(op)));
auto r = op.get();
switch (r) {
case UserConsentVerificationResult::Verified: return "";
case UserConsentVerificationResult::Canceled: return reply(false, "cancelled", "Windows Hello was cancelled.");
case UserConsentVerificationResult::RetriesExhausted: return reply(false, "locked", "Windows Hello is locked after too many tries. Use the password.");
case UserConsentVerificationResult::NotConfiguredForUser: return reply(false, "not_set_up", "Windows Hello is not set up on this PC. Set it up in Settings > Accounts > Sign-in options.");
case UserConsentVerificationResult::DeviceNotPresent: return reply(false, "unavailable", "Windows Hello has no sensor on this PC.");
case UserConsentVerificationResult::DisabledByPolicy: return reply(false, "unavailable", "Windows Hello is disabled by policy on this PC.");
case UserConsentVerificationResult::DeviceBusy: return reply(false, "failed", "The Windows Hello sensor is busy; try again.");
default: return reply(false, "failed", "Windows Hello did not succeed.");
}
} catch (const winrt::hresult_error& e) {
return reply(false, "failed", "Windows Hello failed: " + narrow8(std::wstring(e.message())));
} catch (...) {
return reply(false, "failed", "Windows Hello failed.");
}
}
// ---- the sealed file (DPAPI, current user) ----
static bool readFile(std::string* out) {
HANDLE h = CreateFileW(g_cfg.file.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE) return false;
char buf[8192]; DWORD n = 0;
out->clear();
while (ReadFile(h, buf, sizeof(buf), &n, nullptr) && n > 0) out->append(buf, n);
CloseHandle(h);
return true;
}
static bool writeFile(const std::string& text) {
size_t i = g_cfg.file.find_last_of(L"\\/");
if (i != std::wstring::npos) CreateDirectoryW(g_cfg.file.substr(0, i).c_str(), nullptr);
HANDLE h = CreateFileW(g_cfg.file.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE) return false;
DWORD w = 0;
BOOL ok = WriteFile(h, text.data(), (DWORD)text.size(), &w, nullptr);
CloseHandle(h);
return ok && w == text.size();
}
static bool sealedExists() {
DWORD a = GetFileAttributesW(g_cfg.file.c_str());
return a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY);
}
static bool seal(const std::string& secret, std::string* boxB64) {
DATA_BLOB in = { (DWORD)secret.size(), (BYTE*)secret.data() }, out = {};
std::wstring desc = g_cfg.product + L" biometric";
if (!CryptProtectData(&in, desc.c_str(), nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return false;
std::vector<BYTE> v(out.pbData, out.pbData + out.cbData);
LocalFree(out.pbData);
*boxB64 = b64(v);
return true;
}
static bool unseal(const std::string& boxB64, std::string* secret) {
std::vector<BYTE> v = unb64(boxB64);
if (v.empty()) return false;
DATA_BLOB in = { (DWORD)v.size(), v.data() }, out = {};
if (!CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return false;
secret->assign((char*)out.pbData, out.cbData);
SecureZeroMemory(out.pbData, out.cbData);
LocalFree(out.pbData);
return true;
}
// ---- the ops ----
static std::string opStatus() {
bool avail = false;
std::string msg = status(&avail);
return std::string("{\"ok\":true,\"available\":") + (avail ? "true" : "false") + ",\"kind\":\"hello\",\"message\":\"" + jsonEscape(msg) + "\",\"enrolled\":" + (sealedExists() ? "true" : "false") + "}";
}
static std::string opEnrol(const std::string& token) {
if (g_cfg.file.empty()) return reply(false, "unavailable", "The engine has not named the sealed file yet.");
bool avail = false;
std::string msg = status(&avail);
if (!avail) return reply(false, "unavailable", msg);
std::string e = verify(g_cfg.enrolReason);
if (!e.empty()) return e;
std::string secret;
if (!token.empty()) {
Answer a = post("api/biometric/enrol/take", "{\"token\":\"" + jsonEscape(token) + "\"}");
if (!a.ok) return reply(false, "engine", jsonGet(a.body, "error").empty() ? "the engine did not hand over the password" : jsonGet(a.body, "error"));
secret = jsonGet(a.body, "secret");
} else secret = MARKER;
std::string box;
bool ok = seal(secret, &box);
SecureZeroMemory(&secret[0], secret.size());
if (!ok) return reply(false, "seal", "DPAPI could not seal the secret.");
std::string doc = "{\"version\":1,\"kind\":\"hello\",\"product\":\"" + jsonEscape(narrow8(g_cfg.product)) + "\",\"created\":" + std::to_string((long long)(GetTickCount64() / 1000)) + ",\"box\":\"" + box + "\"}";
if (!writeFile(doc)) return reply(false, "file", "The sealed file could not be written.");
Answer a = post("api/biometric/enrolled", "{\"kind\":\"hello\"}");
if (!a.ok) return reply(false, "engine", "the engine did not record the enrolment");
return reply(true, "", "");
}
static std::string opUnlock() {
std::string text;
if (!sealedExists() || !readFile(&text)) return reply(false, "not_enrolled", "Windows Hello is not turned on for this wallet.");
std::string e = verify(L"Unlock your wallet");
if (!e.empty()) return e;
std::string secret;
if (!unseal(jsonGet(text, "box"), &secret)) return reply(false, "invalidated", "Windows Hello no longer opens this wallet: the sealed password could not be read. Use the password, then turn Windows Hello on again in Settings.");
Answer a = post("api/unlock", "{\"password\":\"" + jsonEscape(secret) + "\"}");
SecureZeroMemory(&secret[0], secret.size());
if (!a.ok) return reply(false, "engine", jsonGet(a.body, "error").empty() ? "the engine did not unlock" : jsonGet(a.body, "error"));
return reply(true, "", "");
}
static std::string opConfirm(const std::string& nonce) {
if (nonce.empty()) return reply(false, "bad_nonce", "No challenge.");
Answer c = get("api/biometric/challenge?nonce=" + nonce);
if (!c.ok) return reply(false, "engine", jsonGet(c.body, "error").empty() ? "the engine does not know this challenge" : jsonGet(c.body, "error"));
std::string e = verify(widen8(jsonGet(c.body, "reason")));
if (!e.empty()) return e;
if (sealedExists()) {
// the sealed file must still open under this account (DPAPI; a reset account leaves it unreadable)
std::string text, secret;
if (!readFile(&text) || !unseal(jsonGet(text, "box"), &secret)) return reply(false, "invalidated", "The sealed file could not be read. Turn Windows Hello on again in Settings.");
SecureZeroMemory(&secret[0], secret.size());
}
Answer a = post("api/biometric/confirm", "{\"nonce\":\"" + jsonEscape(nonce) + "\"}");
if (!a.ok) return reply(false, "engine", jsonGet(a.body, "error").empty() ? "the engine refused the confirmation" : jsonGet(a.body, "error"));
return reply(true, "", "");
}
/// The HOST line from the engine: {"token": "...", "biometric_file": "..."}. Posts the availability at once.
static void configure(const std::string& hostLineJson, const std::wstring& engineURL, const std::wstring& product, const std::wstring& enrolReason, HWND hwnd) {
g_cfg.product = product;
g_cfg.enrolReason = enrolReason;
g_cfg.engineURL = engineURL;
g_cfg.hostToken = jsonGet(hostLineJson, "token");
g_cfg.file = widen8(jsonGet(hostLineJson, "biometric_file"));
g_cfg.hwnd = hwnd;
std::thread([] {
winrt::init_apartment(winrt::apartment_type::multi_threaded);
bool avail = false;
std::string msg = status(&avail);
post("api/biometric/status", std::string("{\"available\":") + (avail ? "true" : "false") + ",\"kind\":\"hello\",\"message\":\"" + jsonEscape(msg) + "\"}");
}).detach();
}
/// A message from the page (the JSON string it posted). `answer` receives the reply JSON with the id put back; it is
/// called on a worker thread, so the host marshals it to the UI thread for PostWebMessageAsJson.
static void handle(const std::string& msg, std::function<void(const std::string&)> answer) {
std::string id = jsonGet(msg, "id"), op = jsonGet(msg, "op"), nonce = jsonGet(msg, "nonce"), token = jsonGet(msg, "token");
std::thread([id, op, nonce, token, answer] {
winrt::init_apartment(winrt::apartment_type::multi_threaded);
std::string r;
if (op == "status") r = opStatus();
else if (op == "enrol") r = opEnrol(token);
else if (op == "unlock") r = opUnlock();
else if (op == "confirm") r = opConfirm(nonce);
else r = reply(false, "bad_op", "unknown op " + op);
if (op != "status") {
post("api/biometric/report", "{\"op\":\"" + jsonEscape(op) + "\",\"ok\":" + (jsonGet(r, "ok") == "true" ? "true" : "false") + ",\"code\":\"" + jsonEscape(jsonGet(r, "code")) + "\",\"message\":\"" + jsonEscape(jsonGet(r, "message")) + "\"}");
}
// put the id in front: {"id":N, ...rest}
std::string withId = "{\"id\":" + (id.empty() ? "0" : id) + "," + r.substr(1);
answer(withId);
}).detach();
}
} // namespace igbio

View file

@ -3,6 +3,7 @@
// WebView2 SDK from NuGet, static loader). It starts igneum-wallet.exe --wrapper next to it, reads "URL ..." /
// "STATE {...}" / "EXIT" from its stdout, shows the URL in a WebView2 control, keeps a tray icon with the state, and on
// quit writes "quit" to the engine's stdin and waits for EXIT. Closing the window hides it to the tray. 4 October 2026.
// Windows Hello (biometric.h, the page's window.chrome.webview.postMessage bridge, the HOST line): 5 October 2026.
//
// Untested on a real PC at the time of writing (written on a Mac): BUILD-WALLET-APP.bat is the first run.
@ -22,11 +23,13 @@
#include <mutex>
#include "WebView2.h"
#include "wallet-version.h"
#include "biometric.h"
using namespace Microsoft::WRL;
#define WM_ENGINE_LINE (WM_APP + 1)
#define WM_TRAY (WM_APP + 2)
#define WM_BIO_REPLY (WM_APP + 3)
#define ID_TRAY_OPEN 1001
#define ID_TRAY_PAUSE 1002
#define ID_TRAY_QUIT 1003
@ -38,6 +41,7 @@ static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
static ComPtr<ICoreWebView2Controller> g_controller;
static ComPtr<ICoreWebView2> g_webview;
static std::wstring g_url, g_status = L"starting the engine";
static std::string g_hostLine; // the engine's HOST {...} line, kept until the window and the URL exist
static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false;
static NOTIFYICONDATAW g_nid = {};
static std::wstring g_trayTitle = L"Igneum Wallet";
@ -187,6 +191,21 @@ static void initWebView() {
}
return S_OK;
}).Get(), nullptr);
// the page's Windows Hello bridge (biometric.h): a JSON string in, a JSON object back on the UI thread
g_webview->add_WebMessageReceived(Callback<ICoreWebView2WebMessageReceivedEventHandler>([](ICoreWebView2*, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT {
LPWSTR src = nullptr;
if (FAILED(args->get_Source(&src)) || !src) return S_OK;
std::wstring origin(src);
CoTaskMemFree(src);
if (origin.rfind(L"http://127.0.0.1:", 0) != 0) return S_OK; // only the engine's own page
LPWSTR text = nullptr;
if (SUCCEEDED(args->TryGetWebMessageAsString(&text)) && text) {
std::string msg = igbio::narrow8(text);
CoTaskMemFree(text);
igbio::handle(msg, [](const std::string& json) { PostMessageW(g_hwnd, WM_BIO_REPLY, 0, (LPARAM) new std::string(json)); });
}
return S_OK;
}).Get(), nullptr);
RECT rc; GetClientRect(g_hwnd, &rc);
g_controller->put_Bounds(rc);
COREWEBVIEW2_COLOR dark = { 255, 12, 12, 14 };
@ -297,6 +316,10 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
g_url = widen(line->substr(4));
if (g_webviewOk) navigate();
else if (!g_webview && g_status.find(L"WebView2") != std::wstring::npos && !g_hintShown) { openInBrowser(g_url); g_hintShown = true; }
} else if (line->rfind("HOST ", 0) == 0) {
// the host token and the sealed file's path; the page never sees this line
g_hostLine = line->substr(5);
igbio::configure(g_hostLine, g_url, L"Igneum Wallet", L"Turn on Windows Hello for your wallet", hwnd);
} else if (line->rfind("STATE ", 0) == 0) {
applyState(line->substr(6));
} else if (line->rfind("ELEVATE ", 0) == 0) {
@ -312,6 +335,12 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
delete line;
return 0;
}
case WM_BIO_REPLY: {
std::string* json = (std::string*)lp;
if (g_webview) g_webview->PostWebMessageAsJson(igbio::widen8(*json).c_str());
delete json;
return 0;
}
case WM_TIMER:
if (wp == ID_QUIT_TIMER) {
DWORD code = 0;

View file

@ -2,6 +2,6 @@
// Keep it equal to app/igneum-wallet/Cargo.toml and the AppVersion default in packaging/windows/Igneum-Wallet.iss.
#ifndef IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_STR "0.1.0"
#define IGNEUM_HOST_VERSION_RC 0,1,0,0
#define IGNEUM_HOST_VERSION_STR "0.1.2"
#define IGNEUM_HOST_VERSION_RC 0,1,2,0
#endif

View file

@ -3,7 +3,8 @@
# same branded image as the miner's (build-dmg.sh, which this script follows step for step). 4 October 2026.
#
# The bundle:
# Contents/MacOS/Igneum Wallet the window (app/mac/IgneumWallet.swift, WKWebView + menu-bar item), compiled here
# Contents/MacOS/Igneum Wallet the window (app/mac/IgneumWallet.swift + Biometric.swift: WKWebView, menu-bar item,
# Touch ID), compiled here
# Contents/MacOS/igneum-wallet the engine (app/igneum-wallet, cargo --release), compiled here unless ENGINE= names one
# Contents/Resources/bin/igneumd the node (vendor/igneum-node/target-integration/release, the devnet-v4 integration
# build): started only when the miner app's node is not running on this Mac
@ -56,7 +57,7 @@ fi
# the window
echo "building the window (app/mac/IgneumWallet.swift)"
(cd "$ROOT/app/mac" && nice -n 19 swiftc -O -target arm64-apple-macos11 -o "$BUILD/window/Igneum Wallet" IgneumWallet.swift -framework Cocoa -framework WebKit 2>&1 | grep -v 'warning\|^ *\^\|^$' || true)
(cd "$ROOT/app/mac" && nice -n 19 swiftc -O -target arm64-apple-macos11 -o "$BUILD/window/Igneum Wallet" IgneumWallet.swift Biometric.swift -framework Cocoa -framework WebKit -framework LocalAuthentication 2>&1 | grep -v 'warning\|^ *\^\|^$' || true)
[ -x "$BUILD/window/Igneum Wallet" ] || { echo "the window did not build"; exit 1; }
# the bundle

View file

@ -9,7 +9,7 @@
#define ArtDir "..\..\brand\icons"
#endif
#ifndef AppVersion
#define AppVersion "0.1.0"
#define AppVersion "0.1.2"
#endif
#define AppName "Igneum Wallet"
#define Publisher "Igneum"