diff --git a/Cargo.lock b/Cargo.lock index 0a970f4..e5e788d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,6 +205,7 @@ dependencies = [ "bitcoin_hashes", "serde", "unicode-normalization", + "zeroize", ] [[package]] @@ -1002,10 +1003,11 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "dig-account" -version = "0.1.2" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", + "bip39", "chia-bls 0.26.0", "chia-protocol", "chia-puzzle-types", @@ -1183,10 +1185,11 @@ dependencies = [ [[package]] name = "dig-session" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40faf7bcc2cad224200ee5eff2f716dd01fa8e0940f8b1e1dadb9e031cee7fe4" +checksum = "ecb21cf421c5f2b1d6e43b35592f71767c72526b4ccf5e7552352c0162ed4ded" dependencies = [ + "bip39", "dig-constants 0.7.0", "dig-identity 0.5.0", "dig-keystore", diff --git a/Cargo.toml b/Cargo.toml index 4faca61..7e273b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-account" -version = "0.1.2" +version = "0.2.0" edition = "2021" rust-version = "1.81" description = "The DIG Network user Account: the fat, strictly-logical crate for everything an account does — the Account+Profile object model, unlock policy + keystore crypto, the in-process identity+money signer, per-profile key/DEK derivation, DID+dig-store mint/sign, and all wallet ops. Headless (no UI). Consumed by dig-app." @@ -19,7 +19,7 @@ dig-social-profile = "0.2" # seam (LocalSigner, verify::derive_summary). Only the `client` half is pulled in — dig-account is the # key-holding custody side, never the running engine (no tokio runtime / rusqlite / offer builders). dig-wallet-backend = { version = "0.16", default-features = false, features = ["client"] } -dig-session = "0.4" +dig-session = "0.5" dig-identity = "0.5" dig-keystore = "0.4.1" dig-constants = "0.7" @@ -35,6 +35,9 @@ serde = { version = "1", features = ["derive"] } zeroize = "1" [dev-dependencies] +# Test-only: the INDEPENDENT BIP-39 expansion used as the reference for the account root, so a +# drift between dig-session's expansion and the standard one is caught rather than cancelling out. +bip39 = "2.0" anyhow = "1" hex = "0.4" tokio = { version = "1", features = ["macros", "rt"] } diff --git a/README.md b/README.md index 8db57bb..6bbcb6a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,24 @@ does not compose a send path: the money signer is reachable without the gate, an authorization to the coin spends that get signed. `SPEC.md` §6.1.1 states the obligations a host takes on, and exactly which of them this crate can and cannot check. +## The recovery phrase + +An account root is 32 bytes of BIP-39 entropy, expanded to the 64-byte HD seed the standard Chia way +before any key is derived. So the 24 words a user writes down restore the same addresses in Sage and any +other conforming wallet — and a phrase exported from Sage restores here. + +- `UnlockedAccount::recovery_phrase()` — the 24 words, over `&self`, so showing a user their backup does + not cost them their session. This is the one secret the public API deliberately exposes; never log it. +- `AccountSession::enroll_from_recovery_phrase(...)` — the restore-on-a-new-machine counterpart. + Fail-closed on an existing account and on an invalid phrase. + +**Adopting 0.2.0 requires a legacy-account path.** Accounts enrolled by the 0.1 line hold a +pre-envelope sealed seed and are **wedged**: unlock surfaces `LegacySeedFormat` and never yields an +`UnlockedAccount`, and re-enrolling at the same `AccountId` returns `AlreadyExists`. A host must detect +that specific error, **preserve** (never delete) the old sealed blob — it may hold value and its +password may live in an OS credential store — surface it in the UI, then re-enrol and show the new +phrase. `SPEC.md` §10 states the obligation. + See [`SPEC.md`](./SPEC.md) for the normative contract. Consumed by `dig-app`. ## License diff --git a/SPEC.md b/SPEC.md index 123d88e..da3e0b3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -25,6 +25,17 @@ Out of scope: chain I/O / broadcast, DID resolution transport, and the concrete ## 2. Object model +### 2.0 The account root is BIP-39 entropy (normative) + +An account's root secret is **32 bytes of BIP-39 entropy** — exactly what a 24-word English mnemonic +encodes. Before ANY key derivation it MUST be expanded to the 64-byte HD seed the standard Chia way +(`entropy -> mnemonic -> to_seed("")`, empty passphrase), which `dig-session` performs; dig-account +consumes the already-expanded seed via `UnlockedMasterSeed::master_seed()` and MUST NOT re-derive or +feed entropy to `SecretKey::from_seed` itself. This is what makes the 24 words a user backs up restore +to the same addresses in Sage and every other conforming wallet (dig_ecosystem #1759). The full +contract, the versioned at-rest envelope, and the fail-closed legacy rule live in `dig-session` +`SPEC.md` §3.3.0/§3.4. + ### 2.1 Account (master seed + profiles; exactly-one-default invariant) An `Account` is one `AccountId` + one-or-more `Profile`s + a `default_profile_ix`. Construction @@ -110,8 +121,16 @@ inside the `UnlockedAccount` returned by a successful unlock/enrol. collect `AuthFactors` via the injected `provider` (§7) → run `policy.authorize` (fail-closed on refusal, before any keystore work) → keystore unlock. Any failure yields an `AccountError` and NO key material. -- `AccountSession::enroll(store, id, password, seed, default_ix) -> UnlockedAccount` is the public - create-and-unlock path; it never returns a raw seed. +- `AccountSession::enroll(store, id, password, entropy, default_ix) -> UnlockedAccount` is the public + create-and-unlock path; `entropy` is 32 bytes of BIP-39 entropy (§2.0) and it never returns a raw seed. +- `AccountSession::enroll_from_recovery_phrase(store, id, password, phrase, default_ix) -> UnlockedAccount` + is the public RESTORE path. It MUST be fail-closed on an already-enrolled account (never clobbering a + live custody root) and on an invalid phrase, producing no key material in either case. Restoring the + phrase reported by `UnlockedAccount::recovery_phrase` MUST reproduce the identical account: same + wallet addresses, same identity keys, same per-profile DEKs. +- `UnlockedAccount::recovery_phrase(&self) -> Zeroizing` — the 24 words. It MUST take `&self`: + showing a user their backup MUST NOT consume or relock the account. This is the ONE secret the public + API deliberately exposes, because a backup the user cannot see is not a backup; it MUST NOT be logged. - `UnlockedAccount` holds the seed behind `Arc` whose `Debug` redacts and whose drop zeroizes. It hands out capability handles (`ProfileSigner`, `WalletOps`) and DEKs derived from the seed; `master_seed()` is `pub(crate)`. `lock(self)` relocks immediately by dropping the handle. @@ -389,7 +408,8 @@ the policy/crypto evaluation stays in-crate (§4.2). `WalletOps::wallet_key` are `pub(crate)`; the public surface exposes only public identifiers. Signing flows only through the in-crate `MoneySigner` seam. - No public getter, `Debug`, `Serialize`, error `Display`, or panic message exposes a seed or a derived - private key. (The per-profile DEK is intentionally returned to the consumer for at-rest decryption; + private key. The single, deliberate exception is `UnlockedAccount::recovery_phrase`, whose whole + purpose is to let the user back the account up; it returns `Zeroizing` and MUST NOT be logged. (The per-profile DEK is intentionally returned to the consumer for at-rest decryption; zeroizing the returned DEK buffer is a tracked follow-up.) - Every unlock/auth/custody decision is fail-closed: ambiguity resolves to an error, never a silent success. @@ -411,6 +431,49 @@ methods/fields/indices), never a redefinition of an existing derivation or forma unavoidable, is a major, explicitly-versioned, migrating event. Golden vectors (§3.2, §3.3) enforce this in CI. +**The one break that happened, and must not happen again.** In 0.2.0 the account root changed from a raw +seed to BIP-39 entropy expanded per §2.0. The HKDF/DEK construction itself is unchanged, but its input +scalar moved, so the frozen profile-DEK golden vector was RE-PINNED rather than migrated. + +The reason that was permissible is narrower than "nobody had an account", and the difference matters +because the next such decision will be measured against it: + +- **Legacy accounts DO exist in the field.** The published dig-session 0.4 / dig-account 0.1 line + auto-enrolled an account at first boot with **no user action**, and such blobs have been verified on + real hosts. Any claim that the exposed population is zero is FALSE and MUST NOT be relied on. +- **What is absent is any sealed ARTIFACT keyed by the old derivation:** no sealed profile blobs, no + wallet store, no funded account (money path unmerged). Nothing *encrypted* under the old DEK became + unreadable — which is the only thing re-pinning a DEK can break — and nothing on chain moved. +- The alternative was shipping a recovery phrase that silently resolves to the wrong account in every + other Chia wallet, which is strictly worse. + +Any FURTHER change to a stored-secret derivation requires a migration path, not a re-pin. + +**Adopting 0.2.0 REQUIRES a legacy-detection-and-re-enrolment path in the host (normative).** An +existing legacy account is WEDGED, not merely unreadable: `AccountSession::unlock` surfaces +dig-session's `LegacySeedFormat` and never yields an `UnlockedAccount`, and +`enroll` / `enroll_from_recovery_phrase` at the same `AccountId` return `AlreadyExists` because +enrolment refuses to overwrite a custody root. No pre-0.2 release exposed `recovery_phrase()`, so the +user was never shown 24 words either. A host MUST therefore: + +1. detect that specific error — a catch-all log line leaves the account permanently and silently + without a signer; +2. **preserve** the old sealed blob rather than deleting it. It is password-sealed, its password may + live in an OS credential store neither crate can read, and a balance cannot be ruled out — + deleting it can destroy the only copy of a funded key; +3. surface the situation in the UI, stating that the account must be re-created and that the preserved + file is the only copy of the old key; +4. re-enrol and show the new recovery phrase. + +Conformance for §2.0 and the phrase API MUST prove, using TWO accounts with unrelated entropy: + +- Each account sits at the **hardcoded literal** bech32m address a standard Chia wallet derives from its + phrase (produced independently via `chia-wallet-sdk`; both sides MUST NOT be computed live). +- Each account's reported phrase restores THAT account's address and DEK, not another's. A + single-account round-trip is insufficient: an implementation that ignored the live root would return a + self-consistent phrase for the WRONG account and still pass. +- `recovery_phrase()` does not consume or relock the account, and the account remains usable after. + ## 11. Conformance (cross-references SYSTEM.md + docs.dig.net) - Node↔user-app identity boundary: dig-account is the user-app-side identity/custody owner; the DIG node diff --git a/src/auth/policy.rs b/src/auth/policy.rs index 48d8e45..1805dd7 100644 --- a/src/auth/policy.rs +++ b/src/auth/policy.rs @@ -211,9 +211,9 @@ mod tests { use std::time::Instant; use dig_keystore::MemoryBackend; - use dig_session::{Password, SEED_LEN}; + use dig_session::{Password, ENTROPY_LEN}; - const SEED: [u8; SEED_LEN] = [0xC3; SEED_LEN]; + const SEED: [u8; ENTROPY_LEN] = [0xC3; ENTROPY_LEN]; const PW: &str = "correct horse battery staple"; /// A manually-advanced clock: `now()` returns `base + advanced` millis. diff --git a/src/keys/dek.rs b/src/keys/dek.rs index e3a4743..6cacd68 100644 --- a/src/keys/dek.rs +++ b/src/keys/dek.rs @@ -18,18 +18,39 @@ pub fn profile_dek(seed: &UnlockedMasterSeed, ix: ProfileIx) -> [u8; 32] { mod tests { use super::*; use dig_keystore::{BackendKey, MemoryBackend}; - use dig_session::{Password, Session, SEED_LEN}; + use dig_session::{Password, Session, ENTROPY_LEN}; use std::sync::Arc; - const SEED: [u8; SEED_LEN] = [0x11; SEED_LEN]; + const SEED: [u8; ENTROPY_LEN] = [0x11; ENTROPY_LEN]; - /// The default-profile DEK for the all-`0x11` seed, pinned byte-for-byte. This freezes the + /// The default-profile DEK for the all-`0x11` **entropy**, pinned byte-for-byte. This freezes the /// at-rest KDF contract: `HKDF-SHA256(salt = DEK_SALT, ikm = IDENTITY_IKM_VERSION || scalar, /// info = PROFILE_DEK_LABEL)` as implemented by `dig-session`. If any of the frozen inputs /// (salt/ikm-version/label) ever changes, this vector breaks — which is exactly the §5.1 /// back-compat guard, since a changed DEK makes every already-sealed profile blob unreadable. + /// + /// # This literal MOVED once, deliberately (dig_ecosystem #1759) + /// + /// The HKDF construction is unchanged; its INPUT scalar moved, because the account root is now + /// the BIP-39-EXPANDED seed rather than the raw entropy. That is a §5.1-class change to a + /// stored-secret derivation, and the reason it was permissible is narrower than "nobody had an + /// account" — **accounts DO exist in the field.** The published dig-session 0.4 / dig-account 0.1 + /// line auto-enrolled an account at first boot with no user action, and such blobs have been + /// verified on real hosts. + /// + /// What is actually absent is any sealed ARTIFACT keyed by the old derivation: no sealed profile + /// blobs, no wallet store, no funded account (the money path is unmerged). So nothing that was + /// *encrypted* under the old DEK became unreadable, which is the only thing re-pinning a DEK can + /// break. That — not an empty population — is why this was a re-pin rather than a migration. + /// + /// It MUST NOT happen a second time: any future change to this value needs an explicit migration, + /// not a re-pin. And note the corollary, which is a real obligation on this crate's consumers — + /// an existing legacy account is WEDGED (`SessionError::LegacySeedFormat` on unlock, + /// `AlreadyExists` on re-enrolment at the same key), so adopting this version REQUIRES a + /// legacy-detection-and-re-enrolment path that PRESERVES the old sealed blob. See `SPEC.md` §10 + /// and dig-session's `LegacySeedFormat` docs. const GOLDEN_DEK0: [u8; 32] = - hex_literal_dek("3285f67598f3a4671ea2226ca9ef990cabe5e7374cad5fe29b81ab7be8d7f543"); + hex_literal_dek("55d71eb769eae86ae13467e03e3735c17f21c59885f2daf5438fdad3aa010f5c"); /// Compile-time hex → 32-byte array (avoids a dev-dependency just for a fixture). const fn hex_literal_dek(s: &str) -> [u8; 32] { diff --git a/src/profile_mint.rs b/src/profile_mint.rs index 11d2bc6..a8a55c4 100644 --- a/src/profile_mint.rs +++ b/src/profile_mint.rs @@ -42,7 +42,7 @@ impl ProfileMinter { mod tests { use super::*; use dig_keystore::{BackendKey, MemoryBackend}; - use dig_session::{Password, Session, SEED_LEN}; + use dig_session::{Password, Session, ENTROPY_LEN}; fn seed() -> Arc { Arc::new( @@ -50,7 +50,7 @@ mod tests { Arc::new(MemoryBackend::new()), BackendKey::new("k".to_string()), Password::new("pw"), - &[0x21; SEED_LEN], + &[0x21; ENTROPY_LEN], ) .unwrap(), ) diff --git a/src/session.rs b/src/session.rs index aec9bea..8f93150 100644 --- a/src/session.rs +++ b/src/session.rs @@ -42,7 +42,7 @@ impl AccountSession { /// Enrol a NEW account and return it already unlocked. /// - /// Seals `seed` under `password` via `store` (fail-closed if the account already exists — never + /// Seals `entropy` — 32 bytes of BIP-39 entropy, the account root — under `password` via `store` (fail-closed if the account already exists — never /// clobbers an existing custody root) and returns a live [`UnlockedAccount`]. The raw master seed /// is never returned: it lives `pub(crate)` inside the handle. This is the public counterpart to /// [`AccountStore::enroll`](crate::store::AccountStore::enroll), which is `pub(crate)` precisely so @@ -51,11 +51,37 @@ impl AccountSession { store: Arc, account: AccountId, password: dig_session::Password, - seed: &[u8; dig_session::SEED_LEN], + entropy: &[u8; dig_session::ENTROPY_LEN], default_profile_ix: ProfileIx, ) -> Result { let seed = store - .enroll(&account, password, seed) + .enroll(&account, password, entropy) + .map_err(|why| AccountError::Keystore(why.to_string()))?; + Ok(UnlockedAccount::new( + account, + Arc::new(seed), + default_profile_ix, + )) + } + + /// Restore an account from its 24-word recovery phrase and return it already unlocked. + /// + /// The counterpart to [`UnlockedAccount::recovery_phrase`]: the phrase shown at creation, typed + /// on a new machine, reproduces the SAME account — same wallet addresses, same identity key, same + /// per-profile DEKs. A phrase exported from any standard Chia wallet works too, because the + /// derivation is the standard one. + /// + /// Fail-closed on an existing account (never clobbers a live custody root) and on an invalid + /// phrase, in which case no key material is produced. + pub fn enroll_from_recovery_phrase( + store: Arc, + account: AccountId, + password: dig_session::Password, + phrase: &str, + default_profile_ix: ProfileIx, + ) -> Result { + let seed = store + .enroll_from_recovery_phrase(&account, password, phrase) .map_err(|why| AccountError::Keystore(why.to_string()))?; Ok(UnlockedAccount::new( account, @@ -111,9 +137,9 @@ mod tests { use crate::auth::provider::{SpendConfirmRequest, SpendDecision}; use crate::auth::second_factor::SecondFactor; use dig_keystore::MemoryBackend; - use dig_session::{Password, SEED_LEN}; + use dig_session::{Password, ENTROPY_LEN}; - const SEED: [u8; SEED_LEN] = [0x9C; SEED_LEN]; + const SEED: [u8; ENTROPY_LEN] = [0x9C; ENTROPY_LEN]; const PW: &str = "correct horse battery staple"; /// A provider that hands back a fixed set of factors — the harness seam under test. diff --git a/src/signer.rs b/src/signer.rs index 877013b..d7cc8a3 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -75,9 +75,9 @@ impl SessionSigner for ProfileSigner { mod tests { use super::*; use dig_keystore::{BackendKey, MemoryBackend}; - use dig_session::{Password, Session, SEED_LEN}; + use dig_session::{Password, Session, ENTROPY_LEN}; - const SEED: [u8; SEED_LEN] = [0x7E; SEED_LEN]; + const SEED: [u8; ENTROPY_LEN] = [0x7E; ENTROPY_LEN]; fn seed() -> Arc { Arc::new( diff --git a/src/store.rs b/src/store.rs index 2a8266b..d567484 100644 --- a/src/store.rs +++ b/src/store.rs @@ -13,7 +13,11 @@ use std::sync::Arc; -use dig_session::{BackendKey, KeychainBackend, Password, Session, UnlockedMasterSeed, SEED_LEN}; +#[cfg(test)] +use dig_session::MASTER_SEED_LEN; +use dig_session::{ + BackendKey, KeychainBackend, Password, Session, UnlockedMasterSeed, ENTROPY_LEN, +}; use crate::id::AccountId; @@ -84,7 +88,7 @@ impl AccountStore { &self, id: &AccountId, password: Password, - seed: &[u8; SEED_LEN], + entropy: &[u8; ENTROPY_LEN], ) -> Result { if self.exists(id)? { return Err(AccountStoreError::AlreadyExists(id.clone())); @@ -93,7 +97,29 @@ impl AccountStore { self.backend.clone(), Self::blob_key(id), password, - seed, + entropy, + )?) + } + + /// Enrol `id` from an existing 24-word recovery `phrase` — the restore-on-a-new-machine path. + /// + /// Identical to [`enroll`](Self::enroll) except that the account root comes from the phrase, so + /// restoring reproduces the same wallet addresses, identity key and per-profile DEKs. Guarded by + /// the same fail-closed `AlreadyExists` check, so a restore can never overwrite a live account. + pub(crate) fn enroll_from_recovery_phrase( + &self, + id: &AccountId, + password: Password, + phrase: &str, + ) -> Result { + if self.exists(id)? { + return Err(AccountStoreError::AlreadyExists(id.clone())); + } + Ok(Session::enroll_from_recovery_phrase( + self.backend.clone(), + Self::blob_key(id), + password, + phrase, )?) } @@ -150,8 +176,17 @@ mod tests { use super::*; use dig_keystore::MemoryBackend; - const SEED_A: [u8; SEED_LEN] = [0xAA; SEED_LEN]; - const SEED_B: [u8; SEED_LEN] = [0xBB; SEED_LEN]; + const ENTROPY_A: [u8; ENTROPY_LEN] = [0xAA; ENTROPY_LEN]; + const ENTROPY_B: [u8; ENTROPY_LEN] = [0xBB; ENTROPY_LEN]; + + /// Independently expand BIP-39 entropy into the 64-byte master HD seed, straight from `bip39` + /// rather than through dig-session, so a drift on either side is caught instead of cancelling + /// out (dig_ecosystem #1759). + fn expanded(entropy: &[u8; ENTROPY_LEN]) -> [u8; MASTER_SEED_LEN] { + bip39::Mnemonic::from_entropy_in(bip39::Language::English, entropy) + .expect("32 bytes is valid 24-word BIP-39 entropy") + .to_seed("") + } const PW: &str = "correct horse battery staple"; fn store() -> AccountStore { @@ -167,35 +202,39 @@ mod tests { let ks = store(); let acct = id("acct-1"); - let enrolled = ks.enroll(&acct, Password::new(PW), &SEED_A).unwrap(); + let enrolled = ks.enroll(&acct, Password::new(PW), &ENTROPY_A).unwrap(); let enrolled_seed = *enrolled.master_seed(); drop(enrolled); // lock let unlocked = ks.unlock(&acct, Password::new(PW)).unwrap(); assert_eq!(*unlocked.master_seed(), enrolled_seed); - assert_eq!(*unlocked.master_seed(), SEED_A); + // The exposed root is the EXPANDED BIP-39 seed, not the stored entropy. Asserting both legs + // is what distinguishes "expanded" from "stored verbatim" — the two are otherwise + // indistinguishable at this seam. + assert_eq!(*unlocked.master_seed(), expanded(&ENTROPY_A)); + assert_ne!(unlocked.master_seed()[..ENTROPY_LEN], ENTROPY_A[..]); } #[test] fn enroll_refuses_to_clobber_an_existing_account() { let ks = store(); let acct = id("acct-1"); - ks.enroll(&acct, Password::new(PW), &SEED_A).unwrap(); + ks.enroll(&acct, Password::new(PW), &ENTROPY_A).unwrap(); // A second enrol under the same id must fail-closed — never overwrite the custody root. - let err = ks.enroll(&acct, Password::new(PW), &SEED_B).unwrap_err(); + let err = ks.enroll(&acct, Password::new(PW), &ENTROPY_B).unwrap_err(); assert!(matches!(err, AccountStoreError::AlreadyExists(_))); // And the original seed is intact. let unlocked = ks.unlock(&acct, Password::new(PW)).unwrap(); - assert_eq!(*unlocked.master_seed(), SEED_A); + assert_eq!(*unlocked.master_seed(), expanded(&ENTROPY_A)); } #[test] fn unlock_with_a_wrong_password_fails_closed() { let ks = store(); let acct = id("acct-1"); - ks.enroll(&acct, Password::new(PW), &SEED_A).unwrap(); + ks.enroll(&acct, Password::new(PW), &ENTROPY_A).unwrap(); assert!(matches!( ks.unlock(&acct, Password::new("wrong")), @@ -222,8 +261,10 @@ mod tests { let ks = store(); assert!(ks.list().unwrap().is_empty()); - ks.enroll(&id("bravo"), Password::new(PW), &SEED_A).unwrap(); - ks.enroll(&id("alpha"), Password::new(PW), &SEED_B).unwrap(); + ks.enroll(&id("bravo"), Password::new(PW), &ENTROPY_A) + .unwrap(); + ks.enroll(&id("alpha"), Password::new(PW), &ENTROPY_B) + .unwrap(); assert_eq!(ks.list().unwrap(), vec![id("alpha"), id("bravo")]); } @@ -231,8 +272,10 @@ mod tests { #[test] fn delete_removes_only_the_named_account() { let ks = store(); - ks.enroll(&id("keep"), Password::new(PW), &SEED_A).unwrap(); - ks.enroll(&id("drop"), Password::new(PW), &SEED_B).unwrap(); + ks.enroll(&id("keep"), Password::new(PW), &ENTROPY_A) + .unwrap(); + ks.enroll(&id("drop"), Password::new(PW), &ENTROPY_B) + .unwrap(); ks.delete(&id("drop")).unwrap(); diff --git a/src/unlocked.rs b/src/unlocked.rs index 7cdd31b..2d8abfa 100644 --- a/src/unlocked.rs +++ b/src/unlocked.rs @@ -7,7 +7,7 @@ use std::sync::Arc; -use dig_session::{UnlockedMasterSeed, SEED_LEN}; +use dig_session::{UnlockedMasterSeed, MASTER_SEED_LEN}; use zeroize::Zeroizing; use crate::id::{AccountId, ProfileIx}; @@ -75,10 +75,26 @@ impl UnlockedAccount { profile_dek(&self.seed, ix) } - /// The raw master seed. `pub(crate)` — it never leaves dig-account; the money-signer + key - /// derivation paths inside the crate are its only consumers. + /// The 24-word BIP-39 recovery phrase for this account. + /// + /// Takes `&self`: showing a user their phrase must not cost them their session, so the account + /// stays unlocked afterwards. This is the ONE secret the public API deliberately exposes — a + /// backup the user cannot see is not a backup — and it is the counterpart to + /// [`AccountSession::enroll_from_recovery_phrase`](crate::session::AccountSession::enroll_from_recovery_phrase). + /// + /// The phrase is the STANDARD Chia derivation, so it also restores in Sage and any other + /// conforming wallet. The returned `String` is `Zeroizing`; **never log it**. + pub fn recovery_phrase(&self) -> Zeroizing { + self.seed.recovery_phrase() + } + + /// The expanded 64-byte master HD seed. `pub(crate)` — it never leaves dig-account; the + /// money-signer + key derivation paths inside the crate are its only consumers. + /// + /// This is the BIP-39-EXPANDED seed, not the stored entropy, so every key derived from it lands + /// where a standard Chia wallet expects (dig_ecosystem #1759). #[allow(dead_code)] // Phase 2: consumed by the money-signer path (dig-wallet-backend LocalSigner). - pub(crate) fn master_seed(&self) -> Zeroizing<[u8; SEED_LEN]> { + pub(crate) fn master_seed(&self) -> Zeroizing<[u8; MASTER_SEED_LEN]> { self.seed.master_seed() } @@ -95,9 +111,10 @@ mod tests { use crate::keys::dek::profile_dek; use dig_ipc_protocol::signer::SessionSigner; use dig_keystore::{BackendKey, MemoryBackend}; + use dig_session::ENTROPY_LEN; use dig_session::{Password, Session}; - const SEED: [u8; SEED_LEN] = [0x5A; SEED_LEN]; + const ENTROPY: [u8; ENTROPY_LEN] = [0x5A; ENTROPY_LEN]; fn unlocked(default_ix: ProfileIx) -> UnlockedAccount { let seed = Arc::new( @@ -105,7 +122,7 @@ mod tests { Arc::new(MemoryBackend::new()), BackendKey::new("k".to_string()), Password::new("pw"), - &SEED, + &ENTROPY, ) .unwrap(), ); @@ -157,6 +174,73 @@ mod tests { assert_eq!(via_ops.secret_key(), expected.secret_key()); } + /// The canonical public BIP-39 test mnemonic (24 words of all-zero entropy) and the address a + /// STANDARD Chia wallet derives from it at wallet index 0. Both frozen literals, produced + /// independently via `chia-wallet-sdk` — never computed live on both sides, or a dependency bump + /// could move them together and mask a regression (dig_ecosystem #1759). + const TEST_PHRASE: &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"; + const TEST_ADDRESS_0: &str = "xch16grurcglcwcv6arjarr720yd9wqhp9gkx3k8h25lhwg8pl7vl6ysuax0gy"; + + /// A SECOND account with unrelated entropy. [`TEST_PHRASE`] is all-ZERO entropy, which makes it + /// blind to any property about WHICH account is in play: a bug that ignored the live root and + /// derived from zeros would look correct. Every "the right account" assertion below therefore uses + /// two accounts and a truthful control. + const OTHER_PHRASE: &str = + "fog spot notable regret pizza coffee harvest ensure fog spot notable regret pizza coffee harvest ensure fog spot notable regret pizza coffee harvest equal"; + const OTHER_ADDRESS_0: &str = "xch1vpxzuu6aqfu790qcrcppcr2gmju4f5tpuuznuv2lx3g79v2jxc7qxttpzt"; + + fn restore(phrase: &str, id: &str) -> UnlockedAccount { + crate::session::AccountSession::enroll_from_recovery_phrase( + Arc::new(crate::store::AccountStore::new(Arc::new( + dig_keystore::MemoryBackend::new(), + ))), + AccountId::new(id), + dig_session::Password::new("pw"), + phrase, + ProfileIx::ROOT, + ) + .expect("a valid phrase must restore") + } + + fn wallet_address(acct: &UnlockedAccount) -> String { + crate::keys::wallet_key::WalletKey::from_seed_at(&acct.master_seed()[..], ProfileIx::ROOT) + .address() + .expect("a derived wallet key always encodes to an address") + } + + #[test] + fn account_sits_at_the_standard_chia_address_for_its_phrase() { + // The whole point of #1759: the phrase a user backs up resolves to the SAME address in Sage. + assert_eq!(wallet_address(&restore(TEST_PHRASE, "a")), TEST_ADDRESS_0); + } + + #[test] + fn recovery_phrase_does_not_consume_the_account() { + // Showing a user their backup must not cost them their session. + let acct = restore(TEST_PHRASE, "a"); + let first = acct.recovery_phrase(); + assert_eq!(first.split_whitespace().count(), 24); + assert_eq!(&*first, &*acct.recovery_phrase()); + // Still fully usable afterwards. + assert_eq!(wallet_address(&acct), TEST_ADDRESS_0); + } + + #[test] + fn each_accounts_phrase_restores_that_account_and_not_another() { + // Two actors: a phrase that ignored the live root would be self-consistent yet belong to the + // WRONG account, and a single-account round-trip cannot see that. + let a = restore(TEST_PHRASE, "a"); + let b = restore(OTHER_PHRASE, "b"); + assert_ne!(&*a.recovery_phrase(), &*b.recovery_phrase()); + + for (acct, expected) in [(&a, TEST_ADDRESS_0), (&b, OTHER_ADDRESS_0)] { + assert_eq!(wallet_address(acct), expected); + let again = restore(&acct.recovery_phrase(), "restored"); + assert_eq!(wallet_address(&again), expected); + assert_eq!(again.dek(ProfileIx::ROOT), acct.dek(ProfileIx::ROOT)); + } + } + #[test] fn lock_consumes_the_handle() { // A smoke test that `lock` compiles + runs; the seed drops with the handle. diff --git a/src/wallet/authorizer.rs b/src/wallet/authorizer.rs index 6aa6682..688cc63 100644 --- a/src/wallet/authorizer.rs +++ b/src/wallet/authorizer.rs @@ -100,9 +100,9 @@ impl WalletOps { mod tests { use super::*; use dig_keystore::{BackendKey, MemoryBackend}; - use dig_session::{Password, Session, SEED_LEN}; + use dig_session::{Password, Session, ENTROPY_LEN}; - const SEED: [u8; SEED_LEN] = [0x33; SEED_LEN]; + const SEED: [u8; ENTROPY_LEN] = [0x33; ENTROPY_LEN]; fn seed() -> Arc { Arc::new(