@sciilo.ai/codex-sidecar 0.1.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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +332 -0
- package/bin/sciilo-sidecar.js +221 -0
- package/package.json +53 -0
- package/src/banner.js +63 -0
- package/src/bridge.js +844 -0
- package/src/codex-app-server.js +125 -0
- package/src/codex-cli.js +37 -0
- package/src/config.js +87 -0
- package/src/document-seal.js +195 -0
- package/src/vault.js +493 -0
package/src/vault.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// Sciilo vault — encryption of document contents.
|
|
2
|
+
//
|
|
3
|
+
// This file runs IDENTICALLY in the browser and in the sidecar. It uses Web
|
|
4
|
+
// Crypto only, present natively on both sides (Node >= 22), so it has no
|
|
5
|
+
// dependencies: the guarantee shown to users promises "a handful of files,
|
|
6
|
+
// readable end to end". One more third-party library is one less promise.
|
|
7
|
+
//
|
|
8
|
+
// What the server receives and cannot open:
|
|
9
|
+
// - the vault record: salt + sealed data key (no usable secret)
|
|
10
|
+
// - contents: nonce | ciphertext | authentication tag
|
|
11
|
+
//
|
|
12
|
+
// What the server always sees: ids, dates, titles, structure. That is
|
|
13
|
+
// deliberate — titles keep the library usable without opening the vault.
|
|
14
|
+
|
|
15
|
+
const VERSION = 1
|
|
16
|
+
|
|
17
|
+
// PBKDF2 rather than Argon2id: Web Crypto does not provide Argon2, and adding
|
|
18
|
+
// it would force a WASM blob into the browser bundle AND the sidecar. The
|
|
19
|
+
// trade-off is explicit and REVERSIBLE: `kdf` and `iterations` travel with the
|
|
20
|
+
// vault record, so moving to Argon2id later re-encrypts no document at all —
|
|
21
|
+
// only the 32 bytes of the data key. That is the whole point of the
|
|
22
|
+
// indirection below.
|
|
23
|
+
const KDF = 'PBKDF2-SHA-512'
|
|
24
|
+
const ITERATIONS = 600_000
|
|
25
|
+
|
|
26
|
+
const RECOVERY_CONTEXT = `sciilo.vault.v${VERSION}|recovery`
|
|
27
|
+
|
|
28
|
+
const SALT_BYTES = 16
|
|
29
|
+
const NONCE_BYTES = 12 // 96 bits: the size GCM is proven secure for
|
|
30
|
+
const DEK_BITS = 256
|
|
31
|
+
|
|
32
|
+
const subtle = globalThis.crypto?.subtle
|
|
33
|
+
|
|
34
|
+
if (!subtle) {
|
|
35
|
+
throw new Error('Web Crypto unavailable: the vault needs a modern browser or Node >= 22.')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const utf8 = new TextEncoder()
|
|
39
|
+
const fromUtf8 = new TextDecoder()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a vault. Called once, at sign-up.
|
|
43
|
+
*
|
|
44
|
+
* Nothing returned here allows recovering the password or the data key: the
|
|
45
|
+
* record can be stored in the database without further precautions.
|
|
46
|
+
*/
|
|
47
|
+
export async function createVault(password) {
|
|
48
|
+
|
|
49
|
+
const salt = randomBytes(SALT_BYTES)
|
|
50
|
+
const kek = await deriveKek(password, salt, ITERATIONS)
|
|
51
|
+
// The data key is drawn at random, NEVER derived from the password. That is
|
|
52
|
+
// what lets a password change re-seal 32 bytes instead of re-encrypting the
|
|
53
|
+
// whole library.
|
|
54
|
+
const dek = await subtle.generateKey(
|
|
55
|
+
{name: 'AES-GCM', length: DEK_BITS},
|
|
56
|
+
true, // extractable: see the note on exportDek()
|
|
57
|
+
['encrypt', 'decrypt'],
|
|
58
|
+
)
|
|
59
|
+
// A second, independent way in. Without it, resetting a forgotten password
|
|
60
|
+
// would hand back the account and destroy the library with it: the new
|
|
61
|
+
// key-encryption key cannot unseal the data key sealed by the old one.
|
|
62
|
+
const recoveryCode = newRecoveryCode()
|
|
63
|
+
const recoverySalt = randomBytes(SALT_BYTES)
|
|
64
|
+
const record = await sealDek(dek, kek, salt)
|
|
65
|
+
record.recoverySalt = toBase64(recoverySalt)
|
|
66
|
+
record.recoveryDek = await sealDekWith(dek, recoveryCode, recoverySalt)
|
|
67
|
+
return {record, dek, recoveryCode}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Crockford base32: no I, L, O or U, so nothing can be misread off a piece of
|
|
71
|
+
// paper, and nothing spells a word by accident. 32 characters carry 160 bits —
|
|
72
|
+
// far beyond brute force, whatever the derivation cost.
|
|
73
|
+
const RECOVERY_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
74
|
+
const RECOVERY_CHARS = 32
|
|
75
|
+
const RECOVERY_GROUP = 4
|
|
76
|
+
|
|
77
|
+
function newRecoveryCode() {
|
|
78
|
+
|
|
79
|
+
const draw = randomBytes(RECOVERY_CHARS)
|
|
80
|
+
let code = ''
|
|
81
|
+
for (let index = 0; index < RECOVERY_CHARS; index += 1) {
|
|
82
|
+
if (index > 0 && index % RECOVERY_GROUP === 0) code += '-'
|
|
83
|
+
code += RECOVERY_ALPHABET[draw[index] % RECOVERY_ALPHABET.length]
|
|
84
|
+
}
|
|
85
|
+
return code
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Accepts a recovery code as a human would copy it: any case, with or without
|
|
90
|
+
* the dashes, and with the characters Crockford says are confusable folded onto
|
|
91
|
+
* the ones they are mistaken for.
|
|
92
|
+
*/
|
|
93
|
+
export function normaliseRecoveryCode(code) {
|
|
94
|
+
|
|
95
|
+
return String(code ?? '')
|
|
96
|
+
.toUpperCase()
|
|
97
|
+
.replace(/[\s-]/g, '')
|
|
98
|
+
.replace(/[IL]/g, '1')
|
|
99
|
+
.replace(/O/g, '0')
|
|
100
|
+
.replace(/U/g, 'V')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Opens the vault with the recovery code instead of the password.
|
|
105
|
+
*
|
|
106
|
+
* Used after a password reset: the account comes back through e-mail, the
|
|
107
|
+
* contents come back through this. Both are needed, and neither alone is
|
|
108
|
+
* enough — which is exactly the property we want.
|
|
109
|
+
*/
|
|
110
|
+
export async function openVaultWithRecovery(recoveryCode, record) {
|
|
111
|
+
|
|
112
|
+
assertRecord(record)
|
|
113
|
+
if (!record.recoveryDek || !record.recoverySalt) {
|
|
114
|
+
throw new Error('This vault has no recovery code.')
|
|
115
|
+
}
|
|
116
|
+
const salt = fromBase64(record.recoverySalt)
|
|
117
|
+
const key = await deriveKek(normaliseRecoveryCode(recoveryCode), salt, record.iterations)
|
|
118
|
+
const wrapped = fromBase64(record.recoveryDek)
|
|
119
|
+
try {
|
|
120
|
+
return await subtle.unwrapKey(
|
|
121
|
+
'raw',
|
|
122
|
+
wrapped.subarray(NONCE_BYTES),
|
|
123
|
+
key,
|
|
124
|
+
{
|
|
125
|
+
name: 'AES-GCM',
|
|
126
|
+
iv: wrapped.subarray(0, NONCE_BYTES),
|
|
127
|
+
additionalData: utf8.encode(RECOVERY_CONTEXT),
|
|
128
|
+
},
|
|
129
|
+
{name: 'AES-GCM', length: DEK_BITS},
|
|
130
|
+
true,
|
|
131
|
+
['encrypt', 'decrypt'],
|
|
132
|
+
)
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error('Vault locked: wrong recovery code, or altered record.')
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function sealDekWith(dek, recoveryCode, salt) {
|
|
139
|
+
|
|
140
|
+
const key = await deriveKek(normaliseRecoveryCode(recoveryCode), salt, ITERATIONS)
|
|
141
|
+
const nonce = randomBytes(NONCE_BYTES)
|
|
142
|
+
const wrapped = await subtle.wrapKey('raw', dek, key,
|
|
143
|
+
{name: 'AES-GCM', iv: nonce, additionalData: utf8.encode(RECOVERY_CONTEXT)})
|
|
144
|
+
return toBase64(concat(nonce, new Uint8Array(wrapped)))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Opens an existing vault. Called at sign-in, with the password just typed.
|
|
149
|
+
*
|
|
150
|
+
* A wrong password does not "return false": GCM fails authentication and this
|
|
151
|
+
* throws. There is therefore no oracle telling a wrong password apart from a
|
|
152
|
+
* corrupted record.
|
|
153
|
+
*/
|
|
154
|
+
export async function openVault(password, record) {
|
|
155
|
+
|
|
156
|
+
assertRecord(record)
|
|
157
|
+
const salt = fromBase64(record.salt)
|
|
158
|
+
const kek = await deriveKek(password, salt, record.iterations)
|
|
159
|
+
const wrapped = fromBase64(record.wrappedDek)
|
|
160
|
+
try {
|
|
161
|
+
return await subtle.unwrapKey(
|
|
162
|
+
'raw',
|
|
163
|
+
wrapped.subarray(NONCE_BYTES),
|
|
164
|
+
kek,
|
|
165
|
+
{name: 'AES-GCM', iv: wrapped.subarray(0, NONCE_BYTES), additionalData: dekContext()},
|
|
166
|
+
{name: 'AES-GCM', length: DEK_BITS},
|
|
167
|
+
true,
|
|
168
|
+
['encrypt', 'decrypt'],
|
|
169
|
+
)
|
|
170
|
+
} catch {
|
|
171
|
+
throw new Error('Vault locked: wrong password, or altered record.')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Changes the password WITHOUT touching any document.
|
|
177
|
+
*
|
|
178
|
+
* Only the salt and the sealed data key change: already encrypted contents stay
|
|
179
|
+
* readable, because they are encrypted by the data key, which is unchanged.
|
|
180
|
+
*/
|
|
181
|
+
export async function rewrapVault(dek, newPassword, previous) {
|
|
182
|
+
|
|
183
|
+
const salt = randomBytes(SALT_BYTES)
|
|
184
|
+
const kek = await deriveKek(newPassword, salt, ITERATIONS)
|
|
185
|
+
const record = await sealDek(dek, kek, salt)
|
|
186
|
+
// The recovery code is a separate way in and survives password changes: the
|
|
187
|
+
// slip of paper someone put away stays valid. Losing it here would be a
|
|
188
|
+
// silent trap, since nothing would tell them until the day it matters.
|
|
189
|
+
if (previous?.recoveryDek && previous?.recoverySalt) {
|
|
190
|
+
record.recoverySalt = previous.recoverySalt
|
|
191
|
+
record.recoveryDek = previous.recoveryDek
|
|
192
|
+
}
|
|
193
|
+
return record
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Encrypts a content. `context` BINDS the ciphertext to its exact location.
|
|
198
|
+
*
|
|
199
|
+
* Without that binding, a server unable to read would still be able to MOVE:
|
|
200
|
+
* copy somebody else's encrypted content into your document, or put an old
|
|
201
|
+
* version back in place of the current one. You would decrypt the lot without
|
|
202
|
+
* noticing. The GCM authentication tag covers this context, so a moved block no
|
|
203
|
+
* longer opens.
|
|
204
|
+
*/
|
|
205
|
+
export async function sealField(dek, plaintext, context) {
|
|
206
|
+
|
|
207
|
+
const nonce = randomBytes(NONCE_BYTES)
|
|
208
|
+
const sealed = await subtle.encrypt(
|
|
209
|
+
{name: 'AES-GCM', iv: nonce, additionalData: contextBytes(context)},
|
|
210
|
+
dek,
|
|
211
|
+
utf8.encode(String(plaintext ?? '')),
|
|
212
|
+
)
|
|
213
|
+
return toBase64(concat(nonce, new Uint8Array(sealed)))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Decrypts a content. Throws if the block was altered, truncated, or moved to a
|
|
218
|
+
* document or field other than the one it was written for.
|
|
219
|
+
*/
|
|
220
|
+
export async function openField(dek, packed, context) {
|
|
221
|
+
|
|
222
|
+
const bytes = fromBase64(packed)
|
|
223
|
+
if (bytes.length <= NONCE_BYTES) {
|
|
224
|
+
throw new Error('Unreadable encrypted content: block too short.')
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const clear = await subtle.decrypt(
|
|
228
|
+
{name: 'AES-GCM', iv: bytes.subarray(0, NONCE_BYTES), additionalData: contextBytes(context)},
|
|
229
|
+
dek,
|
|
230
|
+
bytes.subarray(NONCE_BYTES),
|
|
231
|
+
)
|
|
232
|
+
return fromUtf8.decode(clear)
|
|
233
|
+
} catch {
|
|
234
|
+
throw new Error('Unreadable encrypted content: altered, or filed under another identity.')
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The context of a field. Two different fields of the same document, or the
|
|
240
|
+
* same field across two documents, never share a context.
|
|
241
|
+
*/
|
|
242
|
+
export function fieldContext({documentId, field, ownerId = ''}) {
|
|
243
|
+
|
|
244
|
+
if (!documentId || !field) {
|
|
245
|
+
throw new Error('Incomplete context: documentId and field are required.')
|
|
246
|
+
}
|
|
247
|
+
return `sciilo.vault.v${VERSION}|${documentId}|${ownerId}|${field}`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Exports the data key in the clear. Reserved for sealing it towards the
|
|
252
|
+
* sidecar, which receives it encrypted for its ephemeral public key — never to
|
|
253
|
+
* write it to disk nor to hand it to the server.
|
|
254
|
+
*/
|
|
255
|
+
export async function exportDek(dek) {
|
|
256
|
+
|
|
257
|
+
return new Uint8Array(await subtle.exportKey('raw', dek))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Re-imports a data key obtained from exportDek(). */
|
|
261
|
+
export async function importDek(raw) {
|
|
262
|
+
|
|
263
|
+
return subtle.importKey('raw', raw, {name: 'AES-GCM', length: DEK_BITS}, true,
|
|
264
|
+
['encrypt', 'decrypt'])
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A twin of the data key that can encrypt and decrypt but can no longer be
|
|
269
|
+
* exported.
|
|
270
|
+
*
|
|
271
|
+
* This is what gets kept between page loads. Stored as-is in IndexedDB, the
|
|
272
|
+
* browser hands back a usable handle whose bytes no script can read: an
|
|
273
|
+
* injection can abuse the key while it sits on the page, but cannot carry it
|
|
274
|
+
* off to decrypt the library offline, forever.
|
|
275
|
+
*
|
|
276
|
+
* The privileged operations — changing the password, sealing the key for a
|
|
277
|
+
* sidecar — still need the exportable original, therefore the password. That
|
|
278
|
+
* the key can only leave the browser right after someone proves who they are
|
|
279
|
+
* is a property worth having, not a limitation to work around.
|
|
280
|
+
*/
|
|
281
|
+
export async function lockDown(dek) {
|
|
282
|
+
|
|
283
|
+
return subtle.importKey('raw', await exportDek(dek),
|
|
284
|
+
{name: 'AES-GCM', length: DEK_BITS}, false, ['encrypt', 'decrypt'])
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- handing the data key to the sidecar -----------------------------------
|
|
288
|
+
//
|
|
289
|
+
// The agent must read and write contents, so the sidecar needs the data key.
|
|
290
|
+
// It cannot come from the server, which does not have it, and it must not be
|
|
291
|
+
// written to the sidecar's config file: a key at rest on the machine survives
|
|
292
|
+
// reboots, backups and stolen laptops, which is precisely what we are avoiding.
|
|
293
|
+
//
|
|
294
|
+
// So the sidecar mints a throwaway key pair when it starts and publishes the
|
|
295
|
+
// public half. The browser seals the data key for that public key alone and
|
|
296
|
+
// sends it through the Sciilo relay, which carries a block it cannot open. The
|
|
297
|
+
// private half never leaves the sidecar's memory and dies with the process.
|
|
298
|
+
|
|
299
|
+
const HANDOFF_CONTEXT = `sciilo.vault.v${VERSION}|handoff`
|
|
300
|
+
const HANDOFF_CURVE = {name: 'ECDH', namedCurve: 'P-256'}
|
|
301
|
+
const PUBLIC_KEY_BYTES = 65 // uncompressed P-256 point
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Mints the throwaway key pair. Called by the sidecar at start-up, once.
|
|
305
|
+
*
|
|
306
|
+
* The private key is non-extractable: it cannot be serialised, therefore it
|
|
307
|
+
* cannot be written to disk, even by mistake.
|
|
308
|
+
*/
|
|
309
|
+
export async function createHandoffKeypair() {
|
|
310
|
+
|
|
311
|
+
const pair = await subtle.generateKey(HANDOFF_CURVE, false, ['deriveBits'])
|
|
312
|
+
const publicKey = new Uint8Array(await subtle.exportKey('raw', pair.publicKey))
|
|
313
|
+
return {publicKey: toBase64(publicKey), privateKey: pair.privateKey}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Seals the data key for one recipient. Called by the browser when a sidecar
|
|
318
|
+
* announces itself.
|
|
319
|
+
*
|
|
320
|
+
* A fresh ephemeral key pair is used every time, so two handoffs of the same
|
|
321
|
+
* data key share nothing: an observer of the relay cannot tell they carry the
|
|
322
|
+
* same secret.
|
|
323
|
+
*/
|
|
324
|
+
export async function sealDekFor(dek, recipientPublicKey) {
|
|
325
|
+
|
|
326
|
+
const recipient = await subtle.importKey(
|
|
327
|
+
'raw', fromBase64(recipientPublicKey), HANDOFF_CURVE, false, [])
|
|
328
|
+
const ephemeral = await subtle.generateKey(HANDOFF_CURVE, false, ['deriveBits'])
|
|
329
|
+
const shared = await handoffKey(ephemeral.privateKey, recipient)
|
|
330
|
+
|
|
331
|
+
const nonce = randomBytes(NONCE_BYTES)
|
|
332
|
+
const wrapped = await subtle.wrapKey('raw', dek, shared,
|
|
333
|
+
{name: 'AES-GCM', iv: nonce, additionalData: utf8.encode(HANDOFF_CONTEXT)})
|
|
334
|
+
const ephemeralPublic = new Uint8Array(await subtle.exportKey('raw', ephemeral.publicKey))
|
|
335
|
+
return toBase64(concat(ephemeralPublic, concat(nonce, new Uint8Array(wrapped))))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Opens a sealed data key. Called by the sidecar with the private half it
|
|
340
|
+
* minted at start-up.
|
|
341
|
+
*
|
|
342
|
+
* Throws for anything not sealed for this exact sidecar — including a block
|
|
343
|
+
* replayed from another session, since the key pair changes at every start.
|
|
344
|
+
*/
|
|
345
|
+
export async function openSealedDek(privateKey, sealed) {
|
|
346
|
+
|
|
347
|
+
const bytes = fromBase64(sealed)
|
|
348
|
+
if (bytes.length <= PUBLIC_KEY_BYTES + NONCE_BYTES) {
|
|
349
|
+
throw new Error('Unreadable sealed key: block too short.')
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const ephemeral = await subtle.importKey(
|
|
353
|
+
'raw', bytes.subarray(0, PUBLIC_KEY_BYTES), HANDOFF_CURVE, false, [])
|
|
354
|
+
const shared = await handoffKey(privateKey, ephemeral)
|
|
355
|
+
return await subtle.unwrapKey(
|
|
356
|
+
'raw',
|
|
357
|
+
bytes.subarray(PUBLIC_KEY_BYTES + NONCE_BYTES),
|
|
358
|
+
shared,
|
|
359
|
+
{
|
|
360
|
+
name: 'AES-GCM',
|
|
361
|
+
iv: bytes.subarray(PUBLIC_KEY_BYTES, PUBLIC_KEY_BYTES + NONCE_BYTES),
|
|
362
|
+
additionalData: utf8.encode(HANDOFF_CONTEXT),
|
|
363
|
+
},
|
|
364
|
+
{name: 'AES-GCM', length: DEK_BITS},
|
|
365
|
+
true,
|
|
366
|
+
['encrypt', 'decrypt'],
|
|
367
|
+
)
|
|
368
|
+
} catch {
|
|
369
|
+
throw new Error('Unreadable sealed key: not sealed for this sidecar, or altered.')
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function handoffKey(privateKey, publicKey) {
|
|
374
|
+
|
|
375
|
+
// The raw shared secret is never used as a key directly: HKDF separates it
|
|
376
|
+
// from any other use of the same curve and binds it to this protocol.
|
|
377
|
+
const shared = await subtle.deriveBits({name: 'ECDH', public: publicKey}, privateKey, 256)
|
|
378
|
+
const material = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey'])
|
|
379
|
+
return subtle.deriveKey(
|
|
380
|
+
{name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: utf8.encode(HANDOFF_CONTEXT)},
|
|
381
|
+
material,
|
|
382
|
+
{name: 'AES-GCM', length: 256},
|
|
383
|
+
false,
|
|
384
|
+
['wrapKey', 'unwrapKey'],
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// --- internals -------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
async function deriveKek(password, salt, iterations) {
|
|
391
|
+
|
|
392
|
+
if (typeof password !== 'string' || password.length === 0) {
|
|
393
|
+
throw new Error('A password is required to derive the vault key.')
|
|
394
|
+
}
|
|
395
|
+
const material = await subtle.importKey(
|
|
396
|
+
'raw', utf8.encode(password), 'PBKDF2', false, ['deriveKey'])
|
|
397
|
+
// The key-encryption key can ONLY seal and unseal the data key. It cannot
|
|
398
|
+
// encrypt a document: were it to leak, it would open nothing on its own.
|
|
399
|
+
return subtle.deriveKey(
|
|
400
|
+
{name: 'PBKDF2', salt, iterations, hash: 'SHA-512'},
|
|
401
|
+
material,
|
|
402
|
+
{name: 'AES-GCM', length: 256},
|
|
403
|
+
false, // non-extractable: it never leaves
|
|
404
|
+
['wrapKey', 'unwrapKey'],
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function sealDek(dek, kek, salt) {
|
|
409
|
+
|
|
410
|
+
const nonce = randomBytes(NONCE_BYTES)
|
|
411
|
+
const wrapped = await subtle.wrapKey('raw', dek, kek,
|
|
412
|
+
{name: 'AES-GCM', iv: nonce, additionalData: dekContext()})
|
|
413
|
+
return {
|
|
414
|
+
version: VERSION,
|
|
415
|
+
kdf: KDF,
|
|
416
|
+
iterations: ITERATIONS,
|
|
417
|
+
salt: toBase64(salt),
|
|
418
|
+
wrappedDek: toBase64(concat(nonce, new Uint8Array(wrapped))),
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function dekContext() {
|
|
423
|
+
|
|
424
|
+
return utf8.encode(`sciilo.vault.v${VERSION}|dek`)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function contextBytes(context) {
|
|
428
|
+
|
|
429
|
+
const value = typeof context === 'string' ? context : fieldContext(context ?? {})
|
|
430
|
+
return utf8.encode(value)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function assertRecord(record) {
|
|
434
|
+
|
|
435
|
+
if (!record || typeof record !== 'object') {
|
|
436
|
+
throw new Error('Missing vault record.')
|
|
437
|
+
}
|
|
438
|
+
if (record.version !== VERSION) {
|
|
439
|
+
throw new Error(`Unsupported vault version: ${record.version}.`)
|
|
440
|
+
}
|
|
441
|
+
if (record.kdf !== KDF) {
|
|
442
|
+
throw new Error(`Unsupported derivation: ${record.kdf}.`)
|
|
443
|
+
}
|
|
444
|
+
if (!Number.isInteger(record.iterations) || record.iterations < 100_000) {
|
|
445
|
+
throw new Error('Invalid or too weak iteration count.')
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function randomBytes(length) {
|
|
450
|
+
|
|
451
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(length))
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function concat(head, tail) {
|
|
455
|
+
|
|
456
|
+
const out = new Uint8Array(head.length + tail.length)
|
|
457
|
+
out.set(head, 0)
|
|
458
|
+
out.set(tail, head.length)
|
|
459
|
+
return out
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// btoa/atob rather than Buffer: Buffer does not exist in a browser, and this
|
|
463
|
+
// file must stay strictly identical on both sides.
|
|
464
|
+
//
|
|
465
|
+
// The bytes reach btoa in slices, not one character at a time. A pasted image
|
|
466
|
+
// or a board thumbnail seals into megabytes, and appending byte by byte cost
|
|
467
|
+
// more than the encryption itself — an order of magnitude more, and it grew
|
|
468
|
+
// FASTER than the payload, so the biggest documents were the ones that froze
|
|
469
|
+
// the page. 8 KB per slice stays far under every engine's apply() argument
|
|
470
|
+
// limit while flattening that curve.
|
|
471
|
+
const B64_SLICE = 0x2000
|
|
472
|
+
|
|
473
|
+
function toBase64(bytes) {
|
|
474
|
+
|
|
475
|
+
let binary = ''
|
|
476
|
+
for (let index = 0; index < bytes.length; index += B64_SLICE) {
|
|
477
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(index, index + B64_SLICE))
|
|
478
|
+
}
|
|
479
|
+
return btoa(binary)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function fromBase64(value) {
|
|
483
|
+
|
|
484
|
+
if (typeof value !== 'string') throw new Error('Encrypted block expected as base64.')
|
|
485
|
+
const binary = atob(value)
|
|
486
|
+
const out = new Uint8Array(binary.length)
|
|
487
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
488
|
+
out[index] = binary.charCodeAt(index)
|
|
489
|
+
}
|
|
490
|
+
return out
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export const VAULT_PARAMS = Object.freeze({version: VERSION, kdf: KDF, iterations: ITERATIONS})
|