seemore 1.10.8 → 1.11.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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The stored key: one IndexedDB record per site, keyed by its scope and salt ({@link recordId}).
3
+ *
4
+ * IndexedDB rather than `localStorage` because it is the one store a page and a service
5
+ * worker share, and because it holds a `CryptoKey` as the object itself — non-extractable,
6
+ * so page code can use it but never read its bytes. Keyed by salt, so a renamed site (or a
7
+ * changed `auth.id`) finds no record and shows the lock screen.
8
+ */
9
+
10
+ export interface KeyRecord {
11
+ kek: CryptoKey;
12
+ /** Milliseconds since the epoch of the last navigation. */
13
+ lastSeen: number;
14
+ }
15
+
16
+ export interface KeyStore {
17
+ get(id: string): Promise<KeyRecord | undefined>;
18
+ put(id: string, record: KeyRecord): Promise<void>;
19
+ delete(id: string): Promise<void>;
20
+ }
21
+
22
+ const DATABASE = 'seemore-auth';
23
+ const STORE = 'keys';
24
+
25
+ export function indexedDbStore(factory: IDBFactory = indexedDB): KeyStore {
26
+ const open = () =>
27
+ new Promise<IDBDatabase>((resolve, reject) => {
28
+ const request = factory.open(DATABASE, 1);
29
+ request.onupgradeneeded = () => request.result.createObjectStore(STORE);
30
+ request.onsuccess = () => resolve(request.result);
31
+ request.onerror = () => reject(request.error ?? new Error('IndexedDB is unavailable.'));
32
+ });
33
+
34
+ const run = async <T>(mode: IDBTransactionMode, action: (store: IDBObjectStore) => IDBRequest): Promise<T> => {
35
+ const db = await open();
36
+ try {
37
+ return await new Promise<T>((resolve, reject) => {
38
+ const transaction = db.transaction(STORE, mode);
39
+ const request = action(transaction.objectStore(STORE));
40
+ transaction.oncomplete = () => resolve(request.result as T);
41
+ transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed.'));
42
+ transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted.'));
43
+ });
44
+ } finally {
45
+ db.close();
46
+ }
47
+ };
48
+
49
+ return {
50
+ get: (id) => run<KeyRecord | undefined>('readonly', (store) => store.get(id)),
51
+ put: (id, record) => run<void>('readwrite', (store) => store.put(record, id)),
52
+ delete: (id) => run<void>('readwrite', (store) => store.delete(id)),
53
+ };
54
+ }
@@ -0,0 +1,57 @@
1
+ /// <reference lib="webworker" />
2
+ /**
3
+ * `sw.js`: the thin wrapper that wires {@link createAuthWorker} to real service worker events.
4
+ * Bundled by the build, which writes `SEEMORE_AUTH` in front of it.
5
+ */
6
+ import { LOCK_MESSAGE } from './files.js';
7
+ import { indexedDbStore } from './store.js';
8
+ import { createAuthWorker } from './worker.js';
9
+
10
+ declare const SEEMORE_AUTH: { publicFiles: string[] };
11
+
12
+ const sw = self as unknown as ServiceWorkerGlobalScope;
13
+
14
+ const worker = createAuthWorker({
15
+ scope: sw.registration.scope,
16
+ store: indexedDbStore(),
17
+ fetch: (url, init) => fetch(url, init),
18
+ now: () => Date.now(),
19
+ publicFiles: SEEMORE_AUTH.publicFiles,
20
+ // Redeployed without `auth`: remove this worker, so the next visit is an ordinary one.
21
+ retire: async () => {
22
+ await sw.registration.unregister();
23
+ },
24
+ });
25
+
26
+ sw.addEventListener('install', () => {
27
+ void sw.skipWaiting();
28
+ });
29
+
30
+ sw.addEventListener('activate', (event) => {
31
+ event.waitUntil(sw.clients.claim());
32
+ });
33
+
34
+ sw.addEventListener('fetch', (event) => {
35
+ const { request } = event;
36
+ const described = {
37
+ url: request.url,
38
+ method: request.method,
39
+ navigate: request.mode === 'navigate',
40
+ range: request.headers.get('Range'),
41
+ send: () => fetch(request),
42
+ };
43
+ if (!worker.intercepts(described)) return;
44
+ // Anything unexpected falls back to the network, which only ever holds ciphertext.
45
+ event.respondWith(worker.handle(described).catch(() => fetch(request)));
46
+ });
47
+
48
+ sw.addEventListener('message', (event) => {
49
+ const data = event.data as { type?: unknown } | null;
50
+ if (data?.type !== LOCK_MESSAGE) return;
51
+ event.waitUntil(
52
+ worker
53
+ .lock()
54
+ .catch(() => undefined)
55
+ .then(() => event.ports[0]?.postMessage('locked')),
56
+ );
57
+ });
@@ -0,0 +1,408 @@
1
+ /**
2
+ * The service worker's decisions, as a pure module.
3
+ *
4
+ * Every dependency — the network, the key store, the clock — arrives as an argument, so Node
5
+ * tests drive it without a browser. `sw.ts` is the thin wrapper that wires it to real events.
6
+ *
7
+ * The worker takes over only what the site itself serves: its lock shell, and ciphertext.
8
+ * Navigations to the site get the lock shell or the decrypted app; its files are decrypted with
9
+ * the content key held in memory and answered with the right type, so the app's own imports,
10
+ * images, PDFs and search index load unchanged. Anything else under the scope — another site
11
+ * sharing the origin, or this site after `auth` was turned off — passes through untouched.
12
+ */
13
+ import { MAGIC, decryptFile, isEncrypted, parseManifest, unlockManifest, type AuthManifest } from './crypto.js';
14
+ import { APP_FILE, MANIFEST_FILE, PUBLIC_FILES, SHELL_CONFIG_ID, recordId } from './files.js';
15
+ import type { KeyStore } from './store.js';
16
+
17
+ /** The parts of a `Request` the worker reads; a plain object, because Node cannot build a navigation `Request`. */
18
+ export interface AuthRequest {
19
+ url: string;
20
+ method: string;
21
+ /** `request.mode === 'navigate'`. */
22
+ navigate: boolean;
23
+ range?: string | null;
24
+ /**
25
+ * Fetch the request exactly as the browser made it: headers, credentials and all. Content
26
+ * that is not this site's is answered with this, untouched.
27
+ */
28
+ send?: () => Promise<Response>;
29
+ }
30
+
31
+ export interface AuthWorkerOptions {
32
+ /** Absolute URL of the registration scope, with its trailing slash. */
33
+ scope: string;
34
+ store: KeyStore;
35
+ fetch: (url: string, init?: RequestInit) => Promise<Response>;
36
+ now: () => number;
37
+ /** Public files beyond {@link PUBLIC_FILES} — the configured favicon. */
38
+ publicFiles?: readonly string[];
39
+ /** Called once the site is no longer protected (its manifest is gone); `sw.ts` unregisters. */
40
+ retire?: () => Promise<void> | void;
41
+ }
42
+
43
+ export interface AuthWorker {
44
+ /** Whether the worker answers this request at all. */
45
+ intercepts(request: AuthRequest): boolean;
46
+ handle(request: AuthRequest): Promise<Response>;
47
+ /** Forget the stored key and the content key: the Lock button. */
48
+ lock(): Promise<void>;
49
+ }
50
+
51
+ interface Unlocked {
52
+ salt: string;
53
+ contentKey: CryptoKey;
54
+ }
55
+
56
+ export function createAuthWorker(options: AuthWorkerOptions): AuthWorker {
57
+ const scope = new URL(options.scope);
58
+ const publicFiles = new Set<string>([...PUBLIC_FILES, ...(options.publicFiles ?? [])]);
59
+
60
+ // Lives only in worker memory. A browser stops idle workers, and the next request
61
+ // re-derives it from the stored KEK and the manifest.
62
+ let unlocked: Unlocked | undefined;
63
+ let unlocking: Promise<Unlocked | undefined> | undefined;
64
+ let retired = false;
65
+ // Bumped whenever the key is forgotten, so work that read the stored key before cannot bring
66
+ // it back afterwards.
67
+ let generation = 0;
68
+
69
+ const urlFor = (path: string) => new URL(path, scope).href;
70
+ const record = (salt: string) => recordId(scope.href, salt);
71
+
72
+ /** The request as the browser made it, or a plain fetch of its address where there is none (tests). */
73
+ const send = (request: AuthRequest) =>
74
+ request.send?.() ??
75
+ options.fetch(request.url, request.navigate ? { cache: 'no-cache', redirect: 'manual' } : { cache: 'default' });
76
+
77
+ function pathOf(url: string): string | undefined {
78
+ const parsed = new URL(url);
79
+ if (parsed.origin !== scope.origin || !parsed.pathname.startsWith(scope.pathname)) return undefined;
80
+ const raw = parsed.pathname.slice(scope.pathname.length);
81
+ try {
82
+ return decodeURIComponent(raw);
83
+ } catch {
84
+ return raw;
85
+ }
86
+ }
87
+
88
+ /** The manifest, or `undefined` when the host no longer has one. Throws when the host cannot be reached. */
89
+ async function loadManifest(): Promise<AuthManifest | undefined> {
90
+ const response = await options.fetch(urlFor(MANIFEST_FILE), { cache: 'no-store' });
91
+ if (response.status === 404 || response.status === 410) return undefined;
92
+ if (!response.ok) throw new Error(`${MANIFEST_FILE} answered ${response.status}.`);
93
+ try {
94
+ return parseManifest(await response.json());
95
+ } catch {
96
+ // A host fallback page served in place of a missing manifest.
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ /** The site was redeployed without `auth`: stop intercepting, and let `sw.ts` unregister. */
102
+ async function retire(): Promise<void> {
103
+ if (retired) return;
104
+ retired = true;
105
+ unlocked = undefined;
106
+ await options.retire?.();
107
+ }
108
+
109
+ /**
110
+ * Whose a response is: this site's lock shell (built for this scope — a protected site nested
111
+ * under it has its own), ciphertext, or `undefined` for anything else.
112
+ */
113
+ async function ownership(response: Response): Promise<'shell' | 'ciphertext' | undefined> {
114
+ if (!response.ok && response.status !== 404) return undefined;
115
+ if (await startsEncrypted(response.clone())) return 'ciphertext';
116
+ const base = await shellBase(response.clone());
117
+ return base !== undefined && new URL(base, scope).href === scope.href ? 'shell' : undefined;
118
+ }
119
+
120
+ async function lockShell(): Promise<Response> {
121
+ const response = await options.fetch(urlFor('index.html'), { cache: 'no-cache' });
122
+ if (!response.ok) return response;
123
+ return new Response(await response.arrayBuffer(), { status: 200, headers: htmlHeaders() });
124
+ }
125
+
126
+ async function forget(salt: string): Promise<void> {
127
+ generation += 1;
128
+ unlocked = undefined;
129
+ await options.store.delete(record(salt));
130
+ }
131
+
132
+ /** The content key, from memory or re-derived from the stored KEK. Concurrent callers share one derivation. */
133
+ function currentKey(): Promise<Unlocked | undefined> {
134
+ if (unlocked !== undefined) return Promise.resolve(unlocked);
135
+ unlocking ??= (async () => {
136
+ const started = generation;
137
+ try {
138
+ const manifest = await loadManifest();
139
+ if (manifest === undefined) return undefined;
140
+ const stored = await options.store.get(record(manifest.kdf.salt));
141
+ if (stored === undefined) return undefined;
142
+ const contentKey = await unlockManifest(manifest, stored.kek);
143
+ // Locked while this was deriving: the key it read has been deleted since.
144
+ if (generation !== started) return undefined;
145
+ unlocked = { salt: manifest.kdf.salt, contentKey };
146
+ return unlocked;
147
+ } catch {
148
+ return undefined;
149
+ } finally {
150
+ unlocking = undefined;
151
+ }
152
+ })();
153
+ return unlocking;
154
+ }
155
+
156
+ /** Ciphertext from the network, or the response to pass on instead. */
157
+ async function fetchCiphertext(url: string, cache: RequestCache): Promise<Uint8Array<ArrayBuffer> | Response> {
158
+ const response = await options.fetch(url, { cache });
159
+ if (!response.ok) return response;
160
+ const bytes = new Uint8Array(await response.arrayBuffer());
161
+ // A host's fallback page for a file that no longer exists, served with a 200.
162
+ return isEncrypted(bytes) ? bytes : notFound();
163
+ }
164
+
165
+ /** The file in `response` decrypted, a 404 for the lock shell standing in for it, or `response` itself when it is not this site's. */
166
+ async function decrypted(url: string, path: string, range: string | null, response: Response): Promise<Response> {
167
+ if (!response.ok) return response;
168
+ const owner = await ownership(response);
169
+ if (owner === 'shell') return notFound();
170
+ if (owner === undefined) return response;
171
+
172
+ let key = await currentKey();
173
+ if (key === undefined) return new Response(null, { status: 403 });
174
+
175
+ let bytes: Uint8Array<ArrayBuffer> | Response = new Uint8Array(await response.arrayBuffer());
176
+ try {
177
+ return respond(await decryptFile(key.contentKey, path, bytes), path, range);
178
+ } catch {
179
+ // A deploy happened under the open tab: a new content key, and perhaps a stale cached
180
+ // copy of the file. Unwrap the new manifest and try once more, bypassing the cache.
181
+ unlocked = undefined;
182
+ key = await currentKey();
183
+ if (key === undefined) return new Response(null, { status: 403 });
184
+
185
+ bytes = await fetchCiphertext(url, 'reload');
186
+ if (bytes instanceof Response) return bytes;
187
+ try {
188
+ return respond(await decryptFile(key.contentKey, path, bytes), path, range);
189
+ } catch {
190
+ return notFound();
191
+ }
192
+ }
193
+ }
194
+
195
+ async function file(request: AuthRequest, path: string): Promise<Response> {
196
+ if (request.range) {
197
+ // Only a whole file shows the header that proves it is this site's, and only a whole file
198
+ // decrypts, so a ranged request is fetched whole first. Anything else is sent as it was
199
+ // made, Range header included.
200
+ const whole = await options.fetch(request.url, { cache: 'default' });
201
+ if (whole.ok && (await startsEncrypted(whole.clone()))) return await decrypted(request.url, path, request.range, whole);
202
+ void whole.body?.cancel().catch(() => undefined);
203
+ }
204
+ return await decrypted(request.url, path, null, await send(request));
205
+ }
206
+
207
+ async function navigate(request: AuthRequest, path: string): Promise<Response> {
208
+ // Asked of the network first, so a page that is not this site's is never taken over. Every
209
+ // route of a protected site answers with its lock shell (`index.html`, `404.html` or
210
+ // `200.html`); a file opened directly answers with ciphertext.
211
+ const page = await send(request);
212
+ const owner = await ownership(page);
213
+ if (owner === undefined) {
214
+ // Another site sharing the origin, or this site redeployed without `auth`.
215
+ if ((await loadManifest().catch(() => null)) === undefined) await retire();
216
+ return page;
217
+ }
218
+
219
+ const manifest = await loadManifest();
220
+ if (manifest === undefined) {
221
+ await retire();
222
+ return page;
223
+ }
224
+ if (owner === 'shell') void page.body?.cancel().catch(() => undefined);
225
+ const salt = manifest.kdf.salt;
226
+ const started = generation;
227
+
228
+ const stored = await options.store.get(record(salt));
229
+ if (stored === undefined) {
230
+ // Deleted from the page (the Lock button), perhaps before the worker heard about it.
231
+ unlocked = undefined;
232
+ return await lockShell();
233
+ }
234
+
235
+ if (options.now() - stored.lastSeen > manifest.remember * 1000) {
236
+ await forget(salt);
237
+ return await lockShell();
238
+ }
239
+
240
+ let contentKey: CryptoKey;
241
+ try {
242
+ contentKey = await unlockManifest(manifest, stored.kek);
243
+ } catch {
244
+ // The stored key no longer opens the manifest: the password changed.
245
+ await forget(salt);
246
+ return await lockShell();
247
+ }
248
+
249
+ // Sliding expiry, measured from the last navigation — never per asset.
250
+ await options.store.put(record(salt), { ...stored, lastSeen: options.now() });
251
+ if (generation !== started) {
252
+ // Locked while this navigation was unlocking: undo the write that may have restored the key.
253
+ await options.store.delete(record(salt));
254
+ return await lockShell();
255
+ }
256
+ unlocked = { salt, contentKey };
257
+
258
+ if (owner === 'ciphertext') {
259
+ // A file opened directly (a PDF link). One that does not open with this site's key belongs
260
+ // to another site, and passes through.
261
+ const opened = await decrypted(request.url, path, null, page);
262
+ if (opened.ok) return opened;
263
+ return opened.status === 403 ? await lockShell() : await send(request);
264
+ }
265
+
266
+ const app = await decrypted(urlFor(APP_FILE), APP_FILE, null, await options.fetch(urlFor(APP_FILE), { cache: 'no-cache' }));
267
+ // The app would not open even after a fresh manifest — a deploy caught half-uploaded. The
268
+ // lock shell is a page the visitor can act on; an empty error response is not.
269
+ if (!app.ok || !isHtml(app)) return await lockShell();
270
+ return new Response(await app.arrayBuffer(), { status: 200, headers: htmlHeaders() });
271
+ }
272
+
273
+ return {
274
+ intercepts(request) {
275
+ if (retired || request.method !== 'GET') return false;
276
+ const path = pathOf(request.url);
277
+ if (path === undefined) return false;
278
+ if (request.navigate) return !(publicFiles.has(path) && !path.endsWith('.html'));
279
+ return path !== '' && !publicFiles.has(path);
280
+ },
281
+
282
+ async handle(request) {
283
+ const path = pathOf(request.url) ?? '';
284
+ if (request.navigate) return await navigate(request, path);
285
+ return await file(request, path);
286
+ },
287
+
288
+ async lock() {
289
+ const known = unlocked?.salt;
290
+ generation += 1;
291
+ unlocked = undefined;
292
+ const salt = known ?? (await loadManifest())?.kdf.salt;
293
+ if (salt !== undefined) await forget(salt);
294
+ },
295
+ };
296
+ }
297
+
298
+ /** Whether a response body begins with the encrypted-file magic, reading no more than that. */
299
+ async function startsEncrypted(response: Response): Promise<boolean> {
300
+ const reader = response.body?.getReader();
301
+ if (reader === undefined) return false;
302
+ const head: number[] = [];
303
+ try {
304
+ while (head.length < MAGIC.length) {
305
+ const { done, value } = await reader.read();
306
+ if (done) break;
307
+ head.push(...value.subarray(0, MAGIC.length - head.length));
308
+ }
309
+ } finally {
310
+ void reader.cancel().catch(() => undefined);
311
+ }
312
+ return MAGIC.every((byte, index) => head[index] === byte);
313
+ }
314
+
315
+ /** The `base` a seemore lock shell was built for, read from its config element; `undefined` for any other response. */
316
+ async function shellBase(response: Response): Promise<string | undefined> {
317
+ if (!isHtml(response)) return undefined;
318
+ const config = new RegExp(`<script[^>]*\\bid="${SHELL_CONFIG_ID}"[^>]*>([^<]*)</script>`).exec(await response.text());
319
+ if (config === null) return undefined;
320
+ try {
321
+ const { base } = JSON.parse(config[1] ?? '') as { base?: unknown };
322
+ return typeof base === 'string' ? base : undefined;
323
+ } catch {
324
+ return undefined;
325
+ }
326
+ }
327
+
328
+ function isHtml(response: Response): boolean {
329
+ return (response.headers.get('Content-Type') ?? '').includes('text/html');
330
+ }
331
+
332
+ function htmlHeaders(): Headers {
333
+ return new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
334
+ }
335
+
336
+ function notFound(): Response {
337
+ return new Response(null, { status: 404 });
338
+ }
339
+
340
+ function respond(bytes: Uint8Array<ArrayBuffer>, path: string, range: string | null): Response {
341
+ const headers = new Headers({ 'Content-Type': contentType(path), 'Accept-Ranges': 'bytes' });
342
+ const span = parseRange(range, bytes.length);
343
+ if (span === undefined) return new Response(bytes, { status: 200, headers });
344
+ if (span === 'unsatisfiable') return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${bytes.length}` } });
345
+
346
+ const [start, end] = span;
347
+ headers.set('Content-Range', `bytes ${start}-${end}/${bytes.length}`);
348
+ return new Response(bytes.slice(start, end + 1), { status: 206, headers });
349
+ }
350
+
351
+ /**
352
+ * A single `bytes=` range, as a PDF viewer asks for: the span to send, `'unsatisfiable'` when it
353
+ * starts past the end, or `undefined` for anything else, which gets the whole file.
354
+ */
355
+ export function parseRange(header: string | null, size: number): [number, number] | 'unsatisfiable' | undefined {
356
+ const match = header === null ? null : /^bytes=(\d*)-(\d*)$/.exec(header.trim());
357
+ if (match === null || size === 0) return undefined;
358
+ const [, from = '', to = ''] = match;
359
+ if (from === '' && to === '') return undefined;
360
+
361
+ if (from === '') {
362
+ const suffix = Math.min(Number(to), size);
363
+ return suffix === 0 ? 'unsatisfiable' : [size - suffix, size - 1];
364
+ }
365
+
366
+ const start = Number(from);
367
+ if (to !== '' && Number(to) < start) return undefined;
368
+ if (start >= size) return 'unsatisfiable';
369
+ return [start, to === '' ? size - 1 : Math.min(Number(to), size - 1)];
370
+ }
371
+
372
+ const CONTENT_TYPES: Record<string, string> = {
373
+ html: 'text/html; charset=utf-8',
374
+ js: 'text/javascript; charset=utf-8',
375
+ mjs: 'text/javascript; charset=utf-8',
376
+ css: 'text/css; charset=utf-8',
377
+ json: 'application/json',
378
+ map: 'application/json',
379
+ txt: 'text/plain; charset=utf-8',
380
+ md: 'text/markdown; charset=utf-8',
381
+ csv: 'text/csv; charset=utf-8',
382
+ xml: 'application/xml',
383
+ svg: 'image/svg+xml',
384
+ png: 'image/png',
385
+ jpg: 'image/jpeg',
386
+ jpeg: 'image/jpeg',
387
+ gif: 'image/gif',
388
+ webp: 'image/webp',
389
+ avif: 'image/avif',
390
+ ico: 'image/x-icon',
391
+ bmp: 'image/bmp',
392
+ pdf: 'application/pdf',
393
+ wasm: 'application/wasm',
394
+ woff: 'font/woff',
395
+ woff2: 'font/woff2',
396
+ ttf: 'font/ttf',
397
+ otf: 'font/otf',
398
+ mp4: 'video/mp4',
399
+ webm: 'video/webm',
400
+ mp3: 'audio/mpeg',
401
+ wav: 'audio/wav',
402
+ ogg: 'audio/ogg',
403
+ };
404
+
405
+ export function contentType(path: string): string {
406
+ const extension = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase() ?? '';
407
+ return CONTENT_TYPES[extension] ?? 'application/octet-stream';
408
+ }