feat(dig-wallet): node-managed unlock auth + per-transaction sign-unlock (#432)#29
Merged
Merged
Conversation
| #[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(); |
4b9c974 to
357b0ab
Compare
| /// 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>
357b0ab to
8623d0b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
auth.unlockgrants a READ-ONLY session and loads NO key. Each signature requires a freshauth.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).session_unlock_all(OFF by default) holds the signer for the session.password(default; the seed's at-rest KDF root via dig-keystore Argon2id),totp(RFC-6238 viatotp-rs; secret sealed at rest under the password),passkey(WebAuthn — behind the same interface; real ceremony deferred, see Deferrals).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.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
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-service→ all 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 warnings→ clean.cargo fmt --check→ clean.Key tests: per-tx requires a fresh sign-unlock per signature; the decrypted signer is not retained after a sign (a
Weakupgrade returnsNonepost-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; everyauth.*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(allsend_*/combine/split/sign_coin_spends/submit_transaction/offer/mint/transfer methods) +WalletCustody::signer. The change is additive and contained:current_signernow consults the attachedUnlockAuthfirst (falls back to legacy custody when no auth is attached — simulator/tests unchanged). New surface:sage::authmodule,WalletCustody::{verify_password, sign_once},WalletBackend::{with_auth, auth, consume_sign_grant}+auth.*dispatch,wallet_authzauth.prefix. No existing signature/semantics changed.Version bump (minor — new capability)
Workspace
dig-node0.24.0 → 0.25.0;dig-wallet0.10.0 → 0.11.0.Deferrals (explicit)
webauthn-rspulls OpenSSL (a Windows-CI build hazard) and the RP/origin is defined by the paired extension. ThePasskeymethod +enroll_passkey_begin/finish+ verify are present behind the interface but fail closed with a clear message, so the active method never becomespasskeyon this node until #433 finalizes it (with a SoftPasskey headless test).chia-bls0.26SecretKeyis notZeroizeOnDrop; 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