feat(ecash): the wallet can now be restored from a phrase (NUT-13)
Demo images / Build & push demo images (push) Failing after 2m15s

Until now every Cashu proof this node held was backed by a secret drawn
from OsRng and written to exactly one file. Losing wallet/ecash.json
lost the coins outright — no phrase to write down, and nothing the mint
could do about it. Ecash is a bearer instrument, so "one file, no
backup" was the sharpest edge in the wallet.

NUT-13 derives each proof's secret and blinding factor from (seed,
keyset id, counter) instead. The wallet becomes a phrase, and the coins
can be re-derived and re-claimed — here or in any other NUT-13 wallet.

The phrase is its own 24 words, derived from the node master seed over a
fixed HKDF path. Both halves matter: it is still covered by the node's
recovery phrase, so there is nothing extra to write down; but it is
portable, so restoring ecash into Minibits or cdk-cli does not mean
handing over the key to the entire node.

It sits on disk unencrypted, deliberately. The master seed needs the
operator's password to open, which no background mint or swap can ask
for; and this file lives beside wallet/ecash.json, which already holds
spendable bearer secrets in plaintext. It regenerates exactly those
secrets, so it is the same sensitivity class as the file next to it.
0600, like identity/nostr_secret, which is derived and persisted the
same way.

Counters are reserved *before* the mint call and never rolled back. A
gap costs a restore scan a few extra probes; a reused counter costs a
coin, because two proofs with the same secret can only be spent once.

Restore is the half that cannot be done offline: a re-derived secret is
not money until the mint's signature over it exists. /v1/restore returns
those signatures; unblinding reconstitutes the proofs. It is additive
and idempotent — coins already held are skipped by secret, spent ones
are counted but not added — so it is safe to press on a working wallet,
which is when someone is most likely to reach for it.

Existing nodes activate on the first visit to Settings → Ecash backup
phrase: that password prompt is the only moment the master seed can
legitimately be opened. New nodes get it at onboarding. Until then the
behaviour is exactly as before — valid proofs, no backup — and the card
says so rather than implying a backup already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-17 07:56:34 -04:00
co-authored by Claude Opus 5
parent 579287ba48
commit 59fffc809f
9 changed files with 1197 additions and 31 deletions
+58
View File
@@ -39,6 +39,7 @@ const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1";
const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1";
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/v1";
const CASHU_ENTROPY_INFO: &[u8] = b"archipelago/cashu/bip39-entropy/v1";
// ─── MasterSeed ─────────────────────────────────────────────────────────
@@ -300,6 +301,30 @@ pub fn derive_lnd_entropy(seed: &MasterSeed) -> Result<[u8; 16]> {
Ok(entropy)
}
/// Derive the ecash (Cashu, NUT-13) wallet's own 24-word BIP-39 mnemonic.
///
/// The ecash wallet gets a **separate mnemonic** rather than being handed the
/// node's own 24 words, and both halves of that matter:
///
/// - it is still covered by the node's recovery phrase, because it is derived
/// from the master seed over a fixed domain-separated path — restore the
/// node from its words and the same ecash wallet comes back, with nothing
/// extra for the operator to write down;
/// - but it is *portable*. NUT-13 is a standard, so these words restore the
/// ecash in Minibits, Nutstash or `cdk-cli`. Showing the node seed here
/// would have made "back up my ecash" and "hand over the key to the entire
/// node" the same action.
///
/// One-way by construction: HKDF cannot be run backwards, so a leaked ecash
/// mnemonic does not expose the master seed or any other derived key.
pub fn derive_cashu_mnemonic(seed: &MasterSeed) -> Result<bip39::Mnemonic> {
let mut entropy = hkdf_derive_32(seed.as_bytes(), CASHU_ENTROPY_INFO)?;
let mnemonic = bip39::Mnemonic::from_entropy(&entropy)
.map_err(|e| anyhow::anyhow!("Failed to derive the ecash mnemonic: {}", e));
entropy.zeroize();
mnemonic
}
// ─── Encrypted Seed Storage ─────────────────────────────────────────────
/// Encrypt `plaintext` with Argon2(passphrase) + ChaCha20-Poly1305.
@@ -657,6 +682,39 @@ mod tests {
assert_eq!(e1.len(), 16);
}
/// The ecash mnemonic must be reproducible from the node's words alone —
/// that reproducibility is the entire backup story ("your 24 words already
/// cover your ecash").
#[test]
fn cashu_mnemonic_is_reproducible_from_the_node_seed() {
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let a = derive_cashu_mnemonic(&seed).unwrap();
let b = derive_cashu_mnemonic(&seed).unwrap();
assert_eq!(a.to_string(), b.to_string());
assert_eq!(a.word_count(), 24);
// A different node seed must yield a different ecash wallet, or two
// nodes would derive each other's coins.
let (other_words, _) = MasterSeed::generate().unwrap();
let (_, other_seed) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
assert_ne!(a.to_string(), derive_cashu_mnemonic(&other_seed).unwrap().to_string());
}
/// It must NOT be the node's own phrase. Restoring ecash into a
/// third-party wallet means handing these words over, and that must never
/// be the same as handing over the node.
#[test]
fn cashu_mnemonic_is_not_the_node_mnemonic() {
let (node_mnemonic, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let cashu = derive_cashu_mnemonic(&seed).unwrap();
assert_ne!(cashu.to_string(), node_mnemonic.to_string());
// And knowing the ecash words must not re-derive the node seed: they
// are a one-way HKDF descendant, so the seeds they expand to differ.
let cashu_seed = MasterSeed::from_mnemonic(&cashu);
assert_ne!(cashu_seed.as_bytes(), seed.as_bytes());
}
#[test]
fn test_generate_produces_24_words() {
let (mnemonic, _seed) = MasterSeed::generate().unwrap();