diff --git a/app/igneum-common/src/http.rs b/app/igneum-common/src/http.rs index 08c4a23c5..44b318e89 100644 --- a/app/igneum-common/src/http.rs +++ b/app/igneum-common/src/http.rs @@ -15,6 +15,12 @@ pub struct Req { pub host: Option, /// X-Igneum-Host: the window host's token (the engine printed it on stdout; the page never sees it) pub host_token: Option, + /// X-Igneum-Bridge: present on a page's call to the wallet's page bridge (a custom header, so the browser sends a + /// CORS preflight first and a plain form post from a stranger never reaches the handler) + pub bridge_header: bool, + /// Access-Control-Request-Private-Network: the browser asks (Chrome's private network access) before a page on the + /// public web reaches 127.0.0.1; the bridge answers the preflight with the allow header + pub private_network_ask: bool, } pub fn read_request(stream: &mut TcpStream) -> Option { @@ -27,6 +33,7 @@ pub fn read_request(stream: &mut TcpStream) -> Option { let target = parts.next()?.to_string(); let mut content_length = 0usize; let (mut origin, mut sec_fetch_site, mut host, mut host_token) = (None, None, None, None); + let (mut bridge_header, mut private_network_ask) = (false, false); loop { let mut h = String::new(); reader.read_line(&mut h).ok()?; @@ -46,6 +53,10 @@ pub fn read_request(stream: &mut TcpStream) -> Option { host = Some(v.to_string()); } else if k.eq_ignore_ascii_case("x-igneum-host") { host_token = Some(v.to_string()); + } else if k.eq_ignore_ascii_case("x-igneum-bridge") { + bridge_header = true; + } else if k.eq_ignore_ascii_case("access-control-request-private-network") { + private_network_ask = v.eq_ignore_ascii_case("true"); } } } @@ -60,7 +71,7 @@ pub fn read_request(stream: &mut TcpStream) -> Option { Some((p, q)) => (p.to_string(), q.to_string()), None => (target, String::new()), }; - Some(Req { method, path, query, body, origin, sec_fetch_site, host, host_token }) + Some(Req { method, path, query, body, origin, sec_fetch_site, host, host_token, bridge_header, private_network_ask }) } /// R4.3.7: a mutating request must come from the dashboard itself. A browser sends Sec-Fetch-Site (same-origin for @@ -136,6 +147,48 @@ where Ok(port) } +/// Binds 127.0.0.1 on a FIXED port (the wallet's page bridge, 8 October 2026: a page on the web can only find the +/// wallet at a port it knows) and serves every connection on its own thread through `handle`. Err when the port is taken. +pub fn serve_on(port: u16, handle: F) -> std::io::Result<()> +where + F: Fn(TcpStream) + Send + Sync + 'static, +{ + let listener = TcpListener::bind(("127.0.0.1", port))?; + let handle = std::sync::Arc::new(handle); + std::thread::spawn(move || { + for conn in listener.incoming() { + let Ok(stream) = conn else { continue }; + let handle = handle.clone(); + std::thread::spawn(move || handle(stream)); + } + }); + Ok(()) +} + +/// A response with extra headers (the bridge's CORS and private-network answers), Connection: close, no cache. +pub fn respond_with(stream: &mut TcpStream, status: u16, ctype: &str, body: &[u8], extra: &[(&str, &str)]) { + let reason = match status { + 200 => "OK", + 204 => "No Content", + 400 => "Bad Request", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + _ => "Error", + }; + let mut head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\n", body.len()); + for (k, v) in extra { + head.push_str(k); + head.push_str(": "); + head.push_str(v); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body); + let _ = stream.flush(); +} + /// The per-launch dashboard token: 32 hex characters from the OS. pub fn new_token() -> String { let mut raw = [0u8; 16]; diff --git a/app/igneum-wallet/Cargo.lock b/app/igneum-wallet/Cargo.lock index 1bc3e1038..d596a2e69 100644 --- a/app/igneum-wallet/Cargo.lock +++ b/app/igneum-wallet/Cargo.lock @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "igneum-wallet" -version = "0.1.5" +version = "0.1.6" dependencies = [ "argon2", "bip32", diff --git a/app/igneum-wallet/Cargo.toml b/app/igneum-wallet/Cargo.toml index 6f571392b..c848d5443 100644 --- a/app/igneum-wallet/Cargo.toml +++ b/app/igneum-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-wallet" -version = "0.1.5" +version = "0.1.6" edition = "2021" description = "Igneum Wallet engine: keeps the key encrypted, signs in Rust, reads a node, verifies finality certificates itself, and serves the window on 127.0.0.1" license = "MIT" diff --git a/app/igneum-wallet/README.md b/app/igneum-wallet/README.md index ab0ed5f28..eb5a38cfe 100644 --- a/app/igneum-wallet/README.md +++ b/app/igneum-wallet/README.md @@ -13,6 +13,7 @@ packaging: `packaging/mac/build-wallet-dmg.sh`, `packaging/windows/Igneum-Wallet | 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 | | 0.1.3 | 5 Oct 2026 | the update card: one centred card over the window when a new version is ready, with what changed and one tap to install (`ui/update-card.js`) | +| 0.1.6 | 8 Oct 2026 | the page bridge (`src/bridge.rs`, `src/eip712.rs`, `site/wallet-provider.js`): websites connect through 127.0.0.1:26811 after your approval in the window; eth_requestAccounts, eth_chainId, eth_sendTransaction, personal_sign and typed data, each shown and confirmed here (Touch ID or Windows Hello when enrolled), the finality line after a transaction; Settings lists the connected sites; a page without approval gets nothing | | 0.1.4 | 5 Oct 2026 | the lock screen (`ui/lock-screen.js`): the coin on the ember glow, the name, the short address, one control; the Touch ID sheet on a tap, once by itself when the wallet opens (setting), never on an idle lock; locking is a 250 ms transition; the idle lock's minutes in Settings (1, 5, 15, 60, never) | ## The lock screen (ui/lock-screen.js, index.html #screen-unlock, app.js onLockScreen; 0.1.4) diff --git a/app/igneum-wallet/src/bridge.rs b/app/igneum-wallet/src/bridge.rs new file mode 100644 index 000000000..f9f9398a4 --- /dev/null +++ b/app/igneum-wallet/src/bridge.rs @@ -0,0 +1,491 @@ +//! The page bridge (0.1.6, 8 October 2026, main's order: igneum.network/swap connects any injected wallet, the Igneum +//! Wallet signed only inside its own window). An EIP-1193 provider for pages: the engine listens on a FIXED loopback port +//! (BRIDGE_PORT, igneum_common::http::serve_on) beside its token-guarded window server; a page's shim +//! (site/wallet-provider.js) POSTs JSON-RPC to /bridge/rpc with an Origin the browser sets and the X-Igneum-Bridge +//! header (so a preflight runs first). Nothing leaves without the person's word in the wallet's own window: +//! +//! - a site is approved once (eth_requestAccounts raises a Connect request; Approve in the window stores the origin +//! in the settings; Decline or 5 minutes of silence ends it with 4001); every other method from an origin that is +//! not approved answers 4100 and creates nothing (the known-failed test: a page without approval gets nothing); +//! - eth_sendTransaction, personal_sign and eth_signTypedData(_v4) each raise a request the window shows with the +//! origin and the facts (to, value, the call's bytes, the fee; the message; the domain and the primary type); +//! Confirm signs (through Touch ID or Windows Hello when enrolled, the same gate as a send) and the page's poll +//! reads the hash or the signature; after a transaction the window keeps the finality certificate's line; +//! - reads (eth_call, eth_estimateGas, receipts, balances, blocks, logs) go to the wallet's node for an approved +//! origin, the chain id and net version with them; wallet_switchEthereumChain accepts the wallet's own chain +//! and refuses any other with 4902. +//! +//! This module holds the pure part (what a request means, what a decision does, what the page may read); the engine +//! wires it to the vault, the node and the biometric gate (engine.rs, the "page bridge" section) and the server routes +//! it (server.rs). Tests here run without a vault. +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// The bridge's port: fixed, so a page can find the wallet (IGNEUM_WALLET_BRIDGE_PORT overrides it for tests). +pub const BRIDGE_PORT: u16 = 26811; +/// A request the window has not answered expires after this. +pub const PENDING_TTL: Duration = Duration::from_secs(300); +/// A result the page has not read is kept this long after the decision. +pub const RESULT_TTL: Duration = Duration::from_secs(120); +pub const MAX_PENDING: usize = 8; + +// EIP-1193 provider error codes +pub const E_REJECTED: i64 = 4001; +pub const E_UNAUTHORIZED: i64 = 4100; +pub const E_UNSUPPORTED: i64 = 4200; +pub const E_DISCONNECTED: i64 = 4900; +pub const E_CHAIN: i64 = 4902; +pub const E_PARAMS: i64 = -32602; +pub const E_INTERNAL: i64 = -32603; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Connect, + Send, + Sign, + Typed, +} +impl Kind { + pub fn name(&self) -> &'static str { + match self { + Kind::Connect => "connect", + Kind::Send => "send", + Kind::Sign => "sign", + Kind::Typed => "typed", + } + } +} + +/// One request waiting for the window, or decided and waiting for the page to read it. +#[derive(Clone, Debug)] +pub struct Pending { + pub id: String, + pub origin: String, + pub kind: Kind, + pub created: Instant, + pub created_unix: f64, + /// what the window shows (never the key, never the page's whole payload) + pub detail: Value, + /// what the engine acts on at Confirm (the transfer's fields, the digest to sign) + pub request: Value, + /// the biometric challenge for this request ("" when nothing is enrolled) + pub confirm_nonce: String, + pub confirm_reason: String, + pub done: Option>, + pub decided: Option, +} + +/// What the engine knows at the moment of a request. +pub struct Ask<'a> { + pub address: &'a str, + pub unlocked: bool, + pub chain_id: u64, + pub approved: bool, + pub listening: bool, +} + +/// What a request means. +#[derive(Debug, PartialEq)] +pub enum Reply { + Result(Value), + Error(i64, String), + /// the page polls igneum_poll with this id + Pending(String), + /// a read the engine forwards to its node as it is + Proxy, +} + +const PROXIED: &[&str] = &[ + "eth_call", "eth_estimateGas", "eth_blockNumber", "eth_getBalance", "eth_getTransactionReceipt", "eth_getTransactionByHash", + "eth_gasPrice", "eth_maxPriorityFeePerGas", "eth_feeHistory", "eth_getCode", "eth_getLogs", "eth_getBlockByNumber", "eth_getBlockByHash", + "eth_getTransactionCount", "eth_getStorageAt", "igneum_getTransactionStatus", +]; + +pub struct Bridge { + pub pending: Vec, + /// the last call from each origin (the Settings card's "last seen") + pub last_seen: HashMap, + seq: u64, +} + +fn hex_quantity(v: &Value) -> Option { + match v { + Value::String(s) => { + let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?; + if h.is_empty() { + return Some(0); + } + u128::from_str_radix(h, 16).ok() + } + Value::Number(n) => n.as_u64().map(|x| x as u128), + _ => None, + } +} +pub fn hex_bytes(v: &Value) -> Option> { + let s = v.as_str()?; + let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?; + if h.len() % 2 != 0 || !h.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + (0..h.len() / 2).map(|i| u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).ok()).collect() +} +fn is_address(s: &str) -> bool { + s.len() == 42 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit()) +} +/// A personal_sign message: hex bytes when it looks like hex, else the text itself. +pub fn message_bytes(v: &Value) -> Option> { + let s = v.as_str()?; + if s.starts_with("0x") && s.len() % 2 == 0 && s[2..].chars().all(|c| c.is_ascii_hexdigit()) { + hex_bytes(v) + } else { + Some(s.as_bytes().to_vec()) + } +} + +impl Default for Bridge { + fn default() -> Self { + Self::new() + } +} + +impl Bridge { + pub fn new() -> Bridge { + Bridge { pending: Vec::new(), last_seen: HashMap::new(), seq: 0 } + } + + fn new_id(&mut self) -> String { + self.seq += 1; + let mut raw = [0u8; 8]; + getrandom::getrandom(&mut raw).expect("os randomness"); + format!("{}-{}", self.seq, igneum_common::keys::hex(&raw)) + } + + /// Requests nobody answered in PENDING_TTL end with 4001; results nobody read in RESULT_TTL are dropped. Returns + /// the expired ones (the engine logs them). + pub fn expire(&mut self, now: Instant) -> Vec { + let mut gone = Vec::new(); + for p in self.pending.iter_mut() { + if p.done.is_none() && now.duration_since(p.created) > PENDING_TTL { + p.done = Some(Err((E_REJECTED, "the request expired: nobody answered it in the Igneum Wallet window".into()))); + p.decided = Some(now); + gone.push(p.clone()); + } + } + self.pending.retain(|p| !(p.done.is_some() && p.decided.map(|d| now.duration_since(d) > RESULT_TTL).unwrap_or(false))); + gone + } + + pub fn open(&self) -> Vec<&Pending> { + self.pending.iter().filter(|p| p.done.is_none()).collect() + } + + /// What the page's call means. `approved` is the engine's reading of its settings for this origin. + pub fn request(&mut self, origin: &str, method: &str, params: &Value, a: &Ask, now: Instant, now_unix: f64) -> Reply { + if origin.is_empty() { + return Reply::Error(E_UNAUTHORIZED, "a page bridge call carries its origin".into()); + } + self.last_seen.insert(origin.to_string(), now_unix); + let list = params.as_array().cloned().unwrap_or_default(); + let chain_hex = format!("0x{:x}", a.chain_id); + match method { + "igneum_poll" => { + let id = list.first().and_then(|v| v.as_str()).unwrap_or(""); + return self.poll(origin, id); + } + "eth_requestAccounts" => { + if a.approved { + return if a.unlocked { + Reply::Result(json!([a.address])) + } else { + Reply::Error(E_UNAUTHORIZED, "the Igneum Wallet is locked: unlock it in its window, then try again".into()) + }; + } + if let Some(p) = self.pending.iter().find(|p| p.done.is_none() && p.kind == Kind::Connect && p.origin == origin) { + return Reply::Pending(p.id.clone()); + } + if self.open().len() >= MAX_PENDING { + return Reply::Error(E_INTERNAL, "too many requests are waiting in the wallet window".into()); + } + let id = self.new_id(); + self.pending.push(Pending { id: id.clone(), origin: origin.into(), kind: Kind::Connect, created: now, created_unix: now_unix, detail: json!({ "origin": origin }), request: Value::Null, confirm_nonce: String::new(), confirm_reason: String::new(), done: None, decided: None }); + return Reply::Pending(id); + } + "eth_accounts" => { + return Reply::Result(if a.approved && a.unlocked { json!([a.address]) } else { json!([]) }); + } + "igneum_disconnect" => return Reply::Result(Value::Null), + _ => {} + } + if !a.approved { + return Reply::Error(E_UNAUTHORIZED, "this site is not connected to the Igneum Wallet: call eth_requestAccounts first".into()); + } + match method { + "eth_chainId" => Reply::Result(json!(chain_hex)), + "net_version" => Reply::Result(json!(a.chain_id.to_string())), + "wallet_switchEthereumChain" | "wallet_addEthereumChain" => { + let want = list.first().and_then(|v| v.get("chainId")).and_then(hex_quantity); + match want { + Some(c) if c == a.chain_id as u128 => Reply::Result(Value::Null), + Some(_) => Reply::Error(E_CHAIN, format!("the Igneum Wallet runs one chain, id {} ({chain_hex})", a.chain_id)), + None => Reply::Error(E_PARAMS, "chainId missing".into()), + } + } + "wallet_requestPermissions" | "wallet_getPermissions" => Reply::Result(json!([{ "parentCapability": "eth_accounts", "caveats": [{ "type": "restrictReturnedAccounts", "value": [a.address] }] }])), + "eth_sendTransaction" | "personal_sign" | "eth_sign" | "eth_signTypedData" | "eth_signTypedData_v3" | "eth_signTypedData_v4" => { + if !a.unlocked { + return Reply::Error(E_UNAUTHORIZED, "the Igneum Wallet is locked: unlock it in its window, then try again".into()); + } + if self.open().len() >= MAX_PENDING { + return Reply::Error(E_INTERNAL, "too many requests are waiting in the wallet window".into()); + } + let (kind, request, detail) = match method { + "eth_sendTransaction" => { + let tx = list.first().cloned().unwrap_or(Value::Null); + let from = tx.get("from").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase(); + if !from.is_empty() && from != a.address.to_ascii_lowercase() { + return Reply::Error(E_UNAUTHORIZED, "from is not this wallet's address".into()); + } + let to = tx.get("to").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase(); + if !is_address(&to) { + return Reply::Error(E_PARAMS, "to must be an address (contract creation is not offered)".into()); + } + let value = tx.get("value").map(|v| hex_quantity(v).ok_or(())).unwrap_or(Ok(0)); + let Ok(value) = value else { return Reply::Error(E_PARAMS, "value must be a hex quantity".into()) }; + let data = match tx.get("data").or_else(|| tx.get("input")) { + None | Some(Value::Null) => Vec::new(), + Some(d) => match hex_bytes(d) { + Some(b) => b, + None => return Reply::Error(E_PARAMS, "data must be 0x hex".into()), + }, + }; + if data.len() > 128 * 1024 { + return Reply::Error(E_PARAMS, "data over 128 KiB".into()); + } + let gas = tx.get("gas").or_else(|| tx.get("gasLimit")).and_then(hex_quantity).map(|g| g as u64); + let selector = if data.len() >= 4 { format!("0x{}", igneum_common::keys::hex(&data[..4])) } else { String::new() }; + (Kind::Send, json!({ "to": to, "value": value.to_string(), "data": format!("0x{}", igneum_common::keys::hex(&data)), "gas": gas }), + json!({ "origin": origin, "to": to, "to_display": igneum_common::keys::checksum(&to), "value": value.to_string(), "data_len": data.len(), "selector": selector, "gas": gas })) + } + "personal_sign" | "eth_sign" => { + // personal_sign is [message, address]; eth_sign is [address, message]: take whichever is not the address + let (m, addr) = if method == "personal_sign" { (list.first(), list.get(1)) } else { (list.get(1), list.first()) }; + if let Some(ad) = addr.and_then(|v| v.as_str()) { + if !ad.eq_ignore_ascii_case(a.address) { + return Reply::Error(E_UNAUTHORIZED, "the address is not this wallet's".into()); + } + } + let Some(bytes) = m.and_then(message_bytes) else { return Reply::Error(E_PARAMS, "message missing".into()) }; + if bytes.len() > 64 * 1024 { + return Reply::Error(E_PARAMS, "message over 64 KiB".into()); + } + let text = String::from_utf8(bytes.clone()).ok().filter(|t| !t.chars().any(|c| c.is_control() && c != '\n' && c != '\t')); + let digest = crate::tx::personal_digest(&bytes); + (Kind::Sign, json!({ "digest": format!("0x{}", igneum_common::keys::hex(&digest)) }), + json!({ "origin": origin, "text": text, "bytes": bytes.len(), "hex": if text.is_none() { format!("0x{}", igneum_common::keys::hex(&bytes[..bytes.len().min(64)])) } else { String::new() } })) + } + _ => { + // eth_signTypedData(_v3, _v4): [address, typed data as an object or a JSON string] + if let Some(ad) = list.first().and_then(|v| v.as_str()) { + if !ad.eq_ignore_ascii_case(a.address) { + return Reply::Error(E_UNAUTHORIZED, "the address is not this wallet's".into()); + } + } + let typed = match list.get(1) { + Some(Value::String(s)) => match serde_json::from_str::(s) { + Ok(v) => v, + Err(e) => return Reply::Error(E_PARAMS, format!("typed data is not JSON: {e}")), + }, + Some(v) if v.is_object() => v.clone(), + _ => return Reply::Error(E_PARAMS, "typed data missing".into()), + }; + let t = match crate::eip712::hash(&typed) { + Ok(t) => t, + Err(e) => return Reply::Error(E_PARAMS, format!("typed data: {e}")), + }; + if let Some(c) = t.chain_id { + if c != a.chain_id as u128 { + return Reply::Error(E_CHAIN, format!("the typed data names chain {c}; this wallet is on {}", a.chain_id)); + } + } + let message = typed.get("message").cloned().unwrap_or(Value::Null); + let shown = serde_json::to_string_pretty(&message).unwrap_or_default(); + (Kind::Typed, json!({ "digest": format!("0x{}", igneum_common::keys::hex(&t.digest)) }), + json!({ "origin": origin, "domain": t.domain_name, "primary_type": t.primary_type, "message": if shown.len() > 4000 { format!("{}\n…", &shown[..4000]) } else { shown } })) + } + }; + let id = self.new_id(); + self.pending.push(Pending { id: id.clone(), origin: origin.into(), kind, created: now, created_unix: now_unix, detail, request, confirm_nonce: String::new(), confirm_reason: String::new(), done: None, decided: None }); + Reply::Pending(id) + } + m if PROXIED.contains(&m) => Reply::Proxy, + _ => Reply::Error(E_UNSUPPORTED, format!("{method} is not offered by the Igneum Wallet")), + } + } + + /// The page reads a request's outcome: pending, the result, or the error. Only the origin that made it may read it. + pub fn poll(&mut self, origin: &str, id: &str) -> Reply { + let Some(p) = self.pending.iter().find(|p| p.id == id) else { return Reply::Error(E_INTERNAL, "unknown request".into()) }; + if p.origin != origin { + return Reply::Error(E_UNAUTHORIZED, "not your request".into()); + } + match &p.done { + None => Reply::Pending(id.into()), + Some(Ok(v)) => { + let v = v.clone(); + self.pending.retain(|q| q.id != id); + Reply::Result(v) + } + Some(Err((c, m))) => { + let (c, m) = (*c, m.clone()); + self.pending.retain(|q| q.id != id); + Reply::Error(c, m) + } + } + } + + /// The window's word. Decline ends the request with 4001; Approve hands the request back to the engine, which + /// signs or connects and then `resolve`s it. + pub fn decide(&mut self, id: &str, ok: bool, now: Instant) -> Result { + let p = self.pending.iter_mut().find(|p| p.id == id && p.done.is_none()).ok_or("no such request is waiting")?; + if !ok { + p.done = Some(Err((E_REJECTED, "the person declined in the Igneum Wallet".into()))); + p.decided = Some(now); + } + Ok(p.clone()) + } + + pub fn resolve(&mut self, id: &str, r: Result, now: Instant) { + if let Some(p) = self.pending.iter_mut().find(|p| p.id == id) { + p.done = Some(r); + p.decided = Some(now); + } + } + + pub fn set_challenge(&mut self, id: &str, nonce: &str, reason: &str) { + if let Some(p) = self.pending.iter_mut().find(|p| p.id == id) { + p.confirm_nonce = nonce.into(); + p.confirm_reason = reason.into(); + } + } + + /// The open requests as the window shows them (the oldest first). + pub fn pending_json(&self, enrolled: bool) -> Value { + Value::Array(self.pending.iter().filter(|p| p.done.is_none()).map(|p| { + let mut d = p.detail.clone(); + d["id"] = json!(p.id); + d["kind"] = json!(p.kind.name()); + d["created_at"] = json!(p.created_unix); + d["confirm_needed"] = json!(enrolled && p.kind != Kind::Connect); + d["confirm_nonce"] = json!(p.confirm_nonce); + d["confirm_reason"] = json!(p.confirm_reason); + d + }).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + const ME: &str = "0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826"; + fn ask(approved: bool, unlocked: bool) -> Ask<'static> { + Ask { address: ME, unlocked, chain_id: 4463, approved, listening: true } + } + fn tx() -> Value { + json!([{ "from": ME, "to": "0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7", "value": "0xde0b6b3a7640000", "data": "0x38ed17390000000000000000000000000000000000000000000000000000000000000000" }]) + } + + /// The known-failed shape of 0.1.5: a page could reach nothing at all; with the bridge, a page without approval + /// must still get nothing but the connect question. + #[test] + fn a_page_without_approval_gets_nothing() { + let mut b = Bridge::new(); + let now = Instant::now(); + for (m, p) in [("eth_chainId", json!([])), ("eth_sendTransaction", tx()), ("personal_sign", json!(["hello", ME])), ("eth_call", json!([{}, "latest"])), ("eth_signTypedData_v4", json!([ME, "{}"]))] { + match b.request("https://igneum.network", m, &p, &ask(false, true), now, 1.0) { + Reply::Error(c, _) => assert_eq!(c, E_UNAUTHORIZED, "{m}"), + other => panic!("{m} answered {other:?}"), + } + } + assert_eq!(b.request("https://igneum.network", "eth_accounts", &json!([]), &ask(false, true), now, 1.0), Reply::Result(json!([]))); + assert!(b.open().is_empty(), "no request was created for an unapproved page"); + assert_eq!(b.request("", "eth_requestAccounts", &json!([]), &ask(false, true), now, 1.0), Reply::Error(E_UNAUTHORIZED, "a page bridge call carries its origin".into())); + } + + #[test] + fn a_connect_request_waits_for_the_windows_word_and_only_its_origin_reads_it() { + let mut b = Bridge::new(); + let now = Instant::now(); + let Reply::Pending(id) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), now, 1.0) else { panic!() }; + assert_eq!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), now, 2.0), Reply::Pending(id.clone()), "the same page asking again gets the same request"); + assert_eq!(b.poll("https://igneum.network", &id), Reply::Pending(id.clone())); + assert_eq!(b.poll("https://evil.example", &id), Reply::Error(E_UNAUTHORIZED, "not your request".into())); + let p = b.decide(&id, true, now).unwrap(); + assert_eq!(p.kind, Kind::Connect); + b.resolve(&id, Ok(json!([ME])), now); + assert_eq!(b.poll("https://igneum.network", &id), Reply::Result(json!([ME]))); + assert_eq!(b.poll("https://igneum.network", &id), Reply::Error(E_INTERNAL, "unknown request".into()), "read once"); + // declined + let Reply::Pending(id2) = b.request("https://other.example", "eth_requestAccounts", &json!([]), &ask(false, true), now, 3.0) else { panic!() }; + b.decide(&id2, false, now).unwrap(); + assert!(matches!(b.poll("https://other.example", &id2), Reply::Error(E_REJECTED, _))); + // once approved: the address, the chain, the reads proxied, another chain refused + assert_eq!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(true, true), now, 4.0), Reply::Result(json!([ME]))); + assert_eq!(b.request("https://igneum.network", "eth_chainId", &json!([]), &ask(true, true), now, 4.0), Reply::Result(json!("0x116f"))); + assert_eq!(b.request("https://igneum.network", "eth_call", &json!([{}, "latest"]), &ask(true, true), now, 4.0), Reply::Proxy); + assert_eq!(b.request("https://igneum.network", "wallet_switchEthereumChain", &json!([{ "chainId": "0x116f" }]), &ask(true, true), now, 4.0), Reply::Result(Value::Null)); + assert!(matches!(b.request("https://igneum.network", "wallet_switchEthereumChain", &json!([{ "chainId": "0x1" }]), &ask(true, true), now, 4.0), Reply::Error(E_CHAIN, _))); + assert!(matches!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(true, false), now, 4.0), Reply::Error(E_UNAUTHORIZED, _)), "locked: no address"); + } + + #[test] + fn a_send_a_message_and_typed_data_each_wait_as_their_own_request_with_the_facts_the_window_shows() { + let mut b = Bridge::new(); + let now = Instant::now(); + let Reply::Pending(id) = b.request("https://igneum.network", "eth_sendTransaction", &tx(), &ask(true, true), now, 1.0) else { panic!() }; + let p = b.pending.iter().find(|p| p.id == id).unwrap().clone(); + assert_eq!(p.kind, Kind::Send); + assert_eq!(p.detail["to_display"], json!("0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7")); + assert_eq!(p.detail["value"], json!("1000000000000000000")); + assert_eq!(p.detail["selector"], json!("0x38ed1739")); + assert_eq!(p.detail["data_len"], json!(36)); + assert_eq!(p.request["gas"], Value::Null); + assert!(matches!(b.request("https://igneum.network", "eth_sendTransaction", &json!([{ "from": "0x1111111111111111111111111111111111111111", "to": ME }]), &ask(true, true), now, 1.0), Reply::Error(E_UNAUTHORIZED, _))); + assert!(matches!(b.request("https://igneum.network", "eth_sendTransaction", &json!([{ "from": ME }]), &ask(true, true), now, 1.0), Reply::Error(E_PARAMS, _))); + let Reply::Pending(id2) = b.request("https://igneum.network", "personal_sign", &json!(["0x68656c6c6f", ME]), &ask(true, true), now, 1.0) else { panic!() }; + let p2 = b.pending.iter().find(|p| p.id == id2).unwrap().clone(); + assert_eq!(p2.kind, Kind::Sign); + assert_eq!(p2.detail["text"], json!("hello")); + assert_eq!(p2.request["digest"].as_str().unwrap().len(), 66); + let typed = json!({ "types": { "Person": [{ "name": "name", "type": "string" }] }, "primaryType": "Person", "domain": { "name": "Igneum Swap", "chainId": 4463 }, "message": { "name": "Cow" } }); + let Reply::Pending(id3) = b.request("https://igneum.network", "eth_signTypedData_v4", &json!([ME, typed.to_string()]), &ask(true, true), now, 1.0) else { panic!() }; + let p3 = b.pending.iter().find(|p| p.id == id3).unwrap().clone(); + assert_eq!(p3.kind, Kind::Typed); + assert_eq!(p3.detail["domain"], json!("Igneum Swap")); + assert_eq!(p3.detail["primary_type"], json!("Person")); + let other_chain = json!({ "types": { "Person": [{ "name": "name", "type": "string" }] }, "primaryType": "Person", "domain": { "chainId": 1 }, "message": { "name": "Cow" } }); + assert!(matches!(b.request("https://igneum.network", "eth_signTypedData_v4", &json!([ME, other_chain]), &ask(true, true), now, 1.0), Reply::Error(E_CHAIN, _))); + assert_eq!(b.pending_json(true).as_array().unwrap().len(), 3); + assert_eq!(b.pending_json(true)[0]["confirm_needed"], json!(true)); + // the window's confirm, the engine's resolve, the page's read + b.decide(&id2, true, now).unwrap(); + b.resolve(&id2, Ok(json!("0xsig")), now); + assert_eq!(b.poll("https://igneum.network", &id2), Reply::Result(json!("0xsig"))); + assert_eq!(b.open().len(), 2); + } + + #[test] + fn an_unanswered_request_expires_and_a_read_result_is_dropped() { + let mut b = Bridge::new(); + let t0 = Instant::now(); + let Reply::Pending(id) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), t0, 1.0) else { panic!() }; + assert!(b.expire(t0 + PENDING_TTL / 2).is_empty()); + let gone = b.expire(t0 + PENDING_TTL + Duration::from_secs(1)); + assert_eq!(gone.len(), 1); + assert!(matches!(b.poll("https://igneum.network", &id), Reply::Error(E_REJECTED, m) if m.contains("expired"))); + let Reply::Pending(id2) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), t0, 2.0) else { panic!() }; + b.decide(&id2, false, t0).unwrap(); + b.expire(t0 + RESULT_TTL + Duration::from_secs(1)); + assert!(matches!(b.poll("https://igneum.network", &id2), Reply::Error(E_INTERNAL, _)), "an unread result is gone after RESULT_TTL"); + } +} diff --git a/app/igneum-wallet/src/eip712.rs b/app/igneum-wallet/src/eip712.rs new file mode 100644 index 000000000..9ae99644f --- /dev/null +++ b/app/igneum-wallet/src/eip712.rs @@ -0,0 +1,334 @@ +//! EIP-712 typed data hashing for the page bridge (8 October 2026): the digest a page asks the wallet to sign with +//! eth_signTypedData_v4, computed here so the window can show the domain and the primary type and the key never +//! leaves the engine. Supports the atomic types (uintN, intN, bytesN, bool, address), the dynamic ones (string, +//! bytes), arrays (fixed and dynamic) and nested structs; the domain's fields are the ones present in `domain` +//! (name, version, chainId, verifyingContract, salt) when `types` carries no EIP712Domain of its own. +use serde_json::Value; +use sha3::{Digest, Keccak256}; + +pub struct Typed { + pub digest: [u8; 32], + pub domain_name: String, + pub primary_type: String, + pub chain_id: Option, +} + +fn keccak(b: &[u8]) -> [u8; 32] { + Keccak256::digest(b).into() +} + +/// A 256-bit unsigned number from a JSON number, a decimal string or a 0x hex string, big-endian in 32 bytes. +pub fn u256_bytes(v: &Value) -> Result<[u8; 32], String> { + let mut out = [0u8; 32]; + match v { + Value::Number(n) => { + let x = n.as_u64().ok_or("a number must be a whole non-negative number")?; + out[24..].copy_from_slice(&x.to_be_bytes()); + Ok(out) + } + Value::String(s) => { + let s = s.trim(); + if let Some(h) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + if h.is_empty() || h.len() > 64 || !h.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("not a 256-bit hex number: {s}")); + } + let padded = format!("{:0>64}", h); + for i in 0..32 { + out[i] = u8::from_str_radix(&padded[i * 2..i * 2 + 2], 16).map_err(|e| e.to_string())?; + } + Ok(out) + } else { + if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()) { + return Err(format!("not a decimal number: {s}")); + } + // schoolbook base-10 into 32 big-endian bytes + for c in s.bytes() { + let mut carry = (c - b'0') as u32; + for i in (0..32).rev() { + let x = out[i] as u32 * 10 + carry; + out[i] = (x & 0xff) as u8; + carry = x >> 8; + } + if carry != 0 { + return Err("number over 256 bits".into()); + } + } + Ok(out) + } + } + _ => Err("not a number".into()), + } +} + +fn i256_bytes(v: &Value) -> Result<[u8; 32], String> { + let neg = match v { + Value::Number(n) => n.as_i64().map(|x| x < 0).unwrap_or(false), + Value::String(s) => s.trim().starts_with('-'), + _ => false, + }; + if !neg { + return u256_bytes(v); + } + let mag = match v { + Value::Number(n) => Value::String((n.as_i64().unwrap().unsigned_abs()).to_string()), + Value::String(s) => Value::String(s.trim()[1..].to_string()), + _ => unreachable!(), + }; + let m = u256_bytes(&mag)?; + // two's complement: invert and add one + let mut out = [0u8; 32]; + let mut carry = 1u16; + for i in (0..32).rev() { + let x = (!m[i]) as u16 + carry; + out[i] = (x & 0xff) as u8; + carry = x >> 8; + } + Ok(out) +} + +fn hex_bytes(s: &str) -> Result, String> { + let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).ok_or("bytes must be 0x hex")?; + if h.len() % 2 != 0 || !h.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("bytes must be even-length hex".into()); + } + (0..h.len() / 2).map(|i| u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|e| e.to_string())).collect() +} + +fn is_struct(types: &Value, t: &str) -> bool { + types.get(t).map(|v| v.is_array()).unwrap_or(false) +} + +fn base_type(t: &str) -> &str { + match t.find('[') { + Some(i) => &t[..i], + None => t, + } +} + +/// The struct types `t` depends on, `t` first, the rest sorted by name (EIP-712 encodeType). +fn dependencies(types: &Value, t: &str) -> Result, String> { + let mut found: Vec = Vec::new(); + let mut todo = vec![t.to_string()]; + while let Some(cur) = todo.pop() { + if found.contains(&cur) { + continue; + } + let fields = types.get(&cur).and_then(|v| v.as_array()).ok_or(format!("unknown type {cur}"))?; + found.push(cur.clone()); + for f in fields { + let ft = f.get("type").and_then(|v| v.as_str()).ok_or("a field without a type")?; + let b = base_type(ft); + if is_struct(types, b) && !found.contains(&b.to_string()) { + todo.push(b.to_string()); + } + } + } + let mut rest: Vec = found.iter().skip(1).cloned().collect(); + rest.sort(); + let mut out = vec![found[0].clone()]; + out.extend(rest); + Ok(out) +} + +pub fn encode_type(types: &Value, t: &str) -> Result { + let mut s = String::new(); + for name in dependencies(types, t)? { + let fields = types.get(&name).and_then(|v| v.as_array()).ok_or("type")?; + s.push_str(&name); + s.push('('); + let mut first = true; + for f in fields { + if !first { + s.push(','); + } + first = false; + s.push_str(f.get("type").and_then(|v| v.as_str()).ok_or("field type")?); + s.push(' '); + s.push_str(f.get("name").and_then(|v| v.as_str()).ok_or("field name")?); + } + s.push(')'); + } + Ok(s) +} + +pub fn type_hash(types: &Value, t: &str) -> Result<[u8; 32], String> { + Ok(keccak(encode_type(types, t)?.as_bytes())) +} + +fn encode_value(types: &Value, t: &str, v: &Value) -> Result<[u8; 32], String> { + if let Some(i) = t.find('[') { + // an array: keccak of the concatenated encodings of its elements + let inner = &t[..i]; + let rest = &t[i + 1..]; + let items = v.as_array().ok_or(format!("{t}: not an array"))?; + if let Some(n) = rest.strip_suffix(']').and_then(|n| if n.is_empty() { None } else { n.parse::().ok() }) { + if items.len() != n { + return Err(format!("{t}: {} items, {n} expected", items.len())); + } + } + let tail = &rest[rest.find(']').map(|j| j + 1).unwrap_or(rest.len())..]; + let elem_type = format!("{inner}{tail}"); + let mut cat = Vec::new(); + for it in items { + cat.extend_from_slice(&encode_value(types, &elem_type, it)?); + } + return Ok(keccak(&cat)); + } + if is_struct(types, t) { + return hash_struct(types, t, v); + } + match t { + "string" => Ok(keccak(v.as_str().ok_or("string expected")?.as_bytes())), + "bytes" => Ok(keccak(&hex_bytes(v.as_str().ok_or("bytes expected")?)?)), + "bool" => { + let mut out = [0u8; 32]; + out[31] = if v.as_bool().ok_or("bool expected")? { 1 } else { 0 }; + Ok(out) + } + "address" => { + let b = hex_bytes(v.as_str().ok_or("address expected")?)?; + if b.len() != 20 { + return Err("an address is 20 bytes".into()); + } + let mut out = [0u8; 32]; + out[12..].copy_from_slice(&b); + Ok(out) + } + _ if t.starts_with("uint") => { + let bits: usize = t[4..].parse().map_err(|_| format!("bad type {t}"))?; + if bits == 0 || bits > 256 || bits % 8 != 0 { + return Err(format!("bad type {t}")); + } + u256_bytes(v) + } + _ if t.starts_with("int") => { + let bits: usize = t[3..].parse().map_err(|_| format!("bad type {t}"))?; + if bits == 0 || bits > 256 || bits % 8 != 0 { + return Err(format!("bad type {t}")); + } + i256_bytes(v) + } + _ if t.starts_with("bytes") => { + let n: usize = t[5..].parse().map_err(|_| format!("bad type {t}"))?; + if n == 0 || n > 32 { + return Err(format!("bad type {t}")); + } + let b = hex_bytes(v.as_str().ok_or("bytes expected")?)?; + if b.len() != n { + return Err(format!("{t}: {} bytes given", b.len())); + } + let mut out = [0u8; 32]; + out[..n].copy_from_slice(&b); + Ok(out) + } + _ => Err(format!("unsupported type {t}")), + } +} + +pub fn hash_struct(types: &Value, t: &str, v: &Value) -> Result<[u8; 32], String> { + let fields = types.get(t).and_then(|x| x.as_array()).ok_or(format!("unknown type {t}"))?; + let obj = v.as_object().ok_or(format!("{t}: an object expected"))?; + let mut enc = Vec::new(); + enc.extend_from_slice(&type_hash(types, t)?); + for f in fields { + let name = f.get("name").and_then(|x| x.as_str()).ok_or("field name")?; + let ft = f.get("type").and_then(|x| x.as_str()).ok_or("field type")?; + let fv = obj.get(name).ok_or(format!("{t}.{name} is missing"))?; + enc.extend_from_slice(&encode_value(types, ft, fv)?); + } + Ok(keccak(&enc)) +} + +/// The domain type when `types` has none: the fields present in `domain`, in the canonical order. +fn domain_types(domain: &Value) -> Value { + let order = [("name", "string"), ("version", "string"), ("chainId", "uint256"), ("verifyingContract", "address"), ("salt", "bytes32")]; + let mut v = Vec::new(); + for (n, t) in order { + if domain.get(n).is_some() { + v.push(serde_json::json!({ "name": n, "type": t })); + } + } + Value::Array(v) +} + +/// The EIP-712 digest of a typed-data object ({types, primaryType, domain, message}). +pub fn hash(typed: &Value) -> Result { + let mut types = typed.get("types").cloned().ok_or("typed data without types")?; + let domain = typed.get("domain").cloned().unwrap_or(Value::Object(Default::default())); + if !types.get("EIP712Domain").map(|v| v.is_array()).unwrap_or(false) { + types["EIP712Domain"] = domain_types(&domain); + } + let primary = typed.get("primaryType").and_then(|v| v.as_str()).ok_or("typed data without primaryType")?.to_string(); + let message = typed.get("message").cloned().ok_or("typed data without message")?; + let ds = hash_struct(&types, "EIP712Domain", &domain)?; + let ms = if primary == "EIP712Domain" { None } else { Some(hash_struct(&types, &primary, &message)?) }; + let mut pre = vec![0x19, 0x01]; + pre.extend_from_slice(&ds); + if let Some(m) = ms { + pre.extend_from_slice(&m); + } + let chain_id = domain.get("chainId").and_then(|v| u256_bytes(v).ok()).map(|b| { + let mut x = 0u128; + for byte in &b[16..] { + x = (x << 8) | *byte as u128; + } + x + }); + Ok(Typed { digest: keccak(&pre), domain_name: domain.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(), primary_type: primary, chain_id }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// The specification's own example (EIP-712, "Mail" from Cow to Bob): the three known hashes. + fn mail() -> Value { + json!({ + "types": { + "EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"}, {"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}], + "Person": [{"name": "name", "type": "string"}, {"name": "wallet", "type": "address"}], + "Mail": [{"name": "from", "type": "Person"}, {"name": "to", "type": "Person"}, {"name": "contents", "type": "string"}] + }, + "primaryType": "Mail", + "domain": {"name": "Ether Mail", "version": "1", "chainId": 1, "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"}, + "message": {"from": {"name": "Cow", "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"}, "to": {"name": "Bob", "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"}, "contents": "Hello, Bob!"} + }) + } + fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + #[test] + fn the_specifications_mail_example_hashes_as_published() { + let m = mail(); + assert_eq!(encode_type(&m["types"], "Mail").unwrap(), "Mail(Person from,Person to,string contents)Person(string name,address wallet)"); + assert_eq!(hex(&hash_struct(&m["types"], "EIP712Domain", &m["domain"]).unwrap()), "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f"); // no-secrets-ok: the EIP-712 specification's published example hash + assert_eq!(hex(&hash_struct(&m["types"], "Mail", &m["message"]).unwrap()), "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e"); // no-secrets-ok: the EIP-712 specification's published example hash + let t = hash(&m).unwrap(); + assert_eq!(hex(&t.digest), "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"); // no-secrets-ok: the EIP-712 specification's published example digest + assert_eq!(t.domain_name, "Ether Mail"); + assert_eq!(t.primary_type, "Mail"); + assert_eq!(t.chain_id, Some(1)); + } + #[test] + fn a_domain_without_declared_types_takes_the_fields_present_in_order() { + let mut m = mail(); + m["types"].as_object_mut().unwrap().remove("EIP712Domain"); + assert_eq!(hex(&hash(&m).unwrap().digest), "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"); // no-secrets-ok: the EIP-712 specification's published example digest + } + #[test] + fn numbers_arrays_and_bytes_encode() { + assert_eq!(hex(&u256_bytes(&json!("115792089237316195423570985008687907853269984665640564039457584007913129639935")).unwrap()), "f".repeat(64)); + assert_eq!(hex(&u256_bytes(&json!("0x1f")).unwrap()), format!("{:0>64}", "1f")); + assert_eq!(hex(&u256_bytes(&json!(31)).unwrap()), format!("{:0>64}", "1f")); + assert_eq!(hex(&i256_bytes(&json!(-1)).unwrap()), "f".repeat(64)); + assert!(u256_bytes(&json!("1157920892373161954235709850086879078532699846656405640394575840079131296399350")).is_err()); + let types = json!({ "T": [{"name": "xs", "type": "uint8[]"}, {"name": "b", "type": "bytes4"}, {"name": "ok", "type": "bool"}] }); + let one = hash_struct(&types, "T", &json!({ "xs": [1, 2], "b": "0x01020304", "ok": true })).unwrap(); + let two = hash_struct(&types, "T", &json!({ "xs": [2, 1], "b": "0x01020304", "ok": true })).unwrap(); + assert_ne!(one, two); + assert!(hash_struct(&types, "T", &json!({ "xs": [1], "b": "0x0102", "ok": true })).is_err(), "bytes4 with two bytes is refused"); + assert!(hash_struct(&types, "T", &json!({ "b": "0x01020304", "ok": true })).is_err(), "a missing field is refused"); + } +} diff --git a/app/igneum-wallet/src/engine.rs b/app/igneum-wallet/src/engine.rs index f5f586dc5..9a1c7474a 100644 --- a/app/igneum-wallet/src/engine.rs +++ b/app/igneum-wallet/src/engine.rs @@ -40,6 +40,17 @@ pub struct Settings { /// the first arrival after the person opened the app may raise the Touch ID sheet once, by itself #[serde(default = "yes")] pub ask_on_open: bool, + /// the page bridge (0.1.6): websites may connect through the loopback port, each after its own approval + #[serde(default = "yes")] + pub bridge_on: bool, + /// the sites the person approved in the window (origin, unix s) + #[serde(default)] + pub sites: Vec, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct Site { + pub origin: String, + pub approved_at: f64, } fn yes() -> bool { true @@ -48,7 +59,7 @@ fn yes() -> bool { pub const IDLE_CHOICES: [u64; 5] = [1, 5, 15, 60, 0]; impl Default for Settings { fn default() -> Settings { - Settings { auto_update: true, display_name: String::new(), idle_lock: true, idle_lock_min: Some(biometric::IDLE_LOCK_MIN), ask_on_open: true } + Settings { auto_update: true, display_name: String::new(), idle_lock: true, idle_lock_min: Some(biometric::IDLE_LOCK_MIN), ask_on_open: true, bridge_on: true, sites: Vec::new() } } } impl Settings { @@ -147,6 +158,10 @@ pub struct Shared { gate: Mutex, /// the last time the window reported a person at it (the idle lock) last_activity: Mutex, + /// the page bridge (src/bridge.rs): the requests pages made and the window has to answer + pub bridge: Mutex, + /// whether the bridge listener is up, and why not + pub bridge_listening: Mutex<(bool, u16, String)>, } /// Counts one /api/send from entry to exit, whatever the outcome. @@ -211,6 +226,8 @@ impl Shared { host_token, gate: Mutex::new(Gate::new()), last_activity: Mutex::new(Instant::now()), + bridge: Mutex::new(crate::bridge::Bridge::new()), + bridge_listening: Mutex::new((false, crate::bridge::BRIDGE_PORT, String::from("not started"))), } } @@ -265,7 +282,261 @@ impl Shared { st.history = h.entries.clone(); st.scanned_to = h.scanned_to; } - serde_json::to_value(st).unwrap_or(json!({})) + let mut v = serde_json::to_value(st).unwrap_or(json!({})); + v["bridge"] = self.bridge_json(); + v + } + /// The page bridge as the window reads it: the listener, the open requests, the approved sites. + pub fn bridge_json(&self) -> Value { + let (listening, port, why) = self.bridge_listening.lock().unwrap().clone(); + let settings = self.settings.lock().unwrap(); + let b = self.bridge.lock().unwrap(); + let sites: Vec = settings.sites.iter().map(|x| json!({ "origin": x.origin, "approved_at": x.approved_at, "last_seen": b.last_seen.get(&x.origin).copied() })).collect(); + json!({ "on": settings.bridge_on, "listening": listening, "port": port, "reason": why, "pending": b.pending_json(self.enrolled()), "sites": sites }) + } + pub fn site_approved(&self, origin: &str) -> bool { + let settings = self.settings.lock().unwrap(); + settings.bridge_on && settings.sites.iter().any(|x| x.origin == origin) + } + fn chain_id_now(&self) -> u64 { + let st = self.state.lock().unwrap(); + if st.node.chain_id > 0 { st.node.chain_id } else { self.evm.lock().unwrap().as_ref().and_then(|e| e.chain_id().ok()).unwrap_or(0) } + } + + // ---- the page bridge (src/bridge.rs): a page's call, the window's decision -------------------------------- + + /// A page's JSON-RPC call: {result} | {error: {code, message}} | {pending: id}. + pub fn bridge_request(&self, origin: &str, method: &str, params: &Value) -> Value { + use crate::bridge::{Kind, Reply}; + let now = Instant::now(); + let now_unix = igneum_common::platform::unix_now_f(); + let enrolled = self.enrolled(); + let on = self.settings.lock().unwrap().bridge_on; + if !on { + return json!({ "error": { "code": crate::bridge::E_DISCONNECTED, "message": "websites are switched off in the Igneum Wallet's settings" } }); + } + let address = self.address(); + let ask = crate::bridge::Ask { address: &address, unlocked: self.unlocked(), chain_id: self.chain_id_now(), approved: self.site_approved(origin), listening: true }; + let reply = self.bridge.lock().unwrap().request(origin, method, params, &ask, now, now_unix); + match reply { + Reply::Result(v) => json!({ "result": v }), + Reply::Error(c, m) => json!({ "error": { "code": c, "message": m } }), + Reply::Proxy => { + match self.evm().and_then(|e| e.call(method, params.clone())) { + Ok(v) => json!({ "result": v }), + Err(e) => json!({ "error": { "code": crate::bridge::E_INTERNAL, "message": e } }), + } + } + Reply::Pending(id) => { + // a send needs its price now, so the window shows the fee and the page learns of a refusal at once + let p = self.bridge.lock().unwrap().pending.iter().find(|p| p.id == id).cloned(); + if let Some(p) = p { + if p.kind == Kind::Send && p.done.is_none() { + match self.bridge_price(&p) { + Ok((request, detail, reason, bound)) => { + let mut b = self.bridge.lock().unwrap(); + if let Some(q) = b.pending.iter_mut().find(|q| q.id == id) { + q.request = request; + q.detail = detail; + } + drop(b); + if enrolled { + let nonce = self.gate.lock().unwrap().issue("bridge", &bound, &reason, Instant::now()); + self.bridge.lock().unwrap().set_challenge(&id, &nonce, &reason); + } + } + Err(e) => { + self.bridge.lock().unwrap().resolve(&id, Err((crate::bridge::E_INTERNAL, e)), Instant::now()); + } + } + } else if (p.kind == Kind::Sign || p.kind == Kind::Typed) && enrolled { + let reason = biometric::clip_reason(&format!("Sign for {}", origin.trim_start_matches("https://").trim_start_matches("http://"))); + let nonce = self.gate.lock().unwrap().issue("bridge", &id, &reason, Instant::now()); + self.bridge.lock().unwrap().set_challenge(&id, &nonce, &reason); + } + if p.kind == Kind::Connect { + self.event("info", &format!("{} asks to connect: answer it in the wallet window", origin)); + } else { + self.event("info", &format!("{} asks you to {}: answer it in the wallet window", origin, match p.kind { Kind::Send => "send a transaction", Kind::Sign => "sign a message", Kind::Typed => "sign typed data", Kind::Connect => "connect" })); + } + } + json!({ "pending": id }) + } + } + } + + /// Prices a page's transaction: the nonce, the fees, the gas (the page's or the node's estimate) and the balance + /// check, as a quote does. Returns the request the engine signs at Confirm, the detail the window shows, the + /// biometric prompt's line and its binding. + fn bridge_price(&self, p: &crate::bridge::Pending) -> Result<(Value, Value, String, String), String> { + let evm = self.evm()?; + let me = self.address(); + let to = p.request["to"].as_str().unwrap_or("").to_string(); + let value: u128 = p.request["value"].as_str().unwrap_or("0").parse().map_err(|_| "value")?; + let data = p.request["data"].as_str().unwrap_or("0x").to_string(); + let chain_id = evm.chain_id()?; + let nonce = evm.nonce(&me)?; + let (base, tip) = evm.fees()?; + let gas = match p.request["gas"].as_u64() { + Some(g) if g >= 21_000 => g, + _ => evm.estimate_gas_call(&me, &to, value, &data).map_err(|e| format!("the node refused to estimate the gas: {e}"))?.saturating_mul(12) / 10, + }; + let max_fee = base.saturating_mul(2).saturating_add(tip).max(1); + let fee_max = (gas as u128).saturating_mul(max_fee); + let total = value.checked_add(fee_max).ok_or("amount too large")?; + let balance = evm.balance(&me)?; + if total > balance { + return Err(format!("not enough IGN: {} needed with the fee, {} in the wallet", crate::evm::ign(total, 6), crate::evm::ign(balance, 6))); + } + let request = json!({ "to": to, "value": value.to_string(), "data": data, "gas": gas, "max_fee": max_fee.to_string(), "tip": tip.to_string(), "chain_id": chain_id, "nonce": nonce }); + let mut detail = p.detail.clone(); + detail["gas"] = json!(gas); + detail["fee_max"] = json!(fee_max.to_string()); + detail["fee_max_ign"] = json!(crate::evm::ign(fee_max, 9)); + detail["value_ign"] = json!(crate::evm::ign(value, 18)); + detail["total_max_ign"] = json!(crate::evm::ign(total, 9)); + let reason = biometric::send_reason(&crate::evm::ign(value, 4), &keys::checksum(&to)); + let bound = biometric::send_binding(&to, &value.to_string(), nonce, chain_id); + Ok((request, detail, reason, bound)) + } + + /// The window's decision on a page's request. Approve: a connect stores the site and answers the address; a + /// send signs and sends the priced transaction (the biometric nonce taken first when enrolled); a message or + /// typed data is signed. The page reads the outcome through its poll; the window gets it here too. + pub fn bridge_decide(&self, id: &str, ok: bool, nonce: Option<&str>) -> Result { + use crate::bridge::Kind; + let now = Instant::now(); + let p = self.bridge.lock().unwrap().decide(id, ok, now)?; + if !ok { + self.event("info", &format!("{} declined for {}", match p.kind { Kind::Connect => "connection", Kind::Send => "transaction", Kind::Sign => "message", Kind::Typed => "typed data" }, p.origin)); + return Ok(json!({ "ok": true, "declined": true })); + } + self.touch_activity(); + let outcome: Result = match p.kind { + Kind::Connect => { + let mut settings = self.settings.lock().unwrap(); + if !settings.sites.iter().any(|x| x.origin == p.origin) { + settings.sites.push(Site { origin: p.origin.clone(), approved_at: igneum_common::platform::unix_now_f() }); + settings.save(&self.paths.settings); + } + drop(settings); + self.event("ok", &format!("{} connected", p.origin)); + if self.unlocked() { Ok(json!([self.address()])) } else { Err((crate::bridge::E_UNAUTHORIZED, "the wallet is locked".into())) } + } + Kind::Send => { + if self.enrolled() { + let bound = biometric::send_binding(p.request["to"].as_str().unwrap_or(""), p.request["value"].as_str().unwrap_or(""), p.request["nonce"].as_u64().unwrap_or(0), p.request["chain_id"].as_u64().unwrap_or(0)); + if let Err(e) = self.take_nonce(nonce.unwrap_or(""), "bridge", &bound) { + self.bridge.lock().unwrap().resolve(id, Err((crate::bridge::E_REJECTED, format!("refused: {e}"))), now); + return Err(format!("refused: {e}")); + } + } + self.bridge_send(&p).map_err(|e| (crate::bridge::E_INTERNAL, e)) + } + Kind::Sign | Kind::Typed => { + if self.enrolled() { + if let Err(e) = self.take_nonce(nonce.unwrap_or(""), "bridge", &p.id) { + self.bridge.lock().unwrap().resolve(id, Err((crate::bridge::E_REJECTED, format!("refused: {e}"))), now); + return Err(format!("refused: {e}")); + } + } + let digest_hex = p.request["digest"].as_str().unwrap_or(""); + let d = keys::unhex(digest_hex.trim_start_matches("0x")).filter(|d| d.len() == 32).ok_or("digest")?; + let mut h = [0u8; 32]; + h.copy_from_slice(&d); + let sig = { + let guard = self.secret.lock().unwrap(); + let s = guard.as_ref().ok_or("locked")?; + let raw = keys::unhex(&s.private_key).ok_or("key")?; + let mut k = [0u8; 32]; + k.copy_from_slice(&raw); + let r = crate::tx::sign_digest(&h, &k); + k.zeroize(); + r + }; + match sig { + Ok(sig) => { + self.event("ok", &format!("signed {} for {}", if p.kind == Kind::Sign { "a message" } else { "typed data" }, p.origin)); + Ok(json!(format!("0x{}", keys::hex(&sig)))) + } + Err(e) => Err((crate::bridge::E_INTERNAL, e)), + } + } + }; + let answer = match &outcome { + Ok(v) => json!({ "ok": true, "result": v }), + Err((c, m)) => json!({ "ok": false, "error": m, "code": c }), + }; + self.bridge.lock().unwrap().resolve(id, outcome, Instant::now()); + if answer["ok"] == json!(false) { + return Err(answer["error"].as_str().unwrap_or("failed").to_string()); + } + Ok(answer) + } + + /// Signs and sends a page's priced transaction (the same path as the window's send, with the call's data). + fn bridge_send(&self, p: &crate::bridge::Pending) -> Result { + self.sends.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let _guard = SendGuard(self); + let r = &p.request; + let to = r["to"].as_str().unwrap_or("").to_ascii_lowercase(); + if !keys::valid_address(&to) || to == "0x0000000000000000000000000000000000000000" { + return Err("refused: bad or zero address".into()); + } + let value: u128 = r["value"].as_str().unwrap_or("0").parse().map_err(|_| "bad value")?; + let max_fee: u128 = r["max_fee"].as_str().unwrap_or("0").parse().map_err(|_| "bad fee")?; + let tip: u128 = r["tip"].as_str().unwrap_or("0").parse().map_err(|_| "bad tip")?; + let gas = r["gas"].as_u64().ok_or("gas")?; + let data = keys::unhex(r["data"].as_str().unwrap_or("0x").trim_start_matches("0x")).unwrap_or_default(); + let evm = self.evm()?; + let me = self.address(); + let chain_id = evm.chain_id()?; + if chain_id != r["chain_id"].as_u64().unwrap_or(0) { + return Err("the chain id changed under the request; ask again".into()); + } + let nonce = evm.nonce(&me)?; + let mut to20 = [0u8; 20]; + to20.copy_from_slice(&keys::unhex(&to).ok_or("address")?); + let t = crate::tx::Transfer { chain_id, nonce, max_priority_fee: tip, max_fee, gas_limit: gas, to: to20, value, data }; + let signed = { + let guard = self.secret.lock().unwrap(); + let s = guard.as_ref().ok_or("locked")?; + let raw = keys::unhex(&s.private_key).ok_or("key")?; + let mut k = [0u8; 32]; + k.copy_from_slice(&raw); + let res = crate::tx::sign(&t, &k); + k.zeroize(); + res? + }; + if crate::tx::recover_from(&signed.raw).as_deref() != Some(me.as_str()) { + return Err("refused: the signed transaction does not recover to this wallet".into()); + } + let hash = evm.send_raw(&signed.raw)?; + let e = Entry { hash: hash.clone(), kind: if to == me { "self".into() } else { "sent".into() }, block: 0, block_hash: String::new(), from: me.clone(), to: to.clone(), value: value.to_string(), fee: String::new(), ok: true, time: igneum_common::platform::unix_now(), finality: "pending".into(), checkpoint: None, note: format!("for {}", p.origin) }; + self.event("ok", &format!("sent {} IGN to {} for {} ({})", crate::evm::ign(value, 6), keys::checksum(&to), p.origin, &hash[..10])); + self.send(Cmd::Sent(e)); + Ok(json!({ "hash": hash })) + } + pub fn bridge_sites_remove(&self, origin: &str) -> Result { + let mut settings = self.settings.lock().unwrap(); + settings.sites.retain(|x| x.origin != origin); + settings.save(&self.paths.settings); + drop(settings); + self.event("info", &format!("{} disconnected", origin)); + Ok(json!({ "ok": true })) + } + pub fn set_bridge_on(&self, on: bool) -> Result { + let mut settings = self.settings.lock().unwrap(); + settings.bridge_on = on; + settings.save(&self.paths.settings); + Ok(json!({ "ok": true })) + } + /// The run loop's sweep: expired requests end with 4001 and are logged. + pub fn bridge_sweep(&self) { + let gone = self.bridge.lock().unwrap().expire(Instant::now()); + for p in gone { + self.event("info", &format!("{}'s request expired unanswered", p.origin)); + } } pub fn wrapper_state(&self) -> Value { let st = self.state.lock().unwrap(); @@ -900,6 +1171,7 @@ impl Engine { self.last_scan = Instant::now(); self.scan(); } + self.shared.bridge_sweep(); if let Some(min) = self.shared.idle_lock_due() { self.shared.lock(); self.shared.event("info", &format!("locked after {} idle", if min == 1 { "1 minute".to_string() } else { format!("{min} minutes") })); diff --git a/app/igneum-wallet/src/evm.rs b/app/igneum-wallet/src/evm.rs index 1ea742595..7b7485a0f 100644 --- a/app/igneum-wallet/src/evm.rs +++ b/app/igneum-wallet/src/evm.rs @@ -63,6 +63,15 @@ impl Evm { pub fn estimate_gas(&self, from: &str, to: &str, value: u128) -> Result { q(&self.call("eth_estimateGas", json!([{ "from": from, "to": to, "value": hexq(value) }]))?).map(|v| v as u64).ok_or("eth_estimateGas: no number".into()) } + /// The gas a call needs, with its data (the page bridge's contract calls; the node prices proving gas inside + /// execution gas on Devnet 3, so the node's own estimate is the one to use). + pub fn estimate_gas_call(&self, from: &str, to: &str, value: u128, data: &str) -> Result { + let mut call = json!({ "from": from, "to": to, "value": hexq(value) }); + if data.len() > 2 { + call["data"] = json!(data); + } + q(&self.call("eth_estimateGas", json!([call]))?).map(|v| v as u64).ok_or("eth_estimateGas: no number".into()) + } pub fn send_raw(&self, raw: &[u8]) -> Result { let v = self.call("eth_sendRawTransaction", json!([crate::tx::hex0x(raw)]))?; v.as_str().map(|s| s.to_string()).ok_or("eth_sendRawTransaction: no hash".into()) diff --git a/app/igneum-wallet/src/main.rs b/app/igneum-wallet/src/main.rs index e37b4a52a..ccdc7f652 100644 --- a/app/igneum-wallet/src/main.rs +++ b/app/igneum-wallet/src/main.rs @@ -12,6 +12,8 @@ //! 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 bridge; +mod eip712; mod engine; mod evm; mod finality; @@ -85,6 +87,7 @@ fn main() { std::process::exit(1); } }; + server::start_bridge(shared.clone()); let url = format!("http://127.0.0.1:{port}/t/{token}/"); let url_file = shared.paths.app_dir.join("wallet.url"); let _ = std::fs::write(&url_file, &url); diff --git a/app/igneum-wallet/src/server.rs b/app/igneum-wallet/src/server.rs index d302620aa..82874a576 100644 --- a/app/igneum-wallet/src/server.rs +++ b/app/igneum-wallet/src/server.rs @@ -2,7 +2,7 @@ //! path under `/t//` (igneum_common::http). Mutating calls must come from the window itself (same origin). use crate::engine::{Cmd, Shared}; -use igneum_common::http::{from_dashboard, json_resp, query_param, read_request, respond}; +use igneum_common::http::{from_dashboard, json_resp, query_param, read_request, respond, respond_with}; use serde_json::{json, Value}; use std::net::TcpStream; use std::sync::Arc; @@ -30,6 +30,61 @@ pub fn start(shared: Arc) -> std::io::Result { Ok(port) } +/// The page bridge's listener on its fixed port (src/bridge.rs): GET /bridge/hello (the page's probe), POST /bridge/rpc +/// (JSON-RPC with the page's Origin and the X-Igneum-Bridge header), and the CORS preflight that must come first. +/// Nothing under /t// is served here, and nothing here reads the window's token. +pub fn start_bridge(shared: Arc) { + let port = std::env::var("IGNEUM_WALLET_BRIDGE_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(crate::bridge::BRIDGE_PORT); + let s = shared.clone(); + match igneum_common::http::serve_on(port, move |stream| handle_bridge(stream, s.clone())) { + Ok(()) => { + *shared.bridge_listening.lock().unwrap() = (true, port, String::new()); + shared.log(&format!("page bridge listening on 127.0.0.1:{port} (websites connect here after your approval in the window)")); + } + Err(e) => { + *shared.bridge_listening.lock().unwrap() = (false, port, format!("port {port} is not free: {e}")); + shared.log(&format!("page bridge not listening: port {port} is not free ({e}); websites cannot connect until the wallet restarts with the port free")); + } + } +} + +fn bridge_origin_ok(o: &str) -> bool { + o.starts_with("https://") || o.starts_with("http://localhost") || o.starts_with("http://127.0.0.1") +} + +fn handle_bridge(mut stream: TcpStream, shared: Arc) { + let Some(req) = read_request(&mut stream) else { return }; + let origin = req.origin.clone().unwrap_or_default(); + let cors: Vec<(&str, &str)> = if bridge_origin_ok(&origin) { + vec![("Access-Control-Allow-Origin", origin.as_str()), ("Vary", "Origin"), ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"), ("Access-Control-Allow-Headers", "content-type, x-igneum-bridge"), ("Access-Control-Allow-Private-Network", "true"), ("Access-Control-Max-Age", "600")] + } else { + vec![] + }; + match (req.method.as_str(), req.path.as_str()) { + ("OPTIONS", _) => respond_with(&mut stream, 204, "text/plain", b"", &cors), + ("GET", "/bridge/hello") => { + // the probe: the wallet is here, its version and chain; never an address + let v = json!({ "ok": true, "wallet": "igneum", "version": crate::engine::VERSION, "chain_id": shared.network_json()["chain_id"], "chain_id_hex": shared.network_json()["chain_id_hex"], "bridge": 1 }); + respond_with(&mut stream, 200, "application/json; charset=utf-8", v.to_string().as_bytes(), &cors) + } + ("POST", "/bridge/rpc") => { + if origin.is_empty() || !bridge_origin_ok(&origin) || !req.bridge_header { + respond_with(&mut stream, 403, "application/json; charset=utf-8", json!({ "error": { "code": crate::bridge::E_UNAUTHORIZED, "message": "a page bridge call carries its origin and the X-Igneum-Bridge header" } }).to_string().as_bytes(), &cors); + return; + } + let body: Value = serde_json::from_slice(&req.body).unwrap_or(json!({})); + let id = body.get("id").cloned().unwrap_or(Value::Null); + let method = body.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let params = body.get("params").cloned().unwrap_or(json!([])); + let mut out = shared.bridge_request(&origin, &method, ¶ms); + out["id"] = id; + out["jsonrpc"] = json!("2.0"); + respond_with(&mut stream, 200, "application/json; charset=utf-8", out.to_string().as_bytes(), &cors) + } + _ => respond_with(&mut stream, 404, "text/plain", b"Igneum Wallet page bridge", &cors), + } +} + fn handle(mut stream: TcpStream, shared: Arc) { let Some(req) = read_request(&mut stream) else { return }; if req.path == "/" { @@ -190,6 +245,10 @@ fn api_post(shared: &Arc, path: &str, body: Value, from_host: bool) -> R "/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()), + // ---- the page bridge: the window's decisions (src/bridge.rs) ---- + "/api/bridge/decide" => shared.bridge_decide(&s("id").ok_or("id missing")?, b("ok").unwrap_or(false), s("nonce").as_deref()), + "/api/bridge/sites/remove" => shared.bridge_sites_remove(&s("origin").ok_or("origin missing")?), + "/api/bridge/on" => shared.set_bridge_on(b("on").ok_or("on missing")?), "/api/refresh" => { shared.send(Cmd::Refresh); Ok(json!({ "ok": true })) diff --git a/app/igneum-wallet/src/tx.rs b/app/igneum-wallet/src/tx.rs index 50e77059c..ad452ed8c 100644 --- a/app/igneum-wallet/src/tx.rs +++ b/app/igneum-wallet/src/tx.rs @@ -109,6 +109,45 @@ pub fn sign(t: &Transfer, private_key: &[u8; 32]) -> Result { Ok(Signed { raw, hash }) } +/// Signs a 32-byte digest (EIP-191 personal messages, EIP-712 typed data) with the raw private key: the 65-byte +/// r || s || v signature pages expect (v = 27 + recovery bit), low s, the recovered key checked before anything is +/// returned. The page bridge (8 October 2026). +pub fn sign_digest(h: &[u8; 32], private_key: &[u8; 32]) -> Result<[u8; 65], String> { + let sk = SigningKey::from_bytes(private_key.into()).map_err(|_| "invalid private key")?; + let (sig, rec): (Signature, RecoveryId) = sk.sign_prehash_recoverable(h).map_err(|e| e.to_string())?; + let (sig, rec) = match sig.normalize_s() { + Some(low) => (low, RecoveryId::from_byte(rec.to_byte() ^ 1).ok_or("recovery id")?), + None => (sig, rec), + }; + let vk = VerifyingKey::recover_from_prehash(h, &sig, rec).map_err(|e| format!("recovery check: {e}"))?; + if vk != *sk.verifying_key() { + return Err("the signature does not recover to the signing key".into()); + } + let mut out = [0u8; 65]; + out[..32].copy_from_slice(&sig.r().to_bytes()); + out[32..64].copy_from_slice(&sig.s().to_bytes()); + out[64] = 27 + rec.to_byte(); + Ok(out) +} + +/// The EIP-191 digest of a personal message: keccak("\x19Ethereum Signed Message:\n" + len + message). +pub fn personal_digest(message: &[u8]) -> [u8; 32] { + let mut pre = format!("\x19Ethereum Signed Message:\n{}", message.len()).into_bytes(); + pre.extend_from_slice(message); + Keccak256::digest(&pre).into() +} + +/// The address a 65-byte signature over `h` recovers to, lower-case 0x hex. +pub fn recover_digest(h: &[u8; 32], sig65: &[u8; 65]) -> Option { + let v = sig65[64]; + let rec = RecoveryId::from_byte(if v >= 27 { v - 27 } else { v })?; + let sig = Signature::from_slice(&sig65[..64]).ok()?; + let vk = VerifyingKey::recover_from_prehash(h, &sig, rec).ok()?; + let pk = vk.to_encoded_point(false); + let h2: [u8; 32] = Keccak256::digest(&pk.as_bytes()[1..]).into(); + Some(format!("0x{}", h2[12..].iter().map(|b| format!("{b:02x}")).collect::())) +} + /// A big-endian scalar (r, s: 32 bytes) as an RLP integer: leading zeros stripped, zero is the empty string. pub fn rlp_scalar(b: &[u8], out: &mut Vec) { let first = b.iter().position(|x| *x != 0); @@ -198,6 +237,25 @@ pub fn hex0x(b: &[u8]) -> String { #[cfg(test)] mod tests { + #[test] + fn a_personal_message_signature_recovers_to_the_signer_and_carries_v_27_or_28() { + let key = [7u8; 32]; + let sk = SigningKey::from_bytes((&key).into()).unwrap(); + let pk = sk.verifying_key().to_encoded_point(false); + let addr_h: [u8; 32] = Keccak256::digest(&pk.as_bytes()[1..]).into(); + let me = format!("0x{}", addr_h[12..].iter().map(|b| format!("{b:02x}")).collect::()); + let h = personal_digest(b"hello world"); + // the prefix is the EIP-191 one: the same digest as keccak of the literal prefixed bytes + let mut lit = b"\x19Ethereum Signed Message:\n11hello world".to_vec(); + let direct: [u8; 32] = Keccak256::digest(&lit).into(); + lit.clear(); + assert_eq!(h, direct); + let sig = sign_digest(&h, &key).unwrap(); + assert!(sig[64] == 27 || sig[64] == 28); + assert_eq!(recover_digest(&h, &sig).as_deref(), Some(me.as_str())); + assert!(sig[32] < 0x80, "low s"); + } + use super::*; fn to20() -> [u8; 20] { diff --git a/app/igneum-wallet/ui/app.css b/app/igneum-wallet/ui/app.css index ab7d2e8f7..41fece054 100644 --- a/app/igneum-wallet/ui/app.css +++ b/app/igneum-wallet/ui/app.css @@ -475,3 +475,15 @@ body.has-rail .toast{left:calc(50% + var(--rail) / 2)} .lead{font-size:var(--t-lg)} .step{padding:32px 0 32px} } + +/* the page bridge (0.1.6): a website's request as a card over the window; the Settings card's site rows */ +.bridge-card{max-width:540px} +.bridge-amount{font-family:var(--head);font-weight:700;font-size:28px;color:var(--ember);margin:4px 0 8px;letter-spacing:-.01em} +.bridge-message{white-space:pre-wrap;word-break:break-word;max-height:220px;overflow:auto;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.5;color:var(--ink-2);margin:6px 0 10px;text-align:left} +.bridge-done{margin-top:12px;text-align:left} +.bridge-done .kv{margin-top:8px} +#bridge-origin{margin-top:6px} +.sites{display:flex;flex-direction:column} +.sites .srow .sw-text b{font-weight:600} +.sites .srow .sw-text .dim{display:block;margin-top:2px} +.lock-line.bridge-waiting{color:var(--molten);margin-top:4px} diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js index c8e1c5ef7..82c7a6853 100644 --- a/app/igneum-wallet/ui/app.js +++ b/app/igneum-wallet/ui/app.js @@ -191,6 +191,46 @@ var View = (function () { // ---------- updates: the strip line and the card's note ---------- function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } + // ---- the page bridge (0.1.6, 8 October 2026): what the window says for a page's request; the facts come from the + // engine (src/bridge.rs pending_json), never from the page ---- + function hostOf(origin) { return String(origin || '').replace(/^https?:\/\//, '').replace(/\/.*$/, ''); } + function bridgeWords(p, bioName) { + p = p || {}; var host = hostOf(p.origin), out = { id: p.id, kind: p.kind, host: host, title: '', amount: '', message: '', lines: [], yes: '', no: 'Decline', confirm: !!p.confirm_needed, nonce: p.confirm_nonce || '' }; + var withBio = p.confirm_needed && bioName ? ' with ' + bioName : ''; + if (p.kind === 'connect') { + out.title = host + ' wants to connect'; + out.lines = ['It will see your address and may ask you to sign or send. Nothing leaves without your word here.', 'You can disconnect it any time in Settings.']; + out.yes = 'Connect'; + } else if (p.kind === 'send') { + out.title = host + ' asks you to send'; + out.amount = (p.value_ign !== undefined && p.value_ign !== null ? p.value_ign : ign(p.value || '0', 18)) + ' IGN'; + var call = p.data_len > 0 ? 'a contract call (' + withCommas(p.data_len) + ' bytes' + (p.selector ? ', ' + p.selector : '') + ')' : 'a plain transfer'; + out.lines = ['To ' + shortHex(p.to_display || p.to || '', 6, 4) + ', ' + call + '.']; + out.lines.push(p.fee_max_ign ? 'Fee up to ' + p.fee_max_ign + ' IGN (' + withCommas(p.gas) + ' gas). Total up to ' + p.total_max_ign + ' IGN.' : 'Pricing the fee with the node.'); + out.yes = 'Send' + withBio; + } else if (p.kind === 'sign') { + out.title = host + ' asks you to sign a message'; + out.message = p.text !== null && p.text !== undefined ? p.text : (withCommas(p.bytes || 0) + ' bytes: ' + (p.hex || '')); + out.lines = ['Signing proves this wallet is yours. It moves no coins.']; + out.yes = 'Sign' + withBio; + } else if (p.kind === 'typed') { + out.title = host + ' asks you to sign typed data'; + out.message = p.message || ''; + out.lines = [(p.primary_type || 'Data') + (p.domain ? ' for ' + p.domain : '') + '. Read it before you sign: a permit can let a contract spend tokens.']; + out.yes = 'Sign' + withBio; + } + return out; + } + // the lock screen's line while a page waits + function bridgeWaiting(pending) { var l = pending || []; if (!l.length) return ''; var hosts = []; l.forEach(function (p) { var h = hostOf(p.origin); if (hosts.indexOf(h) < 0) hosts.push(h); }); return (hosts.length === 1 ? hosts[0] + ' is' : hosts.length + ' sites are') + ' waiting: unlock to answer ' + (hosts.length === 1 ? 'it' : 'them'); } + // the Settings card's rows + function siteRows(sites, now) { return (sites || []).map(function (x) { return { origin: x.origin, host: hostOf(x.origin), line: 'connected ' + ago(now - (x.approved_at || now)) + (x.last_seen ? ' · last call ' + ago(now - x.last_seen) : '') }; }); } + function bridgeStatus(b) { + b = b || {}; + if (b.on === false) return 'Off. No website can connect; the switch turns it on.'; + if (b.listening === false) return 'Not listening: ' + (b.reason || 'the port is not free') + '. Quit whatever holds the port and open the wallet again.'; + return 'On. Websites you approve can connect through port ' + (b.port || 26811) + ' on this machine only.'; + } function updateLine(u) { var v = 'Igneum Wallet ' + u.version; if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' }; @@ -220,7 +260,7 @@ var View = (function () { return parts.join(' '); } - return { PAGES: PAGES, page: page, withCommas: withCommas, shortHex: shortHex, ign: ign, ago: ago, timeWord: timeWord, balanceLine: balanceLine, moneyLine: moneyLine, nodeWords: nodeWords, sourceWords: sourceWords, verifyWords: verifyWords, nodeLine: nodeLine, nodeDetails: nodeDetails, pillWords: pillWords, kindWord: kindWord, whoLine: whoLine, stateWord: stateWord, rowModel: rowModel, needsNodeWord: needsNodeWord, gwei: gwei, feeWords: feeWords, sendAsk: sendAsk, bioSentence: bioSentence, idleSentence: idleSentence, updateLine: updateLine, updateNote: updateNote, cap: cap }; + return { PAGES: PAGES, page: page, withCommas: withCommas, shortHex: shortHex, ign: ign, ago: ago, timeWord: timeWord, balanceLine: balanceLine, moneyLine: moneyLine, nodeWords: nodeWords, sourceWords: sourceWords, verifyWords: verifyWords, nodeLine: nodeLine, nodeDetails: nodeDetails, pillWords: pillWords, kindWord: kindWord, whoLine: whoLine, stateWord: stateWord, rowModel: rowModel, needsNodeWord: needsNodeWord, gwei: gwei, feeWords: feeWords, sendAsk: sendAsk, bioSentence: bioSentence, idleSentence: idleSentence, updateLine: updateLine, updateNote: updateNote, cap: cap, hostOf: hostOf, bridgeWords: bridgeWords, bridgeWaiting: bridgeWaiting, siteRows: siteRows, bridgeStatus: bridgeStatus }; })(); if (typeof module === 'object' && module && module.exports) { module.exports = { View: View }; } @@ -411,6 +451,7 @@ if (typeof document !== 'undefined') (function () { $('ask-quit-yes').onclick = function () { $('ask-quit').hidden = true; post('/api/quit').catch(function () {}); toast('Quitting'); }; document.addEventListener('keydown', function (e) { if (e.key !== 'Escape') return; + if (!$('bridge').hidden) { e.preventDefault(); if (bridgeDoneId) $('bridge-close').click(); else bridgeDecide(false); return; } if (!$('upd').hidden) { e.preventDefault(); cardLater(); return; } if (!$('ask-quit').hidden) { $('ask-quit').hidden = true; return; } if (!$('ask-send').hidden) { cancelAsk(); return; } @@ -445,6 +486,7 @@ if (typeof document !== 'undefined') (function () { if (forcedUpdate) s.update = sampleUpdate(forcedUpdate); renderNotices(s); renderUpdateCard(s); + renderBridge(s); if (phase === 'unlock' || locking) renderLock(); $('seg-miner').disabled = !s.miner_wallet_present; if (phase === 'home') { @@ -681,7 +723,84 @@ if (typeof document !== 'undefined') (function () { }; // ---------- Settings ---------- + // ---------- the page bridge (0.1.6): a website's request as a card, the Settings card ---------- + var bridgeOpen = null, bridgeBusy = false, bridgeDoneId = null, bridgeSig = ''; + function renderBridge(s) { + var br = s.bridge || {}, list = (br.pending || []), wrap = $('bridge'); + var ul = $('unlock-bridge'); if (ul) { var wl = View.bridgeWaiting(list); ul.textContent = wl; ul.hidden = !wl; } + if (s.phase !== 'home') { if (!wrap.hidden) { wrap.hidden = true; bridgeOpen = null; bridgeSig = ''; } return; } + if (bridgeDoneId) return; // the sent view stays until Done + var p = list[0] || null; + if (!p) { if (!wrap.hidden) { wrap.hidden = true; bridgeOpen = null; bridgeSig = ''; } return; } + var words = View.bridgeWords(p, p.confirm_needed ? what() : ''), sig = JSON.stringify([p.id, words, p.confirm_nonce]); + if (sig === bridgeSig) return; + bridgeSig = sig; bridgeOpen = p; + setText('bridge-eyebrow', p.kind === 'connect' ? 'a website asks to connect' : p.kind === 'send' ? 'a website asks you to send' : 'a website asks you to sign'); + setText('bridge-title', words.title); + $('bridge-amount').hidden = !words.amount; setText('bridge-amount', words.amount || ''); + $('bridge-message').hidden = !words.message; $('bridge-message').textContent = words.message || ''; + $('bridge-lines').innerHTML = words.lines.map(function (l) { return '
  • ' + esc(l) + '
  • '; }).join(''); + setText('bridge-origin', p.origin); + setText('bridge-yes-text', words.yes); $('bridge-yes').querySelector('.fp-ico').hidden = !(p.confirm_needed && bio.host); + $('bridge-yes').disabled = p.kind === 'send' && !p.fee_max_ign; $('bridge-no').textContent = words.no; + $('bridge-err').textContent = p.confirm_needed && !bio.host ? what() + ' is on for this wallet, and only the Igneum Wallet app window can show it.' : ''; + setLine($('bridge-bio-line'), ''); + $('bridge-done').hidden = true; $('bridge-actions').hidden = false; + if (wrap.hidden) { wrap.hidden = false; $('bridge-card').focus({ preventScroll: true }); } + } + async function bridgeDecide(ok) { + var p = bridgeOpen; if (!p || bridgeBusy) return; + bridgeBusy = true; $('bridge-yes').disabled = true; $('bridge-no').disabled = true; $('bridge-err').textContent = ''; + try { + var nonce = p.confirm_nonce || ''; + if (ok && p.confirm_needed) { + setLine($('bridge-bio-line'), '' + FP + esc('Waiting for ' + what()) + ''); + var c = await bio.call('confirm', { nonce: nonce }); + setLine($('bridge-bio-line'), bioLine(c, 'confirmed')); + if (!c.ok) { bridgeBusy = false; $('bridge-yes').disabled = false; $('bridge-no').disabled = false; return; } + } + var r = await post('/api/bridge/decide', { id: p.id, ok: ok, nonce: nonce }); + if (ok && p.kind === 'send' && r.result && r.result.hash) { + bridgeDoneId = r.result.hash; $('bridge-actions').hidden = true; $('bridge-done').hidden = false; + setText('bridge-done-line', 'Sent for ' + View.hostOf(p.origin) + '. The page has the hash; the chain\u2019s word follows here.'); + $('bridge-hash-box').hidden = false; setText('bridge-hash', r.result.hash); $('bridge-final').innerHTML = ''; + bridgeFinality(r.result.hash); + } else { + $('bridge').hidden = true; bridgeOpen = null; bridgeSig = ''; + toast(ok ? (p.kind === 'connect' ? View.hostOf(p.origin) + ' connected' : 'Signed for ' + View.hostOf(p.origin)) : 'Declined'); + } + } catch (e) { $('bridge-err').textContent = e.message; } + bridgeBusy = false; $('bridge-yes').disabled = false; $('bridge-no').disabled = false; + poll(); + } + // the finality certificate's line after a page's transaction: the node's word, then the checkpoint this wallet verified + async function bridgeFinality(hash) { + for (var i = 0; i < 90 && bridgeDoneId === hash; i++) { + try { + var r = await api('/api/tx/' + hash), st = (r.node_status && r.node_status.state) || 'pending', e = (state && state.history || []).filter(function (x) { return x.hash.toLowerCase() === hash.toLowerCase(); })[0]; + var rows = [['the node says', st], ['block', e && e.block ? View.withCommas(e.block) : 'none yet']]; + if (e && e.checkpoint) rows.push(['checkpoint', View.withCommas(e.checkpoint) + ', verified by this wallet']); + else if (r.verified && r.verified.verified_index) rows.push(['finality', 'the wallet verified checkpoint ' + View.withCommas(r.verified.verified_index) + '; this transaction is final once a locked checkpoint covers its block']); + $('bridge-final').innerHTML = rows.map(function (x) { return '
    ' + esc(x[0]) + '' + esc(x[1]) + '
    '; }).join(''); + if (e && e.finality === 'final') break; + } catch (x) { } + await new Promise(function (res) { setTimeout(res, 2000); }); + } + } + $('bridge-yes').onclick = function () { bridgeDecide(true); }; + $('bridge-no').onclick = function () { bridgeDecide(false); }; + $('bridge-close').onclick = function () { bridgeDoneId = null; $('bridge').hidden = true; bridgeOpen = null; bridgeSig = ''; if (state) renderBridge(state); }; + $('bridge-on').addEventListener('change', function () { var on = this.checked; post('/api/bridge/on', { on: on }).then(function () { toast(on ? 'Websites can connect after your approval' : 'Websites cannot connect'); poll(); }).catch(function (e) { toast(e.message); }); }); + $('sites').addEventListener('click', function (e) { var b = e.target.closest('[data-site]'); if (!b) return; var o = b.dataset.site; post('/api/bridge/sites/remove', { origin: o }).then(function () { toast(View.hostOf(o) + ' disconnected'); poll(); }).catch(function (x) { toast(x.message); }); }); + function renderSites(s) { + var br = s.bridge || {}; + if (document.activeElement !== $('bridge-on')) $('bridge-on').checked = br.on !== false; + setText('bridge-status', View.bridgeStatus(br)); + var rows = View.siteRows(br.sites, s.now); + $('sites').innerHTML = rows.length ? rows.map(function (r) { return '
    ' + esc(r.host) + ' ' + esc(r.line) + '
    '; }).join('') : '

    No website is connected.

    '; + } function renderSettings(s) { + renderSites(s); var b = s.biometric || {}, w = what(), host = hostHere(); setText('bio-title', w); setText('bio-sentence', View.bioSentence(b, w, host)); diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html index 281779d17..834d21667 100644 --- a/app/igneum-wallet/ui/index.html +++ b/app/igneum-wallet/ui/index.html @@ -145,6 +145,7 @@

    Igneum Wallet

    +
    +
    +

    Websites

    page bridge
    + +
    +

    A site asks once; you answer in this window. Each site sees your address and can ask you to sign or send; every request is shown here first. Disconnect a site any time.

    +

    Backup

    @@ -375,6 +382,30 @@ + + +