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:
parent
31af76c564
commit
78b9fab46b
21 changed files with 1778 additions and 63 deletions
352
app/igneum-common/src/biometric.rs
Normal file
352
app/igneum-common/src/biometric.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
2
app/igneum-wallet/Cargo.lock
generated
2
app/igneum-wallet/Cargo.lock
generated
|
|
@ -1940,7 +1940,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "igneum-wallet"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"bip32",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"> </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
328
app/mac/Biometric.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
377
app/windows/biometric.h
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue