cursedbelt-server 4.25.0 โ 4.26.1
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 +8 -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 +25 -0
- package/dist/server/master-lock/masterLock.js +54 -10
- package/package.json +1 -1
- package/src/server/master-lock/attempts.spec.ts +159 -0
- package/src/server/master-lock/attempts.ts +141 -0
- package/src/server/master-lock/guard.spec.ts +23 -0
- package/src/server/master-lock/index.ts +5 -0
- package/src/server/master-lock/masterLock.ts +61 -9
package/README.md
CHANGED
|
@@ -65,6 +65,14 @@ several per colo, wiped by every deploy โ so a Worker's sign-in keeps its ledg
|
|
|
65
65
|
{ ledger, fallback })`, gating with `attempt` (check and charge in one step, so a concurrent burst
|
|
66
66
|
cannot share one count). `src/server/auth/loginThrottleDurable.ts` has the why and the wiring.
|
|
67
67
|
|
|
68
|
+
## ๐ด A master lock on a Worker: `attempts: createD1MasterLockAttempts(db)`
|
|
69
|
+
|
|
70
|
+
A Worker rebuilds `MasterLock` per request, so its guess throttle must be CHARGED where every
|
|
71
|
+
request can see it, before the argon2id verify โ or a burst landing inside one verify is judged
|
|
72
|
+
against one stale count (task 2137). Pass `attempts: createD1MasterLockAttempts(db, { snapshot })`
|
|
73
|
+
(one conditional UPSERT โฆ RETURNING on the `meta` row `master_lock:attempts`).
|
|
74
|
+
`src/server/master-lock/attempts.ts` has the why.
|
|
75
|
+
|
|
68
76
|
## Standards
|
|
69
77
|
|
|
70
78
|
`docs/retention.md`, `docs/activity.md`, `docs/notifications.md`, `docs/engagement.md` โ the
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { D1LikeDatabase } from "../d1/types.js";
|
|
2
|
+
/** What {@link MasterLockAttempts.charge} answers: admitted (and charged), or not (and not). */
|
|
3
|
+
export type MasterLockCharge = {
|
|
4
|
+
admitted: true;
|
|
5
|
+
failures: number;
|
|
6
|
+
} | {
|
|
7
|
+
admitted: false;
|
|
8
|
+
retryAfterMs: number;
|
|
9
|
+
};
|
|
10
|
+
export interface MasterLockAttempts {
|
|
11
|
+
/** What the ledger last knew, for the lock page's countdown. Never a decision. */
|
|
12
|
+
retryAfterMs(now: number): number;
|
|
13
|
+
/**
|
|
14
|
+
* ๐ด CHECK AND CHARGE in one step. Admitted โ one failure is already charged, and
|
|
15
|
+
* `failures` counts it. Refused โ nothing charged; `retryAfterMs` is the wait.
|
|
16
|
+
*/
|
|
17
|
+
charge(now: number): MasterLockCharge | Promise<MasterLockCharge>;
|
|
18
|
+
/** The charged attempt was not a guess (nothing could be verified): take it back. */
|
|
19
|
+
refund(): void | Promise<void>;
|
|
20
|
+
/** A correct verifier: the run is over. */
|
|
21
|
+
clear(): void | Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** The `meta`-style row a D1 ledger keeps its count in, beside the lock's record and state. */
|
|
24
|
+
export declare const MASTER_LOCK_ATTEMPTS_KEY = "master_lock:attempts";
|
|
25
|
+
/**
|
|
26
|
+
* `delayAfter` as a SQL `CASE` over a failure-count expression โ GENERATED from the function,
|
|
27
|
+
* so the statement that admits a guess and the schedule the specs pin cannot disagree.
|
|
28
|
+
*/
|
|
29
|
+
export declare function masterLockDelaySql(count: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* The ledger over a D1 key/value table (`key TEXT PRIMARY KEY, value TEXT`) โ `meta` by default,
|
|
32
|
+
* the table a Worker port already keeps the lock's record in, so it needs no migration.
|
|
33
|
+
*
|
|
34
|
+
* Its writes are AWAITED, never queued on a per-invocation collector: a charge that lands after
|
|
35
|
+
* the verify is the bug this exists to close. `snapshot` is the row as the request's own snapshot
|
|
36
|
+
* read it, for {@link MasterLockAttempts.retryAfterMs}; every decision reads D1 itself.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createD1MasterLockAttempts(db: D1LikeDatabase, options?: {
|
|
39
|
+
table?: string;
|
|
40
|
+
key?: string;
|
|
41
|
+
snapshot?: string | null;
|
|
42
|
+
}): MasterLockAttempts;
|
|
@@ -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. */
|
|
@@ -247,6 +257,16 @@ export declare class MasterLock {
|
|
|
247
257
|
* `req` is optional only so a health check can ask without one. Pass it wherever there
|
|
248
258
|
* IS a request: without it the reply carries no account, and a client would read that
|
|
249
259
|
* as "locked" while holding a perfectly good unlock.
|
|
260
|
+
*
|
|
261
|
+
* ๐ด With a request, `locked` is THIS CALLER's answer โ the one {@link accountFor} gives โ
|
|
262
|
+
* and never the site's. Until 4.26.1 it read {@link unlocked} ("somebody's device is
|
|
263
|
+
* open"), which the lock page's own script takes as "you are in" and answers with
|
|
264
|
+
* `location.reload()`; the guard, asking the per-caller question, serves the lock page
|
|
265
|
+
* again, and the page spins for as long as any other browser stays unlocked โ no password
|
|
266
|
+
* box ever settles. Measured 2026-09-23 on the binary-server inspector: a second Chromium
|
|
267
|
+
* context reloaded `/__lock/` in a tight loop while `/__lock/status` told it
|
|
268
|
+
* `"locked": false` with no cookie at all. Without a request it stays the site-wide
|
|
269
|
+
* health reading, which is all a caller with no request can mean.
|
|
250
270
|
*/
|
|
251
271
|
status(req?: Request): MasterLockStatus;
|
|
252
272
|
/**
|
|
@@ -302,6 +322,11 @@ export declare class MasterLock {
|
|
|
302
322
|
* conclusion for its per-file keys in 2026-08-14; this is that reasoning, kept.)
|
|
303
323
|
*/
|
|
304
324
|
unlock(verifier: string): Promise<MasterLockUnlockResult>;
|
|
325
|
+
/**
|
|
326
|
+
* The default ledger: this instance's own fields, persisted with the live state. `charge`
|
|
327
|
+
* has no `await` between its check and its increment, so within one process it is atomic.
|
|
328
|
+
*/
|
|
329
|
+
private ownAttempts;
|
|
305
330
|
/**
|
|
306
331
|
* Mint a token for one account and start its idle clock. The one place a session is
|
|
307
332
|
* 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;
|
|
@@ -188,6 +191,16 @@ export class MasterLock {
|
|
|
188
191
|
* `req` is optional only so a health check can ask without one. Pass it wherever there
|
|
189
192
|
* IS a request: without it the reply carries no account, and a client would read that
|
|
190
193
|
* as "locked" while holding a perfectly good unlock.
|
|
194
|
+
*
|
|
195
|
+
* ๐ด With a request, `locked` is THIS CALLER's answer โ the one {@link accountFor} gives โ
|
|
196
|
+
* and never the site's. Until 4.26.1 it read {@link unlocked} ("somebody's device is
|
|
197
|
+
* open"), which the lock page's own script takes as "you are in" and answers with
|
|
198
|
+
* `location.reload()`; the guard, asking the per-caller question, serves the lock page
|
|
199
|
+
* again, and the page spins for as long as any other browser stays unlocked โ no password
|
|
200
|
+
* box ever settles. Measured 2026-09-23 on the binary-server inspector: a second Chromium
|
|
201
|
+
* context reloaded `/__lock/` in a tight loop while `/__lock/status` told it
|
|
202
|
+
* `"locked": false` with no cookie at all. Without a request it stays the site-wide
|
|
203
|
+
* health reading, which is all a caller with no request can mean.
|
|
191
204
|
*/
|
|
192
205
|
status(req) {
|
|
193
206
|
const accountId = req ? this.accountFor(req) : null;
|
|
@@ -195,11 +208,11 @@ export class MasterLock {
|
|
|
195
208
|
return {
|
|
196
209
|
configured: this.configured,
|
|
197
210
|
enrollable: this.awaitingEnrollment,
|
|
198
|
-
locked: !this.unlocked,
|
|
211
|
+
locked: req ? accountId === null : !this.unlocked,
|
|
199
212
|
kdf: this.kdf,
|
|
200
213
|
idleMs: this.idleMs,
|
|
201
214
|
remainingMs: this.remainingMs,
|
|
202
|
-
retryAfterMs:
|
|
215
|
+
retryAfterMs: this.attempts.retryAfterMs(this.now()),
|
|
203
216
|
accountId: account?.id ?? null,
|
|
204
217
|
accountLabel: account?.label ?? null,
|
|
205
218
|
};
|
|
@@ -284,13 +297,16 @@ export class MasterLock {
|
|
|
284
297
|
*/
|
|
285
298
|
async unlock(verifier) {
|
|
286
299
|
const now = this.now();
|
|
287
|
-
const retryAfterMs = windowRemaining(this.failures, this.lastFailureAt, now);
|
|
288
300
|
const record = this.record;
|
|
301
|
+
// ๐ด CHARGED before anything slow happens โ see `./attempts.ts`. A guess that lands while
|
|
302
|
+
// another is inside its argon2id verify sees that one already counted.
|
|
303
|
+
const charge = await this.attempts.charge(now);
|
|
289
304
|
// The equalizing verify happens first and unconditionally. `verifier` may be empty
|
|
290
305
|
// or absurd; `verify` neither throws on that nor tells us anything, which is the point.
|
|
306
|
+
// A throw anywhere past the charge leaves it charged: the attempt fails closed.
|
|
291
307
|
let opened = null;
|
|
292
308
|
let unparsable = 0;
|
|
293
|
-
if (
|
|
309
|
+
if (charge.admitted && record) {
|
|
294
310
|
for (const account of record.accounts) {
|
|
295
311
|
const genuine = await verifyArgon(verifier, account.verifierHash);
|
|
296
312
|
// `null` is a hash this build cannot parse. It is NOT a wrong password, and
|
|
@@ -303,20 +319,47 @@ export class MasterLock {
|
|
|
303
319
|
}
|
|
304
320
|
}
|
|
305
321
|
await equalize(verifier);
|
|
306
|
-
if (
|
|
307
|
-
return { ok: false, retryAfterMs };
|
|
322
|
+
if (!charge.admitted)
|
|
323
|
+
return { ok: false, retryAfterMs: charge.retryAfterMs };
|
|
308
324
|
if (!opened) {
|
|
309
325
|
if (unparsable > 0 && unparsable === (record?.accounts.length ?? 0)) {
|
|
326
|
+
// Nothing was verified, so nothing was guessed: the charge goes back.
|
|
327
|
+
await this.attempts.refund();
|
|
310
328
|
this.log("๐ด master lock: every stored verifier hash is unparsable โ nothing can unlock");
|
|
311
329
|
return { ok: false, retryAfterMs: 0 };
|
|
312
330
|
}
|
|
313
|
-
|
|
314
|
-
this.lastFailureAt = now;
|
|
315
|
-
this.saveState();
|
|
316
|
-
return { ok: false, retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, now) };
|
|
331
|
+
return { ok: false, retryAfterMs: windowRemaining(charge.failures, now, now) };
|
|
317
332
|
}
|
|
333
|
+
await this.attempts.clear();
|
|
318
334
|
return { ok: true, ...this.open(opened.id, now) };
|
|
319
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* The default ledger: this instance's own fields, persisted with the live state. `charge`
|
|
338
|
+
* has no `await` between its check and its increment, so within one process it is atomic.
|
|
339
|
+
*/
|
|
340
|
+
ownAttempts() {
|
|
341
|
+
return {
|
|
342
|
+
retryAfterMs: (now) => windowRemaining(this.failures, this.lastFailureAt, now),
|
|
343
|
+
charge: (now) => {
|
|
344
|
+
const wait = windowRemaining(this.failures, this.lastFailureAt, now);
|
|
345
|
+
if (wait > 0)
|
|
346
|
+
return { admitted: false, retryAfterMs: wait };
|
|
347
|
+
this.failures += 1;
|
|
348
|
+
this.lastFailureAt = now;
|
|
349
|
+
this.saveState();
|
|
350
|
+
return { admitted: true, failures: this.failures };
|
|
351
|
+
},
|
|
352
|
+
refund: () => {
|
|
353
|
+
this.failures = Math.max(0, this.failures - 1);
|
|
354
|
+
this.saveState();
|
|
355
|
+
},
|
|
356
|
+
clear: () => {
|
|
357
|
+
this.failures = 0;
|
|
358
|
+
this.lastFailureAt = 0;
|
|
359
|
+
this.saveState();
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
320
363
|
/**
|
|
321
364
|
* Mint a token for one account and start its idle clock. The one place a session is
|
|
322
365
|
* created, so `unlock` and `enroll` cannot disagree about what an unlock is.
|
|
@@ -380,6 +423,7 @@ export class MasterLock {
|
|
|
380
423
|
createdAt: now,
|
|
381
424
|
};
|
|
382
425
|
this.write({ kdf: input.kdf, idleMs: clampIdleMs(this.record?.idleMs), accounts: [account] });
|
|
426
|
+
await this.attempts.clear();
|
|
383
427
|
this.log("master lock: a first master password was chosen");
|
|
384
428
|
return { ok: true, ...this.open(account.id, now) };
|
|
385
429
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.26.1",
|
|
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.",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ๐ด The guess throttle is CHARGED before the verify (task 2137) โ a burst of guesses landing
|
|
3
|
+
* inside one argon2id verify is not judged against one stale count.
|
|
4
|
+
*
|
|
5
|
+
* Proved two ways, because the lock has two ledgers: its own fields (one process โ the Mac's
|
|
6
|
+
* daemons, where a burst is K concurrent `unlock` calls on one instance) and a D1 row (a Worker,
|
|
7
|
+
* where every request builds its own lock over a snapshot and only D1 is shared). Both were run
|
|
8
|
+
* against the pre-fix `MasterLock` and failed: 12 of 12 verified, and on the per-request shape
|
|
9
|
+
* the burst was recorded as ONE failure.
|
|
10
|
+
*/
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { type MasterLockKdfParams, delayAfter, deriveMasterLockVerifier } from "cursedbelt-core/master-lock";
|
|
14
|
+
import { createRemoteD1 } from "../d1/remote.js";
|
|
15
|
+
import { createFakeD1Binding } from "../d1/fakeD1.js";
|
|
16
|
+
import type { D1LikeDatabase } from "../d1/types.js";
|
|
17
|
+
import { MASTER_LOCK_ATTEMPTS_KEY, createD1MasterLockAttempts, masterLockDelaySql } from "./attempts.js";
|
|
18
|
+
import { MasterLock } from "./masterLock.js";
|
|
19
|
+
import { createMemoryMasterLockStore } from "./store.js";
|
|
20
|
+
|
|
21
|
+
const KDF: MasterLockKdfParams = { v: 1, alg: "PBKDF2-SHA256", iter: 1, salt: "AAECAwQFBgcICQoLDA0ODw" };
|
|
22
|
+
const BURST = 12;
|
|
23
|
+
/** Five free, and the sixth is evaluated before the first wait โ the same as one at a time. */
|
|
24
|
+
const ADMITTED = 6;
|
|
25
|
+
|
|
26
|
+
let VERIFIER = "";
|
|
27
|
+
let HASH = "";
|
|
28
|
+
let SEED = "";
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
VERIFIER = await deriveMasterLockVerifier("the owner's master password", KDF);
|
|
31
|
+
HASH = await Bun.password.hash(VERIFIER, { algorithm: "argon2id", memoryCost: 4096, timeCost: 1 });
|
|
32
|
+
SEED = JSON.stringify({ kdf: KDF, verifierHash: HASH });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** How many verifies reached the REAL hash โ a guess that was evaluated, not refused. */
|
|
36
|
+
const realVerify = Bun.password.verify;
|
|
37
|
+
let evaluated = 0;
|
|
38
|
+
function countEvaluations(): void {
|
|
39
|
+
evaluated = 0;
|
|
40
|
+
Bun.password.verify = ((candidate: string, hash: string) => {
|
|
41
|
+
if (hash === HASH) evaluated += 1;
|
|
42
|
+
return realVerify(candidate, hash);
|
|
43
|
+
}) as typeof Bun.password.verify;
|
|
44
|
+
}
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
Bun.password.verify = realVerify;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function d1(): { db: D1LikeDatabase; sqlite: Database } {
|
|
50
|
+
const sqlite = new Database(":memory:");
|
|
51
|
+
sqlite.exec("CREATE TABLE meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL )");
|
|
52
|
+
return { db: createRemoteD1(createFakeD1Binding(sqlite)), sqlite };
|
|
53
|
+
}
|
|
54
|
+
const stored = (sqlite: Database) =>
|
|
55
|
+
JSON.parse(
|
|
56
|
+
(sqlite.query("SELECT value FROM meta WHERE key = ?").get(MASTER_LOCK_ATTEMPTS_KEY) as { value: string } | null)?.value ?? "{}",
|
|
57
|
+
) as { failures?: number; lastFailureAt?: number };
|
|
58
|
+
|
|
59
|
+
describe("๐ด a concurrent burst of wrong verifiers", () => {
|
|
60
|
+
test("one process, one instance: 12 at once โ 6 evaluated, the rest refused", async () => {
|
|
61
|
+
const lock = new MasterLock({ store: createMemoryMasterLockStore(null), seedJson: SEED, now: () => 1_000_000 });
|
|
62
|
+
countEvaluations();
|
|
63
|
+
const answers = await Promise.all(Array.from({ length: BURST }, () => lock.unlock("a wrong guess")));
|
|
64
|
+
expect(answers.every((a) => !a.ok)).toBe(true);
|
|
65
|
+
expect(evaluated).toBe(ADMITTED);
|
|
66
|
+
expect(lock.status().retryAfterMs).toBe(delayAfter(ADMITTED));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a Worker: a fresh lock per request over one D1 โ 6 evaluated, and all 6 are RECORDED", async () => {
|
|
70
|
+
const { db, sqlite } = d1();
|
|
71
|
+
const store = createMemoryMasterLockStore(null);
|
|
72
|
+
const state = createMemoryMasterLockStore(null);
|
|
73
|
+
const now = 1_000_000;
|
|
74
|
+
const request = () => new MasterLock({ store, state, seedJson: SEED, now: () => now, attempts: createD1MasterLockAttempts(db) });
|
|
75
|
+
countEvaluations();
|
|
76
|
+
const answers = await Promise.all(Array.from({ length: BURST }, () => request().unlock("a wrong guess")));
|
|
77
|
+
expect(answers.every((a) => !a.ok)).toBe(true);
|
|
78
|
+
expect(evaluated).toBe(ADMITTED);
|
|
79
|
+
// Recorded = evaluated: never the lost update that counted a burst as one guess.
|
|
80
|
+
expect(stored(sqlite)).toEqual({ failures: ADMITTED, lastFailureAt: now });
|
|
81
|
+
// A failed guess no longer rewrites the live state, so it cannot drop an open unlock.
|
|
82
|
+
expect(state.read()).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("after the window, the owner's ONE correct attempt opens it and ends the run", async () => {
|
|
86
|
+
const { db, sqlite } = d1();
|
|
87
|
+
const store = createMemoryMasterLockStore(null);
|
|
88
|
+
const state = createMemoryMasterLockStore(null);
|
|
89
|
+
let now = 1_000_000;
|
|
90
|
+
const request = () => new MasterLock({ store, state, seedJson: SEED, now: () => now, attempts: createD1MasterLockAttempts(db) });
|
|
91
|
+
await Promise.all(Array.from({ length: BURST }, () => request().unlock("a wrong guess")));
|
|
92
|
+
// Inside the window the right verifier is refused too โ and charges nothing.
|
|
93
|
+
const early = await request().unlock(VERIFIER);
|
|
94
|
+
expect(early).toEqual({ ok: false, retryAfterMs: delayAfter(ADMITTED) });
|
|
95
|
+
expect(stored(sqlite).failures).toBe(ADMITTED);
|
|
96
|
+
now += delayAfter(ADMITTED);
|
|
97
|
+
const opened = await request().unlock(VERIFIER);
|
|
98
|
+
expect(opened.ok).toBe(true);
|
|
99
|
+
expect(stored(sqlite)).toEqual({});
|
|
100
|
+
if (opened.ok) expect(request().presents(new Request("http://x/", { headers: { "x-master-lock": opened.token } }))).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("the D1 ledger", () => {
|
|
105
|
+
test("the SQL schedule IS delayAfter โ every count from 0 past the cap", () => {
|
|
106
|
+
const sqlite = new Database(":memory:");
|
|
107
|
+
for (let n = 0; n <= 40; n++) {
|
|
108
|
+
const row = sqlite.query(`SELECT ${masterLockDelaySql("?1")} AS d`).get(n) as { d: number };
|
|
109
|
+
expect(`${n} โ ${row.d}`).toBe(`${n} โ ${delayAfter(n)}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a charge is refused inside the window, admitted after it, and only admitted ones count", async () => {
|
|
114
|
+
const { db } = d1();
|
|
115
|
+
const ledger = createD1MasterLockAttempts(db);
|
|
116
|
+
for (let i = 1; i <= ADMITTED; i++) expect(await ledger.charge(10_000)).toEqual({ admitted: true, failures: i });
|
|
117
|
+
expect(await ledger.charge(10_000)).toEqual({ admitted: false, retryAfterMs: delayAfter(ADMITTED) });
|
|
118
|
+
expect(await ledger.charge(10_000 + delayAfter(ADMITTED) - 1)).toEqual({ admitted: false, retryAfterMs: 1 });
|
|
119
|
+
expect(await ledger.charge(10_000 + delayAfter(ADMITTED))).toEqual({ admitted: true, failures: ADMITTED + 1 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("refund takes one back; clear ends the run; a snapshot feeds the countdown only", async () => {
|
|
123
|
+
const { db, sqlite } = d1();
|
|
124
|
+
const ledger = createD1MasterLockAttempts(db);
|
|
125
|
+
await ledger.charge(5);
|
|
126
|
+
await ledger.charge(5);
|
|
127
|
+
await ledger.refund();
|
|
128
|
+
expect(stored(sqlite)).toEqual({ failures: 1, lastFailureAt: 5 });
|
|
129
|
+
await ledger.clear();
|
|
130
|
+
expect(stored(sqlite)).toEqual({});
|
|
131
|
+
const snap = JSON.stringify({ failures: ADMITTED, lastFailureAt: 100 });
|
|
132
|
+
expect(createD1MasterLockAttempts(db, { snapshot: snap }).retryAfterMs(100)).toBe(delayAfter(ADMITTED));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("an unreadable row reads as no failures โ never a lock nothing can open", async () => {
|
|
136
|
+
const { db, sqlite } = d1();
|
|
137
|
+
sqlite.run("INSERT INTO meta (key, value) VALUES (?, 'not json')", [MASTER_LOCK_ATTEMPTS_KEY]);
|
|
138
|
+
expect(await createD1MasterLockAttempts(db).charge(1)).toEqual({ admitted: true, failures: 1 });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("๐ด a ledger that cannot charge refuses the unlock โ nothing is verified uncharged", async () => {
|
|
142
|
+
const failing = {
|
|
143
|
+
retryAfterMs: () => 0,
|
|
144
|
+
charge: async () => {
|
|
145
|
+
throw new Error("D1 unavailable");
|
|
146
|
+
},
|
|
147
|
+
refund: () => {},
|
|
148
|
+
clear: () => {},
|
|
149
|
+
};
|
|
150
|
+
const lock = new MasterLock({ store: createMemoryMasterLockStore(null), seedJson: SEED, attempts: failing });
|
|
151
|
+
countEvaluations();
|
|
152
|
+
await expect(lock.unlock(VERIFIER)).rejects.toThrow("D1 unavailable");
|
|
153
|
+
expect(evaluated).toBe(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a table name that is not an identifier is refused", () => {
|
|
157
|
+
expect(() => createD1MasterLockAttempts(d1().db, { table: "meta; DROP TABLE meta" })).toThrow("not a table name");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
import type { D1LikeDatabase } from "../d1/types.js";
|
|
28
|
+
|
|
29
|
+
/** What {@link MasterLockAttempts.charge} answers: admitted (and charged), or not (and not). */
|
|
30
|
+
export type MasterLockCharge = { admitted: true; failures: number } | { admitted: false; retryAfterMs: number };
|
|
31
|
+
|
|
32
|
+
export interface MasterLockAttempts {
|
|
33
|
+
/** What the ledger last knew, for the lock page's countdown. Never a decision. */
|
|
34
|
+
retryAfterMs(now: number): number;
|
|
35
|
+
/**
|
|
36
|
+
* ๐ด CHECK AND CHARGE in one step. Admitted โ one failure is already charged, and
|
|
37
|
+
* `failures` counts it. Refused โ nothing charged; `retryAfterMs` is the wait.
|
|
38
|
+
*/
|
|
39
|
+
charge(now: number): MasterLockCharge | Promise<MasterLockCharge>;
|
|
40
|
+
/** The charged attempt was not a guess (nothing could be verified): take it back. */
|
|
41
|
+
refund(): void | Promise<void>;
|
|
42
|
+
/** A correct verifier: the run is over. */
|
|
43
|
+
clear(): void | Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The `meta`-style row a D1 ledger keeps its count in, beside the lock's record and state. */
|
|
47
|
+
export const MASTER_LOCK_ATTEMPTS_KEY = "master_lock:attempts";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `delayAfter` as a SQL `CASE` over a failure-count expression โ GENERATED from the function,
|
|
51
|
+
* so the statement that admits a guess and the schedule the specs pin cannot disagree.
|
|
52
|
+
*/
|
|
53
|
+
export function masterLockDelaySql(count: string): string {
|
|
54
|
+
const cap = delayAfter(Number.MAX_SAFE_INTEGER);
|
|
55
|
+
const arms: string[] = [];
|
|
56
|
+
for (let n = 0; delayAfter(n) < cap && n < 256; n++) arms.push(`WHEN ${count} <= ${n} THEN ${delayAfter(n)}`);
|
|
57
|
+
return `(CASE ${arms.join(" ")} ELSE ${cap} END)`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
61
|
+
|
|
62
|
+
/** One field of the stored JSON, 0 for a missing or unreadable row โ never a throw that
|
|
63
|
+
* would leave the owner facing a lock nothing can open. */
|
|
64
|
+
const field = (column: string, name: string) =>
|
|
65
|
+
`COALESCE(CASE WHEN json_valid(${column}) THEN json_extract(${column}, '$.${name}') END, 0)`;
|
|
66
|
+
|
|
67
|
+
function parseCount(raw: string | null | undefined): { failures: number; lastFailureAt: number } {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(raw ?? "") as { failures?: unknown; lastFailureAt?: unknown };
|
|
70
|
+
const failures = Number(parsed.failures);
|
|
71
|
+
const lastFailureAt = Number(parsed.lastFailureAt);
|
|
72
|
+
return {
|
|
73
|
+
failures: Number.isFinite(failures) ? failures : 0,
|
|
74
|
+
lastFailureAt: Number.isFinite(lastFailureAt) ? lastFailureAt : 0,
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return { failures: 0, lastFailureAt: 0 };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The ledger over a D1 key/value table (`key TEXT PRIMARY KEY, value TEXT`) โ `meta` by default,
|
|
83
|
+
* the table a Worker port already keeps the lock's record in, so it needs no migration.
|
|
84
|
+
*
|
|
85
|
+
* Its writes are AWAITED, never queued on a per-invocation collector: a charge that lands after
|
|
86
|
+
* the verify is the bug this exists to close. `snapshot` is the row as the request's own snapshot
|
|
87
|
+
* read it, for {@link MasterLockAttempts.retryAfterMs}; every decision reads D1 itself.
|
|
88
|
+
*/
|
|
89
|
+
export function createD1MasterLockAttempts(
|
|
90
|
+
db: D1LikeDatabase,
|
|
91
|
+
options: { table?: string; key?: string; snapshot?: string | null } = {},
|
|
92
|
+
): MasterLockAttempts {
|
|
93
|
+
const table = options.table ?? "meta";
|
|
94
|
+
if (!IDENTIFIER.test(table)) throw new Error(`master lock attempts: "${table}" is not a table name`);
|
|
95
|
+
const key = options.key ?? MASTER_LOCK_ATTEMPTS_KEY;
|
|
96
|
+
let known = parseCount(options.snapshot);
|
|
97
|
+
const value = `${table}.value`;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
retryAfterMs: (now) => windowRemaining(known.failures, known.lastFailureAt, now),
|
|
101
|
+
|
|
102
|
+
async charge(now) {
|
|
103
|
+
// ONE statement: the row is created at 1, or incremented only WHERE the window its own
|
|
104
|
+
// count implies has passed. A refused guess matches no row, so RETURNING is empty.
|
|
105
|
+
const charged = await db
|
|
106
|
+
.prepare(
|
|
107
|
+
`INSERT INTO ${table} (key, value) VALUES (?1, json_object('failures', 1, 'lastFailureAt', ?2))
|
|
108
|
+
ON CONFLICT(key) DO UPDATE SET value = json_object(
|
|
109
|
+
'failures', ${field(value, "failures")} + 1, 'lastFailureAt', ?2)
|
|
110
|
+
WHERE ${field(value, "lastFailureAt")} + ${masterLockDelaySql(field(value, "failures"))} <= ?2
|
|
111
|
+
RETURNING value`,
|
|
112
|
+
)
|
|
113
|
+
.bind(key, now)
|
|
114
|
+
.first<{ value: string }>();
|
|
115
|
+
if (charged) {
|
|
116
|
+
known = parseCount(charged.value);
|
|
117
|
+
return { admitted: true, failures: known.failures };
|
|
118
|
+
}
|
|
119
|
+
const row = await db.prepare(`SELECT value FROM ${table} WHERE key = ?`).bind(key).first<{ value: string }>();
|
|
120
|
+
known = parseCount(row?.value);
|
|
121
|
+
return { admitted: false, retryAfterMs: Math.max(1, windowRemaining(known.failures, known.lastFailureAt, now)) };
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async refund() {
|
|
125
|
+
await db
|
|
126
|
+
.prepare(
|
|
127
|
+
`UPDATE ${table} SET value = json_object(
|
|
128
|
+
'failures', MAX(${field("value", "failures")} - 1, 0), 'lastFailureAt', ${field("value", "lastFailureAt")})
|
|
129
|
+
WHERE key = ?`,
|
|
130
|
+
)
|
|
131
|
+
.bind(key)
|
|
132
|
+
.run();
|
|
133
|
+
known = { ...known, failures: Math.max(0, known.failures - 1) };
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async clear() {
|
|
137
|
+
await db.prepare(`DELETE FROM ${table} WHERE key = ?`).bind(key).run();
|
|
138
|
+
known = { failures: 0, lastFailureAt: 0 };
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -432,3 +432,26 @@ describe("two locked apps sharing a hostname", () => {
|
|
|
432
432
|
expect((await a.guard.handle(xhr("/api/items", onlyB)))?.status).toBe(401);
|
|
433
433
|
});
|
|
434
434
|
});
|
|
435
|
+
|
|
436
|
+
describe("๐ด the status route answers for THIS CALLER, never the site", () => {
|
|
437
|
+
// The lock page's script reloads whenever status says `locked: false`. Read site-wide, one
|
|
438
|
+
// unlocked browser made every OTHER browser's lock page reload for ever (4.26.1).
|
|
439
|
+
test("another browser's unlock does not tell a cookieless caller it is in", async () => {
|
|
440
|
+
const { guard, lock } = build();
|
|
441
|
+
const cookie = await open(guard);
|
|
442
|
+
expect(lock.unlocked).toBe(true); // the site IS open, for somebody
|
|
443
|
+
|
|
444
|
+
const stranger = await (await guard.handle(xhr(MASTER_LOCK_PATHS.status)))?.json();
|
|
445
|
+
expect(stranger.locked).toBe(true);
|
|
446
|
+
|
|
447
|
+
const owner = await (await guard.handle(xhr(MASTER_LOCK_PATHS.status, cookie)))?.json();
|
|
448
|
+
expect(owner.locked).toBe(false);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("a request-less health reading is still the site's", async () => {
|
|
452
|
+
const { guard, lock } = build();
|
|
453
|
+
expect(lock.status().locked).toBe(true);
|
|
454
|
+
await open(guard);
|
|
455
|
+
expect(lock.status().locked).toBe(false);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
@@ -46,6 +46,11 @@ export {
|
|
|
46
46
|
type MasterLockDirectoryOptions,
|
|
47
47
|
masterLockPrincipalKey,
|
|
48
48
|
} from "./principals.js";
|
|
49
|
+
export {
|
|
50
|
+
MASTER_LOCK_ATTEMPTS_KEY,
|
|
51
|
+
type MasterLockAttempts,
|
|
52
|
+
createD1MasterLockAttempts,
|
|
53
|
+
} from "./attempts.js";
|
|
49
54
|
export {
|
|
50
55
|
FIRST_ACCOUNT_ID,
|
|
51
56
|
MasterLock,
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
isMasterLockKdfParams,
|
|
58
58
|
windowRemaining,
|
|
59
59
|
} from "cursedbelt-core/master-lock";
|
|
60
|
+
import type { MasterLockAttempts, MasterLockCharge } from "./attempts.js";
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
63
|
* ONE master password, and therefore one TENANT of the app behind it.
|
|
@@ -179,6 +180,13 @@ export interface MasterLockOptions {
|
|
|
179
180
|
* the store has nothing.
|
|
180
181
|
*/
|
|
181
182
|
seedJson?: string | null;
|
|
183
|
+
/**
|
|
184
|
+
* Where the guess throttle is CHARGED. Omitted, it is this instance's own count โ atomic
|
|
185
|
+
* within one process, which is all the Mac's daemons are. ๐ด Required wherever the lock is
|
|
186
|
+
* rebuilt per request (a Worker): pass `createD1MasterLockAttempts`, or K guesses landing
|
|
187
|
+
* inside one argon2id verify are judged against one stale count (task 2137, `./attempts.ts`).
|
|
188
|
+
*/
|
|
189
|
+
attempts?: MasterLockAttempts;
|
|
182
190
|
/** Injected clock. The tests drive it; production omits it. */
|
|
183
191
|
now?: () => number;
|
|
184
192
|
/**
|
|
@@ -265,10 +273,13 @@ export class MasterLock {
|
|
|
265
273
|
|
|
266
274
|
private failures = 0;
|
|
267
275
|
private lastFailureAt = 0;
|
|
276
|
+
/** See {@link MasterLockOptions.attempts}. */
|
|
277
|
+
private readonly attempts: MasterLockAttempts;
|
|
268
278
|
|
|
269
279
|
constructor(options: MasterLockOptions) {
|
|
270
280
|
this.store = options.store;
|
|
271
281
|
this.stateStore = options.state ?? null;
|
|
282
|
+
this.attempts = options.attempts ?? this.ownAttempts();
|
|
272
283
|
this.loadState();
|
|
273
284
|
this.now = options.now ?? Date.now;
|
|
274
285
|
this.onLockedChange = options.onLockedChange;
|
|
@@ -364,6 +375,16 @@ export class MasterLock {
|
|
|
364
375
|
* `req` is optional only so a health check can ask without one. Pass it wherever there
|
|
365
376
|
* IS a request: without it the reply carries no account, and a client would read that
|
|
366
377
|
* as "locked" while holding a perfectly good unlock.
|
|
378
|
+
*
|
|
379
|
+
* ๐ด With a request, `locked` is THIS CALLER's answer โ the one {@link accountFor} gives โ
|
|
380
|
+
* and never the site's. Until 4.26.1 it read {@link unlocked} ("somebody's device is
|
|
381
|
+
* open"), which the lock page's own script takes as "you are in" and answers with
|
|
382
|
+
* `location.reload()`; the guard, asking the per-caller question, serves the lock page
|
|
383
|
+
* again, and the page spins for as long as any other browser stays unlocked โ no password
|
|
384
|
+
* box ever settles. Measured 2026-09-23 on the binary-server inspector: a second Chromium
|
|
385
|
+
* context reloaded `/__lock/` in a tight loop while `/__lock/status` told it
|
|
386
|
+
* `"locked": false` with no cookie at all. Without a request it stays the site-wide
|
|
387
|
+
* health reading, which is all a caller with no request can mean.
|
|
367
388
|
*/
|
|
368
389
|
status(req?: Request): MasterLockStatus {
|
|
369
390
|
const accountId = req ? this.accountFor(req) : null;
|
|
@@ -371,11 +392,11 @@ export class MasterLock {
|
|
|
371
392
|
return {
|
|
372
393
|
configured: this.configured,
|
|
373
394
|
enrollable: this.awaitingEnrollment,
|
|
374
|
-
locked: !this.unlocked,
|
|
395
|
+
locked: req ? accountId === null : !this.unlocked,
|
|
375
396
|
kdf: this.kdf,
|
|
376
397
|
idleMs: this.idleMs,
|
|
377
398
|
remainingMs: this.remainingMs,
|
|
378
|
-
retryAfterMs:
|
|
399
|
+
retryAfterMs: this.attempts.retryAfterMs(this.now()),
|
|
379
400
|
accountId: account?.id ?? null,
|
|
380
401
|
accountLabel: account?.label ?? null,
|
|
381
402
|
};
|
|
@@ -464,13 +485,16 @@ export class MasterLock {
|
|
|
464
485
|
*/
|
|
465
486
|
async unlock(verifier: string): Promise<MasterLockUnlockResult> {
|
|
466
487
|
const now = this.now();
|
|
467
|
-
const retryAfterMs = windowRemaining(this.failures, this.lastFailureAt, now);
|
|
468
488
|
const record = this.record;
|
|
489
|
+
// ๐ด CHARGED before anything slow happens โ see `./attempts.ts`. A guess that lands while
|
|
490
|
+
// another is inside its argon2id verify sees that one already counted.
|
|
491
|
+
const charge: MasterLockCharge = await this.attempts.charge(now);
|
|
469
492
|
// The equalizing verify happens first and unconditionally. `verifier` may be empty
|
|
470
493
|
// or absurd; `verify` neither throws on that nor tells us anything, which is the point.
|
|
494
|
+
// A throw anywhere past the charge leaves it charged: the attempt fails closed.
|
|
471
495
|
let opened: MasterLockAccount | null = null;
|
|
472
496
|
let unparsable = 0;
|
|
473
|
-
if (
|
|
497
|
+
if (charge.admitted && record) {
|
|
474
498
|
for (const account of record.accounts) {
|
|
475
499
|
const genuine = await verifyArgon(verifier, account.verifierHash);
|
|
476
500
|
// `null` is a hash this build cannot parse. It is NOT a wrong password, and
|
|
@@ -482,23 +506,50 @@ export class MasterLock {
|
|
|
482
506
|
}
|
|
483
507
|
await equalize(verifier);
|
|
484
508
|
|
|
485
|
-
if (
|
|
509
|
+
if (!charge.admitted) return { ok: false, retryAfterMs: charge.retryAfterMs };
|
|
486
510
|
if (!opened) {
|
|
487
511
|
if (unparsable > 0 && unparsable === (record?.accounts.length ?? 0)) {
|
|
512
|
+
// Nothing was verified, so nothing was guessed: the charge goes back.
|
|
513
|
+
await this.attempts.refund();
|
|
488
514
|
this.log(
|
|
489
515
|
"๐ด master lock: every stored verifier hash is unparsable โ nothing can unlock",
|
|
490
516
|
);
|
|
491
517
|
return { ok: false, retryAfterMs: 0 };
|
|
492
518
|
}
|
|
493
|
-
|
|
494
|
-
this.lastFailureAt = now;
|
|
495
|
-
this.saveState();
|
|
496
|
-
return { ok: false, retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, now) };
|
|
519
|
+
return { ok: false, retryAfterMs: windowRemaining(charge.failures, now, now) };
|
|
497
520
|
}
|
|
498
521
|
|
|
522
|
+
await this.attempts.clear();
|
|
499
523
|
return { ok: true, ...this.open(opened.id, now) };
|
|
500
524
|
}
|
|
501
525
|
|
|
526
|
+
/**
|
|
527
|
+
* The default ledger: this instance's own fields, persisted with the live state. `charge`
|
|
528
|
+
* has no `await` between its check and its increment, so within one process it is atomic.
|
|
529
|
+
*/
|
|
530
|
+
private ownAttempts(): MasterLockAttempts {
|
|
531
|
+
return {
|
|
532
|
+
retryAfterMs: (now) => windowRemaining(this.failures, this.lastFailureAt, now),
|
|
533
|
+
charge: (now) => {
|
|
534
|
+
const wait = windowRemaining(this.failures, this.lastFailureAt, now);
|
|
535
|
+
if (wait > 0) return { admitted: false, retryAfterMs: wait };
|
|
536
|
+
this.failures += 1;
|
|
537
|
+
this.lastFailureAt = now;
|
|
538
|
+
this.saveState();
|
|
539
|
+
return { admitted: true, failures: this.failures };
|
|
540
|
+
},
|
|
541
|
+
refund: () => {
|
|
542
|
+
this.failures = Math.max(0, this.failures - 1);
|
|
543
|
+
this.saveState();
|
|
544
|
+
},
|
|
545
|
+
clear: () => {
|
|
546
|
+
this.failures = 0;
|
|
547
|
+
this.lastFailureAt = 0;
|
|
548
|
+
this.saveState();
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
502
553
|
/**
|
|
503
554
|
* Mint a token for one account and start its idle clock. The one place a session is
|
|
504
555
|
* created, so `unlock` and `enroll` cannot disagree about what an unlock is.
|
|
@@ -566,6 +617,7 @@ export class MasterLock {
|
|
|
566
617
|
createdAt: now,
|
|
567
618
|
};
|
|
568
619
|
this.write({ kdf: input.kdf, idleMs: clampIdleMs(this.record?.idleMs), accounts: [account] });
|
|
620
|
+
await this.attempts.clear();
|
|
569
621
|
this.log("master lock: a first master password was chosen");
|
|
570
622
|
return { ok: true, ...this.open(account.id, now) };
|
|
571
623
|
}
|