feat(session)!: BIP-39/Chia-conformant account root, versioned seed envelope, recovery_phrase() - #6
Merged
MichaelTaylor3d merged 4 commits intoJul 29, 2026
Conversation
Lane stub so the branch + draft PR exist before implementation (§1.8 push-early). No behaviour change. Co-Authored-By: Claude <noreply@anthropic.com>
… recovery_phrase()
A DIG 24-word recovery phrase previously restored to a DIFFERENT address than
Sage and every other standard Chia wallet: the 32 stored bytes were handed
straight to `SecretKey::from_seed`, skipping BIP-39's PBKDF2 expansion. That
fails silently — the user sees a valid-looking phrase resolve to an empty
account, with no error at all.
The 32 stored bytes are now BIP-39 **entropy**, re-expanded
`entropy -> 24 words -> to_seed("") -> SecretKey::from_seed` at
`master_seed()`. Identity, per-profile DEK and wallet all read that one
expanded seed, so no two of them can disagree about the root. At-rest length is
unchanged (32 bytes IS the entropy of a 24-word mnemonic).
A versioned envelope (`dig_keystore::opaque`, `version || kind || entropy`)
makes a stored blob DECLARE what its bytes mean. The two encodings are
byte-indistinguishable, so a pre-envelope raw-seed blob FAILS CLOSED with
`SessionError::LegacySeedFormat` and is never reinterpreted as entropy.
Adds `UnlockedMasterSeed::recovery_phrase()` over `&self` — showing a user
their phrase must not cost them their session.
BREAKING CHANGE: `SEED_LEN` is replaced by `ENTROPY_LEN` (32, stored) and
`MASTER_SEED_LEN` (64, expanded); removing the ambiguous name is deliberate so
a stale call site fails to COMPILE rather than deriving a silently wrong key.
`master_seed()` now returns the 64-byte expanded seed. For a given 32 stored
bytes this MOVES the identity public key and the profile DEK, which is sound
only because the exposed population is zero (no deployed account store; the
money path is parked unmerged).
Refs DIG-Network/dig_ecosystem#1759
Co-Authored-By: Claude <noreply@anthropic.com>
… API Records the derivation contract normatively so a reimplementation cannot reintroduce the entropy-as-seed bug: SPEC.md gains §3.3.0 (the root is BIP-39 entropy, every derivation reads the EXPANDED seed, empty passphrase) and §3.4 (the versioned at-rest envelope and its fail-closed rule), plus the CONF-1..10 conformance list and the two new custody invariants (no secret in an error message; never log a phrase). Adds the account/recovery-phrase section to the README, and bumps to 0.5.0 — minor per SemVer for 0.x, carrying the breaking API change recorded in the previous commit. Refs DIG-Network/dig_ecosystem#1759 Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
marked this pull request as ready for review
July 29, 2026 06:41
…tract; pin the DEK literals Gate fixes for dig_ecosystem#1759. **bip39's `zeroize` feature is now enabled, and the comment claiming it does not exist is corrected.** It does: bip39 2.2 derives `Zeroize + ZeroizeOnDrop` on `Mnemonic` behind an optional-dependency feature (which is why it is absent from the `[features]` table). `expand_entropy` builds a `Mnemonic` on EVERY derivation and its word indices are a complete copy of the account root, so without the feature un-wiped copies of the root accumulated in memory. CONF-11 asserts the trait bound at COMPILE time, so dropping the feature now fails to build rather than silently regressing. **The legacy-account contract now lives on the crate, not just in a ticket.** Legacy `DIGVK1` blobs DO exist in the field — the published 0.4 line auto-enrolled an account at first boot with no user action — and such an account is WEDGED, not merely unreadable: unlock returns `LegacySeedFormat` and never a handle, re-enrolment at the same key returns `AlreadyExists`, and no pre-envelope release ever showed the user 24 words. A consumer that merely logs the error leaves the account permanently and silently without a signer. The `LegacySeedFormat` rustdoc, `SPEC.md` §3.3.0 and the README now state the required remediation — detect specifically, PRESERVE (never delete) the sealed blob, surface it in the UI, re-enrol — and that adopting this version without it is a regression. **The re-pin rationale is corrected to the true one.** It is NOT that no account exists; it is that no sealed ARTIFACT keyed by the old derivation exists (no profile blobs, no wallet store, money path unmerged), so nothing encrypted under the old DEK becomes unreadable. `SPEC.md` says so, and says a further change needs a migration rather than a second re-pin. Also: CONF-12 covers a VALID 12/15/18/21-word phrase, which is the only input that reaches the entropy-length guard (malformed phrases die inside bip39 first) — deleting that guard previously left all 41 tests green while turning a legitimate shorter phrase into a `copy_from_slice` panic on the restore path. The two master-seed DEK golden vectors are now HEX LITERALS as their comments always claimed; both sides had been computed live, so a bip39/dig-identity bump could have moved them together. CONF-1/4/5/6/8 now drive from the NON-degenerate fixture, so the account-identity-blind class has three nets instead of one. And `recovery_phrase`'s docs warn that `Zeroizing<String>` derefs to `String` (so a log call compiles) and that dig-logging's redactor is bundle-time, not write-time. Refs DIG-Network/dig_ecosystem#1759 Co-Authored-By: Claude <noreply@anthropic.com>
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 the #1759 decision by implementing option (b) conform to the standard Chia/BIP-39 derivation, plus option (c)'s versioned envelope so the legacy shape fails closed rather than being reinterpreted.
The bug
A DIG 24-word recovery phrase resolved to a different address than Sage and every other conforming Chia wallet. The 32 stored bytes were handed straight to
SecretKey::from_seed, skipping BIP-39's PBKDF2 expansion. That does not fail — it derives a different, entirely plausible wallet, so a user who lost their machine and typed their DIG phrase into Sage would see an empty account, a valid-looking phrase, and no error at all.The fix
The 32 stored bytes are now BIP-39 entropy, re-expanded
entropy → 24 words → to_seed("") → SecretKey::from_seedatmaster_seed(). Identity, per-profile identity, per-profile DEK and the wallet seed all read that one expanded seed, so no two can disagree about the root.SEED_LENdid not need to grow to 64. BIP-39 entropy for 24 words IS 32 bytes and re-expands losslessly, so there is no at-rest length change and no §5.1 stored-secret length break — the ticket's premise that this requires 32→64 is refuted.The versioned envelope, and why legacy must fail closed
A legacy blob's 32 bytes (raw seed) and a current blob's 32 bytes (entropy) are byte-for-byte indistinguishable. Silently reinterpreting one as the other hands back a working handle for the wrong account — strictly worse than an error. So a stored blob now DECLARES what its bytes mean:
dig_keystore::opaque(magicDIGOP1) — the same audited container (Argon2id + AES-256-GCM + CRC-32) everyKeystore<K>file uses — with plaintextversion:u8 || kind:u8 || entropy. Kinds are append-only (§5.1).DIGVK1blob is detected before decryption and rejected withSessionError::LegacySeedFormat, which tells the caller to re-enrol from the phrase.This needed no dig-keystore change:
format::is_known_magicis a closed allow-list there, so a newKeySchemedefined outside dig-keystore could not be decoded, and aKeyScheme's fixedSECRET_LENcannot carry a version tag anyway.opaqueis the sanctioned arbitrary-length door into the same container. That avoided a third-repo write and an extra publish hop while a consumer lane was blocked.New API
UnlockedMasterSeed::recovery_phrase() -> Zeroizing<String>over&self— showing a user their backup must not cost them their session.Session::enroll_from_recovery_phrase(...)— the restore-on-a-new-machine path. Accepts any capitalisation and any whitespace run; rejecting a correct phrase over a capital letter is a trap, not a safety measure.BREAKING (0.4.0 → 0.5.1)
SEED_LENis removed, replaced byENTROPY_LEN(32, stored) andMASTER_SEED_LEN(64, expanded). Removing the ambiguous name is deliberate: a stale call site now fails to compile rather than deriving a silently wrong key.master_seed()returns the 64-byte expanded seed.Legacy accounts EXIST — an earlier draft of this PR said the exposed population was zero. That was FALSE.
The security gate proved it, and the correction now lives in
SPEC.md§3.3.0, theLegacySeedFormatrustdoc, the README, and dig-account'skeys/dek.rs+SPEC.md§10:origin/main(3.5.0) depends on the publisheddig-session = "0.4"+dig-account = "0.1.1", anddig-app-core/src/account/boot.rs:122/:149auto-enrolaccount.defaultintoFileBackend::new(brand_dir.join("account"))at first boot on every Windows/macOS host, with no user action.%LOCALAPPDATA%\DigNetwork\account\account.default.dks, 105 bytes, magicDIGVK1— 53-byte header + (32 secret + 16 GCM tag) + 4 CRC = exactly one legacy 32-byte raw seed.So the population is every installed dig-app on Windows/macOS that has booted once, at least one verified.
The true, narrower reason the re-pin is sound: no sealed artifact keyed by the old derivation exists — no
profiles/dir, no sealed profile blobs, no wallet store, no funded account (money path parked unmerged on #1702), pre-release per §3.7. Nothing encrypted under the old DEK became unreadable, which is the only thing re-pinning a DEK can break. A further change to a stored-secret derivation needs a migration, not a second re-pin.That legacy file must NOT be treated as safe to delete. It is password-sealed and nobody has read the OS credential store, so a balance cannot be ruled out.
A legacy account is WEDGED, not merely fail-closed — and the crate now says so
unlock_master_seedreturnsLegacySeedFormatand never a handle (session.rs:180->envelope.rs:127);enroll_master_seedat the same key returnsAlreadyExists(envelope.rs:90) because enrolment refuses to overwrite a custody root; and no pre-envelope release exposedrecovery_phrase(), so the user was never shown 24 words. There is no in-crate route back.The code fix belongs on the dig-app adoption path (detect -> preserve/back-up -> re-enrol, surfaced in the UI rather than a
tracing::warn!that silently yields "signing channel not started"). What this PR adds is the contract, so the wedge cannot be adopted blindly: theLegacySeedFormatrustdoc spells out the four required remediation steps, andSPEC.md§3.3.0 makes a legacy-detection-and-re-enrolment path a normative requirement of adopting 0.5. dig-account #5 carries the same requirement in its §10 + README.Blast radius checked
master_seed()— every in-crate caller (public_key,sign,signing_fn,derive_symmetric_key,profile_public_key,profile_sign,profile_derive_symmetric_key) plusSession::{enroll_master_seed, unlock_master_seed}, all updated in this diff. Cross-repo consumers of the changed symbols:dig-accountonly (store.rs,session.rs,unlocked.rs,auth/policy.rs,keys/dek.rs,profile_mint.rs,signer.rs,wallet/authorizer.rs) — migrated in the same unit of work on DIG-Network/dig-account#5.dig-wallet-backendneeds no production change (already standard); confirmed by a new test on DIG-Network/dig-wallet-backend#23. Grepped the whole superproject forSEED_LEN/enroll_master_seed/unlock_master_seed/UnlockedMasterSeed— no other consumer.UnlockedIdentityand the wholeenroll_identity/L1WalletBlspath are untouched, so the 0.2.0 identity blobs are unaffected.Memory hygiene:
bip39'szeroizefeature is now ENABLEDAn earlier revision of
master_seed.rsclaimed "bip39 2.x exposes nozeroizefeature". Wrong — bip39 2.2 derivesZeroize + ZeroizeOnDroponMnemonicbehind an optional-dependency feature, which is why it does not appear in the[features]table (I had read only that table).expand_entropybuilds aMnemonicon every derivation and its word indices are a complete copy of the account root, so without the feature un-wiped copies of the root accumulated in memory. Feature enabled, comment corrected, and CONF-11 asserts the trait bound at compile time — dropping the feature now fails to build.Residual, documented, upstream-owned: bip39 does not wipe its intermediate
to_string()heap buffer or its transient[u8; 33]entropy+checksum stack array.Tests (43 total, coverage 97.52% lines)
The load-bearing one is cross-implementation: a fixed public mnemonic → a hardcoded literal bech32m address produced independently via
chia-wallet-sdk. Computing both sides live would let a dependency bump move them together and mask a regression (thedig-keystorel1_wallet_bls.rsprecedent).abandon…art→xch16grurcglcwcv6arjarr720yd9wqhp9gkx3k8h25lhwg8pl7vl6ysuax0gy(the Sage address)xch1jcvy96pjk…and the crate no longer doesrecovery_phrase()does not consume the handleMnemonic: ZeroizeOnDrop— a COMPILE-TIME pin on thezeroizefeatureCONF-1/4/5/6/8 now drive from the non-degenerate fixture rather than the all-zero one, so the account-identity-blind class has three nets instead of one. The two
tests/master_seed.rsDEK goldens are now hex literals (7bf5db71...,eddb8924...) as their comments always claimed — both sides had been computed live frombip39+dig-identity, so a bump in either could have moved them together; the independently-reconstructed dig-app reference is still asserted alongside, since the literal alone cannot distinguish "frozen bytes drifted" from "the two HKDFs diverged".Mutations executed (each reverted; suite green after)
expand_entropyreturns raw entropy padded to 64MASTER_SEED_LEN = 32+ expansion removed (literally the pre-#1759 code)to_seed("dig")— non-empty passphraserecovery_phrase()ignoresself.entropyenroll_from_recovery_phraseignores the phraseentropy.len() != ENTROPY_LENguard (master_seed.rs:178)copy_from_slicebip39zeroizefeaturerecovery_phrase()ignores the root, re-run after widening the fixturesThe guard-deletion mutation is the second false green this lane found: deleting the entropy-length guard left all 41 tests green, while making a perfectly valid 12-word BIP-39 phrase a reachable
copy_from_slicepanic on the restore path — CONF-7's malformed cases all fail inside bip39 first and never reach the guard. CONF-12 uses valid shorter phrases, with a fixture check asserting bip39 itself accepts them, so the rejection provably comes from DIG's 24-word requirement.The first false green, found and fixed earlier:
recovery_phrase()returning a phrase derived from a fixed entropy passed every test, because the fixture was the all-zero-entropy mnemonic — the strongest-looking canonical vector is exactly the one blind to which account is in play. A bug of that shape would hand a user a phrase that restores someone else's account. Fixed by adding CONF-4b: a second account with unrelated entropy (fog spot notable…equal→xch1vpxzuu6aqfu790qcrcppcr2gmju4f5tpuuznuv2lx3g79v2jxc7qxttpzt) as a truthful control, so each phrase must resolve to its own frozen address. The mutation now fails.Docs
SPEC.mdgains §3.3.0 (the root is entropy; every derivation reads the expanded seed; empty passphrase) and §3.4 (the envelope + the fail-closed rule), the CONF-1..10 conformance list, and two custody invariants (no secret in an error message; never log a phrase). README gains the account/phrase section.dig-logging's BIP-39 redactor (redact.rsMIN_MNEMONIC_RUN = 12, case-insensitive) covers the 24-word form, butrecovery_phrase's docs now warn that it is not protection:Zeroizing<String>derefs toString, soinfo!("{phrase}")compiles, and the redactor runs at bundle time, so a logged phrase sits in plaintext on disk until a bundle is built. dig-session itself has no logging dependency, so it adds no log surface of its own.INFO, no action: every derivation now costs one PBKDF2-HMAC-SHA512/2048 — 0.84 ms measured. Not an amplification lever, but
sign()is no longer free.Refs DIG-Network/dig_ecosystem#1759