From 59fffc809f608c99369af9ce5cba441786c7381d Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 17 Aug 2026 07:56:34 -0400 Subject: [PATCH] feat(ecash): the wallet can now be restored from a phrase (NUT-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/archipelago/src/api/rpc/dispatcher.rs | 3 + core/archipelago/src/api/rpc/seed_rpc.rs | 12 + core/archipelago/src/api/rpc/wallet.rs | 125 ++++ core/archipelago/src/seed.rs | 58 ++ core/archipelago/src/wallet/mint_client.rs | 204 ++++++- core/archipelago/src/wallet/mod.rs | 1 + core/archipelago/src/wallet/nut13.rs | 552 ++++++++++++++++++ neode-ui/src/components/EcashSeedBackup.vue | 267 +++++++++ neode-ui/src/views/settings/BackupSection.vue | 6 + 9 files changed, 1197 insertions(+), 31 deletions(-) create mode 100644 core/archipelago/src/wallet/nut13.rs create mode 100644 neode-ui/src/components/EcashSeedBackup.vue diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 401b3c77..61c6f95b 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -268,6 +268,9 @@ impl RpcHandler { "wallet.ecash-history" => self.handle_wallet_ecash_history().await, "wallet.ecash-network" => self.handle_wallet_ecash_network().await, "wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await, + "wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await, + "wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await, + "wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await, "wallet.networking-profits" => self.handle_wallet_networking_profits().await, // Fedimint ecash (via fedimint-clientd sidecar) "wallet.fedimint-list" => self.handle_wallet_fedimint_list().await, diff --git a/core/archipelago/src/api/rpc/seed_rpc.rs b/core/archipelago/src/api/rpc/seed_rpc.rs index 4479b915..26596a31 100644 --- a/core/archipelago/src/api/rpc/seed_rpc.rs +++ b/core/archipelago/src/api/rpc/seed_rpc.rs @@ -52,6 +52,18 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted( .parse() .context("Invalid mnemonic in memory")?; crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?; + + // Establish the ecash wallet's NUT-13 phrase here too — this is the last + // moment the master seed exists in plaintext during onboarding, and the + // ecash wallet needs its own phrase on disk to mint restorable proofs + // without a password prompt on every background swap. Best-effort: a node + // that fails here still onboards, mints valid coins, and can establish the + // phrase later from Settings → Back up ecash. + let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic); + if let Err(e) = crate::wallet::nut13::establish_from_master(data_dir, &master).await { + tracing::warn!("Could not establish the ecash wallet phrase at onboarding: {e:#}"); + } + *state = None; Ok(true) } diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index 16b813fc..2009d559 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -246,6 +246,131 @@ impl RpcHandler { })) } + /// `wallet.ecash-seed-status` — whether this wallet has a NUT-13 phrase + /// yet, and therefore whether its coins can be restored at all. + /// + /// Deliberately says nothing secret. `active: false` is the honest answer + /// for a node that predates NUT-13: its existing proofs live in exactly one + /// file and nothing can bring them back, which the UI needs to be able to + /// say plainly rather than implying a backup exists. + pub(super) async fn handle_wallet_ecash_seed_status(&self) -> Result { + let data_dir = &self.config.data_dir; + let active = crate::wallet::nut13::seed_exists(data_dir); + let source = match crate::wallet::nut13::load_seed(data_dir).await { + Ok(Some(seed)) => Some(seed.source()), + _ => None, + }; + // Whether the node has an encrypted master seed decides whether the + // "set up" path can derive from it, which is what the operator is + // promised: your node's 24 words already cover your ecash. + Ok(serde_json::json!({ + "active": active, + "source": source, + "can_activate": crate::seed::seed_exists(data_dir), + })) + } + + /// `wallet.ecash-seed-reveal` — show the ecash wallet's 24 words, and + /// establish them from the node's master seed if this is the first time. + /// + /// Gated exactly like `seed.reveal` and `lnd.seed-reveal`: authenticated + /// session, password re-verification, TOTP when enabled. The words are + /// returned to the caller only and never logged. + /// + /// Reveal doubles as activation because the master seed is encrypted at + /// rest: this password prompt is the only moment the node can legitimately + /// open it, so it is also the only moment the ecash phrase can be derived + /// from it. A node that has never been here mints valid but unrecoverable + /// proofs; one visit fixes that for every proof minted afterwards. + pub(super) async fn handle_wallet_ecash_seed_reveal( + &self, + params: Option, + ) -> Result { + use zeroize::Zeroize; + + let params = params.unwrap_or_default(); + let data_dir = &self.config.data_dir; + + let mut password = self.verify_reveal_auth(¶ms, "the ecash seed").await?; + + // Already established: just open it. No master seed needed, so this + // still works on a node whose backup passphrase has been forgotten. + if let Some(seed) = crate::wallet::nut13::load_seed(data_dir).await? { + password.zeroize(); + let words = seed.words(); + return Ok(serde_json::json!({ + "words": words, + "word_count": words.len(), + "source": seed.source(), + "newly_activated": false, + })); + } + + if !crate::seed::seed_exists(data_dir) { + password.zeroize(); + anyhow::bail!( + "This node has no encrypted seed backup, so an ecash recovery \ + phrase cannot be derived from it." + ); + } + + // The backup passphrase may differ from the login password — same + // fallback `seed.reveal` uses. + let passphrase = params + .get("passphrase") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| password.clone()); + let master = crate::seed::load_seed_encrypted(data_dir, &passphrase).await; + password.zeroize(); + let mnemonic = master.map_err(|_| { + anyhow::anyhow!( + "Could not decrypt the saved seed. If you set a separate backup \ + passphrase during setup, enter that passphrase." + ) + })?; + let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic); + let seed = crate::wallet::nut13::establish_from_master(data_dir, &master).await?; + + let words = seed.words(); + Ok(serde_json::json!({ + "words": words, + "word_count": words.len(), + "source": seed.source(), + "newly_activated": true, + })) + } + + /// `wallet.ecash-restore` — rebuild the wallet's coins from its NUT-13 + /// phrase by asking a mint which re-derived secrets it has signed. + /// + /// Defaults to the wallet's own mint; `mint_url` targets another one, for + /// a wallet whose coins were spread across mints. + pub(super) async fn handle_wallet_ecash_restore( + &self, + params: Option, + ) -> Result { + let params = params.unwrap_or_default(); + let mint_url = match params.get("mint_url").and_then(|v| v.as_str()) { + Some(url) if !url.trim().is_empty() => url.trim().to_string(), + _ => { + crate::wallet::ecash::load_wallet(&self.config.data_dir) + .await? + .mint_url + } + }; + + let outcome = + crate::wallet::ecash::restore_from_seed(&self.config.data_dir, &mint_url).await?; + Ok(serde_json::json!({ + "mint_url": mint_url, + "recovered_sats": outcome.recovered_sats, + "recovered_proofs": outcome.recovered_proofs, + "already_spent": outcome.already_spent, + "keysets_scanned": outcome.keysets_scanned, + })) + } + pub(super) async fn handle_wallet_networking_profits(&self) -> Result { let summary = profits::get_networking_profits(&self.config.data_dir).await?; Ok(serde_json::json!({ diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index 9f2fad60..ed3406c5 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -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 { + 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(); diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 44e2f6a2..e533b44e 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -12,9 +12,11 @@ use super::cashu::{ amount_to_denominations, is_truncated_v2_keyset_id, BlindSignature, BlindedMessageRequest, CashuToken, KeysetInfo, MintKeyset, Proof, }; +use super::nut13::RecoverySource; use anyhow::{Context, Result}; +use bitcoin::secp256k1; use serde::{Deserialize, Serialize}; -use tracing::debug; +use tracing::{debug, warn}; /// Default timeout for mint API calls. const MINT_TIMEOUT_SECS: u64 = 10; @@ -130,10 +132,19 @@ fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Erro pub struct MintClient { url: String, client: reqwest::Client, + /// NUT-13 output source. When set, every proof this client creates has a + /// secret derived from the wallet's phrase and is therefore restorable; + /// when absent, secrets are random and live only in `wallet/ecash.json`. + recovery: Option, } impl MintClient { /// Create a new mint client for the given mint URL. + /// + /// Proofs minted through a client built this way are **not** recoverable + /// from the wallet phrase. Prefer `ecash::mint_client`, which attaches the + /// NUT-13 source; this stays for callers with no data directory (probes, + /// keyset lookups, tests). pub fn new(mint_url: &str) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS)) @@ -143,6 +154,7 @@ impl MintClient { Ok(Self { url: mint_url.trim_end_matches('/').to_string(), client, + recovery: None, }) } @@ -151,13 +163,67 @@ impl MintClient { Self { url: mint_url.trim_end_matches('/').to_string(), client, + recovery: None, } } + /// Derive this client's blinded outputs from the wallet's NUT-13 phrase, + /// so the proofs it creates can be restored from those words. + pub fn with_recovery(mut self, recovery: Option) -> Self { + self.recovery = recovery; + self + } + pub fn url(&self) -> &str { &self.url } + /// Build the blinded messages for a batch of output amounts, together with + /// the `(secret, blinding factor, amount)` needed to unblind the mint's + /// signatures afterwards. + /// + /// Prefers NUT-13 derivation so the resulting proofs are restorable. Falls + /// back to random secrets when this wallet has no phrase yet, or when the + /// keyset id is one NUT-13 cannot address — a random secret still mints a + /// perfectly valid, spendable proof, so refusing here would break the + /// wallet to protect a backup that does not exist. + async fn blinded_outputs( + &self, + keyset_id: &str, + amounts: &[u64], + ) -> Result<(Vec, Vec<(Vec, secp256k1::SecretKey, u64)>)> { + let derived = match &self.recovery { + Some(source) => match source.next_outputs(keyset_id, amounts.len()).await { + Ok(pairs) => Some(pairs), + Err(e) => { + warn!("Minting unrecoverable proofs — NUT-13 derivation failed: {e:#}"); + None + } + }, + None => None, + }; + + let mut blinded_messages = Vec::with_capacity(amounts.len()); + let mut blinding_data = Vec::with_capacity(amounts.len()); + + for (i, &amount) in amounts.iter().enumerate() { + let (secret, r) = match &derived { + Some(pairs) => pairs[i].clone(), + None => (bdhke::generate_secret(), bdhke::random_blinding_factor()), + }; + let blinded = bdhke::blind_message(&secret, &r)?; + + blinded_messages.push(BlindedMessageRequest { + amount, + id: keyset_id.to_string(), + b_prime: hex::encode(blinded.b_prime.serialize()), + }); + blinding_data.push((secret, r, amount)); + } + + Ok((blinded_messages, blinding_data)) + } + // ── Keyset discovery (NUT-01, NUT-02) ── /// Fetch the active keyset from the mint. @@ -210,6 +276,36 @@ impl MintClient { Ok(keysets) } + /// Fetch one keyset's public keys by id (NUT-01 `GET /v1/keys/{id}`). + /// + /// `/v1/keys` returns only what the mint will still *sign* with, but a + /// restore has to unblind signatures made by keysets that have since been + /// retired — those coins are still spendable, and skipping their keysets + /// would quietly leave money behind. + pub async fn get_keyset(&self, keyset_id: &str) -> Result { + let url = format!("{}/v1/keys/{}", self.url, keyset_id); + let res = self + .client + .get(&url) + .send() + .await + .context("Failed to fetch a mint keyset")?; + if !res.status().is_success() { + anyhow::bail!("Mint keyset request failed: {}", res.status()); + } + let body: serde_json::Value = res.json().await.context("Failed to parse mint keyset")?; + let keysets: Vec = serde_json::from_value( + body.get("keysets") + .cloned() + .unwrap_or(serde_json::json!([])), + ) + .context("Failed to parse keyset")?; + keysets + .into_iter() + .find(|k| k.id == keyset_id) + .ok_or_else(|| anyhow::anyhow!("Mint did not return keyset {keyset_id}")) + } + /// Get the active keyset for the "sat" unit. pub async fn get_active_sat_keyset(&self) -> Result { let keysets = self.get_keys().await?; @@ -276,21 +372,8 @@ impl MintClient { let keyset = self.get_active_sat_keyset().await?; let denominations = amount_to_denominations(amount); - let mut blinded_messages = Vec::new(); - let mut blinding_data = Vec::new(); // (secret, blinding_factor, amount) - - for &denom in &denominations { - let secret = bdhke::generate_secret(); - let r = bdhke::random_blinding_factor(); - let blinded = bdhke::blind_message(&secret, &r)?; - - blinded_messages.push(BlindedMessageRequest { - amount: denom, - id: keyset.id.clone(), - b_prime: hex::encode(blinded.b_prime.serialize()), - }); - blinding_data.push((secret, r, denom)); - } + let (blinded_messages, blinding_data) = + self.blinded_outputs(&keyset.id, &denominations).await?; let url = format!("{}/v1/mint/bolt11", self.url); let client = reqwest::Client::builder() @@ -434,21 +517,8 @@ impl MintClient { target_amounts }; - let mut blinded_messages = Vec::new(); - let mut blinding_data = Vec::new(); - - for &amount in target_amounts { - let secret = bdhke::generate_secret(); - let r = bdhke::random_blinding_factor(); - let blinded = bdhke::blind_message(&secret, &r)?; - - blinded_messages.push(BlindedMessageRequest { - amount, - id: keyset.id.clone(), - b_prime: hex::encode(blinded.b_prime.serialize()), - }); - blinding_data.push((secret, r, amount)); - } + let (blinded_messages, blinding_data) = + self.blinded_outputs(&keyset.id, target_amounts).await?; let url = format!("{}/v1/swap", self.url); let res = self @@ -543,6 +613,78 @@ impl MintClient { Ok(states) } + // ── Restore (NUT-09) ── + + /// Ask the mint which of a batch of blinded messages it has signed before, + /// and hand back its signatures for those. + /// + /// This is the half of the backup story the mint owns. A NUT-13 phrase can + /// re-derive every secret this wallet ever used, but not the mint's + /// signature over them — without that a re-derived secret is not yet money. + /// `/v1/restore` closes the gap: send the blinded messages again, get back + /// the signatures the mint already issued, unblind, and the proofs exist + /// again. + /// + /// The response echoes the subset of `outputs` it recognised alongside the + /// matching `signatures`, so the caller matches on `B_` rather than + /// assuming positions line up — mints are free to return fewer, and + /// assuming otherwise would pair a signature with the wrong secret and + /// silently produce unspendable proofs. + pub async fn restore( + &self, + outputs: &[BlindedMessageRequest], + ) -> Result> { + if outputs.is_empty() { + return Ok(Vec::new()); + } + let url = format!("{}/v1/restore", self.url); + let res = self + .client + .post(&url) + .json(&serde_json::json!({ "outputs": outputs })) + .send() + .await + .context("Failed to ask the mint to restore outputs")?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + return Err(mint_error("Restore", status, &body)); + } + + let body: serde_json::Value = res + .json() + .await + .context("Failed to parse the mint's restore response")?; + + let echoed: Vec = serde_json::from_value( + body.get("outputs") + .cloned() + .unwrap_or(serde_json::json!([])), + ) + .context("Failed to parse restored outputs")?; + let signatures: Vec = serde_json::from_value( + body.get("signatures") + .cloned() + .unwrap_or(serde_json::json!([])), + ) + .context("Failed to parse restored signatures")?; + + if echoed.len() != signatures.len() { + anyhow::bail!( + "Mint restored {} outputs but {} signatures — refusing to pair them", + echoed.len(), + signatures.len() + ); + } + + Ok(echoed + .into_iter() + .map(|o| o.b_prime) + .zip(signatures) + .collect()) + } + /// Receive a CashuToken by swapping its proofs for fresh ones. /// This prevents double-spend and ensures only we can spend the new proofs. /// Repair proofs whose keyset id is a truncated NUT-02 **v2** id. diff --git a/core/archipelago/src/wallet/mod.rs b/core/archipelago/src/wallet/mod.rs index 9f7f92f5..8e1a4d26 100644 --- a/core/archipelago/src/wallet/mod.rs +++ b/core/archipelago/src/wallet/mod.rs @@ -7,4 +7,5 @@ pub mod cashu; pub mod ecash; pub mod fedimint_client; pub mod mint_client; +pub mod nut13; pub mod profits; diff --git a/core/archipelago/src/wallet/nut13.rs b/core/archipelago/src/wallet/nut13.rs new file mode 100644 index 00000000..952d62e6 --- /dev/null +++ b/core/archipelago/src/wallet/nut13.rs @@ -0,0 +1,552 @@ +//! NUT-13 deterministic secrets — what makes the ecash wallet restorable. +//! +//! Until this module existed, 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: there was no phrase to write +//! down, and no amount of talking to the mint could reconstruct them. Ecash is +//! a bearer instrument, so "one file, no backup" was the sharpest edge in the +//! wallet. +//! +//! [NUT-13] fixes that by deriving each proof's secret and blinding factor +//! from `(wallet seed, keyset id, counter)` instead of from randomness. The +//! wallet is then a *phrase*, and the coins can be re-derived and re-claimed +//! from the mint — here, or in any other NUT-13 wallet. +//! +//! Three pieces live here: +//! +//! - **The wallet seed** (`wallet/cashu_seed.json`) — a 24-word BIP-39 +//! mnemonic derived from the node's master seed, so the node's own recovery +//! phrase already covers the ecash. See [`crate::seed::derive_cashu_mnemonic`] +//! for why it is a *separate* phrase rather than the node's own. +//! - **The counters** (`wallet/cashu_counters.json`) — the next unused counter +//! per keyset. Recovery metadata, not funds: losing it costs a restore scan, +//! never coins. +//! - **The derivation itself** — delegated to the reference implementation, so +//! the secrets a third-party wallet re-derives from these words are the same +//! ones we did. +//! +//! ## Why the seed sits on disk in the clear +//! +//! The node's master seed is encrypted at rest and needs the operator's +//! password to open, which no background mint/swap can ask for. This file is +//! not encrypted, and that is deliberate: it lives in the same directory as +//! `wallet/ecash.json`, which already holds spendable bearer secrets in +//! plaintext. A NUT-13 seed regenerates exactly those same secrets, so it is +//! the same sensitivity class as the file beside it — encrypting one and not +//! the other would buy nothing. It is written 0600, matching +//! `identity/nostr_secret`, which is derived and persisted the same way. +//! +//! [NUT-13]: https://github.com/cashubtc/nuts/blob/main/13.md + +use anyhow::{Context, Result}; +use bitcoin::secp256k1::SecretKey; +use cashu::nuts::nut01::SecretKey as CdkSecretKey; +use cashu::nuts::nut02::Id as CdkId; +use cashu::secret::Secret as CdkSecret; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use tokio::fs; +use tracing::{debug, warn}; + +/// The wallet's BIP-39 phrase. One file for both networks: NUT-13 derivation +/// is keyed by keyset id, and a testnet mint's keysets never collide with a +/// real mint's, so the two purses cannot derive each other's secrets. +const SEED_FILE: &str = "wallet/cashu_seed.json"; +/// Next-unused counter per keyset. +const COUNTER_FILE: &str = "wallet/cashu_counters.json"; + +/// Serialises counter reservation within this process. Reservation is a +/// read-modify-write of one small file, and two concurrent mints handing out +/// the same counter would mean two proofs with the same secret — the mint +/// signs both and only one is ever spendable. +static COUNTER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// On-disk shape of `wallet/cashu_seed.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredSeed { + /// The 24-word BIP-39 phrase. + mnemonic: String, + /// How this wallet got its phrase — see [`SeedSource`]. + #[serde(default)] + source: SeedSource, + /// When it was first written, for the operator's benefit. + #[serde(default)] + created_at: String, +} + +/// Where an ecash wallet's phrase came from, which decides what restoring the +/// *node* gets you back. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SeedSource { + /// Derived from the node's master seed. The node's 24 words restore this + /// ecash wallet too — nothing extra to write down. + #[default] + NodeSeed, + /// Generated independently of the node seed. Still a perfectly good + /// NUT-13 wallet, but restoring the node from its recovery phrase will + /// *not* bring it back — only these words will. + Independent, +} + +/// A loaded ecash wallet seed, ready to derive secrets from. +#[derive(Clone)] +pub struct EcashSeed { + /// BIP-39 seed bytes — the NUT-13 input. + seed: [u8; 64], + mnemonic: bip39::Mnemonic, + source: SeedSource, +} + +impl std::fmt::Debug for EcashSeed { + /// Never let the phrase or the seed bytes reach a log line. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EcashSeed") + .field("source", &self.source) + .finish_non_exhaustive() + } +} + +impl EcashSeed { + fn from_mnemonic(mnemonic: bip39::Mnemonic, source: SeedSource) -> Self { + Self { + seed: mnemonic.to_seed(""), + mnemonic, + source, + } + } + + /// The 24 words, for the backup screen. Everything else about this type + /// keeps them out of reach. + pub fn words(&self) -> Vec { + self.mnemonic.words().map(|w| w.to_string()).collect() + } + + pub fn source(&self) -> SeedSource { + self.source + } + + /// Derive the NUT-13 secret and blinding factor for one output. + /// + /// Delegated to the reference implementation rather than reimplemented: + /// NUT-13 uses BIP-32 for v1 keyset ids and an HMAC-SHA256 KDF for v2, and + /// getting either subtly wrong yields a wallet whose words restore + /// *nothing* — a failure that only shows up on the day it matters. + pub fn derive_output(&self, keyset_id: &str, counter: u32) -> Result<(Vec, SecretKey)> { + let id = CdkId::from_str(keyset_id) + .with_context(|| format!("Keyset id {keyset_id} is not one NUT-13 can derive for"))?; + + let secret = CdkSecret::from_seed(&self.seed, id, counter) + .context("NUT-13 secret derivation failed")?; + let blinding = CdkSecretKey::from_seed(&self.seed, id, counter) + .context("NUT-13 blinding-factor derivation failed")?; + let blinding = SecretKey::from_slice(&blinding.to_secret_bytes()) + .context("NUT-13 produced a blinding factor secp256k1 rejects")?; + + Ok((secret.to_bytes(), blinding)) + } +} + +impl Drop for EcashSeed { + fn drop(&mut self) { + use zeroize::Zeroize; + self.seed.zeroize(); + } +} + +fn seed_path(data_dir: &Path) -> PathBuf { + data_dir.join(SEED_FILE) +} + +/// Is this wallet backed by a phrase yet? +pub fn seed_exists(data_dir: &Path) -> bool { + seed_path(data_dir).exists() +} + +/// Load the wallet seed, or `None` if this node has never established one. +/// +/// A *damaged* seed file is an error, not a `None`: silently treating it as +/// "no seed" would send the wallet back to unrecoverable random secrets while +/// telling the operator their backup was fine. +pub async fn load_seed(data_dir: &Path) -> Result> { + let path = seed_path(data_dir); + let Ok(content) = fs::read_to_string(&path).await else { + return Ok(None); + }; + let stored: StoredSeed = serde_json::from_str(&content) + .with_context(|| format!("The ecash seed file is damaged: {}", path.display()))?; + let mnemonic: bip39::Mnemonic = stored + .mnemonic + .parse() + .map_err(|e| anyhow::anyhow!("The stored ecash phrase is not valid BIP-39: {e}"))?; + Ok(Some(EcashSeed::from_mnemonic(mnemonic, stored.source))) +} + +/// Establish the wallet seed from the node's master seed, writing it if this +/// node does not have one yet. +/// +/// Idempotent, and deliberately **never overwrites**: an existing phrase is +/// the only thing that can re-derive the proofs already minted under it, so a +/// re-derivation that disagreed (a different master seed after a restore from +/// different words, say) must not be allowed to replace it. The existing seed +/// is returned instead, and the mismatch is logged. +pub async fn establish_from_master( + data_dir: &Path, + master: &crate::seed::MasterSeed, +) -> Result { + let derived = crate::seed::derive_cashu_mnemonic(master)?; + + if let Some(existing) = load_seed(data_dir).await? { + if existing.mnemonic != derived { + warn!( + "The ecash wallet's phrase does not match the one this node's master seed \ + derives — keeping the existing phrase, because it is what the current \ + proofs were minted under. Back it up from Settings; the node's own \ + recovery phrase does not cover this wallet." + ); + } + return Ok(existing); + } + + write_seed(data_dir, &derived, SeedSource::NodeSeed).await?; + debug!("Established the ecash wallet seed from the node master seed"); + Ok(EcashSeed::from_mnemonic(derived, SeedSource::NodeSeed)) +} + +/// Write the seed file at 0600, creating the wallet directory if needed. +async fn write_seed( + data_dir: &Path, + mnemonic: &bip39::Mnemonic, + source: SeedSource, +) -> Result<()> { + let path = seed_path(data_dir); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .await + .context("Failed to create the wallet directory")?; + } + let stored = StoredSeed { + mnemonic: mnemonic.to_string(), + source, + created_at: chrono::Utc::now().to_rfc3339(), + }; + let content = + serde_json::to_string_pretty(&stored).context("Failed to serialize the ecash seed")?; + fs::write(&path, content) + .await + .context("Failed to write the ecash seed")?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .await + .context("Failed to restrict permissions on the ecash seed")?; + } + Ok(()) +} + +// ── Counters ─────────────────────────────────────────────────────────────── + +/// On-disk shape of `wallet/cashu_counters.json`. +#[derive(Debug, Default, Serialize, Deserialize)] +struct StoredCounters { + /// keyset id → next unused counter. + #[serde(default)] + counters: BTreeMap, +} + +/// Reserve `count` consecutive counters for `keyset_id` and return the first. +/// +/// Written to disk **before** the outputs are used, and never rolled back on +/// failure. A gap in the sequence costs a restore scan a few extra probes; a +/// *reused* counter costs a coin, because two proofs with the same secret can +/// only ever be spent once. So the asymmetry is resolved in favour of gaps. +pub async fn reserve_counters(data_dir: &Path, keyset_id: &str, count: usize) -> Result { + let _guard = COUNTER_LOCK.lock().await; + let path = data_dir.join(COUNTER_FILE); + + let mut state: StoredCounters = match fs::read_to_string(&path).await { + Ok(content) if !content.trim().is_empty() => serde_json::from_str(&content) + .with_context(|| format!("The ecash counter file is damaged: {}", path.display()))?, + _ => StoredCounters::default(), + }; + + let start = *state.counters.get(keyset_id).unwrap_or(&0); + let next = start + .checked_add(u32::try_from(count).context("Absurd output count")?) + .context("NUT-13 counter space exhausted for this keyset")?; + state.counters.insert(keyset_id.to_string(), next); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .await + .context("Failed to create the wallet directory")?; + } + let content = + serde_json::to_string_pretty(&state).context("Failed to serialize ecash counters")?; + fs::write(&path, content) + .await + .context("Failed to persist ecash counters")?; + + Ok(start) +} + +/// Read the next-unused counter for a keyset without reserving anything. +pub async fn counter_for(data_dir: &Path, keyset_id: &str) -> u32 { + let path = data_dir.join(COUNTER_FILE); + let Ok(content) = fs::read_to_string(&path).await else { + return 0; + }; + serde_json::from_str::(&content) + .ok() + .and_then(|s| s.counters.get(keyset_id).copied()) + .unwrap_or(0) +} + +/// Move a keyset's counter forward to at least `next`, so a restore that found +/// coins beyond the recorded point cannot hand the same counters out again. +pub async fn advance_counter_to(data_dir: &Path, keyset_id: &str, next: u32) -> Result<()> { + let current = counter_for(data_dir, keyset_id).await; + if next > current { + reserve_counters(data_dir, keyset_id, (next - current) as usize).await?; + } + Ok(()) +} + +// ── The source handed to the mint client ─────────────────────────────────── + +/// Supplies NUT-13 outputs to [`crate::wallet::mint_client::MintClient`]. +/// +/// Holds the data directory as well as the seed because reserving a counter is +/// a disk write that has to happen before the outputs are handed out. +#[derive(Clone, Debug)] +pub struct RecoverySource { + seed: EcashSeed, + data_dir: PathBuf, +} + +impl RecoverySource { + /// Build a recovery source for this node, or `None` when the wallet has no + /// seed yet. Callers fall back to random secrets in that case, which is + /// exactly the pre-NUT-13 behaviour — correct, just not restorable. + pub async fn load(data_dir: &Path) -> Option { + match load_seed(data_dir).await { + Ok(Some(seed)) => Some(Self { + seed, + data_dir: data_dir.to_path_buf(), + }), + Ok(None) => None, + Err(e) => { + warn!("Ecash wallet seed unusable, minting unrecoverable proofs: {e:#}"); + None + } + } + } + + /// Reserve and derive `count` outputs for `keyset_id`. + pub async fn next_outputs( + &self, + keyset_id: &str, + count: usize, + ) -> Result, SecretKey)>> { + // Fail the derivation *before* burning counters if this keyset id is + // one NUT-13 cannot address. + let start = reserve_counters(&self.data_dir, keyset_id, count).await?; + (0..count) + .map(|i| self.seed.derive_output(keyset_id, start + i as u32)) + .collect() + } + + /// Derive one output at an explicit counter, without reserving — the + /// restore scan's probe, which must be able to re-derive the past. + pub fn derive_at(&self, keyset_id: &str, counter: u32) -> Result<(Vec, SecretKey)> { + self.seed.derive_output(keyset_id, counter) + } + + pub fn data_dir(&self) -> &Path { + &self.data_dir + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::seed::MasterSeed; + + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + /// A real NUT-02 v1 keyset id (the one in the NUT test vectors). + const V1_KEYSET: &str = "009a1f293253e41e"; + /// A NUT-02 v2 keyset id — 33 bytes, version byte 0x01. The two versions + /// take different derivation paths in the spec, so both need covering. + const V2_KEYSET: &str = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821"; + + fn seed() -> EcashSeed { + let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let mnemonic = crate::seed::derive_cashu_mnemonic(&master).unwrap(); + EcashSeed::from_mnemonic(mnemonic, SeedSource::NodeSeed) + } + + /// The whole promise of NUT-13: the same phrase and counter must give back + /// the same secret, or a restore finds nothing. + #[test] + fn the_same_phrase_and_counter_rederive_the_same_output() { + let a = seed(); + let b = seed(); + for keyset in [V1_KEYSET, V2_KEYSET] { + let (s1, r1) = a.derive_output(keyset, 7).unwrap(); + let (s2, r2) = b.derive_output(keyset, 7).unwrap(); + assert_eq!(s1, s2, "secret must be reproducible ({keyset})"); + assert_eq!( + r1.secret_bytes(), + r2.secret_bytes(), + "blinding factor must be reproducible ({keyset})" + ); + } + } + + /// Different counters — and different keysets — must not collide, or two + /// proofs would share a secret and only one could ever be spent. + #[test] + fn different_counters_and_keysets_give_different_outputs() { + let s = seed(); + let (a, _) = s.derive_output(V1_KEYSET, 0).unwrap(); + let (b, _) = s.derive_output(V1_KEYSET, 1).unwrap(); + let (c, _) = s.derive_output(V2_KEYSET, 0).unwrap(); + assert_ne!(a, b, "counter must separate secrets"); + assert_ne!(a, c, "keyset must separate secrets"); + } + + /// The secret must look like the one the rest of the wallet expects: a + /// 32-byte value, hex-encoded, carried as ASCII bytes — the same shape + /// `bdhke::generate_secret` produces. + #[test] + fn a_derived_secret_has_the_shape_the_wallet_already_uses() { + let (secret, _) = seed().derive_output(V1_KEYSET, 0).unwrap(); + assert_eq!(secret.len(), 64, "32 bytes, hex-encoded"); + let text = String::from_utf8(secret).expect("secret must be ASCII hex"); + assert!(hex::decode(&text).is_ok(), "{text}"); + } + + /// A truncated v2 id cannot address a keyset, and must fail loudly rather + /// than deriving from a prefix that means nothing. + #[test] + fn an_unaddressable_keyset_id_is_refused() { + let err = seed() + .derive_output("01fc0ec0e59cd6fa", 0) + .expect_err("short v2 id must not derive"); + assert!(err.to_string().contains("NUT-13"), "{err}"); + } + + #[tokio::test] + async fn counters_are_reserved_in_order_and_never_reused() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + + assert_eq!(reserve_counters(d, V1_KEYSET, 3).await.unwrap(), 0); + assert_eq!(reserve_counters(d, V1_KEYSET, 2).await.unwrap(), 3); + assert_eq!(counter_for(d, V1_KEYSET).await, 5); + + // A second keyset counts independently. + assert_eq!(reserve_counters(d, V2_KEYSET, 1).await.unwrap(), 0); + assert_eq!(counter_for(d, V1_KEYSET).await, 5); + } + + /// Reservation must survive a process restart — the file is the state. + #[tokio::test] + async fn reserved_counters_persist_across_reloads() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + reserve_counters(d, V1_KEYSET, 4).await.unwrap(); + // Nothing cached in memory: read it back cold. + assert_eq!(counter_for(d, V1_KEYSET).await, 4); + assert_eq!(reserve_counters(d, V1_KEYSET, 1).await.unwrap(), 4); + } + + #[tokio::test] + async fn establishing_the_seed_is_idempotent_and_never_overwrites() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + + assert!(!seed_exists(d)); + let first = establish_from_master(d, &master).await.unwrap(); + assert!(seed_exists(d)); + assert_eq!(first.source(), SeedSource::NodeSeed); + + let second = establish_from_master(d, &master).await.unwrap(); + assert_eq!(first.words(), second.words()); + + // A *different* master seed must not replace the phrase the existing + // proofs were minted under. + let (other_words, _) = MasterSeed::generate().unwrap(); + let (_, other_master) = + MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap(); + let third = establish_from_master(d, &other_master).await.unwrap(); + assert_eq!( + first.words(), + third.words(), + "an established ecash phrase must never be silently replaced" + ); + } + + #[tokio::test] + async fn the_seed_file_is_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + establish_from_master(d, &master).await.unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(seed_path(d)).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "the ecash phrase must be owner-only"); + } + } + + /// A damaged seed file must not read back as "this wallet has no backup" — + /// that would quietly return the wallet to unrecoverable random secrets. + #[tokio::test] + async fn a_damaged_seed_file_is_an_error_not_an_absence() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + fs::create_dir_all(d.join("wallet")).await.unwrap(); + fs::write(seed_path(d), "{ truncated").await.unwrap(); + + assert!(load_seed(d).await.is_err()); + assert!( + RecoverySource::load(d).await.is_none(), + "an unusable seed must not be presented as a working one" + ); + } + + #[tokio::test] + async fn the_recovery_source_hands_out_consecutive_outputs() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + establish_from_master(d, &master).await.unwrap(); + + let source = RecoverySource::load(d).await.expect("seed was established"); + let first = source.next_outputs(V1_KEYSET, 2).await.unwrap(); + let second = source.next_outputs(V1_KEYSET, 2).await.unwrap(); + + assert_eq!(first.len(), 2); + // Counters advanced, so no secret repeats across the two batches. + let secrets: std::collections::HashSet<_> = first + .iter() + .chain(second.iter()) + .map(|(s, _)| s.clone()) + .collect(); + assert_eq!(secrets.len(), 4, "counters must not be handed out twice"); + + // And the batch is exactly what re-deriving counters 0..4 gives. + for (i, (secret, _)) in first.iter().chain(second.iter()).enumerate() { + let (expected, _) = source.derive_at(V1_KEYSET, i as u32).unwrap(); + assert_eq!(secret, &expected); + } + } +} diff --git a/neode-ui/src/components/EcashSeedBackup.vue b/neode-ui/src/components/EcashSeedBackup.vue new file mode 100644 index 00000000..9b47b45d --- /dev/null +++ b/neode-ui/src/components/EcashSeedBackup.vue @@ -0,0 +1,267 @@ + + + diff --git a/neode-ui/src/views/settings/BackupSection.vue b/neode-ui/src/views/settings/BackupSection.vue index 906f6acc..c8449945 100644 --- a/neode-ui/src/views/settings/BackupSection.vue +++ b/neode-ui/src/views/settings/BackupSection.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import { rpcClient } from '@/api/rpc-client' import { appConfirm } from '@/composables/useAppConfirm' import SeedRevealPanel from '@/components/SeedRevealPanel.vue' +import EcashSeedBackup from '@/components/EcashSeedBackup.vue' const { t } = useI18n() @@ -317,6 +318,11 @@ defineExpose({ loadBackups }) + + +