Files
archy/core/archipelago/src/wallet/nut13.rs
T

553 lines
22 KiB
Rust
Raw Normal View History

//! 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<String> {
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<u8>, 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<Option<EcashSeed>> {
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<EcashSeed> {
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<String, u32>,
}
/// 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<u32> {
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::<StoredCounters>(&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<Self> {
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<Vec<(Vec<u8>, 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<u8>, 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);
}
}
}