@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.
- package/README.md +37 -2
- package/dist/filex-core.js +10571 -8278
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +95 -87
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +548 -66
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1104 -26
- package/src/components/Breadcrumb.vue +4 -2
- package/src/components/CommandPalette.vue +18 -2
- package/src/components/E2eRecoveryUnlockModal.vue +193 -0
- package/src/components/EncryptedFolderModal.vue +10 -0
- package/src/components/FilterBar.vue +244 -0
- package/src/components/GalleryView.vue +48 -0
- package/src/components/GridView.vue +85 -3
- package/src/components/InspectorPanel.vue +231 -8
- package/src/components/ListView.vue +11 -1
- package/src/components/RecoveryKeyModal.vue +133 -0
- package/src/components/SecondaryPane.vue +15 -1
- package/src/components/SideNav.vue +329 -6
- package/src/components/StarButton.vue +27 -15
- package/src/components/Toolbar.vue +235 -49
- package/src/components/ViewSwitcher.vue +81 -0
- package/src/composables/useFileApi.ts +83 -0
- package/src/composables/useKeyboardShortcuts.ts +7 -0
- package/src/index.ts +33 -1
- package/src/lib/e2ecrypto.ts +716 -70
- package/src/lib/fileFilters.ts +143 -0
- package/src/lib/listing.ts +103 -1
- package/src/lib/star.ts +42 -0
- package/src/lib/tags.ts +105 -0
- package/src/locales/en.ts +154 -2
- package/src/locales/tr.ts +154 -2
- package/src/modals/PermissionsModal.vue +18 -2
- package/src/styles/base.css +743 -0
- package/src/types/ExplorerConfig.ts +53 -1
- package/src/types/FileNode.ts +23 -0
package/src/lib/e2ecrypto.ts
CHANGED
|
@@ -3,35 +3,82 @@
|
|
|
3
3
|
*
|
|
4
4
|
* WebCrypto ONLY — zero dependencies. Design doc: docs/E2E-ENCRYPTION.md.
|
|
5
5
|
*
|
|
6
|
-
* Scheme
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
282
|
+
// User recovery key — 160 bits, Crockford base32, 8 groups of 4
|
|
111
283
|
// ---------------------------------------------------------------------
|
|
112
284
|
|
|
113
|
-
/**
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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 !==
|
|
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
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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(
|
|
183
|
-
const b =
|
|
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
|
|
197
|
-
* content one-shot, wraps the DEK with the
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
233
|
-
* a wrong key / tampered data, and a plain Error when the
|
|
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(
|
|
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
|
|
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
|
|
906
|
+
{ name: 'AES-GCM', iv: buf(dataIV) },
|
|
261
907
|
dek,
|
|
262
|
-
b.slice(HEADER_LEN)
|
|
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) →
|
|
275
|
-
* Lives ONLY in memory — "
|
|
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,
|
|
284
|
-
keys.set(root,
|
|
929
|
+
set(root: string, fmk: CryptoKey): void {
|
|
930
|
+
keys.set(root, fmk);
|
|
285
931
|
},
|
|
286
|
-
/** Drop one folder's key ("
|
|
932
|
+
/** Drop one folder's key ("Lock"). */
|
|
287
933
|
lock(root: string): void {
|
|
288
934
|
keys.delete(root);
|
|
289
935
|
},
|