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 }) + + +