Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand All @@ -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"
Expand All @@ -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"] }
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 66 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>` — 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<UnlockedMasterSeed>` 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.
Expand Down Expand Up @@ -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<String>` 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.
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/auth/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 25 additions & 4 deletions src/keys/dek.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
4 changes: 2 additions & 2 deletions src/profile_mint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ 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<UnlockedMasterSeed> {
Arc::new(
Session::enroll_master_seed(
Arc::new(MemoryBackend::new()),
BackendKey::new("k".to_string()),
Password::new("pw"),
&[0x21; SEED_LEN],
&[0x21; ENTROPY_LEN],
)
.unwrap(),
)
Expand Down
36 changes: 31 additions & 5 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,11 +51,37 @@ impl AccountSession {
store: Arc<AccountStore>,
account: AccountId,
password: dig_session::Password,
seed: &[u8; dig_session::SEED_LEN],
entropy: &[u8; dig_session::ENTROPY_LEN],
default_profile_ix: ProfileIx,
) -> Result<UnlockedAccount> {
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<AccountStore>,
account: AccountId,
password: dig_session::Password,
phrase: &str,
default_profile_ix: ProfileIx,
) -> Result<UnlockedAccount> {
let seed = store
.enroll_from_recovery_phrase(&account, password, phrase)
.map_err(|why| AccountError::Keystore(why.to_string()))?;
Ok(UnlockedAccount::new(
account,
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<UnlockedMasterSeed> {
Arc::new(
Expand Down
Loading
Loading