//! 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, /// Supplied by the operator, from another NUT-13 wallet. Same caveat as /// `Independent` — the node's recovery phrase does not cover it — but it /// is worth telling apart, because these words exist somewhere else too /// and the operator already knows where. Imported, } impl SeedSource { /// Does restoring the *node* from its recovery phrase bring this wallet /// back? Only a derived phrase can promise that. pub fn covered_by_node_seed(&self) -> bool { matches!(self, Self::NodeSeed) } } /// 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() } /// The phrase as a single string — the input to NUT-13 *and* to the NIP-06 /// Nostr derivation the Minibits profile flow needs (`crate::wallet::minibits`). pub fn phrase(&self) -> String { self.mnemonic.to_string() } /// The 64-byte BIP-39 seed. Same bytes Minibits hashes with SHA-256 to get /// its `seedHash`, so the two wallets agree on wallet identity. pub fn seed_bytes(&self) -> [u8; 64] { self.seed } 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)) } /// Establish a wallet seed that is **not** derived from the node's master /// seed, for a node that has no encrypted master seed to derive from. /// /// Plenty of nodes are in that position: `identity/master_seed.enc` is written /// during onboarding, and any node onboarded before that step existed simply /// does not have one. The choice there is not "derived phrase or independent /// phrase" — it is "independent phrase or **no backup at all**", and a wallet /// whose coins can be restored from words the operator holds is strictly /// better than one whose coins die with a single file. /// /// The cost is stated plainly rather than hidden: the phrase is recorded as /// [`SeedSource::Independent`], and every surface that shows it says that /// restoring the node will *not* bring this wallet back — only these words /// will. That is a real obligation on the operator, so it must never be the /// silent default when derivation was possible; [`establish_from_master`] is /// what a node with a master seed gets. pub async fn establish_independent(data_dir: &Path) -> Result { if let Some(existing) = load_seed(data_dir).await? { return Ok(existing); } // Same guarded generation path as the node's own seed: a named CSPRNG and // the degenerate-entropy check, not a dependency's default (KEY-05). let (mnemonic, _seed) = crate::seed::MasterSeed::generate()?; write_seed(data_dir, &mnemonic, SeedSource::Independent).await?; warn!( "Established an INDEPENDENT ecash backup phrase: this node has no encrypted \ master seed to derive one from, so restoring the node will not restore this \ ecash wallet — only the phrase itself will." ); Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Independent)) } /// Adopt a phrase the operator supplies, from another NUT-13 wallet. /// /// This is the "bring your own" path: it points the wallet at someone else's /// derivation, which is what makes coins held in Minibits, Nutstash or /// `cdk-cli` restorable here. /// /// Replacing a phrase is the one genuinely lossy thing this module can do. /// Coins already in `wallet/ecash.json` stay spendable — they are proofs, not /// derivations, and nothing here touches them — but they were minted under /// the *old* phrase, so a future restore will no longer find them. The old /// phrase is therefore archived rather than overwritten, and replacing an /// established one needs `confirm`. An operator who imports by mistake must /// not lose the only copy of the words their balance was minted under. /// /// Counters are deliberately left alone. They are per-keyset and seed- /// relative, so under a new seed they merely start high — which costs nothing, /// since a restore scans from zero regardless. Resetting them would be the /// dangerous choice if the imported phrase turned out to be the one already /// in use. pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Result { let mnemonic: bip39::Mnemonic = words .split_whitespace() .collect::>() .join(" ") .parse() .map_err(|e| { anyhow::anyhow!( "That is not a valid BIP-39 recovery phrase: {e}. Check for typos — \ every word must come from the BIP-39 word list, and the phrase as \ a whole carries a checksum." ) })?; if let Some(existing) = load_seed(data_dir).await? { if existing.mnemonic == mnemonic { // Importing the phrase already in use: nothing to do, and // certainly nothing to archive. return Ok(existing); } if !confirm { anyhow::bail!( "This wallet already has a backup phrase. Importing a different one \ means coins minted under the current phrase will no longer be \ restorable from words — they stay spendable, but a restore will \ not find them. Reveal and write down the current phrase first, \ then confirm to replace it." ); } archive_seed(data_dir).await?; } write_seed(data_dir, &mnemonic, SeedSource::Imported).await?; warn!("Ecash backup phrase REPLACED by an imported one (the previous phrase, if any, was archived)"); Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Imported)) } /// Move the current seed file aside, timestamped, before it is replaced. /// /// Never deleted and never overwritten: this file may be the last copy of the /// words a balance was minted under, and the whole point of the module is that /// such a thing is not casually destroyed. async fn archive_seed(data_dir: &Path) -> Result<()> { let from = seed_path(data_dir); if !from.exists() { return Ok(()); } let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); let to = data_dir.join(format!("wallet/cashu_seed.replaced-{stamp}.json")); fs::rename(&from, &to).await.with_context(|| { format!( "Could not archive the previous ecash phrase to {}", to.display() ) })?; warn!("Previous ecash phrase archived to {}", to.display()); Ok(()) } /// 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" ); } /// A phrase from another wallet must derive that wallet's secrets — that /// is the entire point of importing one. #[tokio::test] async fn an_imported_phrase_derives_the_other_wallets_secrets() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // Stand in for the other wallet: a known phrase and what it derives. let theirs: bip39::Mnemonic = TEST_MNEMONIC.parse().unwrap(); let expected = EcashSeed::from_mnemonic(theirs.clone(), SeedSource::Imported) .derive_output(V1_KEYSET, 3) .unwrap(); let imported = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap(); assert_eq!(imported.source(), SeedSource::Imported); assert!(!imported.source().covered_by_node_seed()); assert_eq!(imported.derive_output(V1_KEYSET, 3).unwrap().0, expected.0); // And it is what the wallet uses from now on. let reloaded = load_seed(d).await.unwrap().expect("persisted"); assert_eq!(reloaded.words(), imported.words()); } #[tokio::test] async fn importing_over_an_established_phrase_needs_confirmation() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); let original = establish_from_master(d, &master).await.unwrap(); let original_words = original.words(); // Refused without confirmation — replacing a phrase silently would // orphan every coin minted under it. let (other, _) = MasterSeed::generate().unwrap(); let err = import_mnemonic(d, &other.to_string(), false) .await .expect_err("must not replace without confirmation"); assert!( err.to_string().contains("already has a backup phrase"), "{err}" ); assert_eq!( load_seed(d).await.unwrap().unwrap().words(), original_words, "a refused import must change nothing" ); // Confirmed: replaced, and the old phrase archived rather than lost. import_mnemonic(d, &other.to_string(), true).await.unwrap(); assert_eq!( load_seed(d).await.unwrap().unwrap().words(), other.words().map(|w| w.to_string()).collect::>() ); let archived: Vec<_> = std::fs::read_dir(d.join("wallet")) .unwrap() .filter_map(|e| e.ok()) .filter(|e| { e.file_name() .to_string_lossy() .starts_with("cashu_seed.replaced-") }) .collect(); assert_eq!(archived.len(), 1, "the replaced phrase must be kept"); } /// Re-importing the phrase already in use is a no-op, not a replacement — /// it must not archive anything or churn the file. #[tokio::test] async fn importing_the_current_phrase_changes_nothing() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); let first = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap(); let again = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap(); assert_eq!(first.words(), again.words()); let archived = std::fs::read_dir(d.join("wallet")) .unwrap() .filter_map(|e| e.ok()) .filter(|e| { e.file_name() .to_string_lossy() .starts_with("cashu_seed.replaced-") }) .count(); assert_eq!(archived, 0); } #[tokio::test] async fn a_malformed_phrase_is_refused_with_something_actionable() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // Right shape, wrong checksum — the commonest real mistake. let bad = TEST_MNEMONIC.replace(" art", " abandon"); let err = import_mnemonic(d, &bad, false).await.expect_err("checksum"); assert!(err.to_string().contains("BIP-39"), "{err}"); assert!(!seed_exists(d), "a rejected phrase must not be written"); assert!(import_mnemonic(d, "not a phrase", false).await.is_err()); assert!(import_mnemonic(d, "", false).await.is_err()); } /// Whitespace and casing vary wildly in what people paste out of other /// wallets; the words are what matter. #[tokio::test] async fn a_pasted_phrase_survives_untidy_whitespace() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); let messy = format!(" {} ", TEST_MNEMONIC.replace(' ', "\n ")); let imported = import_mnemonic(d, &messy, false).await.unwrap(); assert_eq!(imported.words().len(), 24); assert_eq!(imported.words().join(" "), TEST_MNEMONIC); } #[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); } } }