@brftech/filex-core 0.30.1 → 0.32.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.
Files changed (38) hide show
  1. package/README.md +37 -2
  2. package/dist/filex-core.js +10571 -8278
  3. package/dist/filex-core.js.map +1 -1
  4. package/dist/filex-core.umd.cjs +95 -87
  5. package/dist/filex-core.umd.cjs.map +1 -1
  6. package/dist/index.d.ts +548 -66
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/FileExplorer.vue +1104 -26
  10. package/src/components/Breadcrumb.vue +4 -2
  11. package/src/components/CommandPalette.vue +18 -2
  12. package/src/components/E2eRecoveryUnlockModal.vue +193 -0
  13. package/src/components/EncryptedFolderModal.vue +10 -0
  14. package/src/components/FilterBar.vue +244 -0
  15. package/src/components/GalleryView.vue +48 -0
  16. package/src/components/GridView.vue +85 -3
  17. package/src/components/InspectorPanel.vue +231 -8
  18. package/src/components/ListView.vue +11 -1
  19. package/src/components/RecoveryKeyModal.vue +133 -0
  20. package/src/components/SecondaryPane.vue +15 -1
  21. package/src/components/SideNav.vue +329 -6
  22. package/src/components/StarButton.vue +27 -15
  23. package/src/components/Toolbar.vue +235 -49
  24. package/src/components/ViewSwitcher.vue +81 -0
  25. package/src/composables/useFileApi.ts +83 -0
  26. package/src/composables/useKeyboardShortcuts.ts +7 -0
  27. package/src/index.ts +33 -1
  28. package/src/lib/e2ecrypto.ts +716 -70
  29. package/src/lib/fileFilters.ts +143 -0
  30. package/src/lib/listing.ts +103 -1
  31. package/src/lib/star.ts +42 -0
  32. package/src/lib/tags.ts +105 -0
  33. package/src/locales/en.ts +154 -2
  34. package/src/locales/tr.ts +154 -2
  35. package/src/modals/PermissionsModal.vue +18 -2
  36. package/src/styles/base.css +743 -0
  37. package/src/types/ExplorerConfig.ts +53 -1
  38. package/src/types/FileNode.ts +23 -0
@@ -3,35 +3,82 @@
3
3
  *
4
4
  * WebCrypto ONLY — zero dependencies. Design doc: docs/E2E-ENCRYPTION.md.
5
5
  *
6
- * Scheme (v1):
7
- * folder password ─PBKDF2-SHA256(600k iter, per-folder 16B salt)─▶ KEK (AES-256-GCM)
8
- * per-file random 32B DEK (AES-256-GCM) encrypts the content one-shot;
9
- * the DEK is wrapped with the KEK and stored in the file's own header.
6
+ * ── Scheme ────────────────────────────────────────────────────────────
10
7
  *
11
- * File layout ('filexe2e' magic, fixed 97-byte header):
8
+ * Every encrypted file wraps its own random DEK under ONE key, the folder
9
+ * master key (FMK), and stores the wrapped copy in its own 97-byte header.
10
+ * The FMK is what a "key slot" in the folder marker hands back:
11
+ *
12
+ * password ─PBKDF2-SHA256(600k, 16B salt)─▶ KEK ─┐
13
+ * recovery key ─HKDF-SHA256(16B salt)─▶ RKEK ────┼─▶ unwraps the FMK
14
+ * escrow private key ─RSA-OAEP-256───────────────┘
15
+ * │
16
+ * per-file random 32B DEK ◀── AES-GCM-wrapped by the FMK, in the header
17
+ *
18
+ * Adding a recovery path therefore costs one more wrapped copy of a single
19
+ * 32-byte key in the marker — not a re-encrypt of anything. The file format
20
+ * below is UNCHANGED from v1 and stays that way; only the marker grew.
21
+ *
22
+ * ── Marker versions ───────────────────────────────────────────────────
23
+ *
24
+ * v1 (shipped up to 0.30.1) has no slots: the DEK is wrapped directly by
25
+ * the password KEK. Read that as "the FMK *is* the KEK". Such folders keep
26
+ * opening with nothing but their password, forever — the v1 read path is a
27
+ * first-class path here, not a migration shim.
28
+ *
29
+ * v2 adds the slots. It comes in two flavours, told apart by `fmk`:
30
+ * - `fmk: 'wrapped'` — a fresh random FMK, held in `fmk_pw` wrapped under
31
+ * the password KEK. Every folder created from 0.31 on.
32
+ * - `fmk: 'kek'` — a v1 folder that was given recovery keys in place.
33
+ * Its files were already wrapped under the KEK and are not rewritten, so
34
+ * the FMK stays defined as "the password-derived KEK" and the recovery
35
+ * slots wrap those raw 32 bytes. The password path is byte-identical to
36
+ * v1; only the extra slots are new.
37
+ *
38
+ * ── Invariants ────────────────────────────────────────────────────────
39
+ *
40
+ * - No key, password or recovery key is ever stored, logged or sent to a
41
+ * server. The FMK lives in an in-memory key ring and dies with the tab.
42
+ * - `deriveKek` imports non-extractable. Raw KEK bytes are produced ONLY
43
+ * by `deriveKekBits`, only for a marker whose FMK *is* the KEK
44
+ * (`upgradeMarkerV1`, and `addEscrowSlot` on a folder it produced), and
45
+ * only long enough to wrap them into a slot.
46
+ * - A folder created while escrow was off carries no escrow slot, so the
47
+ * escrow key cannot open it, and nothing the OPERATOR does changes
48
+ * that — not enabling escrow, not adopting it, not any admin action or
49
+ * future version. That is arithmetic, not policy: adding a slot needs
50
+ * the folder master key, and the server has never held a credential
51
+ * that produces one.
52
+ * - The folder's OWNER can, from inside, with the password:
53
+ * `addEscrowSlot`. That is the only door, it opens from one side only,
54
+ * and it is the reason `escrowAvailability` says "not as things stand"
55
+ * rather than "never".
56
+ *
57
+ * File layout ('filexe2e' magic, fixed 97-byte header) — UNCHANGED in v2:
12
58
  * [0..8) magic "filexe2e"
13
59
  * [8] version 0x01
14
60
  * [9..21) wrapIV (12B) — GCM IV of the DEK wrap
15
- * [21..69) wrappedDEK (48B = 32B DEK + 16B GCM tag)
61
+ * [21..69) wrappedDEK (48B = 32B DEK + 16B GCM tag), wrapped by the FMK
16
62
  * [69..81) dataIV (12B) — GCM IV of the content
17
63
  * [81..97) reserved (zeros; v2 chunking/metadata)
18
64
  * [97..) ciphertext (content + 16B GCM tag)
19
- *
20
- * Folder marker `.filex-e2e.json` at the encrypted-folder root:
21
- * { v:1, salt:<b64 16B>, iter:600000, verify:<b64 12B IV || GCM('filex-e2e-verify-v1')> }
22
- *
23
- * The KEK NEVER leaves memory — no storage of any kind. Password loss is
24
- * data loss by design (no recovery path exists anywhere).
25
65
  */
26
66
 
27
67
  export const E2E_MARKER_NAME = '.filex-e2e.json';
28
68
  export const E2E_MAGIC = 'filexe2e';
69
+ /** File-header version byte. Unchanged by the recovery work. */
29
70
  export const E2E_VERSION = 1;
71
+ /** Marker schema version written by this build. v1 markers still read. */
72
+ export const E2E_MARKER_VERSION = 2;
30
73
  export const E2E_DEFAULT_ITERATIONS = 600_000;
31
74
  export const E2E_MIN_ITERATIONS = 600_000;
32
75
  /** MVP single-shot in-memory ceiling — larger uploads are refused with a warning. */
33
76
  export const E2E_MAX_FILE_BYTES = 200 * 1024 * 1024;
34
77
  export const E2E_MIN_PASSWORD_LEN = 8;
78
+ /** Entropy of a user recovery key: 20 bytes = 160 bits = exactly 32 base32 chars. */
79
+ export const E2E_RECOVERY_KEY_BYTES = 20;
80
+ /** The only escrow algorithm this version understands. */
81
+ export const E2E_ESCROW_ALG = 'RSA-OAEP-256';
35
82
 
36
83
  const VERIFY_PLAINTEXT = 'filex-e2e-verify-v1';
37
84
  const MAGIC_BYTES = new TextEncoder().encode(E2E_MAGIC); // 8 bytes
@@ -41,12 +88,58 @@ const WRAPPED_DEK_OFF = 21;
41
88
  const WRAPPED_DEK_LEN = 48;
42
89
  const DATA_IV_OFF = 69;
43
90
  const IV_LEN = 12;
91
+ const FMK_LEN = 32;
92
+ /** HKDF domain separation for the user recovery key. */
93
+ const RK_INFO = 'filex-e2e-recovery-v1';
94
+ const RK_SALT_LEN = 16;
95
+
96
+ /** How the folder master key is obtained from the password slot. */
97
+ export type E2eFmkMode = 'kek' | 'wrapped';
98
+
99
+ /** User-recovery-key slot: HKDF salt + the FMK wrapped under the derived key. */
100
+ export interface E2eRecoverySlot {
101
+ salt: string; // base64, 16B HKDF salt
102
+ blob: string; // base64: 12B IV || AES-GCM(RKEK, FMK)
103
+ }
104
+
105
+ /** Escrow slot: the FMK encrypted to the installation's escrow public key. */
106
+ export interface E2eEscrowSlot {
107
+ /** First 8 bytes of SHA-256(SPKI), hex — names WHICH escrow key this is. */
108
+ kid: string;
109
+ alg: string; // E2E_ESCROW_ALG
110
+ blob: string; // base64: RSA-OAEP-256(escrow public key, FMK)
111
+ }
44
112
 
45
113
  export interface E2eMarker {
46
114
  v: number;
47
- salt: string; // base64
115
+ salt: string; // base64, PBKDF2 salt for the password slot
48
116
  iter: number;
49
117
  verify: string; // base64: 12B IV || AES-GCM ciphertext of VERIFY_PLAINTEXT
118
+ /** v2 only. Absent on a v1 marker, where the FMK is implicitly the KEK. */
119
+ fmk?: E2eFmkMode;
120
+ /** v2 + fmk==='wrapped' only: base64 12B IV || AES-GCM(KEK, FMK). */
121
+ fmk_pw?: string;
122
+ /** v2 only, optional: the user recovery key slot. */
123
+ rk?: E2eRecoverySlot;
124
+ /** v2 only, optional: the operator escrow slot. */
125
+ esc?: E2eEscrowSlot;
126
+ /**
127
+ * v2 only, optional: an ISO timestamp recording that this folder's owner
128
+ * was OFFERED an escrow slot and said no.
129
+ *
130
+ * It lives in the marker rather than in browser storage because the unit
131
+ * of the decision is the FOLDER, not the device: the same person opening
132
+ * the folder from their phone must not be asked again, and a decision
133
+ * that vanished when someone cleared their site data would be no decision
134
+ * at all. It travels with the folder through a move, a backup and a
135
+ * restore, for the same reason the key slots do.
136
+ *
137
+ * It holds no key material and hides nothing from the operator — it is a
138
+ * record of an answer, and its only effect is that filex stops asking.
139
+ * `addEscrowSlot` clears it, so a decline is reversible by the one person
140
+ * who can reverse it.
141
+ */
142
+ esc_declined?: string;
50
143
  }
51
144
 
52
145
  /** Thrown on wrong password / corrupted ciphertext (GCM tag mismatch). */
@@ -74,6 +167,51 @@ export function b64ToBytes(s: string): Uint8Array {
74
167
  return out;
75
168
  }
76
169
 
170
+ /**
171
+ * Copy into a fresh ArrayBuffer — TS 5.9 BufferSource typing rejects views
172
+ * that may wrap a SharedArrayBuffer, and WebCrypto wants a plain buffer.
173
+ */
174
+ function buf(b: Uint8Array): ArrayBuffer {
175
+ return new Uint8Array(b).buffer as ArrayBuffer;
176
+ }
177
+
178
+ /** IV || ciphertext, the shape every AES-GCM blob in the marker uses. */
179
+ function joinIvCt(iv: Uint8Array, ct: Uint8Array): string {
180
+ const out = new Uint8Array(iv.length + ct.length);
181
+ out.set(iv, 0);
182
+ out.set(ct, iv.length);
183
+ return bytesToB64(out);
184
+ }
185
+
186
+ async function gcmSeal(key: CryptoKey, plain: Uint8Array): Promise<string> {
187
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
188
+ const ct = new Uint8Array(
189
+ await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buf(iv) }, key, buf(plain)),
190
+ );
191
+ return joinIvCt(iv, ct);
192
+ }
193
+
194
+ /** Returns null (never throws) on a tag mismatch — i.e. "wrong key". */
195
+ async function gcmOpen(key: CryptoKey, b64: string): Promise<Uint8Array | null> {
196
+ let raw: Uint8Array;
197
+ try {
198
+ raw = b64ToBytes(b64);
199
+ } catch {
200
+ return null;
201
+ }
202
+ if (raw.length <= IV_LEN) return null;
203
+ try {
204
+ const pt = await crypto.subtle.decrypt(
205
+ { name: 'AES-GCM', iv: buf(raw.slice(0, IV_LEN)) },
206
+ key,
207
+ buf(raw.slice(IV_LEN)),
208
+ );
209
+ return new Uint8Array(pt);
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
77
215
  // ---------------------------------------------------------------------
78
216
  // Key derivation
79
217
  // ---------------------------------------------------------------------
@@ -96,9 +234,7 @@ export async function deriveKek(
96
234
  ['deriveKey'],
97
235
  );
98
236
  return crypto.subtle.deriveKey(
99
- // Copy into a fresh ArrayBuffer-backed view TS 5.9 BufferSource typing
100
- // rejects Uint8Array<ArrayBufferLike> that may wrap a SharedArrayBuffer.
101
- { name: 'PBKDF2', salt: new Uint8Array(salt).buffer as ArrayBuffer, iterations, hash: 'SHA-256' },
237
+ { name: 'PBKDF2', salt: buf(salt), iterations, hash: 'SHA-256' },
102
238
  material,
103
239
  { name: 'AES-GCM', length: 256 },
104
240
  false, // non-extractable
@@ -106,11 +242,193 @@ export async function deriveKek(
106
242
  );
107
243
  }
108
244
 
245
+ /**
246
+ * The same 32 bytes as `deriveKek`, but as raw material.
247
+ *
248
+ * ⚠ Used in exactly one place: upgrading a v1 marker, where the files are
249
+ * already wrapped under the KEK and the recovery slots must therefore hold
250
+ * those very bytes. Nothing else may call this — the steady-state password
251
+ * path uses `deriveKek`, whose key cannot be exported.
252
+ */
253
+ async function deriveKekBits(
254
+ password: string,
255
+ salt: Uint8Array,
256
+ iterations: number,
257
+ ): Promise<Uint8Array> {
258
+ const material = await crypto.subtle.importKey(
259
+ 'raw',
260
+ new TextEncoder().encode(password),
261
+ 'PBKDF2',
262
+ false,
263
+ ['deriveBits'],
264
+ );
265
+ const bits = await crypto.subtle.deriveBits(
266
+ { name: 'PBKDF2', salt: buf(salt), iterations, hash: 'SHA-256' },
267
+ material,
268
+ FMK_LEN * 8,
269
+ );
270
+ return new Uint8Array(bits);
271
+ }
272
+
273
+ /** Import raw 32 bytes as the AES-256-GCM folder master key. */
274
+ async function importFmk(raw: Uint8Array): Promise<CryptoKey> {
275
+ return crypto.subtle.importKey('raw', buf(raw), { name: 'AES-GCM' }, false, [
276
+ 'encrypt',
277
+ 'decrypt',
278
+ ]);
279
+ }
280
+
109
281
  // ---------------------------------------------------------------------
110
- // Marker create / verify
282
+ // User recovery key — 160 bits, Crockford base32, 8 groups of 4
111
283
  // ---------------------------------------------------------------------
112
284
 
113
- /** Create a fresh folder marker for `password` (also returns the derived KEK). */
285
+ /** Crockford base32: no I, L, O or U, so it survives being read aloud. */
286
+ const B32_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
287
+
288
+ /**
289
+ * Format 20 raw bytes as the string the user writes down:
290
+ * `XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX` (160 bits, no padding waste).
291
+ */
292
+ export function formatRecoveryKey(raw: Uint8Array): string {
293
+ let bits = 0;
294
+ let acc = 0;
295
+ let out = '';
296
+ for (let i = 0; i < raw.length; i++) {
297
+ acc = (acc << 8) | raw[i];
298
+ bits += 8;
299
+ while (bits >= 5) {
300
+ out += B32_ALPHABET[(acc >>> (bits - 5)) & 31];
301
+ bits -= 5;
302
+ }
303
+ }
304
+ if (bits > 0) out += B32_ALPHABET[(acc << (5 - bits)) & 31];
305
+ return (out.match(/.{1,4}/g) || []).join('-');
306
+ }
307
+
308
+ /** Mint a fresh user recovery key. Shown once, never stored by filex. */
309
+ export function generateRecoveryKey(): string {
310
+ return formatRecoveryKey(crypto.getRandomValues(new Uint8Array(E2E_RECOVERY_KEY_BYTES)));
311
+ }
312
+
313
+ /**
314
+ * Parse a typed-in recovery key back to its 20 bytes, or null when it is not
315
+ * one. Forgiving about how a human retypes it: case, dashes, spaces and the
316
+ * Crockford look-alikes (O to 0, I/L to 1) are all normalised away.
317
+ */
318
+ export function parseRecoveryKey(s: string): Uint8Array | null {
319
+ const clean = (s || '')
320
+ .toUpperCase()
321
+ .replace(/[\s-]/g, '')
322
+ .replace(/O/g, '0')
323
+ .replace(/[IL]/g, '1');
324
+ const need = Math.ceil((E2E_RECOVERY_KEY_BYTES * 8) / 5); // 32 chars
325
+ if (clean.length !== need) return null;
326
+ const out = new Uint8Array(E2E_RECOVERY_KEY_BYTES);
327
+ let acc = 0;
328
+ let bits = 0;
329
+ let n = 0;
330
+ for (const ch of clean) {
331
+ const v = B32_ALPHABET.indexOf(ch);
332
+ if (v < 0) return null;
333
+ acc = (acc << 5) | v;
334
+ bits += 5;
335
+ if (bits >= 8) {
336
+ out[n++] = (acc >>> (bits - 8)) & 0xff;
337
+ bits -= 8;
338
+ }
339
+ }
340
+ return n === E2E_RECOVERY_KEY_BYTES ? out : null;
341
+ }
342
+
343
+ /** HKDF-SHA256 the recovery key into the AES key that wraps the FMK. */
344
+ async function deriveRecoveryKek(raw: Uint8Array, salt: Uint8Array): Promise<CryptoKey> {
345
+ const base = await crypto.subtle.importKey('raw', buf(raw), 'HKDF', false, ['deriveKey']);
346
+ return crypto.subtle.deriveKey(
347
+ {
348
+ name: 'HKDF',
349
+ hash: 'SHA-256',
350
+ salt: buf(salt),
351
+ info: buf(new TextEncoder().encode(RK_INFO)),
352
+ },
353
+ base,
354
+ { name: 'AES-GCM', length: 256 },
355
+ false,
356
+ ['encrypt', 'decrypt'],
357
+ );
358
+ }
359
+
360
+ async function sealRecoverySlot(fmk: Uint8Array, recoveryKey: string): Promise<E2eRecoverySlot> {
361
+ const raw = parseRecoveryKey(recoveryKey);
362
+ if (!raw) throw new Error('e2e: malformed recovery key');
363
+ const salt = crypto.getRandomValues(new Uint8Array(RK_SALT_LEN));
364
+ const rkek = await deriveRecoveryKek(raw, salt);
365
+ const slot = { salt: bytesToB64(salt), blob: await gcmSeal(rkek, fmk) };
366
+ raw.fill(0);
367
+ return slot;
368
+ }
369
+
370
+ // ---------------------------------------------------------------------
371
+ // Escrow (operator recovery) — RSA-OAEP-256
372
+ // ---------------------------------------------------------------------
373
+ //
374
+ // The server holds the PUBLIC half only, so it can wrap new folders' FMKs to
375
+ // the escrow identity. The private half was handed to the admin at install
376
+ // and is supplied back by hand when it is used. A stolen filex database
377
+ // therefore decrypts nothing.
378
+
379
+ /** Import the installation escrow public key (base64 SPKI, as the server serves it). */
380
+ export async function importEscrowPublicKey(spkiB64: string): Promise<CryptoKey> {
381
+ return crypto.subtle.importKey(
382
+ 'spki',
383
+ buf(b64ToBytes(spkiB64)),
384
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
385
+ true,
386
+ ['encrypt'],
387
+ );
388
+ }
389
+
390
+ /** Import the escrow private key the admin pastes in (base64 PKCS#8, PEM tolerated). */
391
+ export async function importEscrowPrivateKey(pkcs8B64: string): Promise<CryptoKey> {
392
+ const clean = (pkcs8B64 || '').replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '');
393
+ return crypto.subtle.importKey(
394
+ 'pkcs8',
395
+ buf(b64ToBytes(clean)),
396
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
397
+ false,
398
+ ['decrypt'],
399
+ );
400
+ }
401
+
402
+ /**
403
+ * Stable short name for an escrow key: first 8 bytes of SHA-256(SPKI), hex.
404
+ * Written into every escrow slot so a marker says WHICH key opens it, and so
405
+ * the UI can tell "this server's escrow key" from "some other one".
406
+ */
407
+ export async function escrowKeyId(spkiB64: string): Promise<string> {
408
+ const d = new Uint8Array(await crypto.subtle.digest('SHA-256', buf(b64ToBytes(spkiB64))));
409
+ return Array.from(d.slice(0, 8))
410
+ .map((x) => x.toString(16).padStart(2, '0'))
411
+ .join('');
412
+ }
413
+
414
+ async function sealEscrowSlot(fmk: Uint8Array, escrowSpkiB64: string): Promise<E2eEscrowSlot> {
415
+ const pub = await importEscrowPublicKey(escrowSpkiB64);
416
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, pub, buf(fmk)));
417
+ return { kid: await escrowKeyId(escrowSpkiB64), alg: E2E_ESCROW_ALG, blob: bytesToB64(ct) };
418
+ }
419
+
420
+ // ---------------------------------------------------------------------
421
+ // Marker create / parse / verify
422
+ // ---------------------------------------------------------------------
423
+
424
+ /**
425
+ * Create a v1 folder marker — the pre-0.31 format, with NO recovery of any
426
+ * kind.
427
+ *
428
+ * @deprecated Use `createEncryptedFolder`. Kept exported, and kept producing
429
+ * a genuine v1 marker, so an embedder pinned to the old API keeps creating
430
+ * folders this build can still open rather than half-formed v2 ones.
431
+ */
114
432
  export async function createMarker(
115
433
  password: string,
116
434
  iterations: number = E2E_DEFAULT_ITERATIONS,
@@ -118,60 +436,389 @@ export async function createMarker(
118
436
  const iter = Math.max(E2E_MIN_ITERATIONS, iterations);
119
437
  const salt = crypto.getRandomValues(new Uint8Array(16));
120
438
  const kek = await deriveKek(password, salt, iter);
121
- const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
122
- const ct = new Uint8Array(
123
- await crypto.subtle.encrypt(
124
- { name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
125
- kek,
126
- new TextEncoder().encode(VERIFY_PLAINTEXT),
127
- ),
128
- );
129
- const verify = new Uint8Array(IV_LEN + ct.length);
130
- verify.set(iv, 0);
131
- verify.set(ct, IV_LEN);
132
- return {
133
- marker: { v: E2E_VERSION, salt: bytesToB64(salt), iter, verify: bytesToB64(verify) },
134
- kek,
439
+ const verify = await gcmSeal(kek, new TextEncoder().encode(VERIFY_PLAINTEXT));
440
+ return { marker: { v: 1, salt: bytesToB64(salt), iter, verify }, kek };
441
+ }
442
+
443
+ export interface CreateFolderOptions {
444
+ iterations?: number;
445
+ /** Base64 SPKI of the installation escrow key, when escrow is enabled. */
446
+ escrowPublicKey?: string | null;
447
+ }
448
+
449
+ export interface CreatedFolder {
450
+ marker: E2eMarker;
451
+ /** The folder master key, ready for encryptFile/decryptFile. */
452
+ fmk: CryptoKey;
453
+ /** Show this ONCE. filex never stores it and can never show it again. */
454
+ recoveryKey: string;
455
+ }
456
+
457
+ /**
458
+ * Create a v2 encrypted folder: random FMK, wrapped under the password KEK,
459
+ * under a freshly minted user recovery key, and — when the installation has
460
+ * escrow enabled — to the escrow public key.
461
+ */
462
+ export async function createEncryptedFolder(
463
+ password: string,
464
+ opts: CreateFolderOptions = {},
465
+ ): Promise<CreatedFolder> {
466
+ const iter = Math.max(E2E_MIN_ITERATIONS, opts.iterations ?? E2E_DEFAULT_ITERATIONS);
467
+ const salt = crypto.getRandomValues(new Uint8Array(16));
468
+ const kek = await deriveKek(password, salt, iter);
469
+ const rawFmk = crypto.getRandomValues(new Uint8Array(FMK_LEN));
470
+ const recoveryKey = generateRecoveryKey();
471
+
472
+ const marker: E2eMarker = {
473
+ v: E2E_MARKER_VERSION,
474
+ salt: bytesToB64(salt),
475
+ iter,
476
+ verify: await gcmSeal(kek, new TextEncoder().encode(VERIFY_PLAINTEXT)),
477
+ fmk: 'wrapped',
478
+ fmk_pw: await gcmSeal(kek, rawFmk),
479
+ rk: await sealRecoverySlot(rawFmk, recoveryKey),
135
480
  };
481
+ if (opts.escrowPublicKey) marker.esc = await sealEscrowSlot(rawFmk, opts.escrowPublicKey);
482
+
483
+ const fmk = await importFmk(rawFmk);
484
+ rawFmk.fill(0);
485
+ return { marker, fmk, recoveryKey };
486
+ }
487
+
488
+ /**
489
+ * Give an existing v1 folder recovery keys, in place and without rewriting a
490
+ * single file.
491
+ *
492
+ * The v1 files are wrapped under the password KEK, so the FMK stays defined
493
+ * as "the KEK" (`fmk: 'kek'`) and the new slots wrap those raw bytes. The
494
+ * password path afterwards is byte-identical to what it was.
495
+ *
496
+ * ⚠ Requires the password — this is only callable at the one moment filex
497
+ * ever has it. There is no way to give a v1 folder recovery without it.
498
+ * ⚠ When the installation has escrow on, this ALSO hands the operator a key
499
+ * to a folder that did not have one. The caller must say so before asking.
500
+ */
501
+ export async function upgradeMarkerV1(
502
+ marker: E2eMarker,
503
+ password: string,
504
+ opts: CreateFolderOptions = {},
505
+ ): Promise<CreatedFolder> {
506
+ if (marker.v !== 1) throw new Error('e2e: not a v1 marker');
507
+ const salt = b64ToBytes(marker.salt);
508
+ const kek = await deriveKek(password, salt, marker.iter);
509
+ // Prove the password before touching anything.
510
+ const ok = await gcmOpen(kek, marker.verify);
511
+ if (!ok || new TextDecoder().decode(ok) !== VERIFY_PLAINTEXT) {
512
+ throw new E2eDecryptError('e2e: wrong password');
513
+ }
514
+ const rawKek = await deriveKekBits(password, salt, marker.iter);
515
+ const recoveryKey = generateRecoveryKey();
516
+ const next: E2eMarker = {
517
+ v: E2E_MARKER_VERSION,
518
+ salt: marker.salt,
519
+ iter: marker.iter,
520
+ verify: marker.verify,
521
+ fmk: 'kek',
522
+ rk: await sealRecoverySlot(rawKek, recoveryKey),
523
+ };
524
+ if (opts.escrowPublicKey) next.esc = await sealEscrowSlot(rawKek, opts.escrowPublicKey);
525
+ rawKek.fill(0);
526
+ return { marker: next, fmk: kek, recoveryKey };
527
+ }
528
+
529
+ /**
530
+ * Give an EXISTING v2 folder an escrow slot, in place, using the folder
531
+ * password its owner has just typed.
532
+ *
533
+ * ── Why this exists ─────────────────────────────────────────────────
534
+ *
535
+ * Escrow used to be all-or-nothing at install time, and then adoptable but
536
+ * never retroactive: on any installation that had been running for a while,
537
+ * escrow covered only folders nobody had created yet. On a real deployment
538
+ * the folders that matter already exist, so "new folders only" means escrow
539
+ * covers nothing anyone cares about.
540
+ *
541
+ * The server still cannot do this, and that has not changed: adding a slot
542
+ * needs the folder master key, which needs a credential the server has never
543
+ * held. What CAN do it is the browser, at the one moment the password is in
544
+ * memory — exactly where `upgradeMarkerV1` already lives. Same moment, same
545
+ * shape, different slot.
546
+ *
547
+ * ⚠⚠ Accepting hands the operator of this installation a second, permanent
548
+ * way into this folder. It is the folder's owner who decides, from inside,
549
+ * with the password; no configuration change and no admin action can do it
550
+ * for them. The caller MUST say that in those words before calling this —
551
+ * see `e2e.escrowoffer.*` in the locales.
552
+ *
553
+ * ⚠ v2 only. A v1 marker has no slots at all; the path for those is
554
+ * `upgradeMarkerV1`, which already seals an escrow slot when the
555
+ * installation has a key and already discloses it. Two doors into the same
556
+ * room would be two chances to get the disclosure wrong.
557
+ *
558
+ * ⚠ No file is re-encrypted, moved or rewritten. Only `.filex-e2e.json`
559
+ * changes, and only by gaining `esc` (and losing `esc_declined`).
560
+ */
561
+ export async function addEscrowSlot(
562
+ marker: E2eMarker,
563
+ password: string,
564
+ escrowPublicKey: string,
565
+ ): Promise<E2eMarker> {
566
+ if (marker.v !== 2) throw new Error('e2e: not a v2 marker');
567
+ if (marker.esc) throw new Error('e2e: this folder already has an escrow slot');
568
+ if (!escrowPublicKey) throw new Error('e2e: no escrow public key');
569
+
570
+ const salt = b64ToBytes(marker.salt);
571
+ const kek = await deriveKek(password, salt, marker.iter);
572
+ // Prove the password before touching anything, exactly as the v1 upgrade
573
+ // does. A wrong password here must not produce a marker at all — half a
574
+ // marker is a folder nobody can open.
575
+ const proof = await gcmOpen(kek, marker.verify);
576
+ if (!proof || new TextDecoder().decode(proof) !== VERIFY_PLAINTEXT) {
577
+ throw new E2eDecryptError('e2e: wrong password');
578
+ }
579
+
580
+ // The raw FMK, by mode. `wrapped` keeps a random FMK in `fmk_pw`, so the
581
+ // bytes come back from a GCM open and no extractable KEK is ever derived.
582
+ // `kek` (a v1 folder upgraded in place) defines the FMK AS the password
583
+ // key, so those very bytes are what the slot has to wrap — the one case
584
+ // that needs `deriveKekBits`, for the same reason `upgradeMarkerV1` does.
585
+ let rawFmk: Uint8Array;
586
+ if (marker.fmk === 'wrapped') {
587
+ const opened = marker.fmk_pw ? await gcmOpen(kek, marker.fmk_pw) : null;
588
+ if (!opened || opened.length !== FMK_LEN) {
589
+ throw new E2eDecryptError('e2e: could not unwrap the folder master key');
590
+ }
591
+ rawFmk = opened;
592
+ } else {
593
+ rawFmk = await deriveKekBits(password, salt, marker.iter);
594
+ }
595
+
596
+ const esc = await sealEscrowSlot(rawFmk, escrowPublicKey);
597
+ rawFmk.fill(0);
598
+
599
+ const next: E2eMarker = { ...marker, esc };
600
+ // A decline that is now moot. Leaving it would make the record say two
601
+ // contradictory things about the same folder.
602
+ delete next.esc_declined;
603
+ return next;
604
+ }
605
+
606
+ /**
607
+ * Record that this folder's owner was offered an escrow slot and declined.
608
+ *
609
+ * A refusal is a decision, not a delay: without this the offer would come
610
+ * back on every single unlock, which is how people learn to click past
611
+ * security dialogs without reading them. Nothing about the folder's keys
612
+ * changes — the only effect is that filex stops asking.
613
+ *
614
+ * Reversible by `addEscrowSlot`, which is the way back for somebody who
615
+ * says no today and changes their mind next month.
616
+ */
617
+ export function declineEscrowSlot(marker: E2eMarker, when: string): E2eMarker {
618
+ return { ...marker, esc_declined: when };
619
+ }
620
+
621
+ /**
622
+ * Whether this folder's owner should be offered an escrow slot, and whether
623
+ * they have already answered.
624
+ *
625
+ * 'n/a' nothing to offer: the installation has no escrow key, the
626
+ * folder already has a slot, or the marker is v1 (whose path
627
+ * is `upgradeMarkerV1`).
628
+ * 'offer' the offer applies and no answer has been recorded.
629
+ * 'declined' the offer applies and the owner said no. Do not ask again;
630
+ * leave a way back.
631
+ *
632
+ * ⚠ This deliberately does NOT look at whether the folder is unlocked. That
633
+ * is the caller's business, and it matters: the offer may only be shown
634
+ * after an unlock actually succeeded, because accepting needs the password
635
+ * and because asking someone who cannot open the folder to give away a key
636
+ * to it is asking the wrong person.
637
+ */
638
+ export type EscrowOfferState = 'n/a' | 'offer' | 'declined';
639
+
640
+ export function escrowOfferState(
641
+ m: E2eMarker | null,
642
+ installationKid: string | null | undefined,
643
+ ): EscrowOfferState {
644
+ if (!installationKid) return 'n/a';
645
+ if (!m || m.v !== 2 || m.esc) return 'n/a';
646
+ return m.esc_declined ? 'declined' : 'offer';
136
647
  }
137
648
 
138
- /** Parse marker JSON text; returns null when the shape is not a v1 marker. */
649
+ /** Parse marker JSON text; returns null when the shape is not a marker we read. */
139
650
  export function parseMarker(text: string): E2eMarker | null {
140
651
  try {
141
652
  const m = JSON.parse(text) as E2eMarker;
142
- if (!m || m.v !== E2E_VERSION) return null;
653
+ if (!m || (m.v !== 1 && m.v !== 2)) return null;
143
654
  if (typeof m.salt !== 'string' || typeof m.verify !== 'string') return null;
144
655
  if (typeof m.iter !== 'number' || m.iter < 1) return null;
656
+ if (m.v === 2) {
657
+ if (m.fmk !== 'kek' && m.fmk !== 'wrapped') return null;
658
+ if (m.fmk === 'wrapped' && typeof m.fmk_pw !== 'string') return null;
659
+ }
145
660
  return m;
146
661
  } catch {
147
662
  return null;
148
663
  }
149
664
  }
150
665
 
666
+ /** True when the folder has a user recovery key slot. */
667
+ export function markerHasRecovery(m: E2eMarker | null): boolean {
668
+ return !!m && m.v === 2 && !!m.rk;
669
+ }
670
+
671
+ /** True when the folder has an operator escrow slot. */
672
+ export function markerHasEscrow(m: E2eMarker | null): boolean {
673
+ return !!m && m.v === 2 && !!m.esc;
674
+ }
675
+
676
+ /**
677
+ * Why the escrow door is, or is not, on offer for this folder.
678
+ *
679
+ * 'off' this installation has no escrow key at all.
680
+ * 'available' the folder is sealed to THIS installation's escrow key.
681
+ * 'predates' the installation has an escrow key, and this folder has no
682
+ * escrow slot: it was created before escrow existed here.
683
+ * 'other-key' the folder carries an escrow slot sealed to a DIFFERENT
684
+ * key id — it came from another installation, via a restore
685
+ * or a copied data directory.
686
+ *
687
+ * ⚠ 'predates' exists because escrow can be ADOPTED by an installation
688
+ * that already has folders (FILEX_INSTALLATION_E2E_ESCROW_ADOPT), and
689
+ * adoption is not retroactive: the folder's master key was wrapped to its
690
+ * recovery paths when the folder was created. Before this distinction
691
+ * existed the dialog simply showed no Escrow tab, which is true but says
692
+ * nothing — an admin who knows escrow is on reads a missing tab as a bug,
693
+ * tries the key anyway, and learns the real answer from a failure. The UI
694
+ * has to say it instead.
695
+ *
696
+ * ⚠⚠ 'predates' means "not as things stand", NOT "never". The folder's
697
+ * owner can add a slot from inside with the password (`addEscrowSlot`,
698
+ * offered at unlock). Any wording built on this state has to leave that
699
+ * door visible, or it tells an operator their escrow key can never reach a
700
+ * folder whose owner could hand it over this afternoon.
701
+ *
702
+ * ⚠ 'other-key' was a quieter lie: the dialog labelled the escrow field
703
+ * with the INSTALLATION's key id whatever the folder's slot said, so a
704
+ * folder restored from another install looked openable by the key the
705
+ * operator has, and was not.
706
+ */
707
+ export type EscrowAvailability = 'off' | 'available' | 'predates' | 'other-key';
708
+
709
+ export function escrowAvailability(
710
+ m: E2eMarker | null,
711
+ installationKid: string | null | undefined,
712
+ ): EscrowAvailability {
713
+ if (!installationKid) return 'off';
714
+ if (!markerHasEscrow(m)) return 'predates';
715
+ return m!.esc!.kid === installationKid ? 'available' : 'other-key';
716
+ }
717
+
151
718
  /**
152
719
  * Check `password` against a folder marker. Resolves to the derived KEK on
153
720
  * success, or `null` on a wrong password (GCM tag mismatch on the verify
154
721
  * blob). Never talks to any server.
722
+ *
723
+ * ⚠ This returns the KEK, not the FMK. On a v1 folder they are the same key;
724
+ * on a v2 `fmk: 'wrapped'` folder they are not. Use `unlockWithPassword` to
725
+ * get the key that actually decrypts files.
155
726
  */
156
727
  export async function verifyPassword(
157
728
  marker: E2eMarker,
158
729
  password: string,
159
730
  ): Promise<CryptoKey | null> {
160
- const salt = b64ToBytes(marker.salt);
161
- const kek = await deriveKek(password, salt, marker.iter);
162
- const verify = b64ToBytes(marker.verify);
163
- if (verify.length <= IV_LEN) return null;
731
+ const kek = await deriveKek(password, b64ToBytes(marker.salt), marker.iter);
732
+ const pt = await gcmOpen(kek, marker.verify);
733
+ if (!pt || new TextDecoder().decode(pt) !== VERIFY_PLAINTEXT) return null;
734
+ return kek;
735
+ }
736
+
737
+ // ---------------------------------------------------------------------
738
+ // Unlock — the three ways to reach the FMK
739
+ // ---------------------------------------------------------------------
740
+
741
+ /** Turn a password KEK into the FMK for this marker. */
742
+ async function fmkFromKek(marker: E2eMarker, kek: CryptoKey): Promise<CryptoKey | null> {
743
+ // v1, and v2 folders upgraded from v1: the files are wrapped by the KEK.
744
+ if (marker.v === 1 || marker.fmk === 'kek') return kek;
745
+ if (!marker.fmk_pw) return null;
746
+ const raw = await gcmOpen(kek, marker.fmk_pw);
747
+ if (!raw || raw.length !== FMK_LEN) return null;
748
+ const fmk = await importFmk(raw);
749
+ raw.fill(0);
750
+ return fmk;
751
+ }
752
+
753
+ /**
754
+ * Unlock with the folder password. Returns the FMK (the key `decryptFile`
755
+ * wants) or null when the password is wrong.
756
+ */
757
+ export async function unlockWithPassword(
758
+ marker: E2eMarker,
759
+ password: string,
760
+ ): Promise<CryptoKey | null> {
761
+ const kek = await verifyPassword(marker, password);
762
+ if (!kek) return null;
763
+ return fmkFromKek(marker, kek);
764
+ }
765
+
766
+ /**
767
+ * Unlock with the user recovery key shown when the folder was created.
768
+ * Returns null for a malformed key, a wrong key, or a folder that has no
769
+ * recovery slot at all — the caller cannot tell those apart, and neither can
770
+ * an attacker.
771
+ */
772
+ export async function unlockWithRecoveryKey(
773
+ marker: E2eMarker,
774
+ recoveryKey: string,
775
+ ): Promise<CryptoKey | null> {
776
+ if (marker.v !== 2 || !marker.rk) return null;
777
+ const raw = parseRecoveryKey(recoveryKey);
778
+ if (!raw) return null;
779
+ let salt: Uint8Array;
164
780
  try {
165
- const pt = await crypto.subtle.decrypt(
166
- { name: 'AES-GCM', iv: verify.slice(0, IV_LEN).buffer as ArrayBuffer },
167
- kek,
168
- verify.slice(IV_LEN).buffer as ArrayBuffer,
781
+ salt = b64ToBytes(marker.rk.salt);
782
+ } catch {
783
+ return null;
784
+ }
785
+ const rkek = await deriveRecoveryKek(raw, salt);
786
+ raw.fill(0);
787
+ const fmkRaw = await gcmOpen(rkek, marker.rk.blob);
788
+ if (!fmkRaw || fmkRaw.length !== FMK_LEN) return null;
789
+ const fmk = await importFmk(fmkRaw);
790
+ fmkRaw.fill(0);
791
+ return fmk;
792
+ }
793
+
794
+ /**
795
+ * Unlock with the installation escrow private key.
796
+ *
797
+ * Returns null when the folder has no escrow slot — which is the case for
798
+ * every folder created while escrow was off, and is why escrow cannot be
799
+ * turned on retroactively.
800
+ */
801
+ export async function unlockWithEscrowKey(
802
+ marker: E2eMarker,
803
+ privateKey: CryptoKey,
804
+ ): Promise<CryptoKey | null> {
805
+ if (marker.v !== 2 || !marker.esc || marker.esc.alg !== E2E_ESCROW_ALG) return null;
806
+ let raw: Uint8Array;
807
+ try {
808
+ raw = new Uint8Array(
809
+ await crypto.subtle.decrypt(
810
+ { name: 'RSA-OAEP' },
811
+ privateKey,
812
+ buf(b64ToBytes(marker.esc.blob)),
813
+ ),
169
814
  );
170
- if (new TextDecoder().decode(pt) !== VERIFY_PLAINTEXT) return null;
171
- return kek;
172
815
  } catch {
173
- return null; // wrong password
816
+ return null; // wrong escrow key, or a slot sealed to another installation
174
817
  }
818
+ if (raw.length !== FMK_LEN) return null;
819
+ const fmk = await importFmk(raw);
820
+ raw.fill(0);
821
+ return fmk;
175
822
  }
176
823
 
177
824
  // ---------------------------------------------------------------------
@@ -179,8 +826,8 @@ export async function verifyPassword(
179
826
  // ---------------------------------------------------------------------
180
827
 
181
828
  /** True when the buffer starts with the 'filexe2e' magic. */
182
- export function hasMagic(buf: ArrayBuffer | Uint8Array): boolean {
183
- const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
829
+ export function hasMagic(data: ArrayBuffer | Uint8Array): boolean {
830
+ const b = data instanceof Uint8Array ? data : new Uint8Array(data);
184
831
  if (b.length < MAGIC_BYTES.length) return false;
185
832
  for (let i = 0; i < MAGIC_BYTES.length; i++) {
186
833
  if (b[i] !== MAGIC_BYTES[i]) return false;
@@ -193,25 +840,28 @@ export function hasMagic(buf: ArrayBuffer | Uint8Array): boolean {
193
840
  // ---------------------------------------------------------------------
194
841
 
195
842
  /**
196
- * Encrypt `content` under the folder KEK: mints a fresh DEK, encrypts the
197
- * content one-shot, wraps the DEK with the KEK and prepends the fixed
843
+ * Encrypt `content` under the folder master key: mints a fresh DEK, encrypts
844
+ * the content one-shot, wraps the DEK with the FMK and prepends the fixed
198
845
  * 'filexe2e' header. Throws when content exceeds E2E_MAX_FILE_BYTES.
846
+ *
847
+ * `fmk` is the key an unlock returned. On a v1 folder that is the password
848
+ * KEK, which is why v1 files keep working untouched.
199
849
  */
200
- export async function encryptFile(kek: CryptoKey, content: ArrayBuffer): Promise<ArrayBuffer> {
850
+ export async function encryptFile(fmk: CryptoKey, content: ArrayBuffer): Promise<ArrayBuffer> {
201
851
  if (content.byteLength > E2E_MAX_FILE_BYTES) {
202
852
  throw new Error('e2e: file exceeds the 200MB single-shot limit');
203
853
  }
204
854
  const rawDek = crypto.getRandomValues(new Uint8Array(32));
205
- const dek = await crypto.subtle.importKey('raw', rawDek.buffer as ArrayBuffer, { name: 'AES-GCM' }, false, [
855
+ const dek = await crypto.subtle.importKey('raw', buf(rawDek), { name: 'AES-GCM' }, false, [
206
856
  'encrypt',
207
857
  ]);
208
858
  const wrapIV = crypto.getRandomValues(new Uint8Array(IV_LEN));
209
859
  const dataIV = crypto.getRandomValues(new Uint8Array(IV_LEN));
210
860
  const wrappedDek = new Uint8Array(
211
- await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIV.buffer as ArrayBuffer }, kek, rawDek.buffer as ArrayBuffer),
861
+ await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buf(wrapIV) }, fmk, buf(rawDek)),
212
862
  );
213
863
  const ct = new Uint8Array(
214
- await crypto.subtle.encrypt({ name: 'AES-GCM', iv: dataIV.buffer as ArrayBuffer }, dek, content),
864
+ await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buf(dataIV) }, dek, content),
215
865
  );
216
866
  // Zero the raw DEK copy as a hygiene measure (best-effort — GC may have
217
867
  // other copies, but don't leave the obvious one around).
@@ -229,11 +879,11 @@ export async function encryptFile(kek: CryptoKey, content: ArrayBuffer): Promise
229
879
  }
230
880
 
231
881
  /**
232
- * Decrypt a 'filexe2e' blob with the folder KEK. Throws E2eDecryptError on
233
- * a wrong key / tampered data, and a plain Error when the header is not an
234
- * e2e file at all.
882
+ * Decrypt a 'filexe2e' blob with the folder master key. Throws
883
+ * E2eDecryptError on a wrong key / tampered data, and a plain Error when the
884
+ * header is not an e2e file at all.
235
885
  */
236
- export async function decryptFile(kek: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer> {
886
+ export async function decryptFile(fmk: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer> {
237
887
  const b = new Uint8Array(data);
238
888
  if (!hasMagic(b) || b.length < HEADER_LEN) {
239
889
  throw new Error('e2e: not an encrypted file');
@@ -246,20 +896,16 @@ export async function decryptFile(kek: CryptoKey, data: ArrayBuffer): Promise<Ar
246
896
  const dataIV = b.slice(DATA_IV_OFF, DATA_IV_OFF + IV_LEN);
247
897
  let rawDek: ArrayBuffer;
248
898
  try {
249
- rawDek = await crypto.subtle.decrypt(
250
- { name: 'AES-GCM', iv: wrapIV.buffer as ArrayBuffer },
251
- kek,
252
- wrappedDek.buffer as ArrayBuffer,
253
- );
899
+ rawDek = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: buf(wrapIV) }, fmk, buf(wrappedDek));
254
900
  } catch {
255
- throw new E2eDecryptError('e2e: DEK unwrap failed (wrong password?)');
901
+ throw new E2eDecryptError('e2e: DEK unwrap failed (wrong key?)');
256
902
  }
257
903
  const dek = await crypto.subtle.importKey('raw', rawDek, { name: 'AES-GCM' }, false, ['decrypt']);
258
904
  try {
259
905
  return await crypto.subtle.decrypt(
260
- { name: 'AES-GCM', iv: dataIV.buffer as ArrayBuffer },
906
+ { name: 'AES-GCM', iv: buf(dataIV) },
261
907
  dek,
262
- b.slice(HEADER_LEN).buffer as ArrayBuffer,
908
+ buf(b.slice(HEADER_LEN)),
263
909
  );
264
910
  } catch {
265
911
  throw new E2eDecryptError('e2e: content decrypt failed');
@@ -271,8 +917,8 @@ export async function decryptFile(kek: CryptoKey, data: ArrayBuffer): Promise<Ar
271
917
  // ---------------------------------------------------------------------
272
918
 
273
919
  /**
274
- * Tiny per-explorer key ring: encrypted-folder root (wire path) → KEK.
275
- * Lives ONLY in memory — "Kilitle" drops the entry, a reload drops all.
920
+ * Tiny per-explorer key ring: encrypted-folder root (wire path) → FMK.
921
+ * Lives ONLY in memory — "Lock" drops the entry, a reload drops all.
276
922
  */
277
923
  export function createKeyRing() {
278
924
  const keys = new Map<string, CryptoKey>();
@@ -280,10 +926,10 @@ export function createKeyRing() {
280
926
  get(root: string): CryptoKey | undefined {
281
927
  return keys.get(root);
282
928
  },
283
- set(root: string, kek: CryptoKey): void {
284
- keys.set(root, kek);
929
+ set(root: string, fmk: CryptoKey): void {
930
+ keys.set(root, fmk);
285
931
  },
286
- /** Drop one folder's key ("Kilitle"). */
932
+ /** Drop one folder's key ("Lock"). */
287
933
  lock(root: string): void {
288
934
  keys.delete(root);
289
935
  },