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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user