feat(wallet): node-custodied wallet + paired-token authz + sign/broadcast on behalf (#370, #371)#22
Merged
Conversation
| #[test] | ||
| fn create_persists_an_encrypted_seed_and_never_returns_the_mnemonic() { | ||
| let (c, path) = fresh(); | ||
| let address = c.create("hunter2pw").unwrap(); |
| // The seed file exists and is ENCRYPTED at rest — its bytes are a dig-keystore container, | ||
| // and decrypting yields a 24-word phrase that is NOT present in plaintext on disk. | ||
| let on_disk = std::fs::read(&path).unwrap(); | ||
| let recovered = seed_store::decrypt_seed(&on_disk, "hunter2pw").unwrap(); |
| #[test] | ||
| fn import_round_trips_the_golden_seed_and_unlock_recovers_the_same_address() { | ||
| let (c, _p) = fresh(); | ||
| let addr_import = c.import(ABANDON, "correcthorse").unwrap(); |
| #[test] | ||
| fn unlock_loads_the_signer_and_lock_clears_it() { | ||
| let (c, _p) = fresh(); | ||
| c.import(ABANDON, "correcthorse").unwrap(); |
| #[test] | ||
| fn delete_requires_the_correct_password_then_removes_the_seed() { | ||
| let (c, path) = fresh(); | ||
| c.create("rightpassword").unwrap(); |
| assert!(path.exists(), "a wrong-password delete must preserve the seed"); | ||
|
|
||
| // Correct password: seed removed, back to `none`. | ||
| c.delete("rightpassword").unwrap(); |
| #[test] | ||
| fn reveal_mnemonic_returns_the_phrase_only_with_the_right_password() { | ||
| let (c, _p) = fresh(); | ||
| c.import(ABANDON, "correcthorse").unwrap(); |
| fn reveal_mnemonic_returns_the_phrase_only_with_the_right_password() { | ||
| let (c, _p) = fresh(); | ||
| c.import(ABANDON, "correcthorse").unwrap(); | ||
| let revealed = c.reveal_mnemonic("correcthorse").unwrap(); |
| // Lock, then unlock: the same on-disk seed re-derives the identical address. | ||
| c.lock(); | ||
| assert_eq!(c.status().state, CustodyState::Locked); | ||
| let addr_unlock = c.unlock("correcthorse").unwrap(); |
| fn invalid_mnemonic_is_rejected_on_import() { | ||
| let (c, _p) = fresh(); | ||
| let err = c | ||
| .import("not a valid bip39 phrase at all", "correcthorse") |
…act (#370, #371) Spec-first anchor for the node side of the thin-client epic (#365): - §7.12 — paired-token authorization for wallet mutation + custody methods over the authorized loopback surface (-32030 for unauthorized callers). - §18.20 — node-custodied wallet provisioning + custody lifecycle (create/import/restore/unlock/lock/status/delete; node-local backup only). - §18.21 — sign + broadcast on behalf of the paired caller via the custodied signer + a real ChiaQuery broadcaster + per-op consent gate (fail-closed). - §18.10 amended: paired-extension custody path supersedes "extension self-custodies" for that path. Bump workspace 0.17.0 -> 0.18.0 and dig-wallet 0.6.0 -> 0.7.0 (feat, minor). Co-Authored-By: Claude <noreply@anthropic.com>
Add crate::sage::custody::WalletCustody — the node-custodied seed lifecycle: create/import/restore/unlock/lock/status/delete, plus node-local reveal_mnemonic for backup. The seed is encrypted at rest via dig-keystore (seed_store); no lifecycle op returns the mnemonic (create returns only the receive address), and reveal is the sole seed egress (node-local, never over the paired boundary). unlock derives + loads the in-memory WalletSigner over HD indices 0..N (the runtime signer load); lock drops it. Wrong password fails closed. Tests (11): status none; create persists an encrypted seed + never returns the mnemonic; import round-trips the golden seed + unlock recovers the same address; unlock loads the signer / lock clears it; wrong-password fails closed; create/import refuse when a wallet exists; delete requires the password; reveal_mnemonic only with the right password; weak password + invalid mnemonic rejected. Co-Authored-By: Claude <noreply@anthropic.com>
Add the sign+broadcast-on-behalf primitives (§18.21): - ChiaQueryBroadcaster — the production Broadcaster, pushing a signed bundle to mainnet via chia_query::push_tx (decentralized peers + coinset fallback, IPv6-first), fail-closed on a non-success mempool status. to_query_bundle does the chia_protocol -> chia_query wire conversion (unit-tested). - BroadcastConsent + ConsentBroadcaster — a one-shot per-op consent gate: forwards to the inner broadcaster ONLY when consent is armed, then disarms; an unconsented broadcast fails closed and the inner broadcaster is never called (nothing spent). Tests: consent gate refuses-without / forwards-once-when-armed; to_query_bundle hex-encodes every field; a tampered/over-spending bundle fails dig-clvm validation (fail-closed); send_xch through dispatch broadcasts ONLY with per-op consent armed. Co-Authored-By: Claude <noreply@anthropic.com>
Add crate::wallet_authz — the pure §7.12 policy: classify a method as custody (wallet.*) | mutation (sign/spend/offer/mint/transfer + state-changing actions) | other, and decide allow/deny against the master control token OR a valid paired token (#280). Wire it into the POST / handler: a custody/mutation wallet method requires authorization (unauthorized -> -32030 UNAUTHORIZED) and is NEVER relayed upstream (a signing/custody request must not leave the loopback node); an authorized call is served locally once the wallet surface is wired on this transport (#368/#369). Tests (6, pure): custody + mutation methods gated; reads/non-wallet open; an unpaired caller (no/wrong/revoked token) denied on EVERY gated method; master or paired token authorizes a gated mutation; reads authorized without a token. Co-Authored-By: Claude <noreply@anthropic.com>
62ef449 to
4138dbf
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.
Closes #370, #371
Node side of the thin-client epic (#365): the node becomes the wallet-key CUSTODIAN and the SIGNER/BROADCASTER for a paired extension caller. Spec-first (SPEC §7.12/§18.20/§18.21), then implemented + tested.
What changed
#370 — node-custodied provisioning + custody lifecycle + paired-token authz
dig-walletsage::custody::WalletCustody— create / import / restore / unlock / lock / status / delete + node-localreveal_mnemonic(backup). Seed encrypted at rest viadig-keystore(seed_store, no hand-rolled crypto).unlockderives + loads the in-memoryWalletSignerover HD indices0..N(the runtime signer load);lockdrops it.dig-node-servicewallet_authz— pure §7.12 policy (classify custody/mutation/other + authorize against master-or-paired token), wired intoPOST /: an unauthorized custody/mutation call is-32030 UNAUTHORIZEDand is never relayed upstream.#371 — sign + broadcast on behalf + per-op consent
sage::spend::ChiaQueryBroadcaster— the productionBroadcaster(chia_query::push_tx= decentralized peers + coinset fallback, IPv6-first), fail-closed on a non-success mempool status.sage::spend::ConsentBroadcaster+BroadcastConsent— one-shot per-op consent gate: forwards to the real broadcaster ONLY when consent is armed, then disarms; unconsented → fail-closed, inner broadcaster never called (nothing spent). Pre-broadcastdig-clvmvalidation stays fail-closed.Trust boundary (custody of mainnet-spending keys)
createreturns only the receive address). The ONLY seed egress is node-local, password-gatedreveal_mnemonic(self-origin backup UI / CLI) — never awallet.*/control.*method, never over the paired boundary.dig-keystore); never logged.wallet.*custody method requires the master control token OR a valid paired token (#280); unauthorized (no/wrong/revoked) →-32030.MockBroadcasteronly).Wire contract (for SYSTEM.md — served on the authorized surface by #368/#369)
Custody (JSON-RPC,
X-Dig-Control-Token= master or paired token required):wallet.create { password }->{ address }(mnemonic never returned)wallet.import { mnemonic, password }->{ address }·wallet.restore { mnemonic, password }->{ address }wallet.unlock { password }->{ address }·wallet.lock {}->{}wallet.status {}->{ state: "none"|"locked"|"unlocked", address? }·wallet.delete { password }->{}Authz: gated = all
wallet.*+ the mutation set (send/spend/offer/mint/transfer +sign_coin_spends/submit_transaction+ state-changing actions); open =get_*/view_*/login. Sign/broadcast reuses the Sage spend shapes (§18.9) with the custodied signer + per-op consent.Scope note
The custody + authz + sign/broadcast capabilities + contract land here on the existing authorized loopback surface. The bidirectional WS transport (#369) and the full dual-transport served wallet surface (#368) remain their own tickets; an authorized custody/mutation call returns a catalogued "not served on this transport yet" until #368/#369 wire it. dig-wallet's engine + custody are fully unit/simulator-tested and ready to serve.
Verification (local; CI on this PR)
cargo fmt --all -- --checkclean ·cargo clippy --workspace --all-targets --locked -- -D warningsclean (exit 0)cargo test -p dig-wallet= 213 passed ·cargo test -p dig-node-service= 92 passedfeat-> MINOR. Root workspace0.17.0 -> 0.18.0;dig-wallet 0.6.0 -> 0.7.0.Unauthorized callers rejected — proving tests:
wallet_authz::tests::unpaired_caller_is_denied_on_every_gated_method(no/wrong/revoked token denied on every mutation + custody method)wallet_authz::tests::master_or_paired_token_authorizes_a_gated_mutationwallet_authz::tests::custody_methods_are_gated·spend_sign_and_offer_methods_are_gated_mutations·reads_and_non_wallet_methods_are_not_gatedsage::rpc::tests::send_xch_broadcasts_only_with_per_op_consent(unconsented broadcast fails closed, nothing spent)sage::spend::tests::consent_broadcaster_refuses_without_consent_and_forwards_once_when_armed·tampered_bundle_fails_dig_clvm_validationsage::custody::tests::create_persists_an_encrypted_seed_and_never_returns_the_mnemonic·wrong_password_fails_closed_on_unlock·unlock_loads_the_signer_and_lock_clears_it🤖 Generated with Claude Code