Skip to content

feat(dig-wallet): node-managed unlock auth + per-transaction sign-unlock (#432)#29

Merged
MichaelTaylor3d merged 1 commit into
mainfrom
feat/node-auth-unlock-core
Jul 12, 2026
Merged

feat(dig-wallet): node-managed unlock auth + per-transaction sign-unlock (#432)#29
MichaelTaylor3d merged 1 commit into
mainfrom
feat/node-auth-unlock-core

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

What & why (#432, child of EPIC #431)

Makes signing safe by default: the dig-node is the LOCAL auth authority (NO central server) gating the node-custodied signer (SPEC §18.21). The decrypted key must not linger in memory.

  • DEFAULT = per-transaction unlock. auth.unlock grants a READ-ONLY session and loads NO key. Each signature requires a fresh auth.sign_unlock → the node decrypts the seed, builds a one-shot signer, signs exactly ONE operation, and drops it (the signer is not resident between signatures).
  • OPT-OUT = session_unlock_all (OFF by default) holds the signer for the session.
  • Auth methods (one active): password (default; the seed's at-rest KDF root via dig-keystore Argon2id), totp (RFC-6238 via totp-rs; secret sealed at rest under the password), passkey (WebAuthn — behind the same interface; real ceremony deferred, see Deferrals).
  • Gates #371 via WalletBackend::current_signer: with auth attached the signer is obtained ONLY through the auth gate; the one-shot grant is consumed after every signing method so it can never leak into a later spend.
  • WS/RPC surface (paired-token gated, §7.12): auth.status · auth.get_method · auth.set_method · auth.set_mode · auth.enroll_totp · auth.enroll_passkey_begin/finish · auth.unlock · auth.sign_unlock · auth.lock.

Design decision (deliberate, documented in SPEC §18.24)

TOTP/passkey are enrolled as a required additional factor gating the password-based unlock, not a passwordless replacement — the password stays the seed's at-rest KDF root, so at-rest strength is preserved for every method (an honest design for a keyless local node). True passwordless replacement needs WebAuthn PRF or a TPM-sealed key → scoped follow-up.

Spec-first

  • SPEC §18.24 — the auth+unlock contract: mode/method, the state machine, the decrypt→sign→zeroize invariant, session-all opt-out, WS methods, the zeroize/residency invariant.
  • SPEC §7.12 — every auth.* method added to the paired-token-gated set.

How verified (this session, CARGO_TARGET_DIR=C:/dna)

  • cargo test -p dig-wallet -p dig-node-serviceall green (dig-wallet lib 281, dig-node-service lib 45, integration binaries 95/7/5/22; 0 failed, 1 live-funds test ignored).
  • cargo clippy -p dig-wallet -p dig-node-service --all-targets --all-features -- -D warningsclean.
  • cargo fmt --check → clean.
  • Simulator/mock only — no live broadcast in CI (spend methods fail at coin selection after the gate; no broadcaster attached).

Key tests: per-tx requires a fresh sign-unlock per signature; the decrypted signer is not retained after a sign (a Weak upgrade returns None post-consume); read-only unlock cannot sign; session-unlock-all signs repeatedly only when opted in; wrong/denied auth mutates nothing; TOTP enroll+verify (happy/wrong-code/wrong-password); password-downgrade re-verifies the current factor; every auth.* is paired-token-gated; the backend-level #371 gate (read-only can't sign, sign-unlock enables exactly one, then locked again).

gitnexus blast radius

Target = the signer entry points WalletBackend::current_signer/require_signer (all send_*/combine/split/sign_coin_spends/submit_transaction/offer/mint/transfer methods) + WalletCustody::signer. The change is additive and contained: current_signer now consults the attached UnlockAuth first (falls back to legacy custody when no auth is attached — simulator/tests unchanged). New surface: sage::auth module, WalletCustody::{verify_password, sign_once}, WalletBackend::{with_auth, auth, consume_sign_grant} + auth.* dispatch, wallet_authz auth. prefix. No existing signature/semantics changed.

Version bump (minor — new capability)

Workspace dig-node 0.24.0 → 0.25.0; dig-wallet 0.10.0 → 0.11.0.

Deferrals (explicit)

  • Passkey/WebAuthn real ceremony → #433 follow-up. webauthn-rs pulls OpenSSL (a Windows-CI build hazard) and the RP/origin is defined by the paired extension. The Passkey method + enroll_passkey_begin/finish + verify are present behind the interface but fail closed with a clear message, so the active method never becomes passkey on this node until #433 finalizes it (with a SoftPasskey headless test).
  • Byte-level scrub of the derived BLS scalarchia-bls 0.26 SecretKey is not ZeroizeOnDrop; the delivered residency guarantee is prompt non-retention (drop the signer) + zeroizing the mnemonic buffer. A key-wrapper scrub is a small follow-up.

Do NOT merge

Draft — the orchestrator adversarial-verifies the zeroize + auth-gate paths (key-exposure risk) before merging. Extension work (#433 Security tab) is the next child, release-first after this merges.

Refs #431 #432 #371 #374 #427

Comment thread crates/dig-wallet/src/sage/auth.rs Fixed
#[test]
fn verify_password_checks_without_loading_a_signer() {
let (c, _p) = fresh();
c.import(ABANDON, "correcthorse", None).unwrap();
#[test]
fn sign_once_builds_a_signer_without_persisting_a_session() {
let (c, _p) = fresh();
let held = c.import(ABANDON, "correcthorse", None).unwrap();
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();
#[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<Arc<WalletSigner>, _>` — inspect the error via `err()`.
let res = c.sign_once(None, "wrong");
));
let _ = std::fs::remove_dir_all(&dir);
let custody = WalletCustody::new(dir.clone(), Network::Testnet11, 2);
custody.import(AUTH_ABANDON, "correcthorse", None).unwrap();
@MichaelTaylor3d MichaelTaylor3d force-pushed the feat/node-auth-unlock-core branch from 4b9c974 to 357b0ab Compare July 12, 2026 19:08
/// 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";
// #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();
// #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();
…ock (#432)

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 <noreply@anthropic.com>
@MichaelTaylor3d MichaelTaylor3d force-pushed the feat/node-auth-unlock-core branch from 357b0ab to 8623d0b Compare July 12, 2026 19:44
@MichaelTaylor3d MichaelTaylor3d marked this pull request as ready for review July 12, 2026 19:59
@MichaelTaylor3d MichaelTaylor3d merged commit e91b855 into main Jul 12, 2026
8 of 9 checks passed
@MichaelTaylor3d MichaelTaylor3d deleted the feat/node-auth-unlock-core branch July 12, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants