cursedbelt-server 2.1.0 → 3.0.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/dist/server/activity/index.d.ts +1 -1
- package/dist/server/activity/index.js +1 -1
- package/dist/server/master-lock/guard.d.ts +10 -0
- package/dist/server/master-lock/guard.js +88 -19
- package/dist/server/master-lock/index.d.ts +1 -1
- package/dist/server/master-lock/index.js +1 -1
- package/dist/server/master-lock/lockPage.d.ts +1 -1
- package/dist/server/master-lock/lockPage.js +68 -3
- package/dist/server/master-lock/masterLock.d.ts +250 -76
- package/dist/server/master-lock/masterLock.js +426 -114
- package/dist/server/master-lock/principals.js +6 -1
- package/dist/server/master-lock/seed.d.ts +5 -1
- package/dist/server/master-lock/seed.js +18 -1
- package/package.json +2 -2
- package/src/server/activity/index.ts +1 -1
- package/src/server/master-lock/accounts.spec.ts +308 -0
- package/src/server/master-lock/guard.spec.ts +94 -7
- package/src/server/master-lock/guard.ts +99 -20
- package/src/server/master-lock/index.ts +3 -0
- package/src/server/master-lock/lockPage.ts +70 -3
- package/src/server/master-lock/masterLock.spec.ts +56 -23
- package/src/server/master-lock/masterLock.ts +529 -151
- package/src/server/master-lock/principals.spec.ts +45 -15
- package/src/server/master-lock/principals.ts +6 -1
- package/src/server/master-lock/seed.spec.ts +7 -2
- package/src/server/master-lock/seed.ts +22 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* ── Retention ───────────────────────────────────────────────────────────────
|
|
12
12
|
* This package never deletes an event. An app whose stream is high-volume opts
|
|
13
|
-
* into the fleet standard (`
|
|
13
|
+
* into the fleet standard (`cursedbelt-server/retention`, in this package) by
|
|
14
14
|
* declaring the table, which puts the number under the owner's control in the
|
|
15
15
|
* station rather than in a constant here:
|
|
16
16
|
*
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* ── Retention ───────────────────────────────────────────────────────────────
|
|
12
12
|
* This package never deletes an event. An app whose stream is high-volume opts
|
|
13
|
-
* into the fleet standard (`
|
|
13
|
+
* into the fleet standard (`cursedbelt-server/retention`, in this package) by
|
|
14
14
|
* declaring the table, which puts the number under the owner's control in the
|
|
15
15
|
* station rather than in a constant here:
|
|
16
16
|
*
|
|
@@ -49,5 +49,15 @@ export interface MasterLockGuardOptions extends LockPageOptions {
|
|
|
49
49
|
export interface MasterLockGuard {
|
|
50
50
|
/** `Response` when handled, `null` when the app should serve the request. */
|
|
51
51
|
handle(req: Request): Promise<Response | null>;
|
|
52
|
+
/**
|
|
53
|
+
* 🔴 **WHICH tenant this request is for** — the answer an app must scope every query by.
|
|
54
|
+
*
|
|
55
|
+
* `null` when the request carries no live unlock, which is exactly when {@link handle}
|
|
56
|
+
* would have refused it. So the safe shape in an app is to read this AFTER the guard has
|
|
57
|
+
* stood aside, and to treat `null` as "serve nothing" rather than "serve the default" —
|
|
58
|
+
* a fallback tenant is how a locked request ends up being answered with somebody's
|
|
59
|
+
* library.
|
|
60
|
+
*/
|
|
61
|
+
accountFor(req: Request): string | null;
|
|
52
62
|
}
|
|
53
63
|
export declare function createMasterLockGuard(options: MasterLockGuardOptions): MasterLockGuard;
|
|
@@ -34,23 +34,23 @@ export function createMasterLockGuard(options) {
|
|
|
34
34
|
/** The ONE refusal. Identical for wrong, throttled, unconfigured and not-unlocked. */
|
|
35
35
|
const refuse = (retryAfterMs = 0) => Response.json({ error: "locked", retryAfterMs }, { status: 401, headers: { ...noStore, [MASTER_LOCK_STATE_HEADER]: "locked" } });
|
|
36
36
|
/**
|
|
37
|
-
* 🔴 May THIS request through — and the
|
|
38
|
-
* security property of the per-person wall.
|
|
37
|
+
* 🔴 May THIS request through — and the answer is a PRESENTED TOKEN, always.
|
|
39
38
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
39
|
+
* Until the accounts model there was a second branch: an app-wide lock let `unlocked`
|
|
40
|
+
* alone open the door, on the reasoning that such a site had one user and one unlock,
|
|
41
|
+
* so the token was merely how a caller with no cookie jar said "it's still me". The
|
|
42
|
+
* per-person wall already refused that shape, and 2026-08-25 measured why — with
|
|
43
|
+
* `unlocked ||` in front, one browser unlocking meant every OTHER browser signed in as
|
|
44
|
+
* that person walked straight through having typed nothing, and an invented
|
|
45
|
+
* `x-master-lock` value was accepted because the check never looked at it.
|
|
44
46
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* `x-master-lock` value would be accepted because the check never looked at it. Two
|
|
51
|
-
* tests in `principals.spec.ts` fail on the `||`, which is why it is gone.
|
|
47
|
+
* The accounts model removes the last reason to keep the branch, and turns it from
|
|
48
|
+
* sloppy into wrong: the password decides WHICH TENANT'S DATA the app serves, so a
|
|
49
|
+
* request with no token has no account, and admitting it means serving whichever
|
|
50
|
+
* library somebody else's browser happens to have open. There is one rule now, for
|
|
51
|
+
* every app.
|
|
52
52
|
*/
|
|
53
|
-
const opens = (lock, req) => lock.
|
|
53
|
+
const opens = (lock, req) => lock.presents(req);
|
|
54
54
|
async function handleOwnRoute(req, url, lock) {
|
|
55
55
|
const path = url.pathname;
|
|
56
56
|
const secure = isSecure(url, req);
|
|
@@ -66,7 +66,22 @@ export function createMasterLockGuard(options) {
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
if (path === MASTER_LOCK_PATHS.status) {
|
|
69
|
-
return Response.json(lock.status(), { headers: noStore });
|
|
69
|
+
return Response.json(lock.status(req), { headers: noStore });
|
|
70
|
+
}
|
|
71
|
+
if (path === MASTER_LOCK_PATHS.hints) {
|
|
72
|
+
// 🔴 Readable WHILE LOCKED, and the only surface here that is. See the path's
|
|
73
|
+
// note in `cursedbelt-core/master-lock/wire.ts`: a hint nobody can read until
|
|
74
|
+
// they are already in is not a hint, and everyone who reaches this has passed
|
|
75
|
+
// the app's own owner-only sign-in. It is a separate route, fetched only when
|
|
76
|
+
// somebody clicks "I forgot", so the default lock screen stays byte-identical
|
|
77
|
+
// whether this app has one tenant or ten.
|
|
78
|
+
return Response.json({ hints: lock.hints() }, { headers: noStore });
|
|
79
|
+
}
|
|
80
|
+
if (path === MASTER_LOCK_PATHS.accounts) {
|
|
81
|
+
// Unlike the hints, this needs the site already open — it names every tenant.
|
|
82
|
+
if (!opens(lock, req))
|
|
83
|
+
return refuse();
|
|
84
|
+
return Response.json({ accounts: lock.summaries(req) }, { headers: noStore });
|
|
70
85
|
}
|
|
71
86
|
if (path === MASTER_LOCK_PATHS.page || path === MASTER_LOCK_PREFIX) {
|
|
72
87
|
// Already open FOR THIS CALLER, so there is nothing to type — send them to
|
|
@@ -79,6 +94,19 @@ export function createMasterLockGuard(options) {
|
|
|
79
94
|
}
|
|
80
95
|
return new Response("not found", { status: 404, headers: noStore });
|
|
81
96
|
}
|
|
97
|
+
if (req.method === "PATCH" && path === MASTER_LOCK_PATHS.accounts) {
|
|
98
|
+
if (!opens(lock, req))
|
|
99
|
+
return refuse();
|
|
100
|
+
const body = await readJson(req);
|
|
101
|
+
const ok = lock.editAccount(req, {
|
|
102
|
+
id: typeof body?.id === "string" ? body.id : "",
|
|
103
|
+
...(typeof body?.label === "string" ? { label: body.label } : {}),
|
|
104
|
+
...(typeof body?.hint === "string" ? { hint: body.hint } : {}),
|
|
105
|
+
});
|
|
106
|
+
if (!ok)
|
|
107
|
+
return Response.json({ error: "wrong" }, { status: 400, headers: noStore });
|
|
108
|
+
return Response.json({ ok: true, accounts: lock.summaries(req) }, { headers: noStore });
|
|
109
|
+
}
|
|
82
110
|
if (req.method !== "POST") {
|
|
83
111
|
return new Response("method not allowed", { status: 405, headers: noStore });
|
|
84
112
|
}
|
|
@@ -93,7 +121,7 @@ export function createMasterLockGuard(options) {
|
|
|
93
121
|
// becomes a way to ask whether a given account has a master password yet.
|
|
94
122
|
if (!result.ok)
|
|
95
123
|
return refuse();
|
|
96
|
-
return Response.json({ ok: true, idleMs: lock.idleMs }, { headers: { ...noStore, "set-cookie": cookie(result.token, secure) } });
|
|
124
|
+
return Response.json({ ok: true, idleMs: lock.idleMs, accountId: result.accountId }, { headers: { ...noStore, "set-cookie": cookie(result.token, secure) } });
|
|
97
125
|
}
|
|
98
126
|
if (path === MASTER_LOCK_PATHS.unlock) {
|
|
99
127
|
const body = await readJson(req);
|
|
@@ -101,7 +129,11 @@ export function createMasterLockGuard(options) {
|
|
|
101
129
|
const result = await lock.unlock(verifier);
|
|
102
130
|
if (!result.ok)
|
|
103
131
|
return refuse(result.retryAfterMs);
|
|
104
|
-
|
|
132
|
+
// 🔴 The account id is in the REPLY and not only in the cookie, because the page
|
|
133
|
+
// that just unlocked has to reload into the right tenant. It names the account
|
|
134
|
+
// that opened and never the ones that did not, so a wrong password still learns
|
|
135
|
+
// nothing about what else is here.
|
|
136
|
+
return Response.json({ ok: true, idleMs: lock.idleMs, accountId: result.accountId }, { headers: { ...noStore, "set-cookie": cookie(result.token, secure) } });
|
|
105
137
|
}
|
|
106
138
|
// Everything below CHANGES something and therefore needs the site already open.
|
|
107
139
|
if (!lock.presents(req))
|
|
@@ -119,11 +151,43 @@ export function createMasterLockGuard(options) {
|
|
|
119
151
|
const idleMs = lock.setIdleMs(Number(body?.idleMs));
|
|
120
152
|
return Response.json({ ok: true, idleMs }, { headers: noStore });
|
|
121
153
|
}
|
|
154
|
+
if (path === MASTER_LOCK_PATHS.accounts) {
|
|
155
|
+
const body = await readJson(req);
|
|
156
|
+
const result = await lock.addAccount(req, {
|
|
157
|
+
currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
|
|
158
|
+
verifier: typeof body?.verifier === "string" ? body.verifier : "",
|
|
159
|
+
label: typeof body?.label === "string" ? body.label : "",
|
|
160
|
+
hint: typeof body?.hint === "string" ? body.hint : "",
|
|
161
|
+
});
|
|
162
|
+
if (!result.ok) {
|
|
163
|
+
return Response.json({ error: result.reason }, { status: 400, headers: noStore });
|
|
164
|
+
}
|
|
165
|
+
// 🔴 No cookie. Creating a tenant does not enter it — switching means typing that
|
|
166
|
+
// tenant's password, which is the entire boundary this feature is made of.
|
|
167
|
+
return Response.json({ ok: true, account: result.account }, { headers: noStore });
|
|
168
|
+
}
|
|
122
169
|
if (path === MASTER_LOCK_PATHS.change) {
|
|
123
170
|
const body = await readJson(req);
|
|
124
|
-
|
|
171
|
+
/*
|
|
172
|
+
* 🔴 An OLDER client sends a freshly-minted `kdf` with a rotation, because that is
|
|
173
|
+
* what the wire looked like before accounts existed. Ignoring it silently would be
|
|
174
|
+
* a lockout, not a compatibility shim: the verifier in the same body was derived
|
|
175
|
+
* under THAT salt, so storing it would leave a hash nothing can ever match — the
|
|
176
|
+
* owner would set a new password, be logged out by the rotation, and find that
|
|
177
|
+
* neither the old nor the new one opens the app.
|
|
178
|
+
*
|
|
179
|
+
* There is no safe way to honour it either, since every account on this app shares
|
|
180
|
+
* one descriptor. So it is refused, loudly, with the one thing that distinguishes
|
|
181
|
+
* it from a wrong password.
|
|
182
|
+
*/
|
|
183
|
+
if (body?.kdf !== undefined) {
|
|
184
|
+
const supplied = body.kdf;
|
|
185
|
+
if (!lock.kdf || supplied?.salt !== lock.kdf.salt) {
|
|
186
|
+
return Response.json({ error: "stale-client" }, { status: 409, headers: noStore });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const result = await lock.change(req, {
|
|
125
190
|
currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
|
|
126
|
-
kdf: body?.kdf,
|
|
127
191
|
verifier: typeof body?.verifier === "string" ? body.verifier : "",
|
|
128
192
|
});
|
|
129
193
|
if (!result.ok) {
|
|
@@ -136,6 +200,11 @@ export function createMasterLockGuard(options) {
|
|
|
136
200
|
return new Response("not found", { status: 404, headers: noStore });
|
|
137
201
|
}
|
|
138
202
|
return {
|
|
203
|
+
accountFor(req) {
|
|
204
|
+
// The SAME resolution the guard itself uses, so an app can never be told a
|
|
205
|
+
// different tenant from the one the wall admitted.
|
|
206
|
+
return resolve(req)?.accountFor(req) ?? null;
|
|
207
|
+
},
|
|
139
208
|
async handle(req) {
|
|
140
209
|
const url = new URL(req.url);
|
|
141
210
|
const own = url.pathname === MASTER_LOCK_PREFIX || url.pathname.startsWith(`${MASTER_LOCK_PREFIX}/`);
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
export { type MasterLockGuard, type MasterLockGuardOptions, createMasterLockGuard } from "./guard";
|
|
17
17
|
export { LOCK_SCRIPT, LOCK_STYLE, type LockPageOptions, lockPageHtml } from "./lockPage";
|
|
18
18
|
export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, type MasterLockDirectoryOptions, masterLockPrincipalKey, } from "./principals";
|
|
19
|
-
export { MasterLock, type MasterLockEnrollResult, type MasterLockOptions, type MasterLockRecord, type MasterLockStore, type MasterLockUnlockResult, readRecord, } from "./masterLock";
|
|
19
|
+
export { FIRST_ACCOUNT_ID, MasterLock, type MasterLockAccount, type MasterLockAddAccountResult, type MasterLockEnrollResult, type MasterLockOptions, type MasterLockRecord, type MasterLockStore, type MasterLockUnlockResult, readRecord, } from "./masterLock";
|
|
20
20
|
export { DEFAULT_SEED_IDLE_MS, generateStagePassword, type MintMasterLockSeedOptions, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed";
|
|
21
21
|
export { MASTER_LOCK_SETTING_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store";
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
export { createMasterLockGuard } from "./guard";
|
|
17
17
|
export { LOCK_SCRIPT, LOCK_STYLE, lockPageHtml } from "./lockPage";
|
|
18
18
|
export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, masterLockPrincipalKey, } from "./principals";
|
|
19
|
-
export { MasterLock, readRecord, } from "./masterLock";
|
|
19
|
+
export { FIRST_ACCOUNT_ID, MasterLock, readRecord, } from "./masterLock";
|
|
20
20
|
export { DEFAULT_SEED_IDLE_MS, generateStagePassword, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed";
|
|
21
21
|
export { MASTER_LOCK_SETTING_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store";
|
|
@@ -21,7 +21,7 @@ export interface LockPageOptions {
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function lockPageHtml(options: LockPageOptions): string;
|
|
23
23
|
/** The stylesheet. Follows the viewer's theme and commits to nothing else. */
|
|
24
|
-
export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n";
|
|
24
|
+
export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}\nbutton.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;\n font-size:12.5px;text-decoration:underline;text-underline-offset:3px}\nbutton.quiet:hover{color:var(--ml-text)}\nbutton.quiet[disabled]{opacity:.5}\n.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);\n border-radius:10px;background:var(--ml-bg);font-size:12.5px}\n.hints dt{font-weight:650;color:var(--ml-text)}\n.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}\n.hints dd:last-child{margin-bottom:0}\n";
|
|
25
25
|
/**
|
|
26
26
|
* The page's script.
|
|
27
27
|
*
|
|
@@ -34,7 +34,7 @@ export function lockPageHtml(options) {
|
|
|
34
34
|
(enroll
|
|
35
35
|
? "You are signed in, but this site needs a master password of your own. " +
|
|
36
36
|
"Choose one now. Nobody else can see it and nobody else can reset it — " +
|
|
37
|
-
"if you forget it, it cannot be recovered."
|
|
37
|
+
"if you forget it, it cannot be recovered, so leave yourself a hint."
|
|
38
38
|
: "This site is locked. Signing in is not enough — enter the master password to continue."));
|
|
39
39
|
return `<!doctype html>
|
|
40
40
|
<html lang="en">
|
|
@@ -64,11 +64,21 @@ export function lockPageHtml(options) {
|
|
|
64
64
|
<input id="ml-confirm" type="password" name="confirm-master-password"
|
|
65
65
|
autocomplete="new-password" required spellcheck="false" enterkeyhint="go"
|
|
66
66
|
minlength="10">
|
|
67
|
+
</label>
|
|
68
|
+
<label class="field">
|
|
69
|
+
<span>Hint <em>optional</em></span>
|
|
70
|
+
<input id="ml-hint" type="text" name="master-password-hint" maxlength="200"
|
|
71
|
+
spellcheck="false" autocomplete="off"
|
|
72
|
+
placeholder="Something only you would understand">
|
|
67
73
|
</label>`
|
|
68
74
|
: ""}
|
|
69
75
|
<button id="ml-submit" type="submit">${enroll ? "Set master password" : "Unlock"}</button>
|
|
70
76
|
</form>
|
|
71
|
-
<p id="ml-status" class="status" role="status" aria-live="polite"></p
|
|
77
|
+
<p id="ml-status" class="status" role="status" aria-live="polite"></p>${enroll
|
|
78
|
+
? ""
|
|
79
|
+
: `
|
|
80
|
+
<button id="ml-forgot" type="button" class="quiet">I forgot which password</button>
|
|
81
|
+
<dl id="ml-hints" class="hints" hidden></dl>`}
|
|
72
82
|
</main>
|
|
73
83
|
<script type="module" src="${MASTER_LOCK_PATHS.script}"></script>
|
|
74
84
|
</body>
|
|
@@ -103,6 +113,16 @@ button{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-acc
|
|
|
103
113
|
button[disabled]{opacity:.6;cursor:progress}
|
|
104
114
|
.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}
|
|
105
115
|
.status[data-tone="error"]{color:var(--ml-danger)}
|
|
116
|
+
.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}
|
|
117
|
+
button.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;
|
|
118
|
+
font-size:12.5px;text-decoration:underline;text-underline-offset:3px}
|
|
119
|
+
button.quiet:hover{color:var(--ml-text)}
|
|
120
|
+
button.quiet[disabled]{opacity:.5}
|
|
121
|
+
.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);
|
|
122
|
+
border-radius:10px;background:var(--ml-bg);font-size:12.5px}
|
|
123
|
+
.hints dt{font-weight:650;color:var(--ml-text)}
|
|
124
|
+
.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
|
|
125
|
+
.hints dd:last-child{margin-bottom:0}
|
|
106
126
|
`;
|
|
107
127
|
/**
|
|
108
128
|
* The page's script.
|
|
@@ -118,8 +138,11 @@ export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
|
|
|
118
138
|
const form = document.getElementById("ml-form");
|
|
119
139
|
const input = document.getElementById("ml-input");
|
|
120
140
|
const confirmInput = document.getElementById("ml-confirm");
|
|
141
|
+
const hintInput = document.getElementById("ml-hint");
|
|
121
142
|
const submit = document.getElementById("ml-submit");
|
|
122
143
|
const status = document.getElementById("ml-status");
|
|
144
|
+
const forgot = document.getElementById("ml-forgot");
|
|
145
|
+
const hints = document.getElementById("ml-hints");
|
|
123
146
|
|
|
124
147
|
function say(message, tone) {
|
|
125
148
|
status.textContent = message;
|
|
@@ -217,7 +240,11 @@ async function doEnroll(password) {
|
|
|
217
240
|
method: "POST",
|
|
218
241
|
credentials: "same-origin",
|
|
219
242
|
headers: { "content-type": "application/json" },
|
|
220
|
-
body: JSON.stringify({
|
|
243
|
+
body: JSON.stringify({
|
|
244
|
+
kdf: params,
|
|
245
|
+
verifier: verifier,
|
|
246
|
+
hint: hintInput ? hintInput.value : "",
|
|
247
|
+
}),
|
|
221
248
|
});
|
|
222
249
|
if (res.ok) { say("Master password set."); location.reload(); return true; }
|
|
223
250
|
// The server collapses every enrollment refusal into one, so this cannot say more
|
|
@@ -243,6 +270,44 @@ async function doUnlock(password) {
|
|
|
243
270
|
return false;
|
|
244
271
|
}
|
|
245
272
|
|
|
273
|
+
// ── the hints, and why they are behind a click ───────────────────────────────
|
|
274
|
+
// Reading them says how many master passwords this app has and what each is called, which
|
|
275
|
+
// is the one fact the rest of this page is built never to disclose. Fetching them on load
|
|
276
|
+
// would leak it to anyone who merely opened the door; a button makes it something the
|
|
277
|
+
// owner ASKS for. Everyone who gets this far has already passed the app's own sign-in.
|
|
278
|
+
if (forgot && hints) {
|
|
279
|
+
forgot.addEventListener("click", async () => {
|
|
280
|
+
if (!hints.hidden) { hints.hidden = true; return; }
|
|
281
|
+
forgot.disabled = true;
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetch(paths.hints, { credentials: "same-origin" });
|
|
284
|
+
const body = await res.json();
|
|
285
|
+
const rows = (body && body.hints) || [];
|
|
286
|
+
hints.replaceChildren();
|
|
287
|
+
if (rows.length === 0) {
|
|
288
|
+
const dd = document.createElement("dd");
|
|
289
|
+
dd.textContent = "No hints have been written yet.";
|
|
290
|
+
hints.append(dd);
|
|
291
|
+
}
|
|
292
|
+
for (const row of rows) {
|
|
293
|
+
const dt = document.createElement("dt");
|
|
294
|
+
dt.textContent = row.label;
|
|
295
|
+
const dd = document.createElement("dd");
|
|
296
|
+
// 🔴 textContent, never innerHTML. The hint is free text the owner typed and this
|
|
297
|
+
// page runs under a CSP that permits no inline script — writing it as markup would
|
|
298
|
+
// be the one way to get some in.
|
|
299
|
+
dd.textContent = row.hint || "— no hint —";
|
|
300
|
+
hints.append(dt, dd);
|
|
301
|
+
}
|
|
302
|
+
hints.hidden = false;
|
|
303
|
+
} catch {
|
|
304
|
+
say("Cannot reach this site right now.", "error");
|
|
305
|
+
} finally {
|
|
306
|
+
forgot.disabled = false;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
246
311
|
form.addEventListener("submit", async (event) => {
|
|
247
312
|
event.preventDefault();
|
|
248
313
|
if (busy || submit.disabled) return;
|