Stop ecash recovery failures from silently reusing or abandoning backup state

This commit is contained in:
archipelago
2026-10-06 14:34:25 -04:00
parent a7cc7084f2
commit 12e2a82b28
5 changed files with 245 additions and 33 deletions
+2 -2
View File
@@ -426,7 +426,7 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu
/// through here. On a node with no phrase yet the source is absent and the
/// behaviour is exactly as it was before: valid proofs, no backup.
async fn mint_client(data_dir: &Path, mint_url: &str) -> Result<MintClient> {
Ok(MintClient::new(mint_url)?.with_recovery(RecoverySource::load(data_dir).await))
Ok(MintClient::new(mint_url)?.with_recovery(RecoverySource::load(data_dir).await?))
}
/// Request a mint quote — returns a Lightning invoice to pay.
@@ -1424,7 +1424,7 @@ pub struct RestoreOutcome {
/// coins or resurrecting spent ones, which matters because the most likely
/// time to press this button is when something already looks wrong.
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
let recovery = RecoverySource::load(data_dir).await.ok_or_else(|| {
let recovery = RecoverySource::load(data_dir).await?.ok_or_else(|| {
anyhow::anyhow!(
"This wallet has no backup phrase yet, so there is nothing to restore from. \
Set one up in Settings → Ecash backup phrase."
+65 -12
View File
@@ -221,11 +221,10 @@ impl MintClient {
/// 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.
/// Uses NUT-13 when the wallet has a recovery source. Derivation or durable
/// counter failures stop before sending a mint request; they must not
/// silently turn a backed-up wallet into one with unrecoverable outputs.
/// Legacy wallets with no seed retain their explicit random-output path.
async fn blinded_outputs(
&self,
keyset_id: &str,
@@ -235,13 +234,14 @@ impl MintClient {
Vec<(Vec<u8>, 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
}
},
Some(source) => Some(
source
.next_outputs(keyset_id, amounts.len())
.await
.context(
"Could not prepare recoverable ecash outputs; no mint request was sent",
)?,
),
None => None,
};
@@ -908,4 +908,57 @@ mod tests {
let client = MintClient::new("http://mint.example.com").unwrap();
assert_eq!(client.url(), "http://mint.example.com");
}
#[tokio::test]
async fn backed_outputs_fail_closed_when_counter_storage_is_damaged() {
let directory = tempfile::tempdir().unwrap();
let (_, master) = crate::seed::MasterSeed::generate().unwrap();
super::super::nut13::establish_from_master(directory.path(), &master)
.await
.unwrap();
let recovery = RecoverySource::load(directory.path())
.await
.unwrap()
.unwrap();
let client = MintClient::new("http://127.0.0.1:1")
.unwrap()
.with_recovery(Some(recovery.clone()));
let counter = directory.path().join("wallet/cashu_counters.json");
tokio::fs::write(&counter, "").await.unwrap();
assert!(client
.blinded_outputs("009a1f293253e41e", &[1, 2])
.await
.is_err());
assert!(tokio::fs::read(&counter).await.unwrap().is_empty());
tokio::fs::remove_file(&counter).await.unwrap();
let (messages, blinding) = client
.blinded_outputs("009a1f293253e41e", &[1, 2])
.await
.unwrap();
assert_eq!(messages.len(), 2);
for (index, (secret, _, _)) in blinding.iter().enumerate() {
assert!(
*secret
== recovery
.derive_at("009a1f293253e41e", index as u32)
.unwrap()
.0
);
}
assert!(client
.blinded_outputs("01fc0ec0e59cd6fa", &[1])
.await
.is_err());
}
#[tokio::test]
async fn an_explicit_legacy_wallet_without_a_seed_still_prepares_outputs() {
let client = MintClient::new("http://127.0.0.1:1").unwrap();
let (messages, blinding) = client
.blinded_outputs("009a1f293253e41e", &[1, 2])
.await
.unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(blinding.len(), 2);
}
}
+135 -19
View File
@@ -197,8 +197,10 @@ pub fn seed_exists(data_dir: &Path) -> bool {
/// 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 content = match fs::read_to_string(&path).await {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).context("Could not read the existing ecash backup seed"),
};
let stored: StoredSeed = serde_json::from_str(&content)
.with_context(|| format!("The ecash seed file is damaged: {}", path.display()))?;
@@ -400,9 +402,10 @@ pub async fn reserve_counters(data_dir: &Path, keyset_id: &str, count: usize) ->
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)
Ok(content) => serde_json::from_str(&content)
.with_context(|| format!("The ecash counter file is damaged: {}", path.display()))?,
_ => StoredCounters::default(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => StoredCounters::default(),
Err(error) => return Err(error).context("Could not read the ecash counter file"),
};
let start = *state.counters.get(keyset_id).unwrap_or(&0);
@@ -418,13 +421,49 @@ pub async fn reserve_counters(data_dir: &Path, keyset_id: &str, count: usize) ->
}
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")?;
persist_counters(&path, content.as_bytes()).await?;
Ok(start)
}
/// Never truncate the active reservation file. A reservation is not usable
/// until both its replacement file and directory entry have reached storage.
async fn persist_counters(path: &Path, content: &[u8]) -> Result<()> {
use tokio::io::AsyncWriteExt;
struct PendingCounterFile(PathBuf);
impl Drop for PendingCounterFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
let parent = path.parent().context("Counter file has no directory")?;
let temporary =
PendingCounterFile(parent.join(format!(".cashu-counters-{}.tmp", uuid::Uuid::new_v4())));
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary.0)
.await
.context("Could not create the ecash counter reservation")?;
file.write_all(content)
.await
.context("Could not write the ecash counter reservation")?;
file.sync_all()
.await
.context("Could not flush the ecash counter reservation")?;
drop(file);
fs::rename(&temporary.0, path)
.await
.context("Could not replace the ecash counter reservation")?;
fs::File::open(parent)
.await?
.sync_all()
.await
.context("Could not flush the ecash counter directory")?;
Ok(())
}
/// 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);
@@ -463,17 +502,13 @@ 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 {
pub async fn load(data_dir: &Path) -> Result<Option<Self>> {
match load_seed(data_dir).await? {
Some(seed) => Ok(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
}
})),
None => Ok(None),
}
}
@@ -485,6 +520,7 @@ impl RecoverySource {
) -> Result<Vec<(Vec<u8>, SecretKey)>> {
// Fail the derivation *before* burning counters if this keyset id is
// one NUT-13 cannot address.
self.seed.derive_output(keyset_id, 0)?;
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))
@@ -763,8 +799,8 @@ mod tests {
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"
RecoverySource::load(d).await.is_err(),
"an unusable seed must not downgrade to an unbacked wallet"
);
}
@@ -775,7 +811,10 @@ mod tests {
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 source = RecoverySource::load(d)
.await
.unwrap()
.expect("seed was established");
let first = source.next_outputs(V1_KEYSET, 2).await.unwrap();
let second = source.next_outputs(V1_KEYSET, 2).await.unwrap();
@@ -794,4 +833,81 @@ mod tests {
assert_eq!(secret, &expected);
}
}
#[tokio::test]
async fn missing_seed_is_distinct_from_a_seed_read_failure() {
let dir = tempfile::tempdir().unwrap();
assert!(RecoverySource::load(dir.path()).await.unwrap().is_none());
fs::create_dir_all(seed_path(dir.path())).await.unwrap();
assert!(load_seed(dir.path()).await.is_err());
assert!(RecoverySource::load(dir.path()).await.is_err());
assert!(seed_path(dir.path()).is_dir());
}
#[tokio::test]
async fn empty_or_corrupt_counters_never_reset_a_reservation() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("wallet")).await.unwrap();
let path = dir.path().join(COUNTER_FILE);
for damaged in ["", " ", "{ truncated"] {
fs::write(&path, damaged).await.unwrap();
assert!(reserve_counters(dir.path(), V1_KEYSET, 1).await.is_err());
assert_eq!(fs::read_to_string(&path).await.unwrap(), damaged);
}
fs::remove_file(&path).await.unwrap();
fs::create_dir(&path).await.unwrap();
assert!(reserve_counters(dir.path(), V1_KEYSET, 1).await.is_err());
assert!(path.is_dir());
}
#[tokio::test]
async fn concurrent_counter_reservations_survive_reload_and_leave_no_temporary_files() {
let dir = tempfile::tempdir().unwrap();
let mut tasks = Vec::new();
for _ in 0..24 {
let path = dir.path().to_path_buf();
tasks.push(tokio::spawn(async move {
reserve_counters(&path, V1_KEYSET, 2).await.unwrap()
}));
}
let mut starts = std::collections::HashSet::new();
for task in tasks {
assert!(starts.insert(task.await.unwrap()));
}
assert_eq!(counter_for(dir.path(), V1_KEYSET).await, 48);
assert_eq!(
reserve_counters(dir.path(), V1_KEYSET, 1).await.unwrap(),
48
);
let entries = std::fs::read_dir(dir.path().join("wallet"))
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect::<Vec<_>>();
assert_eq!(
entries,
vec![std::ffi::OsString::from("cashu_counters.json")]
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(dir.path().join(COUNTER_FILE))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
}
#[tokio::test]
async fn invalid_derivation_does_not_reserve_counters() {
let dir = tempfile::tempdir().unwrap();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
establish_from_master(dir.path(), &master).await.unwrap();
let source = RecoverySource::load(dir.path()).await.unwrap().unwrap();
assert!(source.next_outputs("01fc0ec0e59cd6fa", 1).await.is_err());
assert!(!dir.path().join(COUNTER_FILE).exists());
}
}
+28
View File
@@ -76,3 +76,31 @@ ignored tests**. Logs: `/tmp/archy-peer-payment-selection-tests.log` and
`/tmp/archy-peer-payment-selection-full-tests.log`. Only the required isolated
runner was used. No live wallet data or real payments were involved. This is
source/test qualification; production build and deployment are separate.
## Recovery-metadata prerequisite qualified (2026-10-06)
Seed reads now distinguish genuine absence from I/O failure. Loading a damaged
recovery source returns an error instead of silently enabling random outputs.
A configured recovery source must reserve/derive outputs successfully before a
mint request; derivation/counter failures no longer fall back to random secrets.
Legacy wallets which genuinely have no seed retain their existing output path.
Counter reservations reject empty/corrupt/unreadable existing files rather than
resetting to zero. Updates use an owner-only sibling temporary file, file flush,
atomic replacement and directory flush before returning a usable reservation.
Invalid derivation is rejected before reserving counters. Existing counter
serialization within the management process is preserved; this is not a claim
that a new cross-process wallet lock or the purchase journal has been implemented.
Six new regressions cover damaged seed/counter reads, preserved damaged files,
concurrent reservations/reload/private permissions/temp cleanup, invalid keysets,
no silent random-output fallback and explicit legacy behavior. Wallet-focused
isolated result:150passed,0failed,1existing ignored. Full isolated result:
**1,755passed,0failed,5existing ignored**. Evidence:
`/tmp/archy-wallet-recovery-prerequisites-tests.log` and
`/tmp/archy-wallet-recovery-prerequisites-full-tests.log`.
Read-only checks found well-shaped seed/counter JSON on dev and Yaya; no secret
values were printed and no wallet files were changed by those checks. Production
build/deployment of this prerequisite remains pending. Durable initial purchase
intent, mint-operation recovery, seller receipt and refund recovery remain open.
+15
View File
@@ -831,3 +831,18 @@ payment recovery, timed FIPS playback and complete node-to-node acceptance remai
open. V4V managed installation still requires the pending node-only catalog
signature. The running dev/Yaya dashboard/backend builds are unchanged, and no
additional real payment was made.
Full-suite follow-up: IndeeHub `f2668c0` adds active-rental continuation and
`920f3b0` repairs the subscription regressions and records final qualification.
All143backend tests across14suites now pass, with no skipped/failed tests;
all94frontend tests pass. Both production builds and mobile/desktop library/rental
browser checks pass. Failed original logs are retained. The disposable database,
volume and fixture credentials were removed. These remain source/artifact checks;
no IndeeHub app update was deployed and the full paid-video flow is still open.
Payment-recovery prerequisite under qualification next: preserve errors reading
an existing Cashu seed/counter, stop random-output fallback when an established
recovery source fails, and atomically flush counter reservations before use.
Rust wallet tests are running only through the required isolated test runner.
No real wallet mutation or additional payment is involved. This prerequisite is
not a completed purchase journal, seller receipt or initial-payment recovery gate.