Skip to content

feat(session)!: BIP-39/Chia-conformant account root, versioned seed envelope, recovery_phrase() - #6

Merged
MichaelTaylor3d merged 4 commits into
mainfrom
feat/1759-bip39-chia-conformant-derivation
Jul 29, 2026
Merged

feat(session)!: BIP-39/Chia-conformant account root, versioned seed envelope, recovery_phrase()#6
MichaelTaylor3d merged 4 commits into
mainfrom
feat/1759-bip39-chia-conformant-derivation

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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…artxch16grurcglcwcv6arjarr720yd9wqhp9gkx3k8h25lhwg8pl7vl6ysuax0gy (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…equalxch1vpxzuu6aqfu790qcrcppcr2gmju4f5tpuuznuv2lx3g79v2jxc7qxttpzt) 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

MichaelTaylor3d and others added 3 commits July 28, 2026 22:59
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
MichaelTaylor3d marked this pull request as ready for review July 29, 2026 06:41
@MichaelTaylor3d MichaelTaylor3d changed the title feat(session)!: BIP-39/Chia-conformant master-seed derivation + recovery_phrase() feat(session)!: BIP-39/Chia-conformant account root, versioned seed envelope, recovery_phrase() Jul 29, 2026
…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>
@MichaelTaylor3d
MichaelTaylor3d merged commit 71721f0 into main Jul 29, 2026
9 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the feat/1759-bip39-chia-conformant-derivation branch July 29, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant