rate-limit every passphrase check, and stop address issuance outrunning the scan

two holes found auditing the wallet sidecar after the frozen-utxo fix.

the brute-force backoff lived inside UnlockSession.unlock alone, so /unlock capped
at five guesses a minute while export-seed — the one endpoint that returns the words
in the clear — took unlimited ones. every passphrase check now goes through the same
guard. verifyPassphrase rethrows LOCKED_OUT rather than folding it into `false`, so a
caller can tell "wrong" from "stop".

nextUnused advanced its mark on every issuance, paid or not, so a run of unpaid
addresses walked it past the end of the window the next scan covers; a payment there
would never be found again, and esplora has no rescan to go looking. sources now
declare how far past a scan's last index they can still see, and issuance clamps to
it — re-offering a virgin address rather than handing out one that could lose money.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 20:41:46 +00:00
co-authored by Claude Opus 5
parent bc06fbb2a5
commit e0f6a469aa
8 changed files with 133 additions and 28 deletions
+3 -3
View File
@@ -491,7 +491,7 @@ async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise
if (!passphrase) return badRequest('passphrase is required to delete a seeded wallet');
const sealed = await getSealedSeed(ctx.userId, walletId);
if (!sealed) throw new BackendError('wallet seed is missing', 500, 'NO_SEED');
if (!(await verifyPassphrase(JSON.parse(sealed) as SeedEnvelope, passphrase))) {
if (!(await verifyPassphrase(walletId, JSON.parse(sealed) as SeedEnvelope, passphrase))) {
return json({ error: 'incorrect passphrase' }, 401);
}
}
@@ -530,7 +530,7 @@ async function changePassphraseRoute(ctx: OfficerContext, walletId: number): Pro
if (!oldPassphrase || !newPassphrase) return badRequest('oldPassphrase and newPassphrase are required');
const env = await loadEnvelope(ctx.userId, walletId);
const resealed = await changePassphrase(env, oldPassphrase, newPassphrase);
const resealed = await changePassphrase(walletId, env, oldPassphrase, newPassphrase);
await replaceSealedSeed(ctx.userId, walletId, JSON.stringify(resealed));
// Force a re-unlock under the new passphrase rather than leaving a session opened by the old one.
sessionFor(walletId).lock();
@@ -543,7 +543,7 @@ async function exportSeedRoute(ctx: OfficerContext, walletId: number): Promise<R
if (!passphrase) return badRequest('passphrase is required');
const env = await loadEnvelope(ctx.userId, walletId);
const mnemonic = await exportMnemonic(env, passphrase);
const mnemonic = await exportMnemonic(walletId, env, passphrase);
console.warn(`[wallet] seed exported for wallet ${walletId} by user ${ctx.userId}`);
return json({ mnemonic, hasBip39Passphrase: env.hasBip39Passphrase });
}