@brftech/filex-core 0.30.0 → 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.
- package/README.md +11 -0
- package/dist/filex-core.js +9661 -8550
- 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 +368 -64
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +602 -17
- package/src/components/Breadcrumb.vue +4 -10
- package/src/components/E2eRecoveryUnlockModal.vue +168 -0
- package/src/components/EncryptedFolderModal.vue +10 -0
- package/src/components/GalleryView.vue +37 -0
- package/src/components/GridView.vue +39 -0
- package/src/components/ListView.vue +1 -0
- package/src/components/RecoveryKeyModal.vue +133 -0
- package/src/components/SideNav.vue +168 -5
- package/src/components/StarButton.vue +27 -15
- package/src/composables/useFileApi.ts +38 -0
- package/src/composables/useKeyboardShortcuts.ts +7 -0
- package/src/index.ts +27 -1
- package/src/lib/e2ecrypto.ts +528 -70
- package/src/lib/listing.ts +79 -0
- package/src/lib/star.ts +42 -0
- package/src/lib/tags.ts +105 -0
- package/src/locales/en.ts +71 -2
- package/src/locales/tr.ts +71 -2
- package/src/styles/base.css +252 -0
- package/src/types/ExplorerConfig.ts +36 -0
- package/src/types/FileNode.ts +23 -0
package/src/lib/e2ecrypto.ts
CHANGED
|
@@ -3,35 +3,73 @@
|
|
|
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 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
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
/**
|
|
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 !==
|
|
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
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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(
|
|
183
|
-
const b =
|
|
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
|
|
197
|
-
* content one-shot, wraps the DEK with the
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
233
|
-
* a wrong key / tampered data, and a plain Error when the
|
|
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(
|
|
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
|
|
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
|
|
718
|
+
{ name: 'AES-GCM', iv: buf(dataIV) },
|
|
261
719
|
dek,
|
|
262
|
-
b.slice(HEADER_LEN)
|
|
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) →
|
|
275
|
-
* Lives ONLY in memory — "
|
|
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,
|
|
284
|
-
keys.set(root,
|
|
741
|
+
set(root: string, fmk: CryptoKey): void {
|
|
742
|
+
keys.set(root, fmk);
|
|
285
743
|
},
|
|
286
|
-
/** Drop one folder's key ("
|
|
744
|
+
/** Drop one folder's key ("Lock"). */
|
|
287
745
|
lock(root: string): void {
|
|
288
746
|
keys.delete(root);
|
|
289
747
|
},
|