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
2,533 changes: 2,204 additions & 329 deletions Cargo.lock

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "dig-session"
version = "0.4.0"
version = "0.5.1"
edition = "2021"
rust-version = "1.75"
description = "DIG session/keystore layer: unlock identity signing keys and inject signing primitives. Composes dig-keystore (encrypted key storage) + dig-identity (canonical BLS identity derivation) behind one curated, custody-safe facade."
Expand Down Expand Up @@ -39,6 +39,15 @@ dig-identity = { version = "0.5", features = ["bls"] }
# they are instead confined to the smallest scope and dropped immediately.
zeroize = "1.7"
thiserror = "1"
# BIP-39: the ONE place a recovery phrase is converted. Entropy <-> 24 English
# words, and `to_seed("")` (PBKDF2-HMAC-SHA512, 2048 rounds, EMPTY passphrase —
# the Chia convention) to expand entropy into the 64-byte master HD seed. This
# expansion is what makes a DIG phrase restore identically in Sage; deriving
# straight from the entropy silently forks the wallet (dig_ecosystem #1759).
# `zeroize` makes `Mnemonic` `Zeroize + ZeroizeOnDrop`, which MATTERS here: `expand_entropy` builds a
# `Mnemonic` on EVERY derivation, and its word indices are a complete copy of the account root — without
# the feature those copies accumulate un-wiped in memory. No other languages pulled in.
bip39 = { version = "2.2", features = ["zeroize"] }
# Per-profile DEK derivation. Versions pinned to MATCH dig-app's DEK construction
# (dig-app-core keystore/secrets.rs) byte-for-byte: HKDF-SHA256, RFC 5869. A
# different hkdf/sha2 major here would risk a different DEK and break at-rest
Expand All @@ -52,6 +61,12 @@ dig-constants = "0.7"
[dev-dependencies]
dig-keystore = { version = "0.4", features = ["testing"] }
tempfile = "3"
hex = "0.4"
# Test-only: the INDEPENDENT reference derivation for the cross-implementation
# golden vector (`master_to_wallet_unhardened(..).derive_synthetic()` ->
# `StandardArgs::curry_tree_hash` -> bech32m). Never hand-roll a Chia address.
chia = { version = "0.26", features = ["bls"] }
chia-wallet-sdk = { version = "0.30", default-features = false }

[lints.rust]
unsafe_code = "forbid"
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,60 @@ let identity = Session::unlock::<dig_session::L1WalletBls>(
# }
```

## Accounts: one root, a portable recovery phrase

An account's root is 32 bytes of **BIP-39 entropy**. Every derivation — identity,
per-profile DEK, wallet — reads the seed EXPANDED from it
(`entropy -> 24 words -> to_seed("")`), which is the standard Chia derivation, so
a DIG recovery phrase restores the same addresses in Sage and any other
conforming wallet.

```rust,no_run
use std::sync::Arc;
use dig_session::{Session, FileBackend, BackendKey, Password, ENTROPY_LEN};

# fn main() -> dig_session::Result<()> {
let backend = Arc::new(FileBackend::new("/var/lib/dig/keys"));
let path = BackendKey::new("account");
let password = Password::from("correct horse battery staple");

// Create: any 32 CSPRNG bytes are valid BIP-39 entropy.
let entropy = [0u8; ENTROPY_LEN]; // in production: fill from `OsRng`
let account = Session::enroll_master_seed(backend.clone(), path.clone(), password, &entropy)?;

// Show the user their 24 words — this does NOT consume the handle.
let phrase = account.recovery_phrase();

// The expanded 64-byte seed goes to wallet-backend's `MasterKey::from_seed_bytes`.
let seed = account.master_seed();

// Restore on another machine.
let restored = Session::enroll_from_recovery_phrase(
backend,
BackendKey::new("account-2"),
Password::from("correct horse battery staple"),
&phrase,
)?;
# let _ = (seed, restored);
# Ok(())
# }
```

### Legacy accounts: fail-closed, and your job to remediate

A blob written before the versioned seed envelope held a *raw* seed rather than entropy. Because the
two are byte-indistinguishable, `unlock_master_seed` fails closed with
`SessionError::LegacySeedFormat` instead of reinterpreting them — silently deriving a plausible wrong
wallet would be far worse than an error.

**Those blobs exist in the field**: the published 0.4 line auto-enrolled an account at first boot with
no user action. A legacy account is **wedged** — unlock refuses, and re-enrolling at the same key
returns `AlreadyExists` because enrolment never overwrites a custody root. So **adopting 0.5 requires
a legacy-detection-and-re-enrolment path in your app**: detect the variant specifically, *preserve*
(never delete) the old sealed blob — it may hold value and its password may live in an OS credential
store — tell the user in the UI, then re-enrol and show the new phrase. Merely logging the error
leaves the account permanently and silently without a signer. See the `LegacySeedFormat` rustdoc.

## Design notes

- **No seal / decap.** Recipient message encryption belongs to `dig-message`,
Expand Down
Loading
Loading