From 8623d0b8a9c4da7ae559c91e7b4979e56bc53490 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 12 Jul 2026 11:05:26 -0700 Subject: [PATCH] feat(dig-wallet): node-managed unlock auth + per-transaction sign-unlock (#432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dig-node becomes the LOCAL auth authority (no central server) gating the node-custodied signer (SPEC §18.21). Signing is SAFE BY DEFAULT: `auth.unlock` grants a READ-ONLY session and loads no key; each signature needs a fresh `auth.sign_unlock` -> decrypt seed -> sign ONE op -> drop the signer (the key is not resident between signatures). `session_unlock_all` is the opt-out (OFF by default). Auth model: a PER-WALLET password (the seed's at-rest KDF root) plus a NODE-LEVEL second factor — TOTP (RFC-6238 via totp-rs, secret sealed under a node device key, so 2FA works across all wallets) and passkey/WebAuthn behind the same interface (real ceremony deferred to #433: webauthn-rs pulls OpenSSL + the RP/origin is the extension's). Gates the #371 sign path via `current_signer`; the full `auth.*` WS surface is paired-token gated (§7.12). Back-compat: no auth attached -> legacy path. Hardened per the #432 adversarial review (two rounds): - serialize signing dispatch + panic-safe RAII grant consumption -> one auth authorizes EXACTLY one signature (no gate TOCTOU under concurrency or panic); - enrolling/replacing a factor and switching to session-unlock RE-VERIFY the current factor (a stolen password can't rotate 2FA or downgrade the posture); - one-shot grants + session signers are BOUND to their wallet id (never sign for the wrong wallet, #427); - TOTP is one-time-use with an ATOMIC check-and-advance under a single exclusive lock -> a replay is rejected even under CONCURRENT verifies (RFC-6238 §5.2); - no sibling resident key: create/import/restore load no resident signer and wallet.unlock redirects to auth.* when the gate is attached; - tip.manual/dev_tick/notify_consumed consume the grant (no leak past an auto-tip). Spec-first: SPEC §18.24 + §7.12. Minor bump: dig-node 0.24.0->0.25.0, dig-wallet 0.10->0.11. Refs #431 #432 #371 #374 #427 Co-Authored-By: Claude --- Cargo.lock | 39 +- Cargo.toml | 2 +- SPEC.md | 109 ++- crates/dig-node-service/src/wallet_authz.rs | 48 +- crates/dig-wallet/Cargo.toml | 12 +- crates/dig-wallet/src/sage/auth.rs | 951 ++++++++++++++++++++ crates/dig-wallet/src/sage/custody.rs | 80 ++ crates/dig-wallet/src/sage/mod.rs | 1 + crates/dig-wallet/src/sage/rpc.rs | 489 +++++++++- crates/dig-wallet/src/sage/service.rs | 7 + 10 files changed, 1717 insertions(+), 21 deletions(-) create mode 100644 crates/dig-wallet/src/sage/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 249eac7..a7954da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -396,6 +396,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.22.1" @@ -1369,6 +1375,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.8.0" @@ -1944,7 +1956,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.24.0" +version = "0.25.0" dependencies = [ "axum", "base64", @@ -2019,7 +2031,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.10.0" +version = "0.11.0" dependencies = [ "async-trait", "axum", @@ -2049,6 +2061,7 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", + "totp-rs", "tower 0.5.3", "tower-http", "zeroize", @@ -5349,6 +5362,22 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "totp-rs" +version = "5.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e69a15e21b2ff22c415446983978bded3244195f17d59cb113551c1e806f91" +dependencies = [ + "base32", + "constant_time_eq", + "hmac", + "rand 0.9.4", + "sha1", + "sha2 0.10.9", + "url", + "urlencoding", +] + [[package]] name = "tower" version = "0.4.13" @@ -5548,6 +5577,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index e98ce96..ef96ec3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[workspace.package].version`), so it MUST be set here for a release to fire # (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) keep their own # independent versions — only the released binary tracks the workspace version. -version = "0.24.0" +version = "0.25.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index ce5afef..3b43e2c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -953,7 +953,10 @@ rejected with `-32030 UNAUTHORIZED` before the method runs. **Gated wallet methods (MUST present a token).** The custody-lifecycle group (§18.20 — `wallet.create` / `wallet.import` / `wallet.restore` / `wallet.unlock` / `wallet.lock` / -`wallet.status` / `wallet.list` / `wallet.select` / `wallet.delete`) and the mutation group (the send/spend group §18.9, the offer suite +`wallet.status` / `wallet.list` / `wallet.select` / `wallet.delete`), the node-managed unlock-auth group +(§18.24 — EVERY `auth.*` method: `auth.status` / `auth.get_method` / `auth.set_method` / `auth.set_mode` / +`auth.enroll_totp` / `auth.enroll_passkey_begin` / `auth.enroll_passkey_finish` / `auth.unlock` / +`auth.sign_unlock` / `auth.lock`), and the mutation group (the send/spend group §18.9, the offer suite + DID/NFT mint & transfer §18.9a, and the state-changing record-update actions §18.16) are all gated. These methods are NEVER relayed upstream — a signing/custody request must never leave the loopback node; an authorized call is served locally by the node-custodied wallet (or, until the wallet surface is served @@ -2002,6 +2005,110 @@ Each returns a `TipOutcome` — `{ result: "tipped", txid, dig_amount, recipient Sage stream). Each `/ws` session forwards it as a `{ "type": "tip", "tip": }` push frame, alongside the `sync_status` + `event` frames. +## 18.24. Node-managed unlock authentication + per-transaction sign-unlock (#431/#432) + +The node is the LOCAL authority that gates the node-custodied signer (§18.21). There is **NO central +server**: enrollment + verification are entirely local, credential material is encrypted at rest via +`dig-keystore`, and no auth secret is ever logged or returned over any transport. This makes signing +SAFE BY DEFAULT: the decrypted private key MUST NOT persist in memory beyond a single signature. + +`crate::sage::auth::UnlockAuth` owns the auth+unlock state machine; it holds a handle to the +`WalletCustody` (§18.20) and mediates the effective signer. When an `UnlockAuth` is attached to the +served `WalletBackend` (`with_auth`), it GOVERNS `current_signer()` — the §18.21 sign/broadcast-on-behalf +path obtains a signer ONLY through the auth gate. When no `UnlockAuth` is attached (the simulator / +bring-up-injected-signer path), behaviour is unchanged (back-compat). + +**Unlock mode (the ONLY policy knob).** + +- **`per_transaction` (DEFAULT, secure).** A successful `unlock` grants a READ-ONLY session + (balances/history/reads). It loads NO signer — `current_signer()` is `None`. **Each signing operation + requires a fresh `sign_unlock`**: the node decrypts the seed, builds a one-shot signer, signs exactly + ONE operation, and the signer is DROPPED (not resident) immediately after. The key never persists + beyond that single operation. +- **`session_unlock_all` (OPT-OUT, convenience, OFF by default).** One `unlock` at session start builds + and HOLDS the signer for the session lifetime; `current_signer()` returns it until `lock`. Set via + `auth.set_mode`. + +**Auth model — per-wallet password + NODE-LEVEL second factor.** One method is active at a time; the user +may add TOTP or a passkey on top of the password. + +- The **password is PER-WALLET**: it is the at-rest KDF root that decrypts THAT wallet's seed + (`dig-keystore` Argon2id, §18.18/§18.20). Every `unlock`/`sign_unlock` requires the TARGET wallet's + password (`WalletCustody::verify_password`). +- The **second factor (TOTP / passkey) is NODE-LEVEL** — a single node authentication that authorizes the + unlock across EVERY custodied wallet (#431). Its secret is sealed at rest under a node-level device key + (`auth/node.key`, owner-only), NOT under any wallet password, so 2FA works uniformly for every wallet. + When a second factor is enrolled, `unlock`/`sign_unlock` require BOTH the target wallet's password AND + the node-level factor. A `Credential` carries the password plus, per the active method, a TOTP code or a + WebAuthn assertion; verification requires every factor the active method mandates. (This is an honest, + strong-at-rest design for a keyless local node; true passwordless replacement via WebAuthn PRF or a + TPM-sealed key is a scoped follow-up.) + +- **`password` (default).** Verified by decrypting the addressed wallet's seed. +- **`totp` (RFC-6238, `totp-rs`).** `auth.enroll_totp` generates a fresh node-level secret, seals it under + `auth/node.key`, sets the method to `totp`, and returns the base32 secret + `otpauth://` URI EXACTLY + ONCE. Thereafter `unlock`/`sign_unlock` require the target wallet's password AND a current 6-digit code + (±1 step skew). **A code is ONE-TIME-USE** (RFC-6238 §5.2): the last-accepted time-step is persisted and + a code at a step `<=` it is rejected as a REPLAY (`401`). The check-and-advance is ATOMIC — the + find-step → compare-to-last → advance runs under a single exclusive lock — so two CONCURRENT verifies of + the same code cannot both pass (the replay window holds under concurrency, not just sequentially). +- **`passkey` (WebAuthn).** `auth.enroll_passkey_begin`/`finish` register a node-level credential. + Thereafter `unlock`/`sign_unlock` require the password AND a valid assertion. The real `webauthn-rs` + ceremony is finalized with the paired-extension origin (#433 follow-up) — it fails closed until then, so + the active method never becomes `passkey` on this node. + +**Enrolling or replacing a factor re-verifies the CURRENT factor.** `auth.enroll_totp` / +`auth.enroll_passkey_begin` / `auth.set_method` and switching mode to `session_unlock_all` all run the +FULL current-factor verification (`verify(id, cred)` — password AND, when a second factor is already +active, the live code/assertion) BEFORE rotating/weakening. So an attacker holding the paired token + a +stolen password — the exact threat 2FA backstops — cannot rotate the factor to their own authenticator or +silently downgrade the posture. Password-only enrollment is permitted ONLY while the active method is +`password`. + +**State machine + the §18.21 gate.** + +- `unlock(id?, cred)` → verify per the active method → set the session READ-ONLY (`per_transaction`), or + build + hold the session signer BOUND to that wallet (`session_unlock_all`). Never returns a signer or + key. A wrong/expired/replayed credential is denied (`401`), leaves the state unchanged, loads nothing. +- `sign_unlock(id?, cred)` → verify (FRESH) → decrypt the target wallet's seed, build a one-shot signer + BOUND to that wallet, and ARM it for exactly ONE signing operation. +- **Grants/session signers are BOUND to their wallet id (§18.20a multi-wallet).** `current_signer()` for a + signing op returns the armed grant (or the held session signer) ONLY when its bound wallet id equals the + node's currently-ACTIVE wallet; otherwise it fails closed — a grant armed for wallet A can never sign + when B is active. `current_signer()` resolves: bring-up-injected signer (tests) → the wallet-matched auth + gate signer → (only when NO auth is attached) the legacy held custody signer. +- **Signing dispatch is SERIALIZED + the one-shot grant is consumed panic-safely.** Every key-touching + signing method (`send_xch`/`send_cat`/`combine`/`split`/`sign_coin_spends`/`submit_transaction`/ + `make_offer`/`take_offer`/mint/transfer/… AND `tip.manual`/`tip.dev_tick`/`tip.notify_consumed`, which + sign a $DIG tip) is dispatched under a signing mutex held from before the handler THROUGH grant + consumption, so two concurrent signing calls can never both observe one armed grant (a gate TOCTOU) — + ONE `sign_unlock` authorizes EXACTLY ONE signature. Consumption is via an RAII guard that runs on normal + return AND on a panic unwind, so a panicking handler can never leave the key armed + reusable. +- `lock()` → clear the read-only session, drop the session signer AND any armed grant. + +**No sibling resident key over the paired boundary.** When the auth gate is attached it is the ONLY +signer-loading path: `wallet.create`/`import`/`restore` verify-and-persist but leave NO resident custody +signer (the custody session is locked immediately after provisioning), and `wallet.unlock` is redirected +to `auth.unlock`/`auth.sign_unlock` rather than loading a session-long resident key. + +**Zeroize / residency invariant (adversarial-verified).** In `per_transaction` mode no signer is resident +between signatures: after a `sign_unlock` + one signing operation the one-shot signer is dropped and +`current_signer()` returns `None` — a fresh `sign_unlock` is required for the next signature. Decrypted +mnemonic material is held only in `zeroize::Zeroizing` for the duration of a build and dropped +immediately. (`chia-bls` 0.26 `SecretKey` does not itself zeroize-on-drop; the delivered guarantee is +non-retention — the signer allocation is dropped promptly — plus zeroization of the mnemonic buffer. +Byte-level scrub of the derived scalar via a key wrapper is a scoped follow-up.) + +**WS/RPC surface (paired-token gated, §7.12).** Every `auth.*` method requires the master control token +OR a valid paired token, on every transport (`POST /{method}`, `/ws`, mTLS `9257`). Methods: +`auth.status` (mode, method, session state, whether a sign-grant is armed) · `auth.get_method` / +`auth.set_method` (switch active method — `password` resets to password-only) · `auth.set_mode` +(`per_transaction` | `session_unlock_all`) · `auth.enroll_totp` · `auth.enroll_passkey_begin` / +`auth.enroll_passkey_finish` · `auth.unlock` (read-only session) · `auth.sign_unlock` (per-transaction, +authorizes exactly one signature) · `auth.lock`. Auth secrets are NEVER returned except the one-time TOTP +enrollment secret/URI at `enroll_totp` (needed to provision the authenticator). No auth material crosses +as a wallet/`control.*` result. + ## 19. Peer network — NAT traversal, discovery, address book, and content location The standalone `dig-node` binary runs an L7 peer network (the in-process FFI/browser host does not — diff --git a/crates/dig-node-service/src/wallet_authz.rs b/crates/dig-node-service/src/wallet_authz.rs index 68566d8..ddc739a 100644 --- a/crates/dig-node-service/src/wallet_authz.rs +++ b/crates/dig-node-service/src/wallet_authz.rs @@ -24,6 +24,13 @@ use crate::control::ct_eq; /// new custody method is gated the moment it lands under `wallet.*` — no per-method allowlist. pub const CUSTODY_PREFIX: &str = "wallet."; +/// The node-managed unlock-auth namespace prefix (§18.24, #431/#432): `auth.status`, `auth.unlock`, +/// `auth.sign_unlock`, `auth.set_mode`, `auth.set_method`, `auth.enroll_totp`, `auth.enroll_passkey_*`, +/// `auth.lock`, `auth.get_method`. EVERY `auth.*` method is paired-token gated (§7.12) — even the +/// reads reveal the auth posture (mode/method/session state), and `unlock`/`sign_unlock` gate the +/// node-custodied signer — so a new auth method is gated the moment it lands under `auth.*`. +pub const AUTH_PREFIX: &str = "auth."; + /// Wallet MUTATION methods that MUST be authorized (§7.12): they sign, spend, broadcast, or change /// persisted wallet state. Sourced from the dig-wallet Sage surface (§18.9/§18.9a/§18.16/§18.17). const GATED_WALLET_MUTATIONS: &[&str] = &[ @@ -83,7 +90,8 @@ const GATED_WALLET_MUTATIONS: &[&str] = &[ /// The authorization class of a JSON-RPC method w.r.t. the wallet surface (§7.12). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WalletMethodClass { - /// A custody-lifecycle method (`wallet.*`, §18.20) — GATED. + /// A custody-lifecycle method (`wallet.*`, §18.20) or a node-managed unlock-auth method + /// (`auth.*`, §18.24) — GATED. Custody, /// A wallet MUTATION (sign/spend/offer/mint/transfer + state-changing actions) — GATED. Mutation, @@ -95,7 +103,7 @@ pub enum WalletMethodClass { /// Classify a method against the wallet-authorization policy. PURE. pub fn classify(method: &str) -> WalletMethodClass { - if method.starts_with(CUSTODY_PREFIX) { + if method.starts_with(CUSTODY_PREFIX) || method.starts_with(AUTH_PREFIX) { WalletMethodClass::Custody } else if GATED_WALLET_MUTATIONS.contains(&method) { WalletMethodClass::Mutation @@ -167,6 +175,42 @@ mod tests { } } + #[test] + fn auth_methods_are_gated_and_no_token_is_denied() { + for m in [ + "auth.status", + "auth.get_method", + "auth.set_method", + "auth.set_mode", + "auth.enroll_totp", + "auth.enroll_passkey_begin", + "auth.enroll_passkey_finish", + "auth.unlock", + "auth.sign_unlock", + "auth.lock", + ] { + assert_eq!(classify(m), WalletMethodClass::Custody, "{m}"); + assert!(requires_authorization(m), "{m} must be gated"); + // No token / wrong token → denied; master or paired → allowed. + assert!( + !authorize(m, None, MASTER, is_paired), + "{m}: no token denied" + ); + assert!( + !authorize(m, Some("nope"), MASTER, is_paired), + "{m}: wrong token denied" + ); + assert!( + authorize(m, Some(MASTER), MASTER, is_paired), + "{m}: master ok" + ); + assert!( + authorize(m, Some(PAIRED), MASTER, is_paired), + "{m}: paired ok" + ); + } + } + #[test] fn spend_sign_and_offer_methods_are_gated_mutations() { for m in [ diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index 9f5aba1..daf75b5 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.10.0" +version = "0.11.0" edition = "2021" license = "GPL-2.0-only" description = "DIG Browser built-in Chia wallet sidecar: a local axum server (using digstore-chain + chia-wallet-sdk over coinset.org) that serves a Sage-mirroring wallet UI. Native Rust so BLS signing works; the browser opens it at 127.0.0.1." @@ -93,6 +93,16 @@ dig-keystore = "0.3" tokio-stream = { version = "0.1", features = ["sync"] } futures-core = "0.3" +# -- #431/#432 node-managed unlock auth (§18.24) ---------------------------------------- +# TOTP 2FA (RFC-6238) as an optional required second factor on the password unlock. Pure +# Rust (no OpenSSL): `otpauth` gives the `otpauth://` provisioning URI, `gen_secret` the +# random secret generator. The secret is sealed at rest under the wallet password via the +# `dig-keystore` `opaque` container (§18.18) — never stored in plaintext, never logged. +# (WebAuthn/passkey — SPEC §18.24 — is enrolled behind the same interface; its real +# `webauthn-rs` ceremony is finalized with the extension origin in the follow-up, since +# webauthn-rs pulls OpenSSL and the RP/origin is defined by the paired extension #433.) +totp-rs = { version = "5", features = ["otpauth", "gen_secret"] } + [dev-dependencies] tempfile = "3" # K1Pair — real secp256k1 test key pairs for the vault config / K1 pubkey parse tests. diff --git a/crates/dig-wallet/src/sage/auth.rs b/crates/dig-wallet/src/sage/auth.rs new file mode 100644 index 0000000..f366eb8 --- /dev/null +++ b/crates/dig-wallet/src/sage/auth.rs @@ -0,0 +1,951 @@ +//! Node-managed unlock authentication + per-transaction sign-unlock (#431/#432, SPEC §18.24). +//! +//! The node is the LOCAL authority (no central server) that gates the node-custodied signer +//! (§18.21). This module owns the auth+unlock STATE MACHINE that makes signing SAFE BY DEFAULT: the +//! decrypted private key MUST NOT persist in memory beyond a single signature. +//! +//! # The guarantee +//! +//! A successful [`unlock`](UnlockAuth::unlock) grants a READ-ONLY session (balances/history/reads) +//! and loads NO signer; **each signing operation requires a fresh [`sign_unlock`](UnlockAuth::sign_unlock)** +//! that decrypts the seed, builds a ONE-SHOT signer bound to that wallet, signs exactly one operation, +//! and drops it (the key is not resident between signatures). The `session_unlock_all` mode +//! ([`UnlockMode`]) is the OPT-OUT convenience where one unlock holds the signer for the session; it +//! is OFF by default and switching INTO it re-verifies the current factor. +//! +//! # Auth model (per #431/#432, revised by the #432 adversarial review) +//! +//! - The **password is per-wallet** — it is the at-rest KDF root that decrypts THAT wallet's seed +//! (`dig-keystore` Argon2id, §18.18/§18.20). Every unlock/sign-unlock needs the target wallet's +//! password. +//! - The **second factor (TOTP / passkey) is NODE-LEVEL** — a single node authentication that +//! authorizes the unlock across every wallet (#431). Its secret is sealed at rest under a +//! node-level device key (`auth/node.key`, owner-only), NOT under any wallet password, so 2FA works +//! uniformly for all custodied wallets. When a second factor is enrolled, `unlock`/`sign_unlock` +//! require BOTH the target wallet's password AND the node-level factor. +//! - **Enrolling or replacing a factor re-verifies the CURRENT factor** (not just the password), so an +//! attacker holding the paired token + a stolen password — the exact threat 2FA backstops — cannot +//! rotate the factor to their own authenticator. +//! - **TOTP is one-time-use** (RFC-6238 §5.2): the last-accepted time-step is persisted and a code at a +//! step `<=` it is rejected as a replay. +//! - **Passkey/WebAuthn** is behind the same interface; the real `webauthn-rs` ceremony is finalized +//! with the extension origin (#433 follow-up) — it fails closed until then, so the active method +//! never becomes `passkey` on this node. + +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dig_keystore::{opaque, KdfParams, Password}; +use serde::{Deserialize, Serialize}; +use totp_rs::{Algorithm, Secret, TOTP}; + +use super::custody::WalletCustody; +use super::spend::WalletSigner; +use super::{Error, Result}; + +/// The unlock mode — the ONLY policy knob (§18.24). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum UnlockMode { + /// DEFAULT (secure): an unlock grants a READ-ONLY session; each signature needs a fresh + /// `sign_unlock` → decrypt → sign one → drop. The key is never resident between signatures. + #[default] + PerTransaction, + /// OPT-OUT (convenience, OFF by default): one unlock holds the signer for the session lifetime. + SessionUnlockAll, +} + +/// The active unlock authentication method (§18.24). One is active at a time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum AuthMethod { + /// Password only (the default). The password is the wallet seed's at-rest KDF root. + #[default] + Password, + /// Password + a node-level RFC-6238 TOTP code (2FA). + Totp, + /// Password + a node-level WebAuthn assertion. Ceremony finalized with the extension origin (#433). + Passkey, +} + +/// The reported session state (§18.24). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthSessionState { + /// No active session: no reads authorized, no signer available. + Locked, + /// A read-only session is active (reads authorized); signing still needs a `sign_unlock` + /// (per-transaction) or is enabled via the held session signer (session-unlock-all). + ReadOnly, +} + +/// A presented unlock credential (§18.24). The password is always required (it decrypts the target +/// wallet's seed); the active method dictates which additional NODE-LEVEL factor must also be present. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Credential { + /// The target wallet's password (its seed's at-rest KDF root). Always required. + #[serde(default)] + pub password: String, + /// The current node-level TOTP code — required when the active method is [`AuthMethod::Totp`]. + #[serde(default)] + pub totp_code: Option, + /// The node-level WebAuthn assertion — required when the active method is [`AuthMethod::Passkey`] (#433). + #[serde(default)] + pub passkey_assertion: Option, +} + +impl Credential { + /// A password-only credential (test/convenience helper). + pub fn password(pw: impl Into) -> Self { + Self { + password: pw.into(), + totp_code: None, + passkey_assertion: None, + } + } + /// A password + TOTP-code credential. + pub fn with_totp(pw: impl Into, code: impl Into) -> Self { + Self { + password: pw.into(), + totp_code: Some(code.into()), + passkey_assertion: None, + } + } +} + +/// The one-time TOTP enrollment result (§18.24). Returned ONLY at `enroll_totp` — the caller needs +/// the secret/URI to provision the authenticator (QR). Never re-derivable afterwards. +#[derive(Debug, Clone, Serialize)] +pub struct TotpEnrollment { + /// The base32-encoded shared secret (the authenticator's manual-entry key). + pub secret_base32: String, + /// The `otpauth://totp/...` provisioning URI (rendered as a QR by the caller). + pub otpauth_uri: String, +} + +/// A registered passkey credential (non-secret: id + public key + signature counter). (#433.) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PasskeyCredential { + /// The credential id (base64url). + pub id: String, + /// The credential public key (COSE/base64url). + pub public_key: String, + /// The last-seen signature counter (monotonic anti-clone check). + pub counter: u32, +} + +/// The auth status reported by [`UnlockAuth::status`] (§18.24) / `auth.status`. +#[derive(Debug, Clone, Serialize)] +pub struct AuthStatus { + /// The active unlock mode. + pub mode: UnlockMode, + /// The active authentication method. + pub method: AuthMethod, + /// The current session state. + pub state: AuthSessionState, + /// Whether a one-shot per-transaction sign grant is armed right now. + pub sign_armed: bool, + /// Whether any wallet is custodied on this node (so a caller knows unlock is possible). + pub has_wallet: bool, +} + +/// The persisted, NON-SECRET auth config (`/auth/config.json`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct AuthConfig { + #[serde(default)] + mode: UnlockMode, + #[serde(default)] + method: AuthMethod, + /// The registered passkey credential, when the method is `passkey` (non-secret). + #[serde(default, skip_serializing_if = "Option::is_none")] + passkey: Option, + /// The last-accepted TOTP time-step (RFC-6238 §5.2 one-time-use, #432 Finding B). A code at a + /// step `<=` this is rejected as a replay. Persisted so replay protection survives a restart. + #[serde(default, skip_serializing_if = "Option::is_none")] + totp_last_step: Option, +} + +/// A signer bound to the wallet it was decrypted for (#432 Decision 8): the effective-signer gate +/// only returns it when the op's ACTIVE wallet matches, so a grant/session can never sign for the +/// wrong wallet (multi-wallet, #427). +type BoundSigner = (String, Arc); + +/// The runtime (non-persisted) unlock state. +#[derive(Default)] +struct Runtime { + /// Whether a read-only session is active. + read_only: bool, + /// The held session signer (session-unlock-all mode only), bound to its wallet id. + session_signer: Option, + /// The armed one-shot per-transaction sign grant, bound to its wallet id (consumed after one op). + sign_grant: Option, +} + +/// The subdirectory (under the node config dir) holding the auth config + sealed secrets. +const AUTH_SUBDIR: &str = "auth"; +/// The non-secret auth config filename. +const CONFIG_FILE: &str = "config.json"; +/// The sealed-TOTP-secret filename (a `dig-keystore` container, sealed under the NODE-level key). +const TOTP_FILE: &str = "totp.dig"; +/// The node-level device key filename (owner-only) that seals the node-level 2nd-factor secret. +const NODE_KEY_FILE: &str = "node.key"; +/// Fixed TOTP parameters (RFC-6238 defaults, matching common authenticators). +const TOTP_ISSUER: &str = "DIG Node"; +const TOTP_DIGITS: usize = 6; +const TOTP_SKEW: u8 = 1; +const TOTP_STEP: u64 = 30; + +/// The node-managed unlock authority (§18.24). Owns the auth+unlock state machine over a +/// [`WalletCustody`] (§18.20). Cheap to `clone` — all state is shared behind `Arc`s, so an +/// `unlock`/`sign_unlock`/`lock` on one handle is visible on the others. +#[derive(Clone)] +pub struct UnlockAuth { + /// The node-custodied wallets this authority gates. + custody: WalletCustody, + /// The node config directory (holds `auth/`). + config_dir: PathBuf, + /// The persisted, non-secret config (mode + method + passkey credential + TOTP replay counter). + config: Arc>, + /// The runtime unlock state (read-only flag + session/one-shot signers). + runtime: Arc>, +} + +impl UnlockAuth { + /// Build the unlock authority over `custody`, rooted at `config_dir`. Loads the on-disk auth + /// config (or the secure defaults: `per_transaction` + `password`) — signing starts LOCKED + /// regardless of prior state (the runtime session is never persisted). + pub fn new(custody: WalletCustody, config_dir: PathBuf) -> Self { + let config = Self::read_config(&config_dir).unwrap_or_default(); + Self { + custody, + config_dir, + config: Arc::new(RwLock::new(config)), + runtime: Arc::new(RwLock::new(Runtime::default())), + } + } + + // ---- policy (mode + method) ------------------------------------------ + + /// The active unlock mode. + pub fn mode(&self) -> UnlockMode { + self.config.read().unwrap().mode + } + + /// Set the unlock mode (§18.24, #432 Finding 3). Switching INTO `session_unlock_all` WEAKENS the + /// posture (the next unlock loads a session-resident signer), so it REQUIRES a valid current-factor + /// `cred` — a paired token alone must not silently downgrade. Switching to `per_transaction` + /// (tightening) needs no credential and immediately drops any held session signer. + pub fn set_mode(&self, id: Option<&str>, mode: UnlockMode, cred: &Credential) -> Result<()> { + if mode == UnlockMode::SessionUnlockAll { + self.verify(id, cred)?; + } + { + let mut c = self.config.write().unwrap(); + c.mode = mode; + } + if mode == UnlockMode::PerTransaction { + self.runtime.write().unwrap().session_signer = None; + } + self.persist_config() + } + + /// The active authentication method. + pub fn method(&self) -> AuthMethod { + self.config.read().unwrap().method + } + + /// The current auth status (§18.24). + pub fn status(&self) -> AuthStatus { + let cfg = self.config.read().unwrap(); + let rt = self.runtime.read().unwrap(); + AuthStatus { + mode: cfg.mode, + method: cfg.method, + state: if rt.read_only { + AuthSessionState::ReadOnly + } else { + AuthSessionState::Locked + }, + sign_armed: rt.sign_grant.is_some(), + has_wallet: self.custody.any_wallet(), + } + } + + // ---- enrollment ------------------------------------------------------ + + /// Enroll (or rotate) TOTP as the active NODE-LEVEL method (§18.24, #432 Findings A + Decision 9). + /// + /// Re-verifies the CURRENT factor via `self.verify(id, cred)` FIRST — so when a second factor is + /// already active, the live code is required (an attacker with only the paired token + password + /// cannot re-enroll their own authenticator). Then generates a fresh secret, seals it under the + /// node-level device key (NOT any wallet password), sets the method to `totp`, resets the replay + /// counter, and returns the base32 secret + `otpauth://` URI ONCE. Fails closed on a bad credential. + pub fn enroll_totp(&self, id: Option<&str>, cred: &Credential) -> Result { + // Re-verify the current factor before rotating (Finding A). Password-only enroll is allowed + // ONLY when the method is currently Password (verify then checks just the password). + self.verify(id, cred)?; + let node_key = self.ensure_node_key()?; + let secret = Secret::generate_secret(); + let secret_bytes = secret + .to_bytes() + .map_err(|e| Error::internal(format!("totp secret: {e}")))?; + let totp = Self::build_totp(secret_bytes.clone())?; + // Seal the secret under the NODE-level key (node-level 2nd factor, Decision 9). + let sealed = opaque::seal( + &Password::from(hex::encode(&node_key)), + &secret_bytes, + KdfParams::default(), + ) + .map_err(|e| Error::internal(format!("seal totp secret: {e}")))?; + std::fs::write(self.totp_path(), &sealed) + .map_err(|e| Error::internal(format!("write totp secret: {e}")))?; + restrict_permissions(&self.totp_path()); + { + let mut c = self.config.write().unwrap(); + c.method = AuthMethod::Totp; + c.passkey = None; + c.totp_last_step = None; // fresh secret ⇒ fresh replay window + } + self.persist_config()?; + Ok(TotpEnrollment { + secret_base32: totp.get_secret_base32(), + otpauth_uri: totp.get_url(), + }) + } + + /// Disable any second factor and return to `password`-only (§18.24): re-verify the CURRENT + /// factor's `cred`, then remove the TOTP/passkey material and set the method to `password`. + pub fn set_method_password(&self, id: Option<&str>, cred: &Credential) -> Result<()> { + self.verify(id, cred)?; + let _ = std::fs::remove_file(self.totp_path()); + { + let mut c = self.config.write().unwrap(); + c.method = AuthMethod::Password; + c.passkey = None; + c.totp_last_step = None; + } + self.persist_config() + } + + /// Begin a passkey (WebAuthn) registration (§18.24). Re-verifies the current factor, but the real + /// `webauthn-rs` ceremony is finalized with the extension origin (#433): it fails closed here so + /// the method never becomes `passkey` until that lands. + pub fn enroll_passkey_begin( + &self, + id: Option<&str>, + cred: &Credential, + ) -> Result { + self.verify(id, cred)?; + Err(Self::passkey_deferred()) + } + + /// Finish a passkey registration (see [`Self::enroll_passkey_begin`]) — deferred to #433. + pub fn enroll_passkey_finish(&self, _response: &serde_json::Value) -> Result<()> { + Err(Self::passkey_deferred()) + } + + // ---- the unlock state machine ---------------------------------------- + + /// Authenticate to the node and grant a READ-ONLY session (§18.24). Verifies `cred` per the active + /// method; on success sets the session read-only and — in `session_unlock_all` mode ONLY — builds + /// and HOLDS the signer for the session (bound to its wallet). In the DEFAULT `per_transaction` + /// mode NO signer is loaded. A wrong/expired/replayed credential is denied (`401`), leaves the + /// state unchanged, and loads nothing. + pub fn unlock(&self, id: Option<&str>, cred: &Credential) -> Result { + self.verify(id, cred)?; + let mode = self.mode(); + // Build the session signer BEFORE taking the runtime lock (I/O + key derivation). + let session_signer = if mode == UnlockMode::SessionUnlockAll { + Some(self.build_bound_signer(id, &cred.password)?) + } else { + None + }; + { + let mut rt = self.runtime.write().unwrap(); + rt.read_only = true; + rt.session_signer = session_signer; + rt.sign_grant = None; + } + Ok(self.status()) + } + + /// Authorize exactly ONE signing operation (§18.24). Verifies `cred` FRESH per the active method, + /// decrypts the target wallet's seed, and arms a ONE-SHOT signer BOUND to that wallet + /// ([`Self::consume_sign_grant`] drops it after one op). Required for every signature in the + /// DEFAULT `per_transaction` mode. A wrong/expired/replayed credential is denied (`401`) and arms + /// nothing. + pub fn sign_unlock(&self, id: Option<&str>, cred: &Credential) -> Result { + self.verify(id, cred)?; + let grant = self.build_bound_signer(id, &cred.password)?; + { + let mut rt = self.runtime.write().unwrap(); + rt.read_only = true; + rt.sign_grant = Some(grant); + } + Ok(self.status()) + } + + /// Lock: clear the read-only session and DROP both the session signer and any armed one-shot + /// grant, releasing the decrypted-key allocation. Idempotent. + pub fn lock(&self) { + let mut rt = self.runtime.write().unwrap(); + rt.read_only = false; + rt.session_signer = None; + rt.sign_grant = None; + } + + /// The EFFECTIVE signer the §18.21 sign path obtains (the auth gate, #432 Decision 8). Returns the + /// armed one-shot grant if present (per-transaction), else the held session signer + /// (session-unlock-all) — but ONLY when the bound wallet matches the node's CURRENTLY-ACTIVE + /// wallet. A grant/session bound to a different wallet than the op targets fails closed (`None`), + /// so a signature can never be produced for the wrong wallet. Reading it does NOT consume the + /// one-shot grant. + pub fn effective_signer(&self) -> Option> { + // The Sage sign path (§18.21) signs with the ACTIVE wallet; the grant/session must be bound to + // that same wallet. + let active = self.custody.status(None).id?; + let rt = self.runtime.read().unwrap(); + if let Some((wid, sig)) = &rt.sign_grant { + // An armed grant is authoritative: it must match the active wallet, else fail closed + // (never fall back to a session signer while a grant is armed for a different wallet). + return (*wid == active).then(|| sig.clone()); + } + if let Some((wid, sig)) = &rt.session_signer { + if *wid == active { + return Some(sig.clone()); + } + } + None + } + + /// Consume the one-shot per-transaction sign grant after a single signing operation (§18.24): drop + /// it so the decrypted-key allocation is released and the NEXT signature requires a fresh + /// `sign_unlock`. A no-op when nothing is armed. Panic-safe consumption is enforced by the + /// caller's RAII guard (rpc dispatch). + pub fn consume_sign_grant(&self) { + self.runtime.write().unwrap().sign_grant = None; + } + + // ---- verification ---------------------------------------------------- + + /// Verify a presented credential against the active method (§18.24). The target wallet's password + /// is ALWAYS checked (per-wallet KDF root); the node-level factor is then required per the active + /// method. Fails closed on any missing/wrong/replayed factor. NEVER mutates unlock state (it MAY + /// advance the persisted TOTP replay counter — a successful code is one-time-use). + fn verify(&self, id: Option<&str>, cred: &Credential) -> Result<()> { + self.custody.verify_password(id, &cred.password)?; + match self.method() { + AuthMethod::Password => Ok(()), + AuthMethod::Totp => self.verify_totp(cred.totp_code.as_deref()), + AuthMethod::Passkey => self.verify_passkey(cred.passkey_assertion.as_ref()), + } + } + + /// Verify a node-level TOTP code with RFC-6238 one-time-use (#432 Finding B + re-verify). + /// + /// Opens the node-level secret with the device key, finds the time-step (within ±1) whose code + /// matches, and REJECTS it if that step is `<=` the last-accepted step (a replay). The + /// find → compare-to-`last` → advance-`last` sequence is done ATOMICALLY under a SINGLE + /// `config.write()` guard (an exclusive lock, so no two verifies can both read `last` before either + /// advances it) — since `auth.*` dispatch is NOT otherwise serialized, a non-atomic read-then-write + /// would re-open the in-process replay window under concurrency. The disk persist happens AFTER the + /// guard is released (best-effort durability across restart); the authoritative one-time-use gate is + /// the in-memory monotonic advance under the exclusive lock. + fn verify_totp(&self, code: Option<&str>) -> Result<()> { + let code = code.ok_or_else(|| Error::unauthorized("a TOTP code is required"))?; + let node_key = std::fs::read(self.node_key_path()) + .map_err(|_| Error::internal("TOTP is not enrolled on this node"))?; + let bytes = std::fs::read(self.totp_path()) + .map_err(|_| Error::internal("TOTP is not enrolled on this node"))?; + let secret = opaque::open(&Password::from(hex::encode(&node_key)), &bytes) + .map_err(|_| Error::internal("could not open the TOTP secret"))?; + let totp = Self::build_totp(secret.to_vec())?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let cur = now / TOTP_STEP; + + // ATOMIC check-and-advance: the whole find → compare → advance runs under ONE exclusive write + // guard, so two concurrent verifies of the SAME code cannot both pass `step <= last`. + { + let mut cfg = self.config.write().unwrap(); + let last = cfg.totp_last_step; + // Check the skew window newest-first so the highest matching step wins. + let matched = [cur + 1, cur, cur.saturating_sub(1)] + .into_iter() + .find(|&step| totp.generate(step * TOTP_STEP) == code); + match matched { + None => return Err(Error::unauthorized("invalid or expired TOTP code")), + Some(step) if last.is_some_and(|l| step <= l) => { + return Err(Error::unauthorized("TOTP code already used (replay)")) + } + Some(step) => cfg.totp_last_step = Some(step), + } + // `cfg` (the exclusive guard) drops here. + } + // Persist AFTER releasing the guard — the in-memory advance above already closed the window; + // this only makes the counter durable across a restart. NB: `persist_config` re-locks the + // config, so it MUST NOT run while the write guard is held (that would deadlock). + self.persist_config() + } + + /// Verify a WebAuthn assertion — deferred to #433 (fails closed). + fn verify_passkey(&self, _assertion: Option<&serde_json::Value>) -> Result<()> { + Err(Self::passkey_deferred()) + } + + // ---- helpers --------------------------------------------------------- + + /// Build a signer for the addressed wallet (default: active) bound to its RESOLVED id (#432 + /// Decision 8), decrypting the seed with `password`. The signer is not persisted into custody. + fn build_bound_signer(&self, id: Option<&str>, password: &str) -> Result { + let wallet_id = self + .custody + .status(id) + .id + .ok_or_else(|| Error::not_found("no wallet on this device"))?; + let signer = self.custody.sign_once(id, password)?; + Ok((wallet_id, signer)) + } + + /// Read the node-level device key, creating it (owner-only, random) on first use. + fn ensure_node_key(&self) -> Result> { + std::fs::create_dir_all(self.auth_dir()) + .map_err(|e| Error::internal(format!("create auth dir: {e}")))?; + let path = self.node_key_path(); + if let Ok(k) = std::fs::read(&path) { + if !k.is_empty() { + return Ok(k); + } + } + // 20 random bytes (160-bit) from the same CSPRNG totp-rs uses — ample for a device key. + let key = Secret::generate_secret() + .to_bytes() + .map_err(|e| Error::internal(format!("node key: {e}")))?; + std::fs::write(&path, &key).map_err(|e| Error::internal(format!("write node key: {e}")))?; + restrict_permissions(&path); + Ok(key) + } + + /// Build a [`TOTP`] over `secret` with the fixed DIG parameters (SHA1/6-digit/30s/±1). + fn build_totp(secret: Vec) -> Result { + TOTP::new( + Algorithm::SHA1, + TOTP_DIGITS, + TOTP_SKEW, + TOTP_STEP, + secret, + Some(TOTP_ISSUER.to_string()), + "dig-node".to_string(), + ) + .map_err(|e| Error::internal(format!("build totp: {e}"))) + } + + /// The stable "passkey ceremony is finalized in #433" error (fails closed). + fn passkey_deferred() -> Error { + Error::api( + "passkey (WebAuthn) enrollment is finalized with the paired-extension origin (see #433); \ + use password or TOTP until then", + ) + } + + /// The `auth/` directory under the node config dir. + fn auth_dir(&self) -> PathBuf { + self.config_dir.join(AUTH_SUBDIR) + } + /// The auth config path (`auth/config.json`). + fn config_path(&self) -> PathBuf { + self.auth_dir().join(CONFIG_FILE) + } + /// The sealed-TOTP-secret path (`auth/totp.dig`). + fn totp_path(&self) -> PathBuf { + self.auth_dir().join(TOTP_FILE) + } + /// The node-level device-key path (`auth/node.key`). + fn node_key_path(&self) -> PathBuf { + self.auth_dir().join(NODE_KEY_FILE) + } + + /// Read the on-disk auth config, or `None` when absent/unreadable/corrupt (the caller defaults). + fn read_config(config_dir: &std::path::Path) -> Option { + let bytes = std::fs::read(config_dir.join(AUTH_SUBDIR).join(CONFIG_FILE)).ok()?; + serde_json::from_slice(&bytes).ok() + } + + /// Persist the (non-secret) auth config atomically + owner-only. + fn persist_config(&self) -> Result<()> { + let dir = self.auth_dir(); + std::fs::create_dir_all(&dir) + .map_err(|e| Error::internal(format!("create auth dir: {e}")))?; + let json = { + let c = self.config.read().unwrap(); + serde_json::to_vec_pretty(&*c) + .map_err(|e| Error::internal(format!("serialize auth config: {e}")))? + }; + let path = self.config_path(); + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &json) + .map_err(|e| Error::internal(format!("write auth config: {e}")))?; + restrict_permissions(&tmp); + if path.exists() { + let _ = std::fs::remove_file(&path); + } + std::fs::rename(&tmp, &path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + Error::internal(format!("persist auth config: {e}")) + })?; + Ok(()) + } +} + +/// Restrict a file to owner read/write on Unix (`0600`); best-effort. No-op on non-Unix. +#[cfg(unix)] +fn restrict_permissions(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); +} +#[cfg(not(unix))] +fn restrict_permissions(_path: &std::path::Path) {} + +#[cfg(test)] +mod tests { + use super::super::custody::Network; + use super::*; + + const ABANDON: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon art"; + /// A second, distinct known mnemonic (different fingerprint) for multi-wallet tests. + const LEGAL: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + const PW: &str = "correcthorse"; + + /// A fresh authority over a custody manager with ONE imported (locked) wallet. + fn fresh() -> (UnlockAuth, WalletCustody, PathBuf) { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("dig-node-auth-{}-{}", std::process::id(), n)); + let _ = std::fs::remove_dir_all(&dir); + let custody = WalletCustody::new(dir.clone(), Network::Mainnet, 3); + custody.import(ABANDON, PW, None).unwrap(); + custody.lock(None); + (UnlockAuth::new(custody.clone(), dir.clone()), custody, dir) + } + + /// Generate the CURRENT live TOTP code for an enrolled authority (reads the returned secret). + fn live_code(enroll: &TotpEnrollment) -> String { + let secret = Secret::Encoded(enroll.secret_base32.clone()) + .to_bytes() + .unwrap(); + UnlockAuth::build_totp(secret) + .unwrap() + .generate_current() + .unwrap() + } + + #[test] + fn defaults_are_secure_per_transaction_password_locked() { + let (a, _c, _d) = fresh(); + let s = a.status(); + assert_eq!(s.mode, UnlockMode::PerTransaction); + assert_eq!(s.method, AuthMethod::Password); + assert_eq!(s.state, AuthSessionState::Locked); + assert!(!s.sign_armed); + assert!(s.has_wallet); + assert!(a.effective_signer().is_none()); + } + + #[test] + fn read_only_unlock_grants_reads_but_no_signer() { + let (a, _c, _d) = fresh(); + let s = a.unlock(None, &Credential::password(PW)).unwrap(); + assert_eq!(s.state, AuthSessionState::ReadOnly); + assert!(a.effective_signer().is_none()); + } + + #[test] + fn per_transaction_requires_fresh_sign_unlock_per_signature() { + let (a, _c, _d) = fresh(); + a.unlock(None, &Credential::password(PW)).unwrap(); + assert!(a.effective_signer().is_none()); + + a.sign_unlock(None, &Credential::password(PW)).unwrap(); + assert!(a.effective_signer().is_some()); + assert!(a.status().sign_armed); + + a.consume_sign_grant(); + assert!(a.effective_signer().is_none()); + assert!(!a.status().sign_armed); + + a.sign_unlock(None, &Credential::password(PW)).unwrap(); + assert!(a.effective_signer().is_some()); + } + + #[test] + fn decrypted_signer_is_not_retained_after_a_per_transaction_sign() { + let (a, _c, _d) = fresh(); + a.sign_unlock(None, &Credential::password(PW)).unwrap(); + let weak = { + let s = a.effective_signer().unwrap(); + std::sync::Arc::downgrade(&s) + }; + a.consume_sign_grant(); + assert!( + weak.upgrade().is_none(), + "the decrypted signer must be dropped (not resident) after the signing operation" + ); + assert!(a.effective_signer().is_none()); + } + + #[test] + fn session_unlock_all_signs_repeatedly_only_when_opted_in() { + let (a, _c, _d) = fresh(); + a.set_mode( + None, + UnlockMode::SessionUnlockAll, + &Credential::password(PW), + ) + .unwrap(); + a.unlock(None, &Credential::password(PW)).unwrap(); + assert!(a.effective_signer().is_some()); + a.consume_sign_grant(); + assert!( + a.effective_signer().is_some(), + "the held session signer survives grant consumption" + ); + a.set_mode(None, UnlockMode::PerTransaction, &Credential::password(PW)) + .unwrap(); + assert!( + a.effective_signer().is_none(), + "switching to per-transaction drops the held signer" + ); + } + + #[test] + fn set_mode_to_session_requires_a_valid_credential() { + // #432 Finding 3: a paired token alone must not silently weaken the posture. + let (a, _c, _d) = fresh(); + let enroll = a.enroll_totp(None, &Credential::password(PW)).unwrap(); + // With TOTP active, set_mode→session needs the live code, not just the password. + assert!(a + .set_mode( + None, + UnlockMode::SessionUnlockAll, + &Credential::password(PW) + ) + .is_err()); + assert_eq!(a.mode(), UnlockMode::PerTransaction, "downgrade denied"); + let code = live_code(&enroll); + assert!(a + .set_mode( + None, + UnlockMode::SessionUnlockAll, + &Credential::with_totp(PW, &code) + ) + .is_ok()); + assert_eq!(a.mode(), UnlockMode::SessionUnlockAll); + } + + #[test] + fn wrong_password_denies_and_mutates_nothing() { + let (a, _c, _d) = fresh(); + assert!(a.unlock(None, &Credential::password("wrong")).is_err()); + assert_eq!(a.status().state, AuthSessionState::Locked); + assert!(a.sign_unlock(None, &Credential::password("wrong")).is_err()); + assert!(a.effective_signer().is_none()); + assert!(!a.status().sign_armed); + } + + #[test] + fn lock_drops_every_signer() { + let (a, _c, _d) = fresh(); + a.set_mode( + None, + UnlockMode::SessionUnlockAll, + &Credential::password(PW), + ) + .unwrap(); + a.unlock(None, &Credential::password(PW)).unwrap(); + a.sign_unlock(None, &Credential::password(PW)).unwrap(); + assert!(a.effective_signer().is_some()); + a.lock(); + assert!(a.effective_signer().is_none()); + assert_eq!(a.status().state, AuthSessionState::Locked); + } + + #[test] + fn totp_enroll_then_verify_happy_and_wrong_code() { + let (a, _c, _d) = fresh(); + let enroll = a.enroll_totp(None, &Credential::password(PW)).unwrap(); + assert!(!enroll.secret_base32.is_empty()); + assert!(enroll.otpauth_uri.starts_with("otpauth://totp/")); + assert_eq!(a.method(), AuthMethod::Totp); + + let code = live_code(&enroll); + assert!( + a.unlock(None, &Credential::password(PW)).is_err(), + "totp method requires a code" + ); + assert!(a.unlock(None, &Credential::with_totp(PW, &code)).is_ok()); + assert!(a + .unlock(None, &Credential::with_totp(PW, "000000")) + .is_err()); + assert!(a + .unlock(None, &Credential::with_totp("wrong", &code)) + .is_err()); + } + + #[test] + fn totp_code_is_one_time_use_replay_rejected() { + // #432 Finding B: a captured code must not replay within its ±skew validity window. + let (a, _c, _d) = fresh(); + let enroll = a.enroll_totp(None, &Credential::password(PW)).unwrap(); + let code = live_code(&enroll); + assert!( + a.unlock(None, &Credential::with_totp(PW, &code)).is_ok(), + "first use accepted" + ); + let err = a + .unlock(None, &Credential::with_totp(PW, &code)) + .unwrap_err(); + assert!( + err.message.to_lowercase().contains("replay") || err.message.contains("already used"), + "a replayed code must be rejected, got: {}", + err.message + ); + } + + #[test] + fn concurrent_verify_of_one_code_accepts_exactly_once() { + // #432 re-verify: TWO threads present the SAME live code at the same instant. The atomic + // check-and-advance (single exclusive `config.write()` guard) must let EXACTLY ONE succeed — + // a non-atomic read-then-write would let both pass `step <= last` and re-open the in-process + // replay window (the stolen-password + captured-code threat TOTP backstops). + let (a, _c, _d) = fresh(); + let enroll = a.enroll_totp(None, &Credential::password(PW)).unwrap(); + let code = live_code(&enroll); + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let (r1, r2) = std::thread::scope(|s| { + let mk = |auth: UnlockAuth, code: String, bar: std::sync::Arc| { + s.spawn(move || { + bar.wait(); // align both threads onto verify_totp simultaneously + auth.unlock(None, &Credential::with_totp(PW, &code)).is_ok() + }) + }; + let h1 = mk(a.clone(), code.clone(), barrier.clone()); + let h2 = mk(a.clone(), code.clone(), barrier.clone()); + (h1.join().unwrap(), h2.join().unwrap()) + }); + assert_eq!( + [r1, r2].iter().filter(|ok| **ok).count(), + 1, + "exactly one concurrent verify of the same code may succeed (atomic one-time-use)" + ); + } + + #[test] + fn enroll_totp_rotation_requires_the_current_factor() { + // #432 Finding A: re-enrolling when a 2nd factor is active must require the LIVE code, + // not just the password (else a stolen password re-enrolls the attacker's authenticator). + let (a, _c, _d) = fresh(); + let enroll = a.enroll_totp(None, &Credential::password(PW)).unwrap(); + // Password-only rotation is now refused. + assert!( + a.enroll_totp(None, &Credential::password(PW)).is_err(), + "rotation must require the current TOTP factor" + ); + // With the live code it rotates to a NEW secret. + let code = live_code(&enroll); + let enroll2 = a + .enroll_totp(None, &Credential::with_totp(PW, &code)) + .unwrap(); + assert_ne!( + enroll.secret_base32, enroll2.secret_base32, + "rotation issues a fresh secret" + ); + } + + #[test] + fn totp_is_node_level_and_works_across_wallets() { + // #432 Decision 9: the 2nd factor is node-level, so a code enrolled via wallet A also + // authorizes wallet B (with B's own password). + let (a, custody, _d) = fresh(); + let b = custody.import(LEGAL, "passphrase-b", None).unwrap(); + custody.lock(None); + let enroll = a.enroll_totp(None, &Credential::password(PW)).unwrap(); + + // Unlock wallet B with B's password + the node-level code. + let code = live_code(&enroll); + assert!(a + .unlock(Some(&b.id), &Credential::with_totp("passphrase-b", &code)) + .is_ok()); + } + + #[test] + fn sign_grant_is_bound_to_its_wallet() { + // #432 Decision 8: a grant armed for wallet A must not sign when B is the active wallet. + let (a, custody, _d) = fresh(); + let wallet_a = custody.list()[0].id.clone(); // ABANDON = active + let b = custody.import(LEGAL, "passphrase-b", None).unwrap(); + custody.select(&wallet_a).unwrap(); // A active + custody.lock(None); + + // Arm a grant for A (the active wallet). + a.sign_unlock(None, &Credential::password(PW)).unwrap(); + assert!(a.effective_signer().is_some(), "grant valid for active A"); + + // Switch the active wallet to B → the A-bound grant no longer applies (fail closed). + custody.select(&b.id).unwrap(); + assert!( + a.effective_signer().is_none(), + "a grant bound to A must not sign for the now-active B" + ); + } + + #[test] + fn set_method_back_to_password_requires_current_factor() { + let (a, _c, _d) = fresh(); + let enroll = a.enroll_totp(None, &Credential::password(PW)).unwrap(); + assert!(a + .set_method_password(None, &Credential::password(PW)) + .is_err()); + let code = live_code(&enroll); + assert!(a + .set_method_password(None, &Credential::with_totp(PW, &code)) + .is_ok()); + assert_eq!(a.method(), AuthMethod::Password); + assert!(a.unlock(None, &Credential::password(PW)).is_ok()); + } + + #[test] + fn passkey_enrollment_is_deferred_and_fails_closed() { + let (a, _c, _d) = fresh(); + assert!(a + .enroll_passkey_begin(None, &Credential::password(PW)) + .is_err()); + assert_eq!(a.method(), AuthMethod::Password); + } + + #[test] + fn mode_and_method_persist_across_reconstruction() { + let (a, _c, dir) = fresh(); + a.set_mode( + None, + UnlockMode::SessionUnlockAll, + &Credential::password(PW), + ) + .unwrap(); + a.enroll_totp(None, &Credential::password(PW)).unwrap(); + + let custody2 = WalletCustody::new(dir.clone(), Network::Mainnet, 3); + let a2 = UnlockAuth::new(custody2, dir); + assert_eq!(a2.mode(), UnlockMode::SessionUnlockAll); + assert_eq!(a2.method(), AuthMethod::Totp); + assert_eq!(a2.status().state, AuthSessionState::Locked); + assert!(a2.effective_signer().is_none()); + } +} diff --git a/crates/dig-wallet/src/sage/custody.rs b/crates/dig-wallet/src/sage/custody.rs index 0e8fd95..04ec62b 100644 --- a/crates/dig-wallet/src/sage/custody.rs +++ b/crates/dig-wallet/src/sage/custody.rs @@ -419,6 +419,30 @@ impl WalletCustody { .map(|u| u.signer.clone()) } + /// Verify `password` against the addressed wallet's on-disk seed WITHOUT loading a signer or + /// changing any state (§18.24): decrypt the seed (the decrypted mnemonic is dropped immediately) + /// and return `Ok` iff it decrypts. This is the read-only-session password check the unlock-auth + /// state machine uses — a successful verify grants reads but never makes signing possible. Wrong + /// password fails closed (`401`); a missing wallet is `404`. NEVER mutates custody. + pub fn verify_password(&self, id: Option<&str>, password: &str) -> Result<()> { + let id = self.resolve_id(id)?; + // `read_seed` returns a `Zeroizing`, dropped (and scrubbed) at the end of this scope. + let _ = self.read_seed(&id, password)?; + Ok(()) + } + + /// Build a ONE-SHOT signer for the addressed wallet (default: the ACTIVE wallet) by decrypting its + /// on-disk seed with `password` — WITHOUT inserting it into the persistent `unlocked` session + /// (§18.24 per-transaction sign). The returned `Arc` is the ONLY strong reference; + /// when the caller drops it (after one signing operation) the decrypted-key allocation is released + /// — the key is not retained. Wrong password fails closed (`401`); a missing wallet is `404`. + pub fn sign_once(&self, id: Option<&str>, password: &str) -> Result> { + let id = self.resolve_id(id)?; + let mnemonic = self.read_seed(&id, password)?; + let (signer, _address) = self.build_signer(&mnemonic)?; + Ok(Arc::new(signer)) + } + // ---- internals -------------------------------------------------------- /// Reject a password below the minimum length. @@ -1060,6 +1084,62 @@ mod tests { assert!(c.reveal_mnemonic(Some(&a.id), "wrong").is_err()); } + #[test] + fn verify_password_checks_without_loading_a_signer() { + let (c, _p) = fresh(); + c.import(ABANDON, "correcthorse", None).unwrap(); + c.lock(None); + assert_eq!(c.status(None).state, CustodyState::Locked); + // A correct password verifies; a wrong password fails closed. Neither loads a signer. + assert!(c.verify_password(None, "correcthorse").is_ok()); + assert!(c.verify_password(None, "wrong").is_err()); + assert_eq!( + c.status(None).state, + CustodyState::Locked, + "verify_password must not load a signer" + ); + assert!(c.signer(None).is_none()); + } + + #[test] + fn sign_once_builds_a_signer_without_persisting_a_session() { + let (c, _p) = fresh(); + let held = c.import(ABANDON, "correcthorse", None).unwrap(); + c.lock(None); + assert!(c.signer(None).is_none(), "locked"); + + // sign_once builds a usable signer (same wallet's puzzle hashes) but does NOT persist it. + let one = c.sign_once(None, "correcthorse").unwrap(); + assert!(!one.puzzle_hashes().is_empty()); + assert!( + c.signer(None).is_none(), + "sign_once must not load a persistent session" + ); + assert_eq!(c.status(None).state, CustodyState::Locked); + + // The one-shot signer is the sole owner: dropping it releases the decrypted-key allocation. + let weak = Arc::downgrade(&one); + drop(one); + assert!( + weak.upgrade().is_none(), + "the decrypted signer must not be retained after drop" + ); + let _ = held; + } + + #[test] + fn sign_once_wrong_password_fails_closed() { + let (c, _p) = fresh(); + c.import(ABANDON, "correcthorse", None).unwrap(); + c.lock(None); + // NB: `WalletSigner` deliberately has no `Debug` (it holds secret keys), so we cannot + // `unwrap_err()` the `Result, _>` — inspect the error via `err()`. + let res = c.sign_once(None, "wrong"); + assert!(res.is_err()); + assert_eq!(res.err().unwrap().kind, ErrorKind::Unauthorized); + assert!(c.signer(None).is_none()); + } + #[test] fn weak_password_is_rejected() { let (c, dir) = fresh(); diff --git a/crates/dig-wallet/src/sage/mod.rs b/crates/dig-wallet/src/sage/mod.rs index ecba311..d9a0af0 100644 --- a/crates/dig-wallet/src/sage/mod.rs +++ b/crates/dig-wallet/src/sage/mod.rs @@ -59,6 +59,7 @@ //! image/color-extraction pipeline (SPEC §18.16). pub mod actions; +pub mod auth; pub mod custody; pub mod db; pub mod events; diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 2703ebc..621dad5 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -21,6 +21,7 @@ use serde_json::Value; use chia_wallet_sdk::driver::Cat; +use super::auth::{AuthMethod, Credential, UnlockAuth, UnlockMode}; use super::custody::WalletCustody; use super::db::{CoinRow, OfferDbRow, OptionDbRow, WalletDb}; use super::events::EventBus; @@ -91,6 +92,16 @@ pub struct WalletBackend { /// runtime (#368 runtime signer load) — not only from a signer injected once at construction. /// `None` on the test/simulator path (which injects a fixed signer via [`Self::with_signer`]). custody: Option, + /// The node-managed unlock authority (#431/#432, §18.24), attached at bring-up + /// ([`Self::with_auth`]). When present it GOVERNS [`Self::current_signer`]: the §18.21 sign path + /// obtains a signer ONLY through the auth gate (read-only unlock ⇒ no signer; per-transaction ⇒ a + /// one-shot grant; session-unlock-all ⇒ the held session signer). `None` (the test/simulator path) + /// preserves the legacy behaviour where a `wallet.unlock` alone enables signing. + auth: Option>, + /// Serializes key-touching signing dispatch (#432 Finding 1). Held across a whole signing method + /// through the one-shot grant's consumption, so two concurrent signing calls on the shared backend + /// can never both observe one armed grant (a gate TOCTOU). Reads/non-signing methods never take it. + sign_lock: Arc>, /// The tipping subsystem (#378), attached at bring-up ([`Self::with_tipping`]). `None` disables /// the `tip.*` methods (the transport reports them unavailable). The engine holds the persisted /// config + ledger, the owner resolver, and the tip spender. @@ -128,6 +139,8 @@ impl WalletBackend { events: Arc::new(EventBus::default()), identity: Arc::new(RwLock::new(None)), custody: None, + auth: None, + sign_lock: Arc::new(tokio::sync::Mutex::new(())), tipping: None, tip_events: Arc::new(super::tipping::TipEventBus::default()), } @@ -179,6 +192,21 @@ impl WalletBackend { self.custody.as_ref() } + /// Attach the node-managed unlock authority (#431/#432, §18.24). Once attached it GOVERNS the + /// effective signer: the sign/broadcast-on-behalf path (§18.21) obtains a signer only through the + /// auth gate, so signing is SAFE BY DEFAULT (per-transaction re-auth; key not resident between + /// signatures). Without it, the legacy behaviour (a `wallet.unlock` enables signing for the + /// session) is preserved for the simulator/test path. + pub fn with_auth(mut self, auth: Arc) -> Self { + self.auth = Some(auth); + self + } + + /// The node-managed unlock authority, if attached (#431/#432). + pub fn auth(&self) -> Option<&Arc> { + self.auth.as_ref() + } + /// Attach the network broadcaster (enables `auto_submit` + `submit_transaction`). pub fn with_broadcaster(mut self, broadcaster: Arc) -> Self { self.broadcaster = Some(broadcaster); @@ -905,14 +933,23 @@ impl WalletBackend { .collect() } - /// The EFFECTIVE signing key (#368): the bring-up-injected signer if present, else the signer - /// loaded by the currently-unlocked node custody. `None` ⇒ the wallet is locked / no key is - /// available. Returns an owned `Arc` because the custody signer lives behind custody's own lock, - /// so it cannot be borrowed out of `&self`. + /// The EFFECTIVE signing key (#368/#432): the bring-up-injected signer if present (tests/simulator + /// win), else — when the node-managed unlock authority (§18.24) is attached — the AUTH-GATED + /// signer (a per-transaction one-shot grant, or the held session signer in session-unlock-all + /// mode; `None` when only a read-only session is active). With NO auth attached, the legacy path: + /// the signer of the currently-unlocked node custody. `None` ⇒ the wallet is locked for signing. + /// Returns an owned `Arc` because the gated signer lives behind another lock, so it cannot be + /// borrowed out of `&self`. fn current_signer(&self) -> Option> { - self.signer - .clone() - .or_else(|| self.custody.as_ref().and_then(|c| c.signer(None))) + if let Some(s) = self.signer.clone() { + return Some(s); + } + if let Some(auth) = self.auth.as_ref() { + // The auth gate is authoritative when attached — NEVER fall back to a held custody signer, + // or the per-transaction guarantee (key not resident between signatures) would leak. + return auth.effective_signer(); + } + self.custody.as_ref().and_then(|c| c.signer(None)) } /// The signer, or a locked-wallet error (C.6: spends need node-custodied keys). @@ -2009,9 +2046,28 @@ impl WalletBackend { /// message. This is the ONE handler set both transports share (design C.3), so their /// bodies are byte-identical. pub async fn dispatch(&self, method: &str, body: &str) -> (u16, String) { - match self.dispatch_inner(method, body).await { - Ok(json) => (200, json), - Err(e) => (e.kind.status(), e.message), + if is_signing_method(method) { + // #432 Finding 1 (gate TOCTOU): SERIALIZE signing dispatch. Held across the WHOLE + // dispatch (which reads `current_signer` several times) THROUGH grant consumption, so two + // concurrent signing calls on the shared backend can never both clone one armed grant → + // one `sign_unlock` authorizes exactly one signature. + let _sign_lock = self.sign_lock.lock().await; + // #432 Finding 5 (panic-safe consume): an RAII guard consumes the one-shot grant on ANY + // exit — normal return OR a panic unwind — so a panicking signing handler can never leave + // the decrypted key armed + reusable. Drops BEFORE `_sign_lock` releases (reverse order), + // so consumption is still inside the serialized section. + let _consume = SignGrantGuard { + auth: self.auth.clone(), + }; + match self.dispatch_inner(method, body).await { + Ok(json) => (200, json), + Err(e) => (e.kind.status(), e.message), + } + } else { + match self.dispatch_inner(method, body).await { + Ok(json) => (200, json), + Err(e) => (e.kind.status(), e.message), + } } } @@ -2334,6 +2390,10 @@ impl WalletBackend { // status/delete. Reachable only when custody is attached ([`Self::with_custody`]); // authorization is the transport's concern (the paired-token gate, SPEC §7.12). m if m.starts_with("wallet.") => self.dispatch_custody(m, body)?, + // The node-managed unlock authority (#431/#432, §18.24): status/get_method/set_method/ + // set_mode/enroll_totp/enroll_passkey_*/unlock/sign_unlock/lock. Reachable only when auth + // is attached ([`Self::with_auth`]); every `auth.*` method is paired-token gated (§7.12). + m if m.starts_with("auth.") => self.dispatch_auth(m, body)?, other => { return Err(Error::not_found(format!( "unknown or unsupported method: {other}" @@ -2391,6 +2451,19 @@ impl WalletBackend { let r: CustodyReq = serde_json::from_str(body_s) .map_err(|e| Error::api(format!("invalid request for {method}: {e}")))?; let id = r.id.as_deref(); + // When the node-managed unlock authority (§18.24) is attached it is the ONLY signer-loading + // path (#432 Finding 4): provisioning must NOT leave a session-long resident signer in custody, + // and `wallet.unlock` (which would load one) is redirected to `auth.*`. + let auth_attached = self.auth.is_some(); + // Provision a wallet, then — when auth gates signing — immediately lock its custody session so + // NO resident `WalletSigner` lingers over the paired boundary (the auth gate loads keys on + // demand via `sign_once`). + let provision = |w: super::custody::WalletRef| -> Result { + if auth_attached { + custody.lock(Some(w.id.as_str())); + } + json(w) + }; // create/import/restore/unlock return the wallet ref `{ id, address }`. match method { "wallet.status" => json(custody.status(id)), @@ -2404,10 +2477,21 @@ impl WalletBackend { let active = wallets.iter().find(|w| w.active).map(|w| w.id.clone()); json(ListResp { active, wallets }) } - "wallet.create" => json(custody.create(&r.password, r.label)?), - "wallet.import" => json(custody.import(&r.mnemonic, &r.password, r.label)?), - "wallet.restore" => json(custody.restore(&r.mnemonic, &r.password, r.label)?), - "wallet.unlock" => json(custody.unlock(id, &r.password)?), + "wallet.create" => provision(custody.create(&r.password, r.label)?), + "wallet.import" => provision(custody.import(&r.mnemonic, &r.password, r.label)?), + "wallet.restore" => provision(custody.restore(&r.mnemonic, &r.password, r.label)?), + "wallet.unlock" => { + if auth_attached { + // The auth gate owns signer loading — a resident custody unlock would leak a + // session-long key never used for signing (the gate wins). Redirect the caller. + Err(Error::api( + "this node uses unlock authentication: use auth.unlock (read-only session) \ + or auth.sign_unlock (per-transaction signing)", + )) + } else { + json(custody.unlock(id, &r.password)?) + } + } "wallet.select" => { let sel = r.id.as_deref() @@ -2430,6 +2514,96 @@ impl WalletBackend { } } + /// Dispatch an `auth.*` node-managed unlock method to the attached [`UnlockAuth`] (#431/#432, + /// §18.24). A node without auth attached reports the method unavailable. Every `auth.*` method is + /// paired-token gated by the transport (§7.12). The credential fields (`password`/`totp_code`/ + /// `passkey_assertion`) and the optional `id` (addresses a specific wallet) are read from the body. + fn dispatch_auth(&self, method: &str, body: &str) -> Result { + /// The union of every `auth.*` parameter; each method reads only the fields it needs. + #[derive(serde::Deserialize, Default)] + struct AuthReq { + /// Addresses a specific wallet; absent ⇒ the active wallet. + #[serde(default)] + id: Option, + /// The wallet password (the seed's KDF root) — required by every unlock/enroll. + #[serde(default)] + password: String, + /// The current TOTP code (method = totp). + #[serde(default)] + totp_code: Option, + /// The WebAuthn assertion (method = passkey, #433). + #[serde(default)] + passkey_assertion: Option, + /// The target mode for `auth.set_mode`. + #[serde(default)] + mode: Option, + /// The target method for `auth.set_method`. + #[serde(default)] + method: Option, + /// The registration response for `auth.enroll_passkey_finish` (#433). + #[serde(default)] + response: Option, + } + let auth = self + .auth + .as_ref() + .ok_or_else(|| Error::internal("unlock auth is not enabled on this node"))?; + let body_s = if body.trim().is_empty() { "{}" } else { body }; + let r: AuthReq = serde_json::from_str(body_s) + .map_err(|e| Error::api(format!("invalid request for {method}: {e}")))?; + let id = r.id.as_deref(); + let cred = Credential { + password: r.password.clone(), + totp_code: r.totp_code.clone(), + passkey_assertion: r.passkey_assertion.clone(), + }; + match method { + "auth.status" => json(auth.status()), + "auth.get_method" => json(serde_json::json!({ + "method": auth.method(), + "mode": auth.mode(), + })), + "auth.set_mode" => { + let mode = r + .mode + .ok_or_else(|| Error::api("auth.set_mode requires a `mode`"))?; + // Switching INTO session-unlock-all re-verifies the current factor (#432 Finding 3). + auth.set_mode(id, mode, &cred)?; + json(auth.status()) + } + "auth.set_method" => { + let m = r + .method + .ok_or_else(|| Error::api("auth.set_method requires a `method`"))?; + match m { + // Downgrade to password-only — re-verify the CURRENT factor first. + AuthMethod::Password => { + auth.set_method_password(id, &cred)?; + json(auth.status()) + } + // Enrolling re-verifies the current factor + returns the one-time secret/challenge. + AuthMethod::Totp => json(auth.enroll_totp(id, &cred)?), + AuthMethod::Passkey => json(auth.enroll_passkey_begin(id, &cred)?), + } + } + "auth.enroll_totp" => json(auth.enroll_totp(id, &cred)?), + "auth.enroll_passkey_begin" => json(auth.enroll_passkey_begin(id, &cred)?), + "auth.enroll_passkey_finish" => { + auth.enroll_passkey_finish(&r.response.clone().unwrap_or(Value::Null))?; + json(auth.status()) + } + "auth.unlock" => json(auth.unlock(id, &cred)?), + "auth.sign_unlock" => json(auth.sign_unlock(id, &cred)?), + "auth.lock" => { + auth.lock(); + json(auth.status()) + } + other => Err(Error::not_found(format!( + "unknown or unsupported method: {other}" + ))), + } + } + /// Dispatch a `tip.*` method to the attached tipping engine (#378). A node without the engine /// attached reports the method unavailable. Reads (`get_config`/`get_ledger`) are open; the /// mutations (`set_config`/`manual`/`notify_consumed`/`dev_tick`) are paired-token gated by the @@ -2495,6 +2669,61 @@ fn json(v: T) -> Result { serde_json::to_value(v).map_err(|e| Error::internal(format!("serialize: {e}"))) } +/// The key-touching signing methods (§18.9/§18.9a/§18.23) — the ones that obtain the signer and +/// produce a BLS signature. After any of these, [`WalletBackend::dispatch`] consumes the one-shot +/// per-transaction sign grant (§18.24), so the next signature requires a fresh `auth.sign_unlock` +/// (the "one sign-unlock authorizes exactly one signature" invariant). `tip.manual`/`tip.dev_tick`/ +/// `tip.notify_consumed` are included because each builds+signs a $DIG tip through the SAME signer — a +/// grant must never leak past a tip into a later spend. (The record-update actions, network/theme +/// settings, and the tip reads/`tip.set_config` are NOT here — they do not sign.) +const SIGNING_METHODS: &[&str] = &[ + "send_xch", + "bulk_send_xch", + "send_cat", + "bulk_send_cat", + "combine", + "split", + "multi_send", + "sign_coin_spends", + "submit_transaction", + "make_offer", + "take_offer", + "combine_offers", + "cancel_offer", + "create_did", + "bulk_mint_nfts", + "transfer_nfts", + "transfer_dids", + "mint_option", + "transfer_options", + "exercise_options", + // Tipping (#377/#378) signs a $DIG spend through the SAME signer, so it too consumes the one-shot + // grant — an armed grant must never survive an auto-tip into a later free signature (#432 Finding 6). + "tip.manual", + "tip.dev_tick", + "tip.notify_consumed", +]; + +/// Whether `method` is a key-touching signing method (see [`SIGNING_METHODS`]). +fn is_signing_method(method: &str) -> bool { + SIGNING_METHODS.contains(&method) +} + +/// An RAII guard that consumes the one-shot per-transaction sign grant (§18.24) on drop — normal +/// return OR panic unwind (#432 Finding 5). Held for the duration of a signing dispatch so a +/// panicking handler can never leave the decrypted key armed + reusable for a later free signature. +struct SignGrantGuard { + auth: Option>, +} + +impl Drop for SignGrantGuard { + fn drop(&mut self) { + if let Some(auth) = &self.auth { + auth.consume_sign_grant(); + } + } +} + /// Parse a wire [`Amount`] to `u64` (rejecting values beyond `u64`). fn amount_u64(a: &Amount) -> Result { a.to_u64() @@ -3966,6 +4195,238 @@ mod tests { assert_eq!(state(&body), "none"); } + // ---- #431/#432: the node-managed unlock-auth gate on the #371 sign path ------------------ + + /// The canonical BIP-39 test vector — a KNOWN mnemonic so the custodied signer is deterministic. + const AUTH_ABANDON: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon art"; + + /// A served backend with ONE imported wallet AND the node-managed unlock authority attached + /// (per-transaction default). Custody + auth share the same dir; the wallet is imported (so a + /// one-shot sign can decrypt it) but custody's own session is locked — the AUTH gate, not + /// `wallet.unlock`, governs signing when auth is attached. + async fn auth_gated_backend() -> (WalletBackend, std::sync::Arc) { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "dig-wallet-rpc-authgate-{}-{}", + std::process::id(), + n + )); + let _ = std::fs::remove_dir_all(&dir); + let custody = WalletCustody::new(dir.clone(), Network::Testnet11, 2); + custody.import(AUTH_ABANDON, "correcthorse", None).unwrap(); + custody.lock(None); + let auth = std::sync::Arc::new(UnlockAuth::new(custody.clone(), dir)); + let db = WalletDb::open_in_memory().await.unwrap(); + db.set_initial_sync_complete(true).await.unwrap(); + let be = WalletBackend::new( + db, + Arc::new(MockFallback::default()), + WalletConfig::default(), + ) + .with_custody(custody) + .with_auth(auth.clone()); + (be, auth) + } + + /// **Proves (#432 gate, adversarial):** with auth attached in the DEFAULT per-transaction mode, a + /// read-only `auth.unlock` does NOT enable signing — a spend fails "locked"; a fresh + /// `auth.sign_unlock` enables exactly ONE signature (the spend then proceeds past the signer gate, + /// failing later at coin selection); and after that one op the grant is consumed, so the next + /// spend fails "locked" again until a fresh `auth.sign_unlock`. + #[tokio::test] + async fn per_transaction_auth_gates_the_sign_path() { + let (be, _auth) = auth_gated_backend().await; + let send = r#"{"address":"txch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp7z9wc","amount":1,"fee":0,"auto_submit":false}"#; + let locked = |body: &str| body.contains("locked") || body.contains("signing key"); + + // A read-only unlock authorizes reads but NOT signing. + let (s, b) = be + .dispatch("auth.unlock", r#"{"password":"correcthorse"}"#) + .await; + assert_eq!(s, 200, "read-only unlock: {b}"); + let (_s, body) = be.dispatch("send_xch", send).await; + assert!( + locked(&body), + "read-only session must not sign, got: {body}" + ); + + // A fresh sign-unlock enables ONE signature: the signer resolves (fails later at selection). + let (s, b) = be + .dispatch("auth.sign_unlock", r#"{"password":"correcthorse"}"#) + .await; + assert_eq!(s, 200, "sign-unlock: {b}"); + let (_s, body) = be.dispatch("send_xch", send).await; + assert!( + !locked(&body), + "sign-unlock must resolve the signer for one op, got: {body}" + ); + + // The one-shot grant was consumed by that op — the next spend is locked again. + let (_s, body) = be.dispatch("send_xch", send).await; + assert!( + locked(&body), + "the sign grant is one-shot: a second spend needs fresh auth, got: {body}" + ); + } + + /// **Proves (#432):** the `auth.*` surface is dispatchable and reports the secure defaults; a + /// wrong password is denied (`401`) and arms nothing. + #[tokio::test] + async fn auth_surface_dispatch_and_wrong_password_denied() { + let (be, _auth) = auth_gated_backend().await; + + let (s, body) = be.dispatch("auth.status", "{}").await; + assert_eq!(s, 200); + let st: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(st["mode"], "per_transaction", "secure default mode"); + assert_eq!(st["method"], "password"); + assert_eq!(st["state"], "locked"); + assert_eq!(st["has_wallet"], true); + + // A wrong password is denied and arms nothing. + let (s, _b) = be + .dispatch("auth.sign_unlock", r#"{"password":"wrong"}"#) + .await; + assert_eq!(s, 401, "wrong password → unauthorized"); + let (_s, body) = be.dispatch("auth.status", "{}").await; + assert_eq!( + serde_json::from_str::(&body).unwrap()["sign_armed"], + false, + "a denied sign-unlock arms nothing" + ); + } + + /// **Proves (#432):** the full `auth.*` control surface dispatches — set_mode, get_method, + /// enroll_totp (returns the one-time provisioning secret), set_method back to password, and lock. + #[tokio::test] + async fn auth_control_surface_dispatch() { + let (be, _auth) = auth_gated_backend().await; + + // set_mode → session-unlock-all requires the current factor (password here), reflected in + // get_method (#432 Finding 3). + let (s, b) = be + .dispatch( + "auth.set_mode", + r#"{"mode":"session_unlock_all","password":"correcthorse"}"#, + ) + .await; + assert_eq!(s, 200, "{b}"); + let (_s, body) = be.dispatch("auth.get_method", "{}").await; + let gm: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(gm["mode"], "session_unlock_all"); + assert_eq!(gm["method"], "password"); + + // enroll_totp returns the one-time secret + otpauth URI and switches the method. + let (s, body) = be + .dispatch("auth.enroll_totp", r#"{"password":"correcthorse"}"#) + .await; + assert_eq!(s, 200, "{body}"); + let enroll: Value = serde_json::from_str(&body).unwrap(); + assert!(enroll["otpauth_uri"] + .as_str() + .unwrap() + .starts_with("otpauth://totp/")); + let (_s, body) = be.dispatch("auth.get_method", "{}").await; + assert_eq!( + serde_json::from_str::(&body).unwrap()["method"], + "totp" + ); + + // A missing `mode` is a 400; lock is idempotent + 200. + let (s, _b) = be.dispatch("auth.set_mode", "{}").await; + assert_eq!(s, 400, "set_mode requires a mode"); + let (s, _b) = be.dispatch("auth.lock", "{}").await; + assert_eq!(s, 200); + } + + /// **Proves (#432 Finding 1, concurrency):** one `auth.sign_unlock` authorizes EXACTLY ONE + /// signature even under two concurrent `send_xch` on the shared backend. Without the serializing + /// `sign_lock`, both calls could clone the armed grant before either consumed it (a gate TOCTOU) → + /// two signed spends from one auth. With the fix, exactly one passes the signer gate; the other is + /// "locked". + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn one_sign_unlock_authorizes_exactly_one_concurrent_signature() { + let (be, _auth) = auth_gated_backend().await; + let send = r#"{"address":"txch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp7z9wc","amount":1,"fee":0,"auto_submit":false}"#; + let (s, _b) = be + .dispatch("auth.sign_unlock", r#"{"password":"correcthorse"}"#) + .await; + assert_eq!(s, 200); + + // Two concurrent spends race for the single one-shot grant. + let a = be.clone(); + let b = be.clone(); + let (r1, r2) = tokio::join!( + async move { a.dispatch("send_xch", send).await }, + async move { b.dispatch("send_xch", send).await }, + ); + let locked = |body: &str| body.contains("locked") || body.contains("signing key"); + let n_locked = [&r1.1, &r2.1].iter().filter(|b| locked(b)).count(); + assert_eq!( + n_locked, 1, + "exactly one concurrent spend must be gated (one auth = one signature); got r1={:?} r2={:?}", + r1.1, r2.1 + ); + } + + /// **Proves (#432 Finding 4, no sibling resident key):** with auth attached, `wallet.create`/ + /// `import` leave NO resident custody signer (the auth gate is the only signer-loading path), and + /// `wallet.unlock` is redirected to `auth.*` rather than loading a session-long resident key. + #[tokio::test] + async fn auth_attached_provision_leaves_no_resident_signer_and_unlock_redirects() { + let (be, _auth) = auth_gated_backend().await; // one wallet already imported + locked + let (s, _b) = be + .dispatch("wallet.create", r#"{"password":"anotherpw1"}"#) + .await; + assert_eq!(s, 200, "create succeeds"); + + // NO wallet has a resident signer — provisioning locked the custody session (auth gate wins). + let custody = be.custody().unwrap(); + let list = custody.list(); + assert_eq!(list.len(), 2, "two wallets custodied"); + for w in &list { + assert!( + custody.signer(Some(&w.id)).is_none(), + "wallet {} must have NO resident signer", + w.id + ); + } + + // wallet.unlock is redirected to the auth surface (never loads a resident key). + let (s, body) = be + .dispatch("wallet.unlock", r#"{"password":"correcthorse"}"#) + .await; + assert_eq!(s, 400, "wallet.unlock redirected when auth is attached"); + assert!( + body.contains("unlock authentication") || body.contains("auth.unlock"), + "redirect message points to auth.*, got: {body}" + ); + } + + /// **Proves (#432 Finding 5, panic-safe consume):** the RAII [`SignGrantGuard`] consumes the + /// one-shot grant on drop — the mechanism that guarantees a panicking signing handler cannot leave + /// the decrypted key armed + reusable (Drop runs on unwind). + #[tokio::test] + async fn sign_grant_guard_consumes_on_drop() { + let (be, auth) = auth_gated_backend().await; + be.dispatch("auth.sign_unlock", r#"{"password":"correcthorse"}"#) + .await; + assert!(auth.effective_signer().is_some(), "armed"); + { + let _guard = SignGrantGuard { + auth: Some(auth.clone()), + }; + // guard drops at end of scope — as it would during a panic unwind + } + assert!( + auth.effective_signer().is_none(), + "the guard's Drop must consume the grant (panic-safe)" + ); + } + /// **Proves:** a wrong password on `wallet.unlock` fails closed (`401`) — the paired caller /// cannot brute the seed for free over the dispatch surface. #[tokio::test] diff --git a/crates/dig-wallet/src/sage/service.rs b/crates/dig-wallet/src/sage/service.rs index b08b48b..dd7a13f 100644 --- a/crates/dig-wallet/src/sage/service.rs +++ b/crates/dig-wallet/src/sage/service.rs @@ -17,6 +17,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; +use super::auth::UnlockAuth; use super::custody::WalletCustody; use super::db::WalletDb; use super::events::EventBus; @@ -95,6 +96,11 @@ impl WalletService { // MULTI-wallet custody (#427) rooted at the node config dir: seeds live under // `/wallets/`, and a legacy single `/wallet-seed.bin` is adopted. let custody = WalletCustody::mainnet(config_dir.to_path_buf()); + // The node-managed unlock authority (#431/#432, §18.24): it GATES the sign/broadcast path so + // signing is SAFE BY DEFAULT (per-transaction re-auth; the key is not resident between + // signatures). It shares the SAME custody state (a `WalletCustody` clone shares its inner + // Arcs), so decrypting a seed for a one-shot sign always uses the on-disk seed + password. + let auth = Arc::new(UnlockAuth::new(custody.clone(), config_dir.to_path_buf())); let tip_events = Arc::new(TipEventBus::default()); // Live-broadcast wiring (§18.12), gated on the config flag. A construction failure (no peer @@ -117,6 +123,7 @@ impl WalletService { let mut base = WalletBackend::new(db, fallback, WalletConfig::default()) .with_events(events.clone()) .with_custody(custody) + .with_auth(auth) .with_tip_events(tip_events.clone()); if let Some(l) = &live { // The GENERAL wallet surface (send/offer/mint) gets the confirming broadcaster + the