cursedbelt-server 3.0.1 → 4.1.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/dist/server/auth/jwt.d.ts +1 -1
- package/dist/server/auth/jwt.js +2 -2
- package/dist/server/master-lock/accountsPage.d.ts +39 -0
- package/dist/server/master-lock/accountsPage.js +455 -0
- package/dist/server/master-lock/guard.js +39 -1
- package/dist/server/master-lock/index.d.ts +10 -2
- package/dist/server/master-lock/index.js +10 -2
- package/dist/server/master-lock/lockPage.d.ts +9 -0
- package/dist/server/master-lock/lockPage.js +44 -36
- package/dist/server/media-bun/streaming.d.ts +0 -1
- package/dist/server/media-bun/streaming.js +1 -1
- package/dist/server/storage/binaryStore.d.ts +385 -0
- package/dist/server/storage/binaryStore.js +739 -0
- package/dist/server/storage/binaryStoreFake.d.ts +56 -0
- package/dist/server/storage/binaryStoreFake.js +63 -0
- package/dist/server/storage/files/catalogue.d.ts +2 -2
- package/dist/server/storage/files/catalogue.js +18 -208
- package/dist/server/storage/files/fileRoutes.d.ts +2 -20
- package/dist/server/storage/files/fileRoutes.js +1 -74
- package/dist/server/storage/files/index.d.ts +1 -1
- package/dist/server/storage/files/index.js +1 -1
- package/dist/server/storage/files/migrations.js +9 -6
- package/dist/server/storage/files/stack.d.ts +6 -31
- package/dist/server/storage/files/stack.js +0 -27
- package/dist/server/storage/files/types.d.ts +10 -60
- package/dist/server/storage/files/types.js +9 -0
- package/dist/server/storage/index.d.ts +2 -2
- package/dist/server/storage/index.js +1 -1
- package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
- package/dist/server/storage/types.d.ts +0 -9
- package/dist/server/storage/types.js +2 -9
- package/package.json +14 -2
- package/src/leafSubpathsImportNothing.spec.ts +30 -4
- package/src/server/auth/jwt.ts +2 -2
- package/src/server/master-lock/accountsPage.spec.ts +231 -0
- package/src/server/master-lock/accountsPage.ts +465 -0
- package/src/server/master-lock/guard.ts +44 -1
- package/src/server/master-lock/index.ts +22 -2
- package/src/server/master-lock/lockPage.spec.ts +20 -16
- package/src/server/master-lock/lockPage.ts +45 -36
- package/src/server/media-bun/streaming.ts +0 -6
- package/src/server/storage/binaryStore.spec.ts +908 -0
- package/src/server/storage/binaryStore.ts +1049 -0
- package/src/server/storage/binaryStoreFake.ts +111 -0
- package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
- package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
- package/src/server/storage/files/adapter.photo.spec.ts +2 -2
- package/src/server/storage/files/adapter.spec.ts +0 -1
- package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
- package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
- package/src/server/storage/files/catalogue.ts +19 -240
- package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
- package/src/server/storage/files/fileRoutes.spec.ts +3 -97
- package/src/server/storage/files/fileRoutes.ts +2 -96
- package/src/server/storage/files/index.ts +1 -4
- package/src/server/storage/files/migrations.ts +9 -7
- package/src/server/storage/files/stack.spec.ts +11 -111
- package/src/server/storage/files/stack.ts +9 -61
- package/src/server/storage/files/types.ts +20 -68
- package/src/server/storage/index.ts +0 -4
- package/src/server/storage/mountMediaRoutes.ts +1 -7
- package/src/server/storage/types.ts +2 -11
- package/src/shippedFilesAreTracked.spec.ts +3 -2
- package/dist/server/private-media/crypto.d.ts +0 -37
- package/dist/server/private-media/crypto.js +0 -111
- package/dist/server/private-media/index-store.d.ts +0 -31
- package/dist/server/private-media/index-store.js +0 -36
- package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
- package/dist/server/private-media/mountPrivateRoutes.js +0 -219
- package/dist/server/private-media/pipeline.d.ts +0 -37
- package/dist/server/private-media/pipeline.js +0 -97
- package/dist/server/private-media/unlock.d.ts +0 -52
- package/dist/server/private-media/unlock.js +0 -55
- package/src/server/private-media/blobRoute.spec.ts +0 -165
- package/src/server/private-media/crypto.spec.ts +0 -81
- package/src/server/private-media/crypto.ts +0 -155
- package/src/server/private-media/gatePolicy.spec.ts +0 -98
- package/src/server/private-media/index-store.ts +0 -83
- package/src/server/private-media/mountPrivateRoutes.ts +0 -265
- package/src/server/private-media/pipeline.spec.ts +0 -111
- package/src/server/private-media/pipeline.ts +0 -170
- package/src/server/private-media/routes.spec.ts +0 -265
- package/src/server/private-media/unlock.ts +0 -112
- package/src/server/storage/files/catalogue.private.spec.ts +0 -106
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ACCOUNTS page — where the owner changes his master password.
|
|
3
|
+
*
|
|
4
|
+
* ── Why it is here and not in the app ───────────────────────────────────────
|
|
5
|
+
* `cursedbelt@2.5.1`'s React `MasterLockMenuItems` dialog minted a FRESH `kdf` salt on every
|
|
6
|
+
* rotation, which is what the wire looked like before accounts existed. Every account on an
|
|
7
|
+
* app now derives under ONE shared descriptor, so honouring that salt would store a verifier
|
|
8
|
+
* hash nothing could ever match: the owner sets a new password, the rotation logs him out,
|
|
9
|
+
* and neither the old nor the new one opens the app. `guard.ts` therefore answers that shape
|
|
10
|
+
* `409 {"error":"stale-client"}`. That is the safe failure; this page is the fix.
|
|
11
|
+
*
|
|
12
|
+
* It could not be fixed where it broke. `cursedbelt@3.0.0` deleted `src/server` outright and
|
|
13
|
+
* `apps/collections` is two majors behind on the React design system, so reaching the in-app
|
|
14
|
+
* dialog means a migration that is its own program. The lock page already proves that a
|
|
15
|
+
* server-rendered surface under `/__lock/` reaches every app that mounts the guard — no
|
|
16
|
+
* bundle, no framework, no version to migrate, and no app-local copy to drift. So the
|
|
17
|
+
* rotation lives next to the lock, and an app needs one link to it.
|
|
18
|
+
*
|
|
19
|
+
* The owner asked for the rotation in as many words (2026-08-15): *"give me a way to change
|
|
20
|
+
* it to a value I want it to be anytime later."* `bun run accounts` in an app cannot do it —
|
|
21
|
+
* nobody can change a password they have never seen, and this server has never seen one.
|
|
22
|
+
*
|
|
23
|
+
* ── What it is allowed to show ──────────────────────────────────────────────
|
|
24
|
+
* 🔴 It is served ONLY to a request carrying a live unlock, because listing the accounts says
|
|
25
|
+
* how many master passwords exist and what each is called — the one fact the lock page is
|
|
26
|
+
* built never to disclose. The guard enforces that; this file renders a shell that contains
|
|
27
|
+
* no account data at all and fetches the list over `GET /__lock/accounts`, so there is one
|
|
28
|
+
* source of truth for who may read it.
|
|
29
|
+
*
|
|
30
|
+
* ── The derivation ──────────────────────────────────────────────────────────
|
|
31
|
+
* 🔴 Interpolated from {@link MASTER_LOCK_DERIVE_SOURCE}, never re-typed. A second hand copy
|
|
32
|
+
* would be a second thing to keep in step with `cursedbelt-core`, and the pinning spec would
|
|
33
|
+
* only be watching one of them.
|
|
34
|
+
*/
|
|
35
|
+
import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX } from "cursedbelt-core/master-lock";
|
|
36
|
+
import { MASTER_LOCK_DERIVE_SOURCE, type LockPageOptions } from "./lockPage";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The page's own two assets.
|
|
40
|
+
*
|
|
41
|
+
* Authored HERE rather than in `cursedbelt-core`'s `MASTER_LOCK_PATHS`, because nothing but
|
|
42
|
+
* this server ever names them: the document that links them is served by the same process,
|
|
43
|
+
* and no client constructs either URL. Adding them to the shared wire would mean publishing
|
|
44
|
+
* `cursedbelt-core` for a string only one file reads.
|
|
45
|
+
*/
|
|
46
|
+
export const MASTER_LOCK_ACCOUNTS_PAGE_PATHS = {
|
|
47
|
+
style: `${MASTER_LOCK_PREFIX}/accounts.css`,
|
|
48
|
+
script: `${MASTER_LOCK_PREFIX}/accounts.js`,
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
const escapeHtml = (value: string): string =>
|
|
52
|
+
value.replace(
|
|
53
|
+
/[&<>"']/g,
|
|
54
|
+
(ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch] ?? ch,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** The shortest password worth calling one. Matched by every `minlength` below and by the
|
|
58
|
+
* lock page's enrollment form, so a rotation cannot quietly weaken what enrollment demands. */
|
|
59
|
+
export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The document.
|
|
63
|
+
*
|
|
64
|
+
* It links the LOCK's stylesheet first and its own second, so the two pages share one set of
|
|
65
|
+
* colour tokens and a change to the palette cannot land on only one of them.
|
|
66
|
+
*/
|
|
67
|
+
export function accountsPageHtml(options: LockPageOptions): string {
|
|
68
|
+
const label = escapeHtml(options.appLabel);
|
|
69
|
+
const min = MASTER_LOCK_MIN_PASSWORD_LENGTH;
|
|
70
|
+
return `<!doctype html>
|
|
71
|
+
<html lang="en">
|
|
72
|
+
<head>
|
|
73
|
+
<meta charset="utf-8">
|
|
74
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
75
|
+
<meta name="robots" content="noindex, nofollow">
|
|
76
|
+
<title>Master passwords · ${label}</title>
|
|
77
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔑</text></svg>">
|
|
78
|
+
<link rel="stylesheet" href="${MASTER_LOCK_PATHS.style}">
|
|
79
|
+
<link rel="stylesheet" href="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.style}">
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<main class="sheet">
|
|
83
|
+
<header class="sheet-head">
|
|
84
|
+
<div>
|
|
85
|
+
<h1>Master passwords</h1>
|
|
86
|
+
<p class="note">${label} · signed in as <strong id="ml-current">…</strong></p>
|
|
87
|
+
</div>
|
|
88
|
+
<a class="back" href="/">Back to the app</a>
|
|
89
|
+
</header>
|
|
90
|
+
|
|
91
|
+
<section class="panel" aria-labelledby="ml-rotate-title">
|
|
92
|
+
<h2 id="ml-rotate-title">Change this account's master password</h2>
|
|
93
|
+
<p class="note">
|
|
94
|
+
It opens the same content afterwards — the password is how you get in, not what you
|
|
95
|
+
get in to. Every device you have open is locked the moment it changes.
|
|
96
|
+
</p>
|
|
97
|
+
<form id="ml-rotate" autocomplete="off">
|
|
98
|
+
<label class="field">
|
|
99
|
+
<span>Current master password</span>
|
|
100
|
+
<input type="password" name="current" autocomplete="current-password" required
|
|
101
|
+
spellcheck="false">
|
|
102
|
+
</label>
|
|
103
|
+
<label class="field">
|
|
104
|
+
<span>New master password</span>
|
|
105
|
+
<input type="password" name="next" autocomplete="new-password" required
|
|
106
|
+
spellcheck="false" minlength="${min}">
|
|
107
|
+
</label>
|
|
108
|
+
<label class="field">
|
|
109
|
+
<span>Confirm new master password</span>
|
|
110
|
+
<input type="password" name="confirm" autocomplete="new-password" required
|
|
111
|
+
spellcheck="false" minlength="${min}">
|
|
112
|
+
</label>
|
|
113
|
+
<button type="submit">Change master password</button>
|
|
114
|
+
<p class="status" role="status" aria-live="polite"></p>
|
|
115
|
+
</form>
|
|
116
|
+
</section>
|
|
117
|
+
|
|
118
|
+
<section class="panel" aria-labelledby="ml-list-title">
|
|
119
|
+
<h2 id="ml-list-title">Accounts on this site</h2>
|
|
120
|
+
<p class="note">
|
|
121
|
+
Each master password is its own account, with its own content. You can rename the one
|
|
122
|
+
you are signed in to and rewrite its hint; the others are only editable from inside them.
|
|
123
|
+
</p>
|
|
124
|
+
<ul id="ml-accounts" class="accounts"><li class="empty">Loading…</li></ul>
|
|
125
|
+
</section>
|
|
126
|
+
|
|
127
|
+
<section class="panel" aria-labelledby="ml-add-title">
|
|
128
|
+
<h2 id="ml-add-title">Add another master password</h2>
|
|
129
|
+
<p class="note">
|
|
130
|
+
A new password is a new, empty account. Adding one does not sign you into it — you stay
|
|
131
|
+
here until you type it on the lock screen. Nobody can recover it, so leave yourself a hint.
|
|
132
|
+
</p>
|
|
133
|
+
<form id="ml-add" autocomplete="off">
|
|
134
|
+
<label class="field">
|
|
135
|
+
<span>Your current master password</span>
|
|
136
|
+
<input type="password" name="current" autocomplete="current-password" required
|
|
137
|
+
spellcheck="false">
|
|
138
|
+
</label>
|
|
139
|
+
<label class="field">
|
|
140
|
+
<span>The new account's master password</span>
|
|
141
|
+
<input type="password" name="next" autocomplete="new-password" required
|
|
142
|
+
spellcheck="false" minlength="${min}">
|
|
143
|
+
</label>
|
|
144
|
+
<label class="field">
|
|
145
|
+
<span>Confirm it</span>
|
|
146
|
+
<input type="password" name="confirm" autocomplete="new-password" required
|
|
147
|
+
spellcheck="false" minlength="${min}">
|
|
148
|
+
</label>
|
|
149
|
+
<label class="field">
|
|
150
|
+
<span>Name</span>
|
|
151
|
+
<input type="text" name="label" required maxlength="60" spellcheck="false"
|
|
152
|
+
autocomplete="off" placeholder="Work, Family, …">
|
|
153
|
+
</label>
|
|
154
|
+
<label class="field">
|
|
155
|
+
<span>Hint <em>optional</em></span>
|
|
156
|
+
<input type="text" name="hint" maxlength="200" spellcheck="false" autocomplete="off"
|
|
157
|
+
placeholder="Something only you would understand">
|
|
158
|
+
</label>
|
|
159
|
+
<button type="submit">Add master password</button>
|
|
160
|
+
<p class="status" role="status" aria-live="polite"></p>
|
|
161
|
+
</form>
|
|
162
|
+
</section>
|
|
163
|
+
</main>
|
|
164
|
+
<script type="module" src="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script}"></script>
|
|
165
|
+
</body>
|
|
166
|
+
</html>
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The page-specific stylesheet. Loaded AFTER `lock.css` and inheriting every `--ml-*` token
|
|
172
|
+
* from it, so this file only ever describes layout this page adds.
|
|
173
|
+
*/
|
|
174
|
+
export const MASTER_LOCK_ACCOUNTS_STYLE = `body{display:block;padding:32px 20px 64px}
|
|
175
|
+
.sheet{max-width:46rem;margin:0 auto;display:flex;flex-direction:column;gap:18px}
|
|
176
|
+
.sheet-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;
|
|
177
|
+
padding:0 2px 2px}
|
|
178
|
+
.sheet-head h1{margin:0 0 4px;font-size:22px;font-weight:660;letter-spacing:-.015em}
|
|
179
|
+
.sheet-head .note{margin:0}
|
|
180
|
+
.back{flex:none;font-size:12.5px;font-weight:600;color:var(--ml-muted);text-decoration:none;
|
|
181
|
+
border:1px solid var(--ml-border);border-radius:8px;padding:7px 12px;background:var(--ml-card)}
|
|
182
|
+
.back:hover{color:var(--ml-text);border-color:var(--ml-muted)}
|
|
183
|
+
.panel{background:var(--ml-card);border:1px solid var(--ml-border);border-radius:14px;
|
|
184
|
+
padding:22px 22px 20px;box-shadow:var(--ml-shadow);text-align:left}
|
|
185
|
+
.panel h2{margin:0 0 6px;font-size:15px;font-weight:650;letter-spacing:-.01em}
|
|
186
|
+
.panel .note{margin:0 0 18px;max-width:42rem}
|
|
187
|
+
.panel form{display:flex;flex-direction:column}
|
|
188
|
+
.panel button{margin-top:4px;align-self:flex-start;width:auto;min-width:13rem;padding:0 18px}
|
|
189
|
+
.panel .status{margin:12px 0 0}
|
|
190
|
+
.status[data-tone="ok"]{color:var(--ml-text)}
|
|
191
|
+
.accounts{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
|
192
|
+
.accounts .empty{color:var(--ml-muted);font-size:13px}
|
|
193
|
+
.row{border:1px solid var(--ml-border);border-radius:11px;padding:14px 16px;background:var(--ml-bg)}
|
|
194
|
+
.row[data-current="true"]{border-color:var(--ml-accent)}
|
|
195
|
+
.row-head{display:flex;align-items:center;gap:8px;margin-bottom:2px}
|
|
196
|
+
.row-head strong{font-size:14px;font-weight:640}
|
|
197
|
+
.badge{font-size:11px;font-weight:650;letter-spacing:.02em;text-transform:uppercase;
|
|
198
|
+
color:var(--ml-accent-text);background:var(--ml-accent);border-radius:999px;padding:2px 8px}
|
|
199
|
+
.row .meta{margin:0;font-size:12.5px;color:var(--ml-muted);overflow-wrap:anywhere}
|
|
200
|
+
.row form{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-top:12px}
|
|
201
|
+
.row .field{flex:1 1 12rem;margin-bottom:0}
|
|
202
|
+
.row button{margin-top:0;min-width:0;width:auto;padding:0 16px;flex:none}
|
|
203
|
+
@media (max-width:640px){
|
|
204
|
+
.sheet-head{flex-direction:column}
|
|
205
|
+
.panel button{align-self:stretch;width:100%}
|
|
206
|
+
}
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The page's script.
|
|
211
|
+
*
|
|
212
|
+
* Every password is turned into a verifier HERE and only the verifier is sent, exactly as on
|
|
213
|
+
* the lock page — so a rotation never puts either the old or the new password on the wire.
|
|
214
|
+
*
|
|
215
|
+
* 🔴 It never sends a `kdf`. The params are one app-wide descriptor that every account
|
|
216
|
+
* derives under; minting a fresh salt on a rotation is precisely the bug this page exists to
|
|
217
|
+
* replace, and `guard.ts` answers that shape `409 stale-client` no matter who sends it.
|
|
218
|
+
*/
|
|
219
|
+
export const MASTER_LOCK_ACCOUNTS_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
|
|
220
|
+
const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
|
|
221
|
+
|
|
222
|
+
// ── derivation ───────────────────────────────────────────────────────────────
|
|
223
|
+
${MASTER_LOCK_DERIVE_SOURCE}
|
|
224
|
+
|
|
225
|
+
const rotateForm = document.getElementById("ml-rotate");
|
|
226
|
+
const addForm = document.getElementById("ml-add");
|
|
227
|
+
const list = document.getElementById("ml-accounts");
|
|
228
|
+
const currentName = document.getElementById("ml-current");
|
|
229
|
+
|
|
230
|
+
/** The app's KDF params, read once. Every derivation on this page uses these and no other. */
|
|
231
|
+
let kdf = null;
|
|
232
|
+
|
|
233
|
+
function say(form, message, tone) {
|
|
234
|
+
const status = form.querySelector(".status");
|
|
235
|
+
status.textContent = message;
|
|
236
|
+
if (tone) status.setAttribute("data-tone", tone);
|
|
237
|
+
else status.removeAttribute("data-tone");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function post(path, body, method) {
|
|
241
|
+
const res = await fetch(path, {
|
|
242
|
+
method: method || "POST",
|
|
243
|
+
credentials: "same-origin",
|
|
244
|
+
headers: { "content-type": "application/json" },
|
|
245
|
+
body: JSON.stringify(body),
|
|
246
|
+
});
|
|
247
|
+
const parsed = await res.json().catch(() => ({}));
|
|
248
|
+
return { ok: res.ok, status: res.status, body: parsed || {} };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* One refusal turned into one sentence.
|
|
253
|
+
*
|
|
254
|
+
* \`stale-client\` can only appear if something OTHER than this page is posting, since this
|
|
255
|
+
* one never sends a kdf — so it is named rather than folded into "something went wrong",
|
|
256
|
+
* which is what sent the owner looking at a 409 with no explanation in the first place.
|
|
257
|
+
*/
|
|
258
|
+
function refusalText(result) {
|
|
259
|
+
if (result.status === 401) return "Your unlock lapsed. Reload and enter the master password again.";
|
|
260
|
+
if (result.status === 409) return "This site refused an out-of-date client. Reload the page and try again.";
|
|
261
|
+
const reason = result.body && result.body.error;
|
|
262
|
+
if (reason === "wrong") return "That is not your current master password.";
|
|
263
|
+
if (reason === "duplicate") return "That password already opens one of these accounts. Pick a different one.";
|
|
264
|
+
if (reason === "invalid") return "That password cannot be used here — it may already open another account.";
|
|
265
|
+
if (reason === "unconfigured") return "This site has no master password set.";
|
|
266
|
+
if (reason === "locked") return "Your unlock lapsed. Reload and enter the master password again.";
|
|
267
|
+
return "That could not be saved. Reload and try again.";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The two checks the browser can make that the server cannot: length, and the retype. */
|
|
271
|
+
function passwordFrom(form) {
|
|
272
|
+
const next = form.elements.next.value;
|
|
273
|
+
const confirm = form.elements.confirm.value;
|
|
274
|
+
if (next.length < MIN_LENGTH) {
|
|
275
|
+
say(form, "Use at least " + MIN_LENGTH + " characters.", "error");
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
if (next !== confirm) {
|
|
279
|
+
say(form, "Those two do not match.", "error");
|
|
280
|
+
form.elements.confirm.value = "";
|
|
281
|
+
form.elements.confirm.focus();
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
return next;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function busy(form, message, work) {
|
|
288
|
+
const button = form.querySelector("button[type=submit]");
|
|
289
|
+
if (button.disabled) return;
|
|
290
|
+
button.disabled = true;
|
|
291
|
+
say(form, message);
|
|
292
|
+
try {
|
|
293
|
+
await work();
|
|
294
|
+
} catch {
|
|
295
|
+
say(form, "Cannot reach this site right now.", "error");
|
|
296
|
+
} finally {
|
|
297
|
+
button.disabled = false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── the list ─────────────────────────────────────────────────────────────────
|
|
302
|
+
// 🔴 textContent for every value, never innerHTML. A label and a hint are free text the
|
|
303
|
+
// owner typed, and this page runs under a CSP that permits no inline script — writing them
|
|
304
|
+
// as markup would be the one way to get some in.
|
|
305
|
+
function renderAccounts(accounts) {
|
|
306
|
+
list.replaceChildren();
|
|
307
|
+
if (accounts.length === 0) {
|
|
308
|
+
const li = document.createElement("li");
|
|
309
|
+
li.className = "empty";
|
|
310
|
+
li.textContent = "No master passwords are set.";
|
|
311
|
+
list.append(li);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
for (const account of accounts) {
|
|
315
|
+
const li = document.createElement("li");
|
|
316
|
+
li.className = "row";
|
|
317
|
+
li.dataset.current = String(account.current === true);
|
|
318
|
+
|
|
319
|
+
const head = document.createElement("div");
|
|
320
|
+
head.className = "row-head";
|
|
321
|
+
const name = document.createElement("strong");
|
|
322
|
+
name.textContent = account.label;
|
|
323
|
+
head.append(name);
|
|
324
|
+
if (account.current) {
|
|
325
|
+
const badge = document.createElement("span");
|
|
326
|
+
badge.className = "badge";
|
|
327
|
+
badge.textContent = "signed in";
|
|
328
|
+
head.append(badge);
|
|
329
|
+
}
|
|
330
|
+
li.append(head);
|
|
331
|
+
|
|
332
|
+
const meta = document.createElement("p");
|
|
333
|
+
meta.className = "meta";
|
|
334
|
+
meta.textContent = account.hint ? "Hint: " + account.hint : "No hint written.";
|
|
335
|
+
li.append(meta);
|
|
336
|
+
|
|
337
|
+
if (account.current) li.append(editForm(account));
|
|
338
|
+
list.append(li);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Rename / re-hint, and only ever for the account the caller's own unlock opened — the
|
|
343
|
+
* server refuses a sibling, so offering the form for one would be a button that lies. */
|
|
344
|
+
function editForm(account) {
|
|
345
|
+
const form = document.createElement("form");
|
|
346
|
+
form.autocomplete = "off";
|
|
347
|
+
|
|
348
|
+
const labelField = field("Name", "label", account.label, 60);
|
|
349
|
+
const hintField = field("Hint", "hint", account.hint, 200);
|
|
350
|
+
const button = document.createElement("button");
|
|
351
|
+
button.type = "submit";
|
|
352
|
+
button.textContent = "Save";
|
|
353
|
+
const status = document.createElement("p");
|
|
354
|
+
status.className = "status";
|
|
355
|
+
status.setAttribute("role", "status");
|
|
356
|
+
status.setAttribute("aria-live", "polite");
|
|
357
|
+
form.append(labelField, hintField, button, status);
|
|
358
|
+
|
|
359
|
+
form.addEventListener("submit", (event) => {
|
|
360
|
+
event.preventDefault();
|
|
361
|
+
busy(form, "Saving…", async () => {
|
|
362
|
+
const result = await post(
|
|
363
|
+
paths.accounts,
|
|
364
|
+
{
|
|
365
|
+
id: account.id,
|
|
366
|
+
label: form.elements.label.value,
|
|
367
|
+
hint: form.elements.hint.value,
|
|
368
|
+
},
|
|
369
|
+
"PATCH",
|
|
370
|
+
);
|
|
371
|
+
if (!result.ok) {
|
|
372
|
+
say(form, refusalText(result), "error");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
renderAccounts(result.body.accounts || []);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
return form;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function field(text, name, value, maxLength) {
|
|
382
|
+
const label = document.createElement("label");
|
|
383
|
+
label.className = "field";
|
|
384
|
+
const span = document.createElement("span");
|
|
385
|
+
span.textContent = text;
|
|
386
|
+
const input = document.createElement("input");
|
|
387
|
+
input.type = "text";
|
|
388
|
+
input.name = name;
|
|
389
|
+
input.value = value;
|
|
390
|
+
input.maxLength = maxLength;
|
|
391
|
+
input.spellcheck = false;
|
|
392
|
+
input.autocomplete = "off";
|
|
393
|
+
label.append(span, input);
|
|
394
|
+
return label;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function load() {
|
|
398
|
+
try {
|
|
399
|
+
const status = await fetch(paths.status, { credentials: "same-origin" });
|
|
400
|
+
if (status.ok) {
|
|
401
|
+
const body = await status.json();
|
|
402
|
+
kdf = body.kdf;
|
|
403
|
+
currentName.textContent = body.accountLabel || "this account";
|
|
404
|
+
}
|
|
405
|
+
const res = await fetch(paths.accounts, { credentials: "same-origin" });
|
|
406
|
+
if (res.status === 401) { location.href = paths.page; return; }
|
|
407
|
+
const body = await res.json();
|
|
408
|
+
renderAccounts(body.accounts || []);
|
|
409
|
+
} catch {
|
|
410
|
+
list.replaceChildren();
|
|
411
|
+
const li = document.createElement("li");
|
|
412
|
+
li.className = "empty";
|
|
413
|
+
li.textContent = "Cannot reach this site right now.";
|
|
414
|
+
list.append(li);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── the rotation ─────────────────────────────────────────────────────────────
|
|
419
|
+
rotateForm.addEventListener("submit", (event) => {
|
|
420
|
+
event.preventDefault();
|
|
421
|
+
const next = passwordFrom(rotateForm);
|
|
422
|
+
if (next === null) return;
|
|
423
|
+
busy(rotateForm, "Changing… this takes a moment.", async () => {
|
|
424
|
+
if (!kdf) { say(rotateForm, "Cannot reach this site right now.", "error"); return; }
|
|
425
|
+
const result = await post(paths.change, {
|
|
426
|
+
currentVerifier: await deriveVerifier(rotateForm.elements.current.value, kdf),
|
|
427
|
+
verifier: await deriveVerifier(next, kdf),
|
|
428
|
+
});
|
|
429
|
+
if (!result.ok) {
|
|
430
|
+
say(rotateForm, refusalText(result), "error");
|
|
431
|
+
rotateForm.elements.current.value = "";
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
// The rotation locked every device, this one included, and dropped the cookie with it.
|
|
435
|
+
// So there is nothing to stay on: send them to the lock screen to type the new one.
|
|
436
|
+
say(rotateForm, "Changed. Every device is locked — sign in with the new password.", "ok");
|
|
437
|
+
setTimeout(() => { location.href = paths.page; }, 1200);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// ── adding one ───────────────────────────────────────────────────────────────
|
|
442
|
+
addForm.addEventListener("submit", (event) => {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
const next = passwordFrom(addForm);
|
|
445
|
+
if (next === null) return;
|
|
446
|
+
busy(addForm, "Adding… this takes a moment.", async () => {
|
|
447
|
+
if (!kdf) { say(addForm, "Cannot reach this site right now.", "error"); return; }
|
|
448
|
+
const result = await post(paths.accounts, {
|
|
449
|
+
currentVerifier: await deriveVerifier(addForm.elements.current.value, kdf),
|
|
450
|
+
verifier: await deriveVerifier(next, kdf),
|
|
451
|
+
label: addForm.elements.label.value,
|
|
452
|
+
hint: addForm.elements.hint.value,
|
|
453
|
+
});
|
|
454
|
+
if (!result.ok) {
|
|
455
|
+
say(addForm, refusalText(result), "error");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
addForm.reset();
|
|
459
|
+
say(addForm, "Added. It is empty until you sign in with it.", "ok");
|
|
460
|
+
await load();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
load();
|
|
465
|
+
`;
|
|
@@ -17,6 +17,12 @@ import {
|
|
|
17
17
|
MASTER_LOCK_STATE_HEADER,
|
|
18
18
|
masterLockCookieName,
|
|
19
19
|
} from "cursedbelt-core/master-lock";
|
|
20
|
+
import {
|
|
21
|
+
MASTER_LOCK_ACCOUNTS_PAGE_PATHS,
|
|
22
|
+
MASTER_LOCK_ACCOUNTS_SCRIPT,
|
|
23
|
+
MASTER_LOCK_ACCOUNTS_STYLE,
|
|
24
|
+
accountsPageHtml,
|
|
25
|
+
} from "./accountsPage";
|
|
20
26
|
import { LOCK_SCRIPT, LOCK_STYLE, type LockPageOptions, lockPageHtml } from "./lockPage";
|
|
21
27
|
import type { MasterLock } from "./masterLock";
|
|
22
28
|
|
|
@@ -160,9 +166,27 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
|
|
|
160
166
|
// whether this app has one tenant or ten.
|
|
161
167
|
return Response.json({ hints: lock.hints() }, { headers: noStore });
|
|
162
168
|
}
|
|
169
|
+
if (path === MASTER_LOCK_ACCOUNTS_PAGE_PATHS.style) {
|
|
170
|
+
return new Response(MASTER_LOCK_ACCOUNTS_STYLE, {
|
|
171
|
+
headers: { "content-type": "text/css; charset=utf-8", ...noStore },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (path === MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script) {
|
|
175
|
+
return new Response(MASTER_LOCK_ACCOUNTS_SCRIPT, {
|
|
176
|
+
headers: { "content-type": "text/javascript; charset=utf-8", ...noStore },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
163
179
|
if (path === MASTER_LOCK_PATHS.accounts) {
|
|
164
180
|
// Unlike the hints, this needs the site already open — it names every tenant.
|
|
165
|
-
|
|
181
|
+
// A DOCUMENT request while locked is answered with the lock page in place, for
|
|
182
|
+
// the reason the app's own paths are: unlocking then reloads to where they were
|
|
183
|
+
// going, which is the management page, instead of dropping them at the root.
|
|
184
|
+
if (!opens(lock, req)) return wantsDocument(req) ? lockPage(options, lock) : refuse();
|
|
185
|
+
// 🔴 Content-negotiated, one route. The browser asks for a PAGE and gets the
|
|
186
|
+
// management surface; the page's own `fetch` asks for DATA and gets the list it
|
|
187
|
+
// renders. One path means one rule about who may read it, so the page can never
|
|
188
|
+
// be reachable by a caller the JSON would have refused.
|
|
189
|
+
if (wantsDocument(req)) return accountsPage(options);
|
|
166
190
|
return Response.json({ accounts: lock.summaries(req) }, { headers: noStore });
|
|
167
191
|
}
|
|
168
192
|
if (path === MASTER_LOCK_PATHS.page || path === MASTER_LOCK_PREFIX) {
|
|
@@ -341,6 +365,25 @@ function lockPage(options: LockPageOptions, lock: MasterLock): Response {
|
|
|
341
365
|
});
|
|
342
366
|
}
|
|
343
367
|
|
|
368
|
+
/**
|
|
369
|
+
* The accounts page. Same wall as the lock page — one CSP, no inline script, never framed —
|
|
370
|
+
* because it carries the one form that can change a master password.
|
|
371
|
+
*
|
|
372
|
+
* It is handed NO account data: the shell fetches the list over this same route's JSON half,
|
|
373
|
+
* so there is exactly one place that decides who may read it.
|
|
374
|
+
*/
|
|
375
|
+
function accountsPage(options: LockPageOptions): Response {
|
|
376
|
+
return new Response(accountsPageHtml(options), {
|
|
377
|
+
status: 200,
|
|
378
|
+
headers: {
|
|
379
|
+
"content-type": "text/html; charset=utf-8",
|
|
380
|
+
"content-security-policy": LOCK_CSP,
|
|
381
|
+
"x-content-type-options": "nosniff",
|
|
382
|
+
...noStore,
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
344
387
|
/** Is this the browser asking for a PAGE, as opposed to the app asking for data? */
|
|
345
388
|
function wantsDocument(req: Request): boolean {
|
|
346
389
|
if (req.method !== "GET") return false;
|
|
@@ -7,14 +7,34 @@
|
|
|
7
7
|
* Adopting it in an app is three things:
|
|
8
8
|
* 1. a {@link MasterLock} built over whatever key/value the app already has,
|
|
9
9
|
* 2. `createMasterLockGuard(...).handle(req)` in front of everything the app serves,
|
|
10
|
-
* 3.
|
|
10
|
+
* 3. one link to `/__lock/accounts` wherever the app keeps its account menu.
|
|
11
11
|
*
|
|
12
12
|
* Removing it is deleting those three. That is the shape the owner asked for: *"the reusable
|
|
13
13
|
* functionality for this should be in a place that makes it easy to share to add or remove
|
|
14
14
|
* from apps in the code, such as cb."*
|
|
15
|
+
*
|
|
16
|
+
* 🔴 Step 3 used to be `cursedbelt/react/master-lock` in the header, and it is a LINK now.
|
|
17
|
+
* That dialog minted a fresh `kdf` salt on every rotation — the pre-accounts wire — which the
|
|
18
|
+
* server has to refuse, so the owner could lock a site and enroll a first password but could
|
|
19
|
+
* not change one. `accountsPage.ts` carries the reasoning; the short version is that a
|
|
20
|
+
* server-rendered surface under `/__lock/` reaches every app that mounts the guard with no
|
|
21
|
+
* bundle, no framework version to migrate, and no app-local copy to drift.
|
|
15
22
|
*/
|
|
23
|
+
export {
|
|
24
|
+
MASTER_LOCK_ACCOUNTS_PAGE_PATHS,
|
|
25
|
+
MASTER_LOCK_ACCOUNTS_SCRIPT,
|
|
26
|
+
MASTER_LOCK_ACCOUNTS_STYLE,
|
|
27
|
+
MASTER_LOCK_MIN_PASSWORD_LENGTH,
|
|
28
|
+
accountsPageHtml,
|
|
29
|
+
} from "./accountsPage";
|
|
16
30
|
export { type MasterLockGuard, type MasterLockGuardOptions, createMasterLockGuard } from "./guard";
|
|
17
|
-
export {
|
|
31
|
+
export {
|
|
32
|
+
LOCK_SCRIPT,
|
|
33
|
+
LOCK_STYLE,
|
|
34
|
+
MASTER_LOCK_DERIVE_SOURCE,
|
|
35
|
+
type LockPageOptions,
|
|
36
|
+
lockPageHtml,
|
|
37
|
+
} from "./lockPage";
|
|
18
38
|
export {
|
|
19
39
|
MASTER_LOCK_PRINCIPAL_KEY_PREFIX,
|
|
20
40
|
MasterLockDirectory,
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { describe, expect, test } from "bun:test";
|
|
15
15
|
import { MASTER_LOCK_PATHS, type MasterLockKdfParams, deriveMasterLockVerifier } from "cursedbelt-core/master-lock";
|
|
16
|
-
import {
|
|
16
|
+
import { MASTER_LOCK_ACCOUNTS_SCRIPT } from "./accountsPage";
|
|
17
|
+
import { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, lockPageHtml } from "./lockPage";
|
|
17
18
|
|
|
18
19
|
const KDF: MasterLockKdfParams = {
|
|
19
20
|
v: 1,
|
|
@@ -24,25 +25,17 @@ const KDF: MasterLockKdfParams = {
|
|
|
24
25
|
const PASSWORD = "correct horse battery staple";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
*
|
|
28
|
+
* Evaluate `deriveVerifier` out of the source that is actually served.
|
|
28
29
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* It assigns itself to `globalThis.__masterLockDerive` for exactly this — a seam that costs
|
|
31
|
+
* one line in the browser and is the difference between a pinned copy and a hopeful one. The
|
|
32
|
+
* constant touches nothing but `crypto`, so it evaluates as-is with no DOM stub.
|
|
32
33
|
*/
|
|
33
34
|
function extractDerive(): (password: string, kdf: MasterLockKdfParams) => Promise<string> {
|
|
34
|
-
const marker = "globalThis.__masterLockDerive = deriveVerifier;";
|
|
35
|
-
const cut = LOCK_SCRIPT.indexOf(marker);
|
|
36
|
-
expect(cut).toBeGreaterThan(0);
|
|
37
|
-
const source = LOCK_SCRIPT.slice(0, cut + marker.length);
|
|
38
|
-
// The prelude reads `document`, which does not exist under `bun test`; the slice above
|
|
39
|
-
// stops before it, but the leading `const` declarations still run — so evaluate in a
|
|
40
|
-
// function scope with those globals stubbed.
|
|
41
35
|
const factory = new Function(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return factory({ getElementById: () => ({ addEventListener() {}, setAttribute() {}, removeAttribute() {} }) });
|
|
36
|
+
`${MASTER_LOCK_DERIVE_SOURCE}\nreturn globalThis.__masterLockDerive;`,
|
|
37
|
+
) as () => (password: string, kdf: MasterLockKdfParams) => Promise<string>;
|
|
38
|
+
return factory();
|
|
46
39
|
}
|
|
47
40
|
|
|
48
41
|
describe("the lock page's hand-copied derivation", () => {
|
|
@@ -62,6 +55,17 @@ describe("the lock page's hand-copied derivation", () => {
|
|
|
62
55
|
await deriveMasterLockVerifier("another password", kdf),
|
|
63
56
|
);
|
|
64
57
|
});
|
|
58
|
+
|
|
59
|
+
test("🔴 EVERY served script uses that one copy, so none can drift on its own", () => {
|
|
60
|
+
// The accounts page derives too — a rotation is two verifiers. If it ever grew its own
|
|
61
|
+
// transcription, the two tests above would keep passing while the owner's correct
|
|
62
|
+
// password was refused on exactly one of the two pages.
|
|
63
|
+
expect(LOCK_SCRIPT).toContain(MASTER_LOCK_DERIVE_SOURCE);
|
|
64
|
+
expect(MASTER_LOCK_ACCOUNTS_SCRIPT).toContain(MASTER_LOCK_DERIVE_SOURCE);
|
|
65
|
+
// And nobody has quietly pasted a second `deriveVerifier` alongside the shared one.
|
|
66
|
+
expect(LOCK_SCRIPT.split("async function deriveVerifier").length - 1).toBe(1);
|
|
67
|
+
expect(MASTER_LOCK_ACCOUNTS_SCRIPT.split("async function deriveVerifier").length - 1).toBe(1);
|
|
68
|
+
});
|
|
65
69
|
});
|
|
66
70
|
|
|
67
71
|
describe("the page itself", () => {
|