feat(account)!: adopt dig-session 0.5 BIP-39 root; expose the recovery phrase - #5
Merged
Merged
Conversation
…overy phrase) Lane stub so the branch + draft PR exist before implementation (§1.8 push-early). No behaviour change. Co-Authored-By: Claude <noreply@anthropic.com>
… phrase An account root is now 32 bytes of BIP-39 **entropy** (dig-session 0.5), expanded to the 64-byte HD seed before any derivation, so the 24 words a user backs up restore to the same addresses in Sage and every other conforming Chia wallet (dig_ecosystem #1759). - `UnlockedAccount::recovery_phrase()` over `&self` — the one secret the public API deliberately exposes, because a backup the user cannot see is not a backup, and showing it must not cost them their session. - `AccountSession::enroll_from_recovery_phrase` (+ the `pub(crate)` store path) — the restore-on-a-new-machine counterpart, fail-closed on an existing account and on an invalid phrase. - `SEED_LEN` -> `ENTROPY_LEN` at every seam; `master_seed()` is the expanded 64-byte seed. Tests use TWO accounts with unrelated entropy and frozen bech32m address literals produced independently via chia-wallet-sdk. The all-zero-entropy fixture alone is blind to which account is in play: a `recovery_phrase()` that ignored the live root would return a self-consistent phrase for the WRONG account and a single-account round-trip could not see it. BREAKING CHANGE: `AccountStore::enroll` / `AccountSession::enroll` take BIP-39 entropy rather than a raw seed, `master_seed()` returns 64 bytes, and the frozen profile-DEK golden vector MOVED because its input scalar now derives from the expanded seed. That is a §5.1-class change to a stored-secret derivation, permissible only because the exposed population is zero (no deployed account store; the money path is unmerged). It must not happen twice — any future change needs a migration, not a re-pin. Refs DIG-Network/dig_ecosystem#1759 Co-Authored-By: Claude <noreply@anthropic.com>
SPEC.md gains §2.0 (the account root is BIP-39 entropy; the expanded seed is consumed, never re-derived), the enrol/restore/phrase API contracts in §5, the deliberate recovery_phrase exception to the no-secret-escapes invariant in §8, and a §10 record of the one stored-secret break that happened — why the DEK golden vector was re-pinned rather than migrated, and that a further change needs a migration. Adds the phrase section to the README. Refs DIG-Network/dig_ecosystem#1759 Co-Authored-By: Claude <noreply@anthropic.com>
… path Gate fix G1/G2 for dig_ecosystem#1759. The DEK re-pin was justified as "the exposed population was zero". That is FALSE and was proved so: the published dig-session 0.4 / dig-account 0.1 line auto-enrolled an account at FIRST BOOT with no user action, and such sealed blobs have been verified on real hosts. The claim mattered because it is what the dig-app adoption lane and the next re-pin decision would have trusted — while the same comment asserted the break must not happen twice. The true, narrower reason: no sealed ARTIFACT keyed by the old derivation exists (no profile blobs, no wallet store, no funded account), so nothing encrypted under the old DEK became unreadable — which is the only thing re-pinning a DEK can break. Corrected in `keys/dek.rs` and `SPEC.md` §10. `SPEC.md` §10 and the README now also state normatively that adopting 0.2.0 REQUIRES a legacy-detection-and-re-enrolment path in the host: a legacy account is WEDGED (unlock surfaces `LegacySeedFormat`, re-enrolment at the same `AccountId` returns `AlreadyExists`, and no pre-0.2 release ever showed the user 24 words), so a host that merely logs the error leaves it permanently and silently without a signer. The old sealed blob MUST be preserved, never deleted — it may hold value and its password may live in an OS credential store neither crate can read. Refs DIG-Network/dig_ecosystem#1759 Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
added a commit
to DIG-Network/dig-session
that referenced
this pull request
Jul 29, 2026
…nvelope, recovery_phrase() (#6) 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_seed` at `master_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_LEN` did 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: - Sealed via `dig_keystore::opaque` (magic `DIGOP1`) — the same audited container (Argon2id + AES-256-GCM + CRC-32) every `Keystore<K>` file uses — with plaintext `version:u8 || kind:u8 || entropy`. Kinds are append-only (§5.1). - A pre-envelope `DIGVK1` blob is detected **before** decryption and rejected with `SessionError::LegacySeedFormat`, which tells the caller to re-enrol from the phrase. - An unrecognised version or kind is refused, never guessed. This needed **no dig-keystore change**: `format::is_known_magic` is a closed allow-list there, so a new `KeyScheme` defined outside dig-keystore could not be decoded, and a `KeyScheme`'s fixed `SECRET_LEN` cannot carry a version tag anyway. `opaque` is 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_LEN` is **removed**, replaced by `ENTROPY_LEN` (32, stored) and `MASTER_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. - For a given 32 stored bytes this **MOVES the identity public key and the profile DEK**. ### 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, the `LegacySeedFormat` rustdoc, the README, and dig-account's `keys/dek.rs` + `SPEC.md` §10: - dig-app `origin/main` (3.5.0) depends on the **published** `dig-session = "0.4"` + `dig-account = "0.1.1"`, and `dig-app-core/src/account/boot.rs:122`/`:149` **auto-enrol `account.default`** into `FileBackend::new(brand_dir.join("account"))` at **first boot on every Windows/macOS host, with no user action**. - The artifact was found on the reference machine: `%LOCALAPPDATA%\DigNetwork\account\account.default.dks`, **105 bytes**, magic `DIGVK1` — 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_seed` returns `LegacySeedFormat` and never a handle (`session.rs:180` -> `envelope.rs:127`); `enroll_master_seed` at the same key returns `AlreadyExists` (`envelope.rs:90`) because enrolment refuses to overwrite a custody root; and no pre-envelope release exposed `recovery_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: the `LegacySeedFormat` rustdoc spells out the four required remediation steps, and `SPEC.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`) plus `Session::{enroll_master_seed, unlock_master_seed}`, all updated in this diff. Cross-repo consumers of the changed symbols: **`dig-account` only** (`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-backend` needs **no production change** (already standard); confirmed by a new test on DIG-Network/dig-wallet-backend#23. Grepped the whole superproject for `SEED_LEN` / `enroll_master_seed` / `unlock_master_seed` / `UnlockedMasterSeed` — no other consumer. `UnlockedIdentity` and the whole `enroll_identity` / `L1WalletBls` path are untouched, so the 0.2.0 identity blobs are unaffected. **⚠️ Risk: HIGH** — this is custody code and it moves a stored-secret derivation. It is safe ONLY under the zero-population finding above. ## Memory hygiene: `bip39`'s `zeroize` feature is now ENABLED An earlier revision of `master_seed.rs` claimed "bip39 2.x exposes no `zeroize` feature". **Wrong** — bip39 2.2 derives `Zeroize + ZeroizeOnDrop` on `Mnemonic` behind an *optional-dependency* feature, which is why it does not appear in the `[features]` table (I had read only that 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. 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 (the `dig-keystore` `l1_wallet_bls.rs` precedent). | | property | |---|---| | CONF-1 | `abandon…art` → `xch16grurcglcwcv6arjarr720yd9wqhp9gkx3k8h25lhwg8pl7vl6ysuax0gy` (the Sage address) | | CONF-2 | non-vacuity: the fixture still reproduces the OLD `xch1jcvy96pjk…` and the crate no longer does | | CONF-3 | a legacy blob fails closed — no handle, no address | | CONF-4 / 4b | phrase round-trip, and with **two accounts** each phrase restores ITS OWN account | | CONF-5 | `recovery_phrase()` does not consume the handle | | CONF-6 | identity + DEK derive from the EXPANDED seed (placement, not just outcome) | | CONF-7..10 | invalid phrases rejected without echoing them; user-typed case/whitespace; no overwrite; unknown envelope version/kind refused | | CONF-11 | `Mnemonic: ZeroizeOnDrop` — a COMPILE-TIME pin on the `zeroize` feature | | CONF-12 | a **valid** 12/15/18/21-word phrase is REJECTED, never a panic | CONF-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.rs` DEK goldens are now **hex literals** (`7bf5db71...`, `eddb8924...`) as their comments always claimed — both sides had been computed live from `bip39` + `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) | mutation | result | |---|---| | `expand_entropy` returns raw entropy padded to 64 | **9 tests fail** | | `MASTER_SEED_LEN = 32` + expansion removed (literally the pre-#1759 code) | CONF-1, CONF-2, CONF-6 fail | | `to_seed("dig")` — non-empty passphrase | CONF-1 fails | | legacy-magic guard deleted from the envelope reader | CONF-3 fails | | `recovery_phrase()` ignores `self.entropy` | **SURVIVED at first** — see below | | `enroll_from_recovery_phrase` ignores the phrase | CONF-4b fails | | **delete the `entropy.len() != ENTROPY_LEN` guard** (`master_seed.rs:178`) | CONF-12 fails — panics at `copy_from_slice` | | **drop the `bip39` `zeroize` feature** | CONF-11 fails to COMPILE | | `recovery_phrase()` ignores the root, re-run after widening the fixtures | now **3** tests fail, not 1 | The 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_slice` panic 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.md` gains §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.rs` `MIN_MNEMONIC_RUN = 12`, case-insensitive) covers the 24-word form, but `recovery_phrase`'s docs now warn that it is **not protection**: `Zeroizing<String>` derefs to `String`, so `info!("{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 --------- Co-authored-by: Claude <noreply@anthropic.com>
The lock still carried a source-less `dig-session` entry left behind by the temporary `[patch.crates-io]` used to validate this branch before 0.5.1 was published. A source-less entry is a PATH resolution: the manifest said `dig-session = "0.5"` while the lock silently compiled the local worktree, so `cargo update -p dig-session` could not even match the package. That is the shape that has bitten this ecosystem before — an intermediate consumer's lock pinning something other than the published crate makes a fix miss the very path it was meant to reach. The stale block is dropped and re-resolved from the registry. The lock now carries `source = "registry+..."` and checksum `ecb21cf421c5f2b1d6e43b35592f71767c72526b4ccf5e7552352c0162ed4ded`, which matches the sparse index's `cksum` for 0.5.1 byte-for-byte. The whole diff is those two added lines — no other pin moved. Verified against the PUBLISHED crate, not the path: `cargo metadata --locked` resolves dig-session 0.5.1 to the registry cache, and `cargo test --locked` (no re-resolution permitted) passes 141 tests. 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.
The consumer half of DIG-Network/dig_ecosystem#1759.
UNBLOCKED — verified against the PUBLISHED crate, not a local path
dig-session0.5.1 is live on crates.io, so this branch no longer needs the temporary[patch.crates-io]it was validated under. That patch is gone and the lock was re-resolved from the registry:Cargo.toml→dig-session = "0.5"(a published version; nopath, nogit =— crates.io forbids git deps and a path dep blocks publishing).Cargo.lock→dig-session 0.5.1,source = "registry+https://github.com/rust-lang/crates.io-index",checksum = ecb21cf421c5f2b1d6e43b35592f71767c72526b4ccf5e7552352c0162ed4ded— byte-identical to the sparse index'scksumfor 0.5.1.dig-sessionentry left by the patch, which is a path resolution: the manifest said"0.5"while the build compiled the local worktree, andcargo update -p dig-sessioncould not even match the package. Fixed inf18b954; the entire lock diff is the two added lines, no other pin moved.cargo metadata --lockedresolves dig-session to~/.cargo/registry/src/index.crates.io-*/dig-session-0.5.1/, andcargo test --locked(no re-resolution permitted) passes 141 tests. Clippy-D warningsclean, coverage 95.69% lines — all--locked.What
An account root is now 32 bytes of BIP-39 entropy, expanded to the 64-byte HD seed before any derivation, so the 24 words a user backs up restore to the same addresses in Sage and every other conforming Chia wallet.
UnlockedAccount::recovery_phrase() -> Zeroizing<String>over&self. This is the one secret the public API deliberately exposes — a backup the user cannot see is not a backup — and showing it must not cost them their session. Directly unblocks the dig-app account-setup GUI lane, which had nothing coherent to render.AccountSession::enroll_from_recovery_phrase(store, id, password, phrase, default_ix)(+ thepub(crate)AccountStorepath) — the restore-on-a-new-machine counterpart. Fail-closed on an already-enrolled account (never clobbers a live custody root) and on an invalid phrase.SEED_LEN→ENTROPY_LENat every seam;master_seed()(stillpub(crate)) is the expanded 64-byte seed.BREAKING (0.1.2 → 0.2.0)
AccountStore::enroll/AccountSession::enrolltake BIP-39 entropy, not a raw seed.3285f675…→55d71eb7…). The HKDF construction is unchanged; its input scalar now derives from the expanded seed.Correction: the re-pin was justified as "the exposed population is zero". That was FALSE.
The security gate proved it, and the claim is now corrected in
keys/dek.rs,SPEC.md§10 and the README. The published dig-session 0.4 / dig-account 0.1 line auto-enrols an account at first boot with no user action (dig-app-core/src/account/boot.rs:122/:149→FileBackend::new(brand_dir.join("account"))), and the resulting legacyDIGVK1blob was found on the reference machine (account.default.dks, 105 bytes). So the population is every installed dig-app on Windows/macOS that has booted once.The true, narrower reason: no sealed artifact keyed by the old derivation exists — no
profiles/dir, 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. The false claim mattered because it is what the dig-app adoption lane and the next re-pin decision would have trusted — while the very same comment asserted the break must not happen twice. A further change needs a migration, not a second re-pin.Adopting 0.2.0 REQUIRES a legacy-account path in the host (now normative here)
A legacy account is WEDGED, not merely unreadable:
AccountSession::unlocksurfacesLegacySeedFormatand never yields anUnlockedAccount,enroll/enroll_from_recovery_phraseat the sameAccountIdreturnAlreadyExistsbecause enrolment refuses to overwrite a custody root, and no pre-0.2 release exposedrecovery_phrase()— so the user was never shown 24 words either. A host that merely logs the error leaves the account permanently and silently without a signer.SPEC.md§10 + the README now require: detect that error specifically; PRESERVE the old sealed blob (never delete — it is password-sealed, its password may live in an OS credential store neither crate can read, and a balance cannot be ruled out); surface it in the UI; re-enrol and show the new phrase. The dig-app lane owns the code.Blast radius checked
Every
dig_session::SEED_LEN/master_seed()consumer in this crate:store.rs,session.rs,unlocked.rs,keys/dek.rs,auth/policy.rs,profile_mint.rs,signer.rs,wallet/authorizer.rs.WalletKey::from_seed_at(&seed[..], ix)takes a slice and needed no signature change — it now receives 64 bytes and derives correctly, pinned by the address goldens below.keys/dek.rs::profile_dekstill delegates to dig-session's frozen HKDF (never reimplemented), verified bydelegates_byte_identically_to_dig_session.STOP — read this before touching #1702 (dig-account money path, parked)
#1702 must rebase onto this and take
0.3.0. This PR claims 0.2.0, so #1702's current0.2.0will collide. Same repo, single-writer, so ordering is not optional:Cargo.toml,SPEC.md,src/unlocked.rs— nothing elsesrc/unlocked.rsrecovery_phrase) plus one return-type widening (master_seed()→[u8; MASTER_SEED_LEN]), specifically to keep that rebase cheapCargo.tomldig-session"0.4"→"0.5",bip39added to[dev-dependencies]SPEC.mdsrc/wallet/*,src/session_residency.rs,src/error.rs,scripts/probe-guards.sh,tests/compile_fail/*A substantive change #1702 must absorb, not just a rebase: its money path derives from
master_seed(), which is now the BIP-39-expanded 64-byte seed rather than 32 bytes of entropy. Any golden vector on that branch keyed to the old root will move, and it must move to the standard Chia value — that is the whole point of #1759. It should re-pin against the frozen literals below rather than re-deriving.Coordination detail
Same repo, so single-writer. This branch is cut from
mainand does not touch #1702's files except three it also edits —Cargo.toml,SPEC.md,src/unlocked.rs— and inunlocked.rsthe change is deliberately one added method plus one return-type widening, to keep #1702's rebase cheap. (Restated above, deliberately, because it is easy to miss.) Itssrc/wallet/*,src/session_residency.rsandsrc/error.rschanges are untouched here.Tests
Frozen bech32m address literals, produced independently via
chia-wallet-sdkand identical to the ones pinned in dig-session and dig-wallet-backend — three crates now agree on the same two strings:account_sits_at_the_standard_chia_address_for_its_phrase—abandon…art→xch16grurcglcwcv6arjarr720yd9wqhp9gkx3k8h25lhwg8pl7vl6ysuax0gy.each_accounts_phrase_restores_that_account_and_not_another— two accounts with unrelated entropy; each reported phrase must restore ITS OWN frozen address and DEK.recovery_phrase_does_not_consume_the_account.enroll_then_unlock_recovers_the_same_master_seednow asserts the root equals an independently computed BIP-39 expansion (straight frombip39, not through dig-session) AND is not the stored entropy — asserting both legs is what distinguishes "expanded" from "stored verbatim" at this seam.Why two accounts. The canonical all-zero-entropy mnemonic is blind to which account is in play: an implementation that ignored the live root and derived from zeros would return a self-consistent phrase for the wrong account and pass any single-account round-trip. That exact mutation survived in dig-session before the second actor was added.
Mutations executed (each reverted; 141 tests green after)
enroll_from_recovery_phraseignores the phrase (fixed phrase)each_accounts_phrase_restores…failsUnlockedAccount::recovery_phrasereturns a constant phraseeach_accounts_phrase_restores…failsDocs
SPEC.mdgains §2.0 (the root is BIP-39 entropy; the expanded seed is consumed, never re-derived), the enrol/restore/phrase contracts in §5, the deliberaterecovery_phraseexception to the no-secret-escapes invariant in §8, and the §10 break record + conformance requirements. README gains the phrase section.Refs DIG-Network/dig_ecosystem#1759