@brftech/filex-core 0.30.1 → 0.31.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.
@@ -3,35 +3,73 @@
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 while upgrading a v1 marker, and only long
44
+ * enough to wrap them into the new slots.
45
+ * - A folder created while escrow was off carries no escrow slot, so the
46
+ * escrow key cannot open it. That is arithmetic, not policy.
47
+ *
48
+ * File layout ('filexe2e' magic, fixed 97-byte header) — UNCHANGED in v2:
12
49
  * [0..8) magic "filexe2e"
13
50
  * [8] version 0x01
14
51
  * [9..21) wrapIV (12B) — GCM IV of the DEK wrap
15
- * [21..69) wrappedDEK (48B = 32B DEK + 16B GCM tag)
52
+ * [21..69) wrappedDEK (48B = 32B DEK + 16B GCM tag), wrapped by the FMK
16
53
  * [69..81) dataIV (12B) — GCM IV of the content
17
54
  * [81..97) reserved (zeros; v2 chunking/metadata)
18
55
  * [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
56
  */
26
57
 
27
58
  export const E2E_MARKER_NAME = '.filex-e2e.json';
28
59
  export const E2E_MAGIC = 'filexe2e';
60
+ /** File-header version byte. Unchanged by the recovery work. */
29
61
  export const E2E_VERSION = 1;
62
+ /** Marker schema version written by this build. v1 markers still read. */
63
+ export const E2E_MARKER_VERSION = 2;
30
64
  export const E2E_DEFAULT_ITERATIONS = 600_000;
31
65
  export const E2E_MIN_ITERATIONS = 600_000;
32
66
  /** MVP single-shot in-memory ceiling — larger uploads are refused with a warning. */
33
67
  export const E2E_MAX_FILE_BYTES = 200 * 1024 * 1024;
34
68
  export const E2E_MIN_PASSWORD_LEN = 8;
69
+ /** Entropy of a user recovery key: 20 bytes = 160 bits = exactly 32 base32 chars. */
70
+ export const E2E_RECOVERY_KEY_BYTES = 20;
71
+ /** The only escrow algorithm this version understands. */
72
+ export const E2E_ESCROW_ALG = 'RSA-OAEP-256';
35
73
 
36
74
  const VERIFY_PLAINTEXT = 'filex-e2e-verify-v1';
37
75
  const MAGIC_BYTES = new TextEncoder().encode(E2E_MAGIC); // 8 bytes
@@ -41,12 +79,41 @@ const WRAPPED_DEK_OFF = 21;
41
79
  const WRAPPED_DEK_LEN = 48;
42
80
  const DATA_IV_OFF = 69;
43
81
  const IV_LEN = 12;
82
+ const FMK_LEN = 32;
83
+ /** HKDF domain separation for the user recovery key. */
84
+ const RK_INFO = 'filex-e2e-recovery-v1';
85
+ const RK_SALT_LEN = 16;
86
+
87
+ /** How the folder master key is obtained from the password slot. */
88
+ export type E2eFmkMode = 'kek' | 'wrapped';
89
+
90
+ /** User-recovery-key slot: HKDF salt + the FMK wrapped under the derived key. */
91
+ export interface E2eRecoverySlot {
92
+ salt: string; // base64, 16B HKDF salt
93
+ blob: string; // base64: 12B IV || AES-GCM(RKEK, FMK)
94
+ }
95
+
96
+ /** Escrow slot: the FMK encrypted to the installation's escrow public key. */
97
+ export interface E2eEscrowSlot {
98
+ /** First 8 bytes of SHA-256(SPKI), hex — names WHICH escrow key this is. */
99
+ kid: string;
100
+ alg: string; // E2E_ESCROW_ALG
101
+ blob: string; // base64: RSA-OAEP-256(escrow public key, FMK)
102
+ }
44
103
 
45
104
  export interface E2eMarker {
46
105
  v: number;
47
- salt: string; // base64
106
+ salt: string; // base64, PBKDF2 salt for the password slot
48
107
  iter: number;
49
108
  verify: string; // base64: 12B IV || AES-GCM ciphertext of VERIFY_PLAINTEXT
109
+ /** v2 only. Absent on a v1 marker, where the FMK is implicitly the KEK. */
110
+ fmk?: E2eFmkMode;
111
+ /** v2 + fmk==='wrapped' only: base64 12B IV || AES-GCM(KEK, FMK). */
112
+ fmk_pw?: string;
113
+ /** v2 only, optional: the user recovery key slot. */
114
+ rk?: E2eRecoverySlot;
115
+ /** v2 only, optional: the operator escrow slot. */
116
+ esc?: E2eEscrowSlot;
50
117
  }
51
118
 
52
119
  /** Thrown on wrong password / corrupted ciphertext (GCM tag mismatch). */
@@ -74,6 +141,51 @@ export function b64ToBytes(s: string): Uint8Array {
74
141
  return out;
75
142
  }
76
143
 
144
+ /**
145
+ * Copy into a fresh ArrayBuffer — TS 5.9 BufferSource typing rejects views
146
+ * that may wrap a SharedArrayBuffer, and WebCrypto wants a plain buffer.
147
+ */
148
+ function buf(b: Uint8Array): ArrayBuffer {
149
+ return new Uint8Array(b).buffer as ArrayBuffer;
150
+ }
151
+
152
+ /** IV || ciphertext, the shape every AES-GCM blob in the marker uses. */
153
+ function joinIvCt(iv: Uint8Array, ct: Uint8Array): string {
154
+ const out = new Uint8Array(iv.length + ct.length);
155
+ out.set(iv, 0);
156
+ out.set(ct, iv.length);
157
+ return bytesToB64(out);
158
+ }
159
+
160
+ async function gcmSeal(key: CryptoKey, plain: Uint8Array): Promise<string> {
161
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
162
+ const ct = new Uint8Array(
163
+ await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buf(iv) }, key, buf(plain)),
164
+ );
165
+ return joinIvCt(iv, ct);
166
+ }
167
+
168
+ /** Returns null (never throws) on a tag mismatch — i.e. "wrong key". */
169
+ async function gcmOpen(key: CryptoKey, b64: string): Promise<Uint8Array | null> {
170
+ let raw: Uint8Array;
171
+ try {
172
+ raw = b64ToBytes(b64);
173
+ } catch {
174
+ return null;
175
+ }
176
+ if (raw.length <= IV_LEN) return null;
177
+ try {
178
+ const pt = await crypto.subtle.decrypt(
179
+ { name: 'AES-GCM', iv: buf(raw.slice(0, IV_LEN)) },
180
+ key,
181
+ buf(raw.slice(IV_LEN)),
182
+ );
183
+ return new Uint8Array(pt);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
77
189
  // ---------------------------------------------------------------------
78
190
  // Key derivation
79
191
  // ---------------------------------------------------------------------
@@ -96,9 +208,7 @@ export async function deriveKek(
96
208
  ['deriveKey'],
97
209
  );
98
210
  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' },
211
+ { name: 'PBKDF2', salt: buf(salt), iterations, hash: 'SHA-256' },
102
212
  material,
103
213
  { name: 'AES-GCM', length: 256 },
104
214
  false, // non-extractable
@@ -106,11 +216,193 @@ export async function deriveKek(
106
216
  );
107
217
  }
108
218
 
219
+ /**
220
+ * The same 32 bytes as `deriveKek`, but as raw material.
221
+ *
222
+ * ⚠ Used in exactly one place: upgrading a v1 marker, where the files are
223
+ * already wrapped under the KEK and the recovery slots must therefore hold
224
+ * those very bytes. Nothing else may call this — the steady-state password
225
+ * path uses `deriveKek`, whose key cannot be exported.
226
+ */
227
+ async function deriveKekBits(
228
+ password: string,
229
+ salt: Uint8Array,
230
+ iterations: number,
231
+ ): Promise<Uint8Array> {
232
+ const material = await crypto.subtle.importKey(
233
+ 'raw',
234
+ new TextEncoder().encode(password),
235
+ 'PBKDF2',
236
+ false,
237
+ ['deriveBits'],
238
+ );
239
+ const bits = await crypto.subtle.deriveBits(
240
+ { name: 'PBKDF2', salt: buf(salt), iterations, hash: 'SHA-256' },
241
+ material,
242
+ FMK_LEN * 8,
243
+ );
244
+ return new Uint8Array(bits);
245
+ }
246
+
247
+ /** Import raw 32 bytes as the AES-256-GCM folder master key. */
248
+ async function importFmk(raw: Uint8Array): Promise<CryptoKey> {
249
+ return crypto.subtle.importKey('raw', buf(raw), { name: 'AES-GCM' }, false, [
250
+ 'encrypt',
251
+ 'decrypt',
252
+ ]);
253
+ }
254
+
255
+ // ---------------------------------------------------------------------
256
+ // User recovery key — 160 bits, Crockford base32, 8 groups of 4
257
+ // ---------------------------------------------------------------------
258
+
259
+ /** Crockford base32: no I, L, O or U, so it survives being read aloud. */
260
+ const B32_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
261
+
262
+ /**
263
+ * Format 20 raw bytes as the string the user writes down:
264
+ * `XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX` (160 bits, no padding waste).
265
+ */
266
+ export function formatRecoveryKey(raw: Uint8Array): string {
267
+ let bits = 0;
268
+ let acc = 0;
269
+ let out = '';
270
+ for (let i = 0; i < raw.length; i++) {
271
+ acc = (acc << 8) | raw[i];
272
+ bits += 8;
273
+ while (bits >= 5) {
274
+ out += B32_ALPHABET[(acc >>> (bits - 5)) & 31];
275
+ bits -= 5;
276
+ }
277
+ }
278
+ if (bits > 0) out += B32_ALPHABET[(acc << (5 - bits)) & 31];
279
+ return (out.match(/.{1,4}/g) || []).join('-');
280
+ }
281
+
282
+ /** Mint a fresh user recovery key. Shown once, never stored by filex. */
283
+ export function generateRecoveryKey(): string {
284
+ return formatRecoveryKey(crypto.getRandomValues(new Uint8Array(E2E_RECOVERY_KEY_BYTES)));
285
+ }
286
+
287
+ /**
288
+ * Parse a typed-in recovery key back to its 20 bytes, or null when it is not
289
+ * one. Forgiving about how a human retypes it: case, dashes, spaces and the
290
+ * Crockford look-alikes (O to 0, I/L to 1) are all normalised away.
291
+ */
292
+ export function parseRecoveryKey(s: string): Uint8Array | null {
293
+ const clean = (s || '')
294
+ .toUpperCase()
295
+ .replace(/[\s-]/g, '')
296
+ .replace(/O/g, '0')
297
+ .replace(/[IL]/g, '1');
298
+ const need = Math.ceil((E2E_RECOVERY_KEY_BYTES * 8) / 5); // 32 chars
299
+ if (clean.length !== need) return null;
300
+ const out = new Uint8Array(E2E_RECOVERY_KEY_BYTES);
301
+ let acc = 0;
302
+ let bits = 0;
303
+ let n = 0;
304
+ for (const ch of clean) {
305
+ const v = B32_ALPHABET.indexOf(ch);
306
+ if (v < 0) return null;
307
+ acc = (acc << 5) | v;
308
+ bits += 5;
309
+ if (bits >= 8) {
310
+ out[n++] = (acc >>> (bits - 8)) & 0xff;
311
+ bits -= 8;
312
+ }
313
+ }
314
+ return n === E2E_RECOVERY_KEY_BYTES ? out : null;
315
+ }
316
+
317
+ /** HKDF-SHA256 the recovery key into the AES key that wraps the FMK. */
318
+ async function deriveRecoveryKek(raw: Uint8Array, salt: Uint8Array): Promise<CryptoKey> {
319
+ const base = await crypto.subtle.importKey('raw', buf(raw), 'HKDF', false, ['deriveKey']);
320
+ return crypto.subtle.deriveKey(
321
+ {
322
+ name: 'HKDF',
323
+ hash: 'SHA-256',
324
+ salt: buf(salt),
325
+ info: buf(new TextEncoder().encode(RK_INFO)),
326
+ },
327
+ base,
328
+ { name: 'AES-GCM', length: 256 },
329
+ false,
330
+ ['encrypt', 'decrypt'],
331
+ );
332
+ }
333
+
334
+ async function sealRecoverySlot(fmk: Uint8Array, recoveryKey: string): Promise<E2eRecoverySlot> {
335
+ const raw = parseRecoveryKey(recoveryKey);
336
+ if (!raw) throw new Error('e2e: malformed recovery key');
337
+ const salt = crypto.getRandomValues(new Uint8Array(RK_SALT_LEN));
338
+ const rkek = await deriveRecoveryKek(raw, salt);
339
+ const slot = { salt: bytesToB64(salt), blob: await gcmSeal(rkek, fmk) };
340
+ raw.fill(0);
341
+ return slot;
342
+ }
343
+
109
344
  // ---------------------------------------------------------------------
110
- // Marker create / verify
345
+ // Escrow (operator recovery) — RSA-OAEP-256
111
346
  // ---------------------------------------------------------------------
347
+ //
348
+ // The server holds the PUBLIC half only, so it can wrap new folders' FMKs to
349
+ // the escrow identity. The private half was handed to the admin at install
350
+ // and is supplied back by hand when it is used. A stolen filex database
351
+ // therefore decrypts nothing.
112
352
 
113
- /** Create a fresh folder marker for `password` (also returns the derived KEK). */
353
+ /** Import the installation escrow public key (base64 SPKI, as the server serves it). */
354
+ export async function importEscrowPublicKey(spkiB64: string): Promise<CryptoKey> {
355
+ return crypto.subtle.importKey(
356
+ 'spki',
357
+ buf(b64ToBytes(spkiB64)),
358
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
359
+ true,
360
+ ['encrypt'],
361
+ );
362
+ }
363
+
364
+ /** Import the escrow private key the admin pastes in (base64 PKCS#8, PEM tolerated). */
365
+ export async function importEscrowPrivateKey(pkcs8B64: string): Promise<CryptoKey> {
366
+ const clean = (pkcs8B64 || '').replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '');
367
+ return crypto.subtle.importKey(
368
+ 'pkcs8',
369
+ buf(b64ToBytes(clean)),
370
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
371
+ false,
372
+ ['decrypt'],
373
+ );
374
+ }
375
+
376
+ /**
377
+ * Stable short name for an escrow key: first 8 bytes of SHA-256(SPKI), hex.
378
+ * Written into every escrow slot so a marker says WHICH key opens it, and so
379
+ * the UI can tell "this server's escrow key" from "some other one".
380
+ */
381
+ export async function escrowKeyId(spkiB64: string): Promise<string> {
382
+ const d = new Uint8Array(await crypto.subtle.digest('SHA-256', buf(b64ToBytes(spkiB64))));
383
+ return Array.from(d.slice(0, 8))
384
+ .map((x) => x.toString(16).padStart(2, '0'))
385
+ .join('');
386
+ }
387
+
388
+ async function sealEscrowSlot(fmk: Uint8Array, escrowSpkiB64: string): Promise<E2eEscrowSlot> {
389
+ const pub = await importEscrowPublicKey(escrowSpkiB64);
390
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, pub, buf(fmk)));
391
+ return { kid: await escrowKeyId(escrowSpkiB64), alg: E2E_ESCROW_ALG, blob: bytesToB64(ct) };
392
+ }
393
+
394
+ // ---------------------------------------------------------------------
395
+ // Marker create / parse / verify
396
+ // ---------------------------------------------------------------------
397
+
398
+ /**
399
+ * Create a v1 folder marker — the pre-0.31 format, with NO recovery of any
400
+ * kind.
401
+ *
402
+ * @deprecated Use `createEncryptedFolder`. Kept exported, and kept producing
403
+ * a genuine v1 marker, so an embedder pinned to the old API keeps creating
404
+ * folders this build can still open rather than half-formed v2 ones.
405
+ */
114
406
  export async function createMarker(
115
407
  password: string,
116
408
  iterations: number = E2E_DEFAULT_ITERATIONS,
@@ -118,60 +410,227 @@ export async function createMarker(
118
410
  const iter = Math.max(E2E_MIN_ITERATIONS, iterations);
119
411
  const salt = crypto.getRandomValues(new Uint8Array(16));
120
412
  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,
413
+ const verify = await gcmSeal(kek, new TextEncoder().encode(VERIFY_PLAINTEXT));
414
+ return { marker: { v: 1, salt: bytesToB64(salt), iter, verify }, kek };
415
+ }
416
+
417
+ export interface CreateFolderOptions {
418
+ iterations?: number;
419
+ /** Base64 SPKI of the installation escrow key, when escrow is enabled. */
420
+ escrowPublicKey?: string | null;
421
+ }
422
+
423
+ export interface CreatedFolder {
424
+ marker: E2eMarker;
425
+ /** The folder master key, ready for encryptFile/decryptFile. */
426
+ fmk: CryptoKey;
427
+ /** Show this ONCE. filex never stores it and can never show it again. */
428
+ recoveryKey: string;
429
+ }
430
+
431
+ /**
432
+ * Create a v2 encrypted folder: random FMK, wrapped under the password KEK,
433
+ * under a freshly minted user recovery key, and — when the installation has
434
+ * escrow enabled — to the escrow public key.
435
+ */
436
+ export async function createEncryptedFolder(
437
+ password: string,
438
+ opts: CreateFolderOptions = {},
439
+ ): Promise<CreatedFolder> {
440
+ const iter = Math.max(E2E_MIN_ITERATIONS, opts.iterations ?? E2E_DEFAULT_ITERATIONS);
441
+ const salt = crypto.getRandomValues(new Uint8Array(16));
442
+ const kek = await deriveKek(password, salt, iter);
443
+ const rawFmk = crypto.getRandomValues(new Uint8Array(FMK_LEN));
444
+ const recoveryKey = generateRecoveryKey();
445
+
446
+ const marker: E2eMarker = {
447
+ v: E2E_MARKER_VERSION,
448
+ salt: bytesToB64(salt),
449
+ iter,
450
+ verify: await gcmSeal(kek, new TextEncoder().encode(VERIFY_PLAINTEXT)),
451
+ fmk: 'wrapped',
452
+ fmk_pw: await gcmSeal(kek, rawFmk),
453
+ rk: await sealRecoverySlot(rawFmk, recoveryKey),
135
454
  };
455
+ if (opts.escrowPublicKey) marker.esc = await sealEscrowSlot(rawFmk, opts.escrowPublicKey);
456
+
457
+ const fmk = await importFmk(rawFmk);
458
+ rawFmk.fill(0);
459
+ return { marker, fmk, recoveryKey };
136
460
  }
137
461
 
138
- /** Parse marker JSON text; returns null when the shape is not a v1 marker. */
462
+ /**
463
+ * Give an existing v1 folder recovery keys, in place and without rewriting a
464
+ * single file.
465
+ *
466
+ * The v1 files are wrapped under the password KEK, so the FMK stays defined
467
+ * as "the KEK" (`fmk: 'kek'`) and the new slots wrap those raw bytes. The
468
+ * password path afterwards is byte-identical to what it was.
469
+ *
470
+ * ⚠ Requires the password — this is only callable at the one moment filex
471
+ * ever has it. There is no way to give a v1 folder recovery without it.
472
+ * ⚠ When the installation has escrow on, this ALSO hands the operator a key
473
+ * to a folder that did not have one. The caller must say so before asking.
474
+ */
475
+ export async function upgradeMarkerV1(
476
+ marker: E2eMarker,
477
+ password: string,
478
+ opts: CreateFolderOptions = {},
479
+ ): Promise<CreatedFolder> {
480
+ if (marker.v !== 1) throw new Error('e2e: not a v1 marker');
481
+ const salt = b64ToBytes(marker.salt);
482
+ const kek = await deriveKek(password, salt, marker.iter);
483
+ // Prove the password before touching anything.
484
+ const ok = await gcmOpen(kek, marker.verify);
485
+ if (!ok || new TextDecoder().decode(ok) !== VERIFY_PLAINTEXT) {
486
+ throw new E2eDecryptError('e2e: wrong password');
487
+ }
488
+ const rawKek = await deriveKekBits(password, salt, marker.iter);
489
+ const recoveryKey = generateRecoveryKey();
490
+ const next: E2eMarker = {
491
+ v: E2E_MARKER_VERSION,
492
+ salt: marker.salt,
493
+ iter: marker.iter,
494
+ verify: marker.verify,
495
+ fmk: 'kek',
496
+ rk: await sealRecoverySlot(rawKek, recoveryKey),
497
+ };
498
+ if (opts.escrowPublicKey) next.esc = await sealEscrowSlot(rawKek, opts.escrowPublicKey);
499
+ rawKek.fill(0);
500
+ return { marker: next, fmk: kek, recoveryKey };
501
+ }
502
+
503
+ /** Parse marker JSON text; returns null when the shape is not a marker we read. */
139
504
  export function parseMarker(text: string): E2eMarker | null {
140
505
  try {
141
506
  const m = JSON.parse(text) as E2eMarker;
142
- if (!m || m.v !== E2E_VERSION) return null;
507
+ if (!m || (m.v !== 1 && m.v !== 2)) return null;
143
508
  if (typeof m.salt !== 'string' || typeof m.verify !== 'string') return null;
144
509
  if (typeof m.iter !== 'number' || m.iter < 1) return null;
510
+ if (m.v === 2) {
511
+ if (m.fmk !== 'kek' && m.fmk !== 'wrapped') return null;
512
+ if (m.fmk === 'wrapped' && typeof m.fmk_pw !== 'string') return null;
513
+ }
145
514
  return m;
146
515
  } catch {
147
516
  return null;
148
517
  }
149
518
  }
150
519
 
520
+ /** True when the folder has a user recovery key slot. */
521
+ export function markerHasRecovery(m: E2eMarker | null): boolean {
522
+ return !!m && m.v === 2 && !!m.rk;
523
+ }
524
+
525
+ /** True when the folder has an operator escrow slot. */
526
+ export function markerHasEscrow(m: E2eMarker | null): boolean {
527
+ return !!m && m.v === 2 && !!m.esc;
528
+ }
529
+
151
530
  /**
152
531
  * Check `password` against a folder marker. Resolves to the derived KEK on
153
532
  * success, or `null` on a wrong password (GCM tag mismatch on the verify
154
533
  * blob). Never talks to any server.
534
+ *
535
+ * ⚠ This returns the KEK, not the FMK. On a v1 folder they are the same key;
536
+ * on a v2 `fmk: 'wrapped'` folder they are not. Use `unlockWithPassword` to
537
+ * get the key that actually decrypts files.
155
538
  */
156
539
  export async function verifyPassword(
157
540
  marker: E2eMarker,
158
541
  password: string,
159
542
  ): 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;
543
+ const kek = await deriveKek(password, b64ToBytes(marker.salt), marker.iter);
544
+ const pt = await gcmOpen(kek, marker.verify);
545
+ if (!pt || new TextDecoder().decode(pt) !== VERIFY_PLAINTEXT) return null;
546
+ return kek;
547
+ }
548
+
549
+ // ---------------------------------------------------------------------
550
+ // Unlock — the three ways to reach the FMK
551
+ // ---------------------------------------------------------------------
552
+
553
+ /** Turn a password KEK into the FMK for this marker. */
554
+ async function fmkFromKek(marker: E2eMarker, kek: CryptoKey): Promise<CryptoKey | null> {
555
+ // v1, and v2 folders upgraded from v1: the files are wrapped by the KEK.
556
+ if (marker.v === 1 || marker.fmk === 'kek') return kek;
557
+ if (!marker.fmk_pw) return null;
558
+ const raw = await gcmOpen(kek, marker.fmk_pw);
559
+ if (!raw || raw.length !== FMK_LEN) return null;
560
+ const fmk = await importFmk(raw);
561
+ raw.fill(0);
562
+ return fmk;
563
+ }
564
+
565
+ /**
566
+ * Unlock with the folder password. Returns the FMK (the key `decryptFile`
567
+ * wants) or null when the password is wrong.
568
+ */
569
+ export async function unlockWithPassword(
570
+ marker: E2eMarker,
571
+ password: string,
572
+ ): Promise<CryptoKey | null> {
573
+ const kek = await verifyPassword(marker, password);
574
+ if (!kek) return null;
575
+ return fmkFromKek(marker, kek);
576
+ }
577
+
578
+ /**
579
+ * Unlock with the user recovery key shown when the folder was created.
580
+ * Returns null for a malformed key, a wrong key, or a folder that has no
581
+ * recovery slot at all — the caller cannot tell those apart, and neither can
582
+ * an attacker.
583
+ */
584
+ export async function unlockWithRecoveryKey(
585
+ marker: E2eMarker,
586
+ recoveryKey: string,
587
+ ): Promise<CryptoKey | null> {
588
+ if (marker.v !== 2 || !marker.rk) return null;
589
+ const raw = parseRecoveryKey(recoveryKey);
590
+ if (!raw) return null;
591
+ let salt: Uint8Array;
164
592
  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,
593
+ salt = b64ToBytes(marker.rk.salt);
594
+ } catch {
595
+ return null;
596
+ }
597
+ const rkek = await deriveRecoveryKek(raw, salt);
598
+ raw.fill(0);
599
+ const fmkRaw = await gcmOpen(rkek, marker.rk.blob);
600
+ if (!fmkRaw || fmkRaw.length !== FMK_LEN) return null;
601
+ const fmk = await importFmk(fmkRaw);
602
+ fmkRaw.fill(0);
603
+ return fmk;
604
+ }
605
+
606
+ /**
607
+ * Unlock with the installation escrow private key.
608
+ *
609
+ * Returns null when the folder has no escrow slot — which is the case for
610
+ * every folder created while escrow was off, and is why escrow cannot be
611
+ * turned on retroactively.
612
+ */
613
+ export async function unlockWithEscrowKey(
614
+ marker: E2eMarker,
615
+ privateKey: CryptoKey,
616
+ ): Promise<CryptoKey | null> {
617
+ if (marker.v !== 2 || !marker.esc || marker.esc.alg !== E2E_ESCROW_ALG) return null;
618
+ let raw: Uint8Array;
619
+ try {
620
+ raw = new Uint8Array(
621
+ await crypto.subtle.decrypt(
622
+ { name: 'RSA-OAEP' },
623
+ privateKey,
624
+ buf(b64ToBytes(marker.esc.blob)),
625
+ ),
169
626
  );
170
- if (new TextDecoder().decode(pt) !== VERIFY_PLAINTEXT) return null;
171
- return kek;
172
627
  } catch {
173
- return null; // wrong password
628
+ return null; // wrong escrow key, or a slot sealed to another installation
174
629
  }
630
+ if (raw.length !== FMK_LEN) return null;
631
+ const fmk = await importFmk(raw);
632
+ raw.fill(0);
633
+ return fmk;
175
634
  }
176
635
 
177
636
  // ---------------------------------------------------------------------
@@ -179,8 +638,8 @@ export async function verifyPassword(
179
638
  // ---------------------------------------------------------------------
180
639
 
181
640
  /** 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);
641
+ export function hasMagic(data: ArrayBuffer | Uint8Array): boolean {
642
+ const b = data instanceof Uint8Array ? data : new Uint8Array(data);
184
643
  if (b.length < MAGIC_BYTES.length) return false;
185
644
  for (let i = 0; i < MAGIC_BYTES.length; i++) {
186
645
  if (b[i] !== MAGIC_BYTES[i]) return false;
@@ -193,25 +652,28 @@ export function hasMagic(buf: ArrayBuffer | Uint8Array): boolean {
193
652
  // ---------------------------------------------------------------------
194
653
 
195
654
  /**
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
655
+ * Encrypt `content` under the folder master key: mints a fresh DEK, encrypts
656
+ * the content one-shot, wraps the DEK with the FMK and prepends the fixed
198
657
  * 'filexe2e' header. Throws when content exceeds E2E_MAX_FILE_BYTES.
658
+ *
659
+ * `fmk` is the key an unlock returned. On a v1 folder that is the password
660
+ * KEK, which is why v1 files keep working untouched.
199
661
  */
200
- export async function encryptFile(kek: CryptoKey, content: ArrayBuffer): Promise<ArrayBuffer> {
662
+ export async function encryptFile(fmk: CryptoKey, content: ArrayBuffer): Promise<ArrayBuffer> {
201
663
  if (content.byteLength > E2E_MAX_FILE_BYTES) {
202
664
  throw new Error('e2e: file exceeds the 200MB single-shot limit');
203
665
  }
204
666
  const rawDek = crypto.getRandomValues(new Uint8Array(32));
205
- const dek = await crypto.subtle.importKey('raw', rawDek.buffer as ArrayBuffer, { name: 'AES-GCM' }, false, [
667
+ const dek = await crypto.subtle.importKey('raw', buf(rawDek), { name: 'AES-GCM' }, false, [
206
668
  'encrypt',
207
669
  ]);
208
670
  const wrapIV = crypto.getRandomValues(new Uint8Array(IV_LEN));
209
671
  const dataIV = crypto.getRandomValues(new Uint8Array(IV_LEN));
210
672
  const wrappedDek = new Uint8Array(
211
- await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIV.buffer as ArrayBuffer }, kek, rawDek.buffer as ArrayBuffer),
673
+ await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buf(wrapIV) }, fmk, buf(rawDek)),
212
674
  );
213
675
  const ct = new Uint8Array(
214
- await crypto.subtle.encrypt({ name: 'AES-GCM', iv: dataIV.buffer as ArrayBuffer }, dek, content),
676
+ await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buf(dataIV) }, dek, content),
215
677
  );
216
678
  // Zero the raw DEK copy as a hygiene measure (best-effort — GC may have
217
679
  // other copies, but don't leave the obvious one around).
@@ -229,11 +691,11 @@ export async function encryptFile(kek: CryptoKey, content: ArrayBuffer): Promise
229
691
  }
230
692
 
231
693
  /**
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.
694
+ * Decrypt a 'filexe2e' blob with the folder master key. Throws
695
+ * E2eDecryptError on a wrong key / tampered data, and a plain Error when the
696
+ * header is not an e2e file at all.
235
697
  */
236
- export async function decryptFile(kek: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer> {
698
+ export async function decryptFile(fmk: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer> {
237
699
  const b = new Uint8Array(data);
238
700
  if (!hasMagic(b) || b.length < HEADER_LEN) {
239
701
  throw new Error('e2e: not an encrypted file');
@@ -246,20 +708,16 @@ export async function decryptFile(kek: CryptoKey, data: ArrayBuffer): Promise<Ar
246
708
  const dataIV = b.slice(DATA_IV_OFF, DATA_IV_OFF + IV_LEN);
247
709
  let rawDek: ArrayBuffer;
248
710
  try {
249
- rawDek = await crypto.subtle.decrypt(
250
- { name: 'AES-GCM', iv: wrapIV.buffer as ArrayBuffer },
251
- kek,
252
- wrappedDek.buffer as ArrayBuffer,
253
- );
711
+ rawDek = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: buf(wrapIV) }, fmk, buf(wrappedDek));
254
712
  } catch {
255
- throw new E2eDecryptError('e2e: DEK unwrap failed (wrong password?)');
713
+ throw new E2eDecryptError('e2e: DEK unwrap failed (wrong key?)');
256
714
  }
257
715
  const dek = await crypto.subtle.importKey('raw', rawDek, { name: 'AES-GCM' }, false, ['decrypt']);
258
716
  try {
259
717
  return await crypto.subtle.decrypt(
260
- { name: 'AES-GCM', iv: dataIV.buffer as ArrayBuffer },
718
+ { name: 'AES-GCM', iv: buf(dataIV) },
261
719
  dek,
262
- b.slice(HEADER_LEN).buffer as ArrayBuffer,
720
+ buf(b.slice(HEADER_LEN)),
263
721
  );
264
722
  } catch {
265
723
  throw new E2eDecryptError('e2e: content decrypt failed');
@@ -271,8 +729,8 @@ export async function decryptFile(kek: CryptoKey, data: ArrayBuffer): Promise<Ar
271
729
  // ---------------------------------------------------------------------
272
730
 
273
731
  /**
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.
732
+ * Tiny per-explorer key ring: encrypted-folder root (wire path) → FMK.
733
+ * Lives ONLY in memory — "Lock" drops the entry, a reload drops all.
276
734
  */
277
735
  export function createKeyRing() {
278
736
  const keys = new Map<string, CryptoKey>();
@@ -280,10 +738,10 @@ export function createKeyRing() {
280
738
  get(root: string): CryptoKey | undefined {
281
739
  return keys.get(root);
282
740
  },
283
- set(root: string, kek: CryptoKey): void {
284
- keys.set(root, kek);
741
+ set(root: string, fmk: CryptoKey): void {
742
+ keys.set(root, fmk);
285
743
  },
286
- /** Drop one folder's key ("Kilitle"). */
744
+ /** Drop one folder's key ("Lock"). */
287
745
  lock(root: string): void {
288
746
  keys.delete(root);
289
747
  },