cursedbelt-server 4.24.1 → 4.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/server/auth/loginThrottle.d.ts +62 -0
- package/dist/server/auth/loginThrottle.js +63 -1
- package/dist/server/auth/loginThrottleDurable.d.ts +120 -0
- package/dist/server/auth/loginThrottleDurable.js +231 -0
- package/dist/server/master-lock/attempts.d.ts +42 -0
- package/dist/server/master-lock/attempts.js +108 -0
- package/dist/server/master-lock/index.d.ts +1 -0
- package/dist/server/master-lock/index.js +1 -0
- package/dist/server/master-lock/masterLock.d.ts +15 -0
- package/dist/server/master-lock/masterLock.js +43 -9
- package/package.json +7 -1
- package/src/leafSubpathsImportNothing.spec.ts +10 -0
- package/src/server/auth/loginThrottle.spec.ts +82 -0
- package/src/server/auth/loginThrottle.ts +108 -7
- package/src/server/auth/loginThrottleDurable.spec.ts +165 -0
- package/src/server/auth/loginThrottleDurable.ts +291 -0
- package/src/server/master-lock/attempts.spec.ts +159 -0
- package/src/server/master-lock/attempts.ts +141 -0
- package/src/server/master-lock/index.ts +5 -0
- package/src/server/master-lock/masterLock.ts +50 -8
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The master lock's guess throttle, CHARGED before the verify — and, on a Worker, in D1.
|
|
3
|
+
*
|
|
4
|
+
* ── 🔴 Why this file exists (task 2137, 2026-09-23) ─────────────────────────
|
|
5
|
+
* `MasterLock.unlock` used to read its failure count, run the argon2id verify (~0.3–1.4 s on a
|
|
6
|
+
* Worker), and write the count AFTER it. Every guess that arrived during one verify was judged
|
|
7
|
+
* against the same count — so a 12-wide burst was verified whole, where the schedule allows
|
|
8
|
+
* six before the first wait. On `collections`' Worker it was worse: the count lived in a JSON
|
|
9
|
+
* blob snapshotted per request, so the twelve each wrote `failures + 1` over one another and
|
|
10
|
+
* the burst was RECORDED as one guess. The same blob holds the open unlocks, so a failed guess
|
|
11
|
+
* settling after the owner's unlock could also drop his fresh session.
|
|
12
|
+
*
|
|
13
|
+
* `charge` is the one operation that closes it: check the window AND, when it is open, charge
|
|
14
|
+
* a failure in the same step, before anything slow happens. A correct verifier then `clear`s
|
|
15
|
+
* the run; the one outcome that was not a guess at all — every stored hash unparsable — is
|
|
16
|
+
* `refund`ed. Everything else, a verify that throws included, stays charged: fail closed.
|
|
17
|
+
*
|
|
18
|
+
* Two ledgers satisfy it:
|
|
19
|
+
* · the lock's own fields — synchronous, so atomic within one process (the Mac's daemons);
|
|
20
|
+
* · {@link createD1MasterLockAttempts} — one conditional UPSERT … RETURNING, atomic because D1
|
|
21
|
+
* runs one statement at a time, for a Worker whose lock is rebuilt on every request.
|
|
22
|
+
*
|
|
23
|
+
* A windowed refusal charges nothing — the delay throttles the RATE of guesses; it is not
|
|
24
|
+
* itself one (the same rule `apps/vault`'s unlock guard keeps).
|
|
25
|
+
*/
|
|
26
|
+
import { delayAfter, windowRemaining } from "cursedbelt-core/master-lock";
|
|
27
|
+
/** The `meta`-style row a D1 ledger keeps its count in, beside the lock's record and state. */
|
|
28
|
+
export const MASTER_LOCK_ATTEMPTS_KEY = "master_lock:attempts";
|
|
29
|
+
/**
|
|
30
|
+
* `delayAfter` as a SQL `CASE` over a failure-count expression — GENERATED from the function,
|
|
31
|
+
* so the statement that admits a guess and the schedule the specs pin cannot disagree.
|
|
32
|
+
*/
|
|
33
|
+
export function masterLockDelaySql(count) {
|
|
34
|
+
const cap = delayAfter(Number.MAX_SAFE_INTEGER);
|
|
35
|
+
const arms = [];
|
|
36
|
+
for (let n = 0; delayAfter(n) < cap && n < 256; n++)
|
|
37
|
+
arms.push(`WHEN ${count} <= ${n} THEN ${delayAfter(n)}`);
|
|
38
|
+
return `(CASE ${arms.join(" ")} ELSE ${cap} END)`;
|
|
39
|
+
}
|
|
40
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
41
|
+
/** One field of the stored JSON, 0 for a missing or unreadable row — never a throw that
|
|
42
|
+
* would leave the owner facing a lock nothing can open. */
|
|
43
|
+
const field = (column, name) => `COALESCE(CASE WHEN json_valid(${column}) THEN json_extract(${column}, '$.${name}') END, 0)`;
|
|
44
|
+
function parseCount(raw) {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(raw ?? "");
|
|
47
|
+
const failures = Number(parsed.failures);
|
|
48
|
+
const lastFailureAt = Number(parsed.lastFailureAt);
|
|
49
|
+
return {
|
|
50
|
+
failures: Number.isFinite(failures) ? failures : 0,
|
|
51
|
+
lastFailureAt: Number.isFinite(lastFailureAt) ? lastFailureAt : 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { failures: 0, lastFailureAt: 0 };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The ledger over a D1 key/value table (`key TEXT PRIMARY KEY, value TEXT`) — `meta` by default,
|
|
60
|
+
* the table a Worker port already keeps the lock's record in, so it needs no migration.
|
|
61
|
+
*
|
|
62
|
+
* Its writes are AWAITED, never queued on a per-invocation collector: a charge that lands after
|
|
63
|
+
* the verify is the bug this exists to close. `snapshot` is the row as the request's own snapshot
|
|
64
|
+
* read it, for {@link MasterLockAttempts.retryAfterMs}; every decision reads D1 itself.
|
|
65
|
+
*/
|
|
66
|
+
export function createD1MasterLockAttempts(db, options = {}) {
|
|
67
|
+
const table = options.table ?? "meta";
|
|
68
|
+
if (!IDENTIFIER.test(table))
|
|
69
|
+
throw new Error(`master lock attempts: "${table}" is not a table name`);
|
|
70
|
+
const key = options.key ?? MASTER_LOCK_ATTEMPTS_KEY;
|
|
71
|
+
let known = parseCount(options.snapshot);
|
|
72
|
+
const value = `${table}.value`;
|
|
73
|
+
return {
|
|
74
|
+
retryAfterMs: (now) => windowRemaining(known.failures, known.lastFailureAt, now),
|
|
75
|
+
async charge(now) {
|
|
76
|
+
// ONE statement: the row is created at 1, or incremented only WHERE the window its own
|
|
77
|
+
// count implies has passed. A refused guess matches no row, so RETURNING is empty.
|
|
78
|
+
const charged = await db
|
|
79
|
+
.prepare(`INSERT INTO ${table} (key, value) VALUES (?1, json_object('failures', 1, 'lastFailureAt', ?2))
|
|
80
|
+
ON CONFLICT(key) DO UPDATE SET value = json_object(
|
|
81
|
+
'failures', ${field(value, "failures")} + 1, 'lastFailureAt', ?2)
|
|
82
|
+
WHERE ${field(value, "lastFailureAt")} + ${masterLockDelaySql(field(value, "failures"))} <= ?2
|
|
83
|
+
RETURNING value`)
|
|
84
|
+
.bind(key, now)
|
|
85
|
+
.first();
|
|
86
|
+
if (charged) {
|
|
87
|
+
known = parseCount(charged.value);
|
|
88
|
+
return { admitted: true, failures: known.failures };
|
|
89
|
+
}
|
|
90
|
+
const row = await db.prepare(`SELECT value FROM ${table} WHERE key = ?`).bind(key).first();
|
|
91
|
+
known = parseCount(row?.value);
|
|
92
|
+
return { admitted: false, retryAfterMs: Math.max(1, windowRemaining(known.failures, known.lastFailureAt, now)) };
|
|
93
|
+
},
|
|
94
|
+
async refund() {
|
|
95
|
+
await db
|
|
96
|
+
.prepare(`UPDATE ${table} SET value = json_object(
|
|
97
|
+
'failures', MAX(${field("value", "failures")} - 1, 0), 'lastFailureAt', ${field("value", "lastFailureAt")})
|
|
98
|
+
WHERE key = ?`)
|
|
99
|
+
.bind(key)
|
|
100
|
+
.run();
|
|
101
|
+
known = { ...known, failures: Math.max(0, known.failures - 1) };
|
|
102
|
+
},
|
|
103
|
+
async clear() {
|
|
104
|
+
await db.prepare(`DELETE FROM ${table} WHERE key = ?`).bind(key).run();
|
|
105
|
+
known = { failures: 0, lastFailureAt: 0 };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -24,6 +24,7 @@ export { MASTER_LOCK_ACCOUNTS_PAGE_PATHS, MASTER_LOCK_ACCOUNTS_SCRIPT, MASTER_LO
|
|
|
24
24
|
export { MASTER_LOCK_CSP, type MasterLockGuard, type MasterLockGuardOptions, createMasterLockGuard, } from "./guard.js";
|
|
25
25
|
export { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, type LockPageOptions, lockPageHtml, } from "./lockPage.js";
|
|
26
26
|
export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, type MasterLockDirectoryOptions, masterLockPrincipalKey, } from "./principals.js";
|
|
27
|
+
export { MASTER_LOCK_ATTEMPTS_KEY, type MasterLockAttempts, createD1MasterLockAttempts, } from "./attempts.js";
|
|
27
28
|
export { FIRST_ACCOUNT_ID, MasterLock, type MasterLockAccount, type MasterLockAddAccountResult, type MasterLockEnrollResult, type MasterLockOptions, type MasterLockRecord, type MasterLockStore, type MasterLockUnlockResult, readRecord, } from "./masterLock.js";
|
|
28
29
|
export { DEFAULT_SEED_IDLE_MS, generateStagePassword, type MintMasterLockSeedOptions, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed.js";
|
|
29
30
|
export { MASTER_LOCK_SETTING_KEY, MASTER_LOCK_STATE_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store.js";
|
|
@@ -24,6 +24,7 @@ export { MASTER_LOCK_ACCOUNTS_PAGE_PATHS, MASTER_LOCK_ACCOUNTS_SCRIPT, MASTER_LO
|
|
|
24
24
|
export { MASTER_LOCK_CSP, createMasterLockGuard, } from "./guard.js";
|
|
25
25
|
export { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, lockPageHtml, } from "./lockPage.js";
|
|
26
26
|
export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, masterLockPrincipalKey, } from "./principals.js";
|
|
27
|
+
export { MASTER_LOCK_ATTEMPTS_KEY, createD1MasterLockAttempts, } from "./attempts.js";
|
|
27
28
|
export { FIRST_ACCOUNT_ID, MasterLock, readRecord, } from "./masterLock.js";
|
|
28
29
|
export { DEFAULT_SEED_IDLE_MS, generateStagePassword, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed.js";
|
|
29
30
|
export { MASTER_LOCK_SETTING_KEY, MASTER_LOCK_STATE_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type MasterLockAccountSummary, type MasterLockKdfParams, type MasterLockStatus } from "cursedbelt-core/master-lock";
|
|
2
|
+
import type { MasterLockAttempts } from "./attempts.js";
|
|
2
3
|
/**
|
|
3
4
|
* ONE master password, and therefore one TENANT of the app behind it.
|
|
4
5
|
*
|
|
@@ -115,6 +116,13 @@ export interface MasterLockOptions {
|
|
|
115
116
|
* the store has nothing.
|
|
116
117
|
*/
|
|
117
118
|
seedJson?: string | null;
|
|
119
|
+
/**
|
|
120
|
+
* Where the guess throttle is CHARGED. Omitted, it is this instance's own count — atomic
|
|
121
|
+
* within one process, which is all the Mac's daemons are. 🔴 Required wherever the lock is
|
|
122
|
+
* rebuilt per request (a Worker): pass `createD1MasterLockAttempts`, or K guesses landing
|
|
123
|
+
* inside one argon2id verify are judged against one stale count (task 2137, `./attempts.ts`).
|
|
124
|
+
*/
|
|
125
|
+
attempts?: MasterLockAttempts;
|
|
118
126
|
/** Injected clock. The tests drive it; production omits it. */
|
|
119
127
|
now?: () => number;
|
|
120
128
|
/**
|
|
@@ -201,6 +209,8 @@ export declare class MasterLock {
|
|
|
201
209
|
private expiryTimer;
|
|
202
210
|
private failures;
|
|
203
211
|
private lastFailureAt;
|
|
212
|
+
/** See {@link MasterLockOptions.attempts}. */
|
|
213
|
+
private readonly attempts;
|
|
204
214
|
constructor(options: MasterLockOptions);
|
|
205
215
|
/** Is any master password set at all? An app with `false` here admits nobody, and
|
|
206
216
|
* offers the choose-a-password form instead. */
|
|
@@ -302,6 +312,11 @@ export declare class MasterLock {
|
|
|
302
312
|
* conclusion for its per-file keys in 2026-08-14; this is that reasoning, kept.)
|
|
303
313
|
*/
|
|
304
314
|
unlock(verifier: string): Promise<MasterLockUnlockResult>;
|
|
315
|
+
/**
|
|
316
|
+
* The default ledger: this instance's own fields, persisted with the live state. `charge`
|
|
317
|
+
* has no `await` between its check and its increment, so within one process it is atomic.
|
|
318
|
+
*/
|
|
319
|
+
private ownAttempts;
|
|
305
320
|
/**
|
|
306
321
|
* Mint a token for one account and start its idle clock. The one place a session is
|
|
307
322
|
* created, so `unlock` and `enroll` cannot disagree about what an unlock is.
|
|
@@ -93,9 +93,12 @@ export class MasterLock {
|
|
|
93
93
|
expiryTimer = null;
|
|
94
94
|
failures = 0;
|
|
95
95
|
lastFailureAt = 0;
|
|
96
|
+
/** See {@link MasterLockOptions.attempts}. */
|
|
97
|
+
attempts;
|
|
96
98
|
constructor(options) {
|
|
97
99
|
this.store = options.store;
|
|
98
100
|
this.stateStore = options.state ?? null;
|
|
101
|
+
this.attempts = options.attempts ?? this.ownAttempts();
|
|
99
102
|
this.loadState();
|
|
100
103
|
this.now = options.now ?? Date.now;
|
|
101
104
|
this.onLockedChange = options.onLockedChange;
|
|
@@ -199,7 +202,7 @@ export class MasterLock {
|
|
|
199
202
|
kdf: this.kdf,
|
|
200
203
|
idleMs: this.idleMs,
|
|
201
204
|
remainingMs: this.remainingMs,
|
|
202
|
-
retryAfterMs:
|
|
205
|
+
retryAfterMs: this.attempts.retryAfterMs(this.now()),
|
|
203
206
|
accountId: account?.id ?? null,
|
|
204
207
|
accountLabel: account?.label ?? null,
|
|
205
208
|
};
|
|
@@ -284,13 +287,16 @@ export class MasterLock {
|
|
|
284
287
|
*/
|
|
285
288
|
async unlock(verifier) {
|
|
286
289
|
const now = this.now();
|
|
287
|
-
const retryAfterMs = windowRemaining(this.failures, this.lastFailureAt, now);
|
|
288
290
|
const record = this.record;
|
|
291
|
+
// 🔴 CHARGED before anything slow happens — see `./attempts.ts`. A guess that lands while
|
|
292
|
+
// another is inside its argon2id verify sees that one already counted.
|
|
293
|
+
const charge = await this.attempts.charge(now);
|
|
289
294
|
// The equalizing verify happens first and unconditionally. `verifier` may be empty
|
|
290
295
|
// or absurd; `verify` neither throws on that nor tells us anything, which is the point.
|
|
296
|
+
// A throw anywhere past the charge leaves it charged: the attempt fails closed.
|
|
291
297
|
let opened = null;
|
|
292
298
|
let unparsable = 0;
|
|
293
|
-
if (
|
|
299
|
+
if (charge.admitted && record) {
|
|
294
300
|
for (const account of record.accounts) {
|
|
295
301
|
const genuine = await verifyArgon(verifier, account.verifierHash);
|
|
296
302
|
// `null` is a hash this build cannot parse. It is NOT a wrong password, and
|
|
@@ -303,20 +309,47 @@ export class MasterLock {
|
|
|
303
309
|
}
|
|
304
310
|
}
|
|
305
311
|
await equalize(verifier);
|
|
306
|
-
if (
|
|
307
|
-
return { ok: false, retryAfterMs };
|
|
312
|
+
if (!charge.admitted)
|
|
313
|
+
return { ok: false, retryAfterMs: charge.retryAfterMs };
|
|
308
314
|
if (!opened) {
|
|
309
315
|
if (unparsable > 0 && unparsable === (record?.accounts.length ?? 0)) {
|
|
316
|
+
// Nothing was verified, so nothing was guessed: the charge goes back.
|
|
317
|
+
await this.attempts.refund();
|
|
310
318
|
this.log("🔴 master lock: every stored verifier hash is unparsable — nothing can unlock");
|
|
311
319
|
return { ok: false, retryAfterMs: 0 };
|
|
312
320
|
}
|
|
313
|
-
|
|
314
|
-
this.lastFailureAt = now;
|
|
315
|
-
this.saveState();
|
|
316
|
-
return { ok: false, retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, now) };
|
|
321
|
+
return { ok: false, retryAfterMs: windowRemaining(charge.failures, now, now) };
|
|
317
322
|
}
|
|
323
|
+
await this.attempts.clear();
|
|
318
324
|
return { ok: true, ...this.open(opened.id, now) };
|
|
319
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* The default ledger: this instance's own fields, persisted with the live state. `charge`
|
|
328
|
+
* has no `await` between its check and its increment, so within one process it is atomic.
|
|
329
|
+
*/
|
|
330
|
+
ownAttempts() {
|
|
331
|
+
return {
|
|
332
|
+
retryAfterMs: (now) => windowRemaining(this.failures, this.lastFailureAt, now),
|
|
333
|
+
charge: (now) => {
|
|
334
|
+
const wait = windowRemaining(this.failures, this.lastFailureAt, now);
|
|
335
|
+
if (wait > 0)
|
|
336
|
+
return { admitted: false, retryAfterMs: wait };
|
|
337
|
+
this.failures += 1;
|
|
338
|
+
this.lastFailureAt = now;
|
|
339
|
+
this.saveState();
|
|
340
|
+
return { admitted: true, failures: this.failures };
|
|
341
|
+
},
|
|
342
|
+
refund: () => {
|
|
343
|
+
this.failures = Math.max(0, this.failures - 1);
|
|
344
|
+
this.saveState();
|
|
345
|
+
},
|
|
346
|
+
clear: () => {
|
|
347
|
+
this.failures = 0;
|
|
348
|
+
this.lastFailureAt = 0;
|
|
349
|
+
this.saveState();
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
320
353
|
/**
|
|
321
354
|
* Mint a token for one account and start its idle clock. The one place a session is
|
|
322
355
|
* created, so `unlock` and `enroll` cannot disagree about what an unlock is.
|
|
@@ -380,6 +413,7 @@ export class MasterLock {
|
|
|
380
413
|
createdAt: now,
|
|
381
414
|
};
|
|
382
415
|
this.write({ kdf: input.kdf, idleMs: clampIdleMs(this.record?.idleMs), accounts: [account] });
|
|
416
|
+
await this.attempts.clear();
|
|
383
417
|
this.log("master lock: a first master password was chosen");
|
|
384
418
|
return { ok: true, ...this.open(account.id, now) };
|
|
385
419
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.26.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -173,6 +173,12 @@
|
|
|
173
173
|
"source": "./src/server/auth/loginThrottle.ts",
|
|
174
174
|
"import": "./dist/server/auth/loginThrottle.js"
|
|
175
175
|
},
|
|
176
|
+
"./login-throttle/durable": {
|
|
177
|
+
"types": "./dist/server/auth/loginThrottleDurable.d.ts",
|
|
178
|
+
"bun": "./src/server/auth/loginThrottleDurable.ts",
|
|
179
|
+
"source": "./src/server/auth/loginThrottleDurable.ts",
|
|
180
|
+
"import": "./dist/server/auth/loginThrottleDurable.js"
|
|
181
|
+
},
|
|
176
182
|
"./maps-budget": {
|
|
177
183
|
"types": "./dist/server/maps-budget/mapsBudget.d.ts",
|
|
178
184
|
"bun": "./src/server/maps-budget/mapsBudget.ts",
|
|
@@ -124,6 +124,16 @@ const LEAVES = [
|
|
|
124
124
|
/** The failed-login backoff. Every app with a login door; shipped as 2.5.1. */
|
|
125
125
|
evaluates: 'createLoginThrottle',
|
|
126
126
|
},
|
|
127
|
+
{
|
|
128
|
+
subpath: './login-throttle/durable',
|
|
129
|
+
/**
|
|
130
|
+
* The throttle's Durable Object and its client — a Worker's ledger shared by every isolate
|
|
131
|
+
* (4.25.0, patterns task 2136). It runs on `workerd`, so it may import nothing but the
|
|
132
|
+
* throttle it serves; its types for the object's state and namespace are structural.
|
|
133
|
+
*/
|
|
134
|
+
evaluates: 'createRemoteLoginThrottle',
|
|
135
|
+
siblings: ['src/server/auth/loginThrottle.ts'],
|
|
136
|
+
},
|
|
127
137
|
{
|
|
128
138
|
subpath: './binary-store',
|
|
129
139
|
/**
|
|
@@ -211,3 +211,85 @@ describe('createLoginThrottle', () => {
|
|
|
211
211
|
expect(t.size).toBe(1);
|
|
212
212
|
});
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
describe('attempt — check and charge in one step', () => {
|
|
216
|
+
it('🔴 a burst of concurrent attempts is admitted only up to the threshold', async () => {
|
|
217
|
+
const c = clock();
|
|
218
|
+
const t = createLoginThrottle({ now: c.now, threshold: 3, globalThreshold: 1000 });
|
|
219
|
+
// Every attempt is admitted BEFORE any verify finishes — the shape of K concurrent guesses
|
|
220
|
+
// each waiting on argon2id. With check-then-recordFailure all ten would pass one stale count.
|
|
221
|
+
const verdicts = Array.from({ length: 10 }, () => t.attempt('ip'));
|
|
222
|
+
// Threshold 3 → charges 1–3 free, the 4th arms the delay but is itself admitted (the same
|
|
223
|
+
// "one extra" the threshold option documents), the 5th onward are refused.
|
|
224
|
+
expect(verdicts.filter((v) => v.allowed).length).toBe(4);
|
|
225
|
+
expect(verdicts[4]).toEqual({ allowed: false, scope: 'ip', retryAfterSeconds: 1 });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('a success after an attempt clears the charge', () => {
|
|
229
|
+
const c = clock();
|
|
230
|
+
const t = createLoginThrottle({ now: c.now, threshold: 1, globalThreshold: 1 });
|
|
231
|
+
expect(t.attempt('ip').allowed).toBe(true);
|
|
232
|
+
t.recordSuccess('ip');
|
|
233
|
+
expect(t.attempt('ip').allowed).toBe(true);
|
|
234
|
+
t.recordSuccess('ip');
|
|
235
|
+
expect(t.check('ip')).toEqual({ allowed: true });
|
|
236
|
+
expect(t.size).toBe(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('a refused attempt is not itself charged', () => {
|
|
240
|
+
const c = clock();
|
|
241
|
+
const t = createLoginThrottle({ now: c.now, threshold: 0, baseDelayMs: 1_000, globalThreshold: 1000 });
|
|
242
|
+
expect(t.attempt('ip').allowed).toBe(true); // charged: failure 1 → 1 s delay
|
|
243
|
+
for (let i = 0; i < 5; i++) expect(t.attempt('ip').allowed).toBe(false);
|
|
244
|
+
c.advance(1_000);
|
|
245
|
+
// Still failure 1's delay that just elapsed — not 2^5 s from five refused attempts.
|
|
246
|
+
expect(t.attempt('ip').allowed).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('snapshot / restore', () => {
|
|
251
|
+
it('a throttle restored from a snapshot enforces what the original had seen', () => {
|
|
252
|
+
const c = clock();
|
|
253
|
+
const first = createLoginThrottle({ now: c.now, threshold: 2, globalThreshold: 1000 });
|
|
254
|
+
for (let i = 0; i < 3; i++) first.recordFailure('Attacker');
|
|
255
|
+
expect(first.check('attacker').allowed).toBe(false);
|
|
256
|
+
const snap = JSON.parse(JSON.stringify(first.snapshot()));
|
|
257
|
+
const second = createLoginThrottle({ now: c.now, threshold: 2, globalThreshold: 1000, restore: snap });
|
|
258
|
+
expect(second.check('attacker').allowed).toBe(false);
|
|
259
|
+
expect(second.check('someone-else').allowed).toBe(true);
|
|
260
|
+
// The count carries on rather than restarting.
|
|
261
|
+
c.advance(10_000);
|
|
262
|
+
const next = second.recordFailure('attacker');
|
|
263
|
+
expect(next.allowed).toBe(false);
|
|
264
|
+
if (!next.allowed) expect(next.retryAfterSeconds).toBe(2);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('keeps the global counter, and only the most recent keys under a limit', () => {
|
|
268
|
+
const c = clock();
|
|
269
|
+
const t = createLoginThrottle({ now: c.now, globalThreshold: 3 });
|
|
270
|
+
for (let i = 0; i < 5; i++) {
|
|
271
|
+
c.advance(1);
|
|
272
|
+
t.recordFailure(`ip-${i}`);
|
|
273
|
+
}
|
|
274
|
+
const snap = t.snapshot(2);
|
|
275
|
+
expect(snap.entries.map(([k]) => k)).toEqual(['ip-4', 'ip-3']);
|
|
276
|
+
expect(snap.global.failures).toBe(5);
|
|
277
|
+
const restored = createLoginThrottle({ now: c.now, globalThreshold: 3, restore: snap });
|
|
278
|
+
const v = restored.check('a-new-ip');
|
|
279
|
+
expect(v.allowed).toBe(false);
|
|
280
|
+
if (!v.allowed) expect(v.scope).toBe('global');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('an unreadable snapshot starts empty rather than throwing', () => {
|
|
284
|
+
const bad = [
|
|
285
|
+
{ v: 2, global: { failures: 99, lockedUntil: 9e15, lastSeen: 0 }, entries: [] },
|
|
286
|
+
{ v: 1, global: { failures: 'x' }, entries: [['k', { failures: null }], 'junk'] },
|
|
287
|
+
{ v: 1, global: null, entries: null },
|
|
288
|
+
];
|
|
289
|
+
for (const restore of bad) {
|
|
290
|
+
const t = createLoginThrottle({ restore: restore as never });
|
|
291
|
+
expect(t.check('k')).toEqual({ allowed: true });
|
|
292
|
+
expect(t.size).toBe(0);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -45,6 +45,25 @@
|
|
|
45
45
|
* global counter keeps working after eviction, which is precisely the case where it
|
|
46
46
|
* matters most.
|
|
47
47
|
*
|
|
48
|
+
* 🔴 **"Per-process" is one ISOLATE on a Cloudflare Worker, and that is not a process.**
|
|
49
|
+
* Cloudflare runs an isolate per colo (often several), discards them without warning and
|
|
50
|
+
* builds a fresh one on every deploy, so a module-scope throttle there is global to one
|
|
51
|
+
* isolate only: a guesser reaching N isolates gets N × `globalThreshold` free failures,
|
|
52
|
+
* and one built per REQUEST has no memory at all (patterns' Worker until 2026-09-23). A
|
|
53
|
+
* Worker keeps its ledger in a Durable Object instead — `./loginThrottleDurable.ts`
|
|
54
|
+
* (`cursedbelt-server/login-throttle/durable`), which serves THIS algorithm from one
|
|
55
|
+
* named object and persists it, so every isolate and every colo meets the same counts.
|
|
56
|
+
*
|
|
57
|
+
* ── `attempt`, not `check` then `recordFailure` ─────────────────────────────
|
|
58
|
+
* `check` → verify → `recordFailure` has a gap exactly as long as the verify: argon2id,
|
|
59
|
+
* hundreds of ms. Every request that arrives inside it reads the same count, so K
|
|
60
|
+
* concurrent guesses are all admitted and the counter learns of them afterwards — the
|
|
61
|
+
* throttle limits guesses per BURST, and the burst is as wide as the attacker's
|
|
62
|
+
* concurrency. `attempt` closes the gap: it checks and, when admitted, charges a
|
|
63
|
+
* failure in the same synchronous step, so the (threshold+1)th concurrent request is
|
|
64
|
+
* refused. A success then clears the charge (`recordSuccess`), and a failure needs no
|
|
65
|
+
* second call. The Durable Object serves `attempt` as one operation for the same reason.
|
|
66
|
+
*
|
|
48
67
|
* Pair with the coarse per-IP `createRateLimiter` (`../middleware/rateLimit`); this is
|
|
49
68
|
* the strict per-identity layer.
|
|
50
69
|
*/
|
|
@@ -74,6 +93,28 @@ export interface LoginThrottleOptions {
|
|
|
74
93
|
windowMs?: number;
|
|
75
94
|
/** Hard cap on tracked keys, so the table cannot be a memory vector. Default 4096. */
|
|
76
95
|
maxKeys?: number;
|
|
96
|
+
/**
|
|
97
|
+
* Start from a persisted {@link LoginThrottle.snapshot} rather than empty — how a Durable
|
|
98
|
+
* Object (`./loginThrottleDurable.ts`) survives its own eviction. An unreadable snapshot
|
|
99
|
+
* (wrong version, wrong shape) starts EMPTY for the keys and is ignored, never thrown on.
|
|
100
|
+
*/
|
|
101
|
+
restore?: LoginThrottleSnapshot | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One key's (or the global counter's) ledger line — the unit {@link LoginThrottleSnapshot} holds. */
|
|
105
|
+
export interface LoginThrottleEntry {
|
|
106
|
+
failures: number;
|
|
107
|
+
/** When the current lockout ends; 0 when not locked. */
|
|
108
|
+
lockedUntil: number;
|
|
109
|
+
lastSeen: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A throttle's whole state as plain data, for a store that outlives the throttle. */
|
|
113
|
+
export interface LoginThrottleSnapshot {
|
|
114
|
+
v: 1;
|
|
115
|
+
global: LoginThrottleEntry;
|
|
116
|
+
/** Most recently seen first, `[normalizedKey, entry]`. */
|
|
117
|
+
entries: Array<[string, LoginThrottleEntry]>;
|
|
77
118
|
}
|
|
78
119
|
|
|
79
120
|
export type ThrottleVerdict =
|
|
@@ -83,6 +124,13 @@ export type ThrottleVerdict =
|
|
|
83
124
|
export interface LoginThrottle {
|
|
84
125
|
/** May this key attempt a login right now? */
|
|
85
126
|
check(key: string): ThrottleVerdict;
|
|
127
|
+
/**
|
|
128
|
+
* `check`, and when allowed, charge a failure in the SAME step — so concurrent attempts
|
|
129
|
+
* cannot all be admitted against one count. Follow an allowed attempt with
|
|
130
|
+
* `recordSuccess` when the credential was right; a wrong one needs no further call.
|
|
131
|
+
* See the header's `attempt` section.
|
|
132
|
+
*/
|
|
133
|
+
attempt(key: string): ThrottleVerdict;
|
|
86
134
|
/** Record a failed attempt. Returns the verdict the NEXT attempt would get. */
|
|
87
135
|
recordFailure(key: string): ThrottleVerdict;
|
|
88
136
|
/** Record a success — clears this key and the global counter. */
|
|
@@ -91,8 +139,26 @@ export interface LoginThrottle {
|
|
|
91
139
|
reset(key: string): void;
|
|
92
140
|
/** Tracked-key count, for the bounded-growth test. */
|
|
93
141
|
readonly size: number;
|
|
142
|
+
/** The state as plain data — the `limit` most recently seen keys (default all) and the global counter. */
|
|
143
|
+
snapshot(limit?: number): LoginThrottleSnapshot;
|
|
94
144
|
}
|
|
95
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The same ledger across a round trip — `createRemoteLoginThrottle` in
|
|
148
|
+
* `./loginThrottleDurable.ts`. A door that awaits every call takes either shape:
|
|
149
|
+
* {@link LoginThrottleLike}.
|
|
150
|
+
*/
|
|
151
|
+
export interface AsyncLoginThrottle {
|
|
152
|
+
check(key: string): Promise<ThrottleVerdict>;
|
|
153
|
+
attempt(key: string): Promise<ThrottleVerdict>;
|
|
154
|
+
recordFailure(key: string): Promise<ThrottleVerdict>;
|
|
155
|
+
recordSuccess(key: string): Promise<void>;
|
|
156
|
+
reset(key: string): Promise<void>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What a sign-in handler should accept: the in-process throttle or the Durable Object's. */
|
|
160
|
+
export type LoginThrottleLike = LoginThrottle | AsyncLoginThrottle;
|
|
161
|
+
|
|
96
162
|
/**
|
|
97
163
|
* The client address to key on — the LAST `X-Forwarded-For` entry (what our proxy
|
|
98
164
|
* saw), falling back to `X-Real-IP`, the socket peer, and finally a shared constant.
|
|
@@ -142,12 +208,16 @@ export function clientKeyOfContext(req: Request, env: unknown): string {
|
|
|
142
208
|
return clientKeyOf(req, address);
|
|
143
209
|
}
|
|
144
210
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
211
|
+
type Attempt = LoginThrottleEntry;
|
|
212
|
+
|
|
213
|
+
/** A persisted entry is trusted only as far as its three numbers are finite. */
|
|
214
|
+
const readEntry = (raw: unknown): Attempt | null => {
|
|
215
|
+
const e = raw as Partial<Attempt> | null | undefined;
|
|
216
|
+
if (!e || typeof e !== 'object') return null;
|
|
217
|
+
const { failures, lockedUntil, lastSeen } = e;
|
|
218
|
+
if (![failures, lockedUntil, lastSeen].every((n) => typeof n === 'number' && Number.isFinite(n))) return null;
|
|
219
|
+
return { failures: failures as number, lockedUntil: lockedUntil as number, lastSeen: lastSeen as number };
|
|
220
|
+
};
|
|
151
221
|
|
|
152
222
|
/**
|
|
153
223
|
* Keys are compared case- and whitespace-insensitively.
|
|
@@ -172,6 +242,18 @@ export function createLoginThrottle(options: LoginThrottleOptions = {}): LoginTh
|
|
|
172
242
|
|
|
173
243
|
const attempts = new Map<string, Attempt>();
|
|
174
244
|
const global: Attempt = { failures: 0, lockedUntil: 0, lastSeen: 0 };
|
|
245
|
+
const restored = options.restore;
|
|
246
|
+
if (restored && restored.v === 1) {
|
|
247
|
+
const g = readEntry(restored.global);
|
|
248
|
+
if (g) Object.assign(global, g);
|
|
249
|
+
// Oldest first, so the Map's insertion order matches what it would have been live.
|
|
250
|
+
const entries = Array.isArray(restored.entries) ? [...restored.entries].reverse() : [];
|
|
251
|
+
for (const pair of entries) {
|
|
252
|
+
if (!Array.isArray(pair) || typeof pair[0] !== 'string') continue;
|
|
253
|
+
const entry = readEntry(pair[1]);
|
|
254
|
+
if (entry) attempts.set(normalizeKey(pair[0]), entry);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
175
257
|
|
|
176
258
|
/** Delay for the nth failure past a threshold: base·2^(n-1), capped. */
|
|
177
259
|
const delayFor = (failures: number, limit: number, cap: number): number => {
|
|
@@ -215,11 +297,19 @@ export function createLoginThrottle(options: LoginThrottleOptions = {}): LoginTh
|
|
|
215
297
|
return { allowed: true };
|
|
216
298
|
};
|
|
217
299
|
|
|
218
|
-
|
|
300
|
+
const api: LoginThrottle = {
|
|
219
301
|
check(key) {
|
|
220
302
|
return verdict(key, now());
|
|
221
303
|
},
|
|
222
304
|
|
|
305
|
+
attempt(key) {
|
|
306
|
+
const gate = verdict(key, now());
|
|
307
|
+
if (!gate.allowed) return gate;
|
|
308
|
+
// Charged BEFORE the caller verifies anything — see the header's `attempt` section.
|
|
309
|
+
api.recordFailure(key);
|
|
310
|
+
return gate;
|
|
311
|
+
},
|
|
312
|
+
|
|
223
313
|
recordFailure(rawKey) {
|
|
224
314
|
const key = normalizeKey(rawKey);
|
|
225
315
|
const nowMs = now();
|
|
@@ -257,5 +347,16 @@ export function createLoginThrottle(options: LoginThrottleOptions = {}): LoginTh
|
|
|
257
347
|
get size() {
|
|
258
348
|
return attempts.size;
|
|
259
349
|
},
|
|
350
|
+
|
|
351
|
+
snapshot(limit) {
|
|
352
|
+
const byRecency = [...attempts.entries()].sort((a, b) => b[1].lastSeen - a[1].lastSeen);
|
|
353
|
+
const kept = limit === undefined ? byRecency : byRecency.slice(0, Math.max(0, limit));
|
|
354
|
+
return {
|
|
355
|
+
v: 1,
|
|
356
|
+
global: { ...global },
|
|
357
|
+
entries: kept.map(([key, entry]) => [key, { ...entry }]),
|
|
358
|
+
};
|
|
359
|
+
},
|
|
260
360
|
};
|
|
361
|
+
return api;
|
|
261
362
|
}
|