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
+33 -15
View File
@@ -352,15 +352,7 @@ export class UnlockSession {
}
async unlock(env: SeedEnvelope, ownerPassphrase: string, ttlSec = DEFAULT_TTL_SEC): Promise<void> {
checkLockout(this.walletId);
let opened: OpenedSeed;
try {
opened = await openEnvelope(env, ownerPassphrase);
} catch (err) {
recordFailure(this.walletId);
throw err;
}
attempts.delete(this.walletId);
const opened = await openGuarded(this.walletId, env, ownerPassphrase);
this.lock(); // replace any existing session rather than leaking the old root
this.root = await rootFromSeed(opened);
@@ -421,15 +413,40 @@ export function lockAll(): void {
for (const s of sessions.values()) s.lock();
}
/**
* Open an envelope under the same backoff `unlock()` uses.
*
* EVERY passphrase check goes through here, not just the session one. The lockout used to live inside
* UnlockSession.unlock alone, which left export-seed — the one endpoint that returns the words in the
* clear — accepting unlimited guesses, while /unlock capped at five a minute. An attacker with a session
* cookie would simply never have used /unlock.
*/
async function openGuarded(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise<OpenedSeed> {
checkLockout(walletId);
let opened: OpenedSeed;
try {
opened = await openEnvelope(env, ownerPassphrase);
} catch (err) {
recordFailure(walletId);
throw err;
}
attempts.delete(walletId);
return opened;
}
/**
* Verify a passphrase without opening a session — used before destructive operations (seed export,
* wallet deletion) so they need a fresh confirmation even when the wallet is already unlocked.
*
* Rethrows a LOCKED_OUT BackendError rather than folding it into `false`: "wrong passphrase" and "stop
* guessing" are different answers, and a caller that showed the first for both would loop forever.
*/
export async function verifyPassphrase(env: SeedEnvelope, ownerPassphrase: string): Promise<boolean> {
export async function verifyPassphrase(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise<boolean> {
try {
await openEnvelope(env, ownerPassphrase);
await openGuarded(walletId, env, ownerPassphrase);
return true;
} catch {
} catch (err) {
if (err instanceof BackendError && err.code === 'LOCKED_OUT') throw err;
return false;
}
}
@@ -439,8 +456,8 @@ export async function verifyPassphrase(env: SeedEnvelope, ownerPassphrase: strin
* can back up or migrate. Always requires the passphrase even if a session is open, and callers must
* gate it behind a fresh confirmation.
*/
export async function exportMnemonic(env: SeedEnvelope, ownerPassphrase: string): Promise<string> {
const opened = await openEnvelope(env, ownerPassphrase);
export async function exportMnemonic(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise<string> {
const opened = await openGuarded(walletId, env, ownerPassphrase);
return opened.mnemonic;
}
@@ -450,11 +467,12 @@ export async function exportMnemonic(env: SeedEnvelope, ownerPassphrase: string)
* old passphrase, cannot decrypt anything written after a rotation.
*/
export async function changePassphrase(
walletId: number,
env: SeedEnvelope,
oldPassphrase: string,
newPassphrase: string,
): Promise<SeedEnvelope> {
const opened = await openEnvelope(env, oldPassphrase);
const opened = await openGuarded(walletId, env, oldPassphrase);
return sealSeed(opened.mnemonic, newPassphrase, opened.bip39Passphrase || undefined);
}