@3sln/trove 0.0.5 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/packages/core/src/collections/index.js +146 -1
  4. package/packages/core/src/encryption/envelope.js +444 -0
  5. package/packages/core/src/encryption/exposure.js +101 -0
  6. package/packages/core/src/encryption/keys.js +88 -0
  7. package/packages/core/src/encryption/policy.js +112 -0
  8. package/packages/core/src/encryption/rotation.js +313 -0
  9. package/packages/core/src/index.js +17 -1
  10. package/packages/core/src/links.js +85 -0
  11. package/packages/core/src/metadata/memory.js +3 -1
  12. package/packages/core/src/metadata/sqlite.js +27 -7
  13. package/packages/core/src/scan.js +35 -1
  14. package/packages/core/src/storage/cost.js +228 -0
  15. package/packages/core/src/storage/drivers.js +13 -4
  16. package/packages/core/src/storage/registry.js +37 -4
  17. package/packages/core/src/uploads.js +197 -15
  18. package/packages/core/src/vfs.js +148 -9
  19. package/packages/server/src/engine/providers/core.js +38 -3
  20. package/packages/server/src/index.js +8 -0
  21. package/packages/server/src/routes.js +29 -1
  22. package/packages/web/dist/assets/main-y778bpte.js +356 -0
  23. package/packages/web/dist/assets/{main-f0f2tfhp.js.map → main-y778bpte.js.map} +10 -8
  24. package/packages/web/dist/assets/styles-nfy8t3n1.css +1 -0
  25. package/packages/web/dist/index.html +9 -3
  26. package/packages/web/dist/sw.js +1 -1
  27. package/packages/web/src/bl/actions.js +20 -4
  28. package/packages/web/src/bl/services.js +64 -4
  29. package/packages/web/src/platform/api.js +134 -11
  30. package/packages/web/src/styles.css +3 -0
  31. package/packages/web/src/ui/components/overlays.js +13 -0
  32. package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
  33. package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
package/README.md CHANGED
@@ -163,6 +163,12 @@ TROVE_S3_ACCESS_KEY_ID=… # or AWS_ACCESS_KEY_ID
163
163
  TROVE_S3_SECRET_ACCESS_KEY=… # or AWS_SECRET_ACCESS_KEY
164
164
  TROVE_S3_PATH_STYLE=true # MinIO / custom endpoints
165
165
 
166
+ # Which store types a COLLECTION may be created on. Defaults to everything this
167
+ # runtime registered; naming a subset takes the rest off the collection form and
168
+ # refuses them. Worth setting on Workers, where `memory` is offered because it is
169
+ # portable but produces a collection that loses its uploads on isolate recycle.
170
+ TROVE_STORAGE_DRIVERS=s3 # subset of: memory | filesystem | s3
171
+
166
172
  # Metadata (file tree + facets)
167
173
  TROVE_METADATA=sqlite # memory | sqlite
168
174
  TROVE_DB_PATH=./data/trove.db
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.5",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
6
6
  "repository": {
@@ -13,6 +13,9 @@
13
13
  // secrets (access keys); treat that store as sensitive.
14
14
 
15
15
  import { TroveError } from '../errors.js';
16
+ import { normalizeEncryption, describeEncryption } from '../encryption/policy.js';
17
+ import { newCollectionKey, fromHex, toHex } from '../encryption/keys.js';
18
+
16
19
  import { PrefixedStorage } from '../storage/prefixed.js';
17
20
  import { newId } from '../util.js';
18
21
 
@@ -77,6 +80,120 @@ export class CollectionService {
77
80
  .sort((a, b) => (a.name || '').localeCompare(b.name || ''));
78
81
  }
79
82
 
83
+ /**
84
+ * The encryption settings for a collection, generating a key the first time.
85
+ *
86
+ * The key is generated, not derived from anything a user types. A passphrase would buy
87
+ * nothing: the server knows the key regardless — it hands it to clients and decrypts for
88
+ * indexers — so there is no protection to gain from the user holding it, and every cost
89
+ * would still apply. A random 256-bit key cannot be forgotten, guessed, or shoulder-read,
90
+ * and needs no prompt in front of the collection.
91
+ *
92
+ * Generated ONCE. Re-enabling, or changing the rules, keeps the existing key: every
93
+ * stored object names the key it was sealed with, so quietly minting a new one would
94
+ * orphan all of them. Replacing a key is rotation, and rotation re-encrypts.
95
+ *
96
+ * @returns {Promise<{encryption: object, dataKey: Uint8Array|null}>}
97
+ */
98
+ async #encryptionFor(patch, existing, ring) {
99
+ if (!patch || patch.enabled === false) return { encryption: null, keys: ring || null };
100
+ if (existing?.fingerprint && ring?.[existing.fingerprint]) {
101
+ // Keep the whole ring; only the rules can change here.
102
+ return { encryption: normalizeEncryption(patch, existing.fingerprint), keys: ring };
103
+ }
104
+ const { dataKey, config } = await newCollectionKey();
105
+ return {
106
+ encryption: normalizeEncryption(patch, config.fingerprint),
107
+ keys: { ...(ring || {}), [config.fingerprint]: toHex(dataKey) },
108
+ };
109
+ }
110
+
111
+ /**
112
+ * The key an object was sealed with, or the collection's current key.
113
+ *
114
+ * A collection holds a RING, not a key: rotation adds a new key and makes it current,
115
+ * then re-encrypts objects onto it in the background. Until that finishes both keys are
116
+ * live, and an object is opened with whichever one its envelope names — which is the
117
+ * whole reason every object carries a fingerprint. Retiring a key is only safe once
118
+ * nothing names it any more.
119
+ *
120
+ * Server-side only. Callers are the transfer plans, which hand the right key to a client
121
+ * that may read the collection, and indexing, which decrypts to read content. Never
122
+ * reachable through `describe`.
123
+ *
124
+ * @param {string} collectionId
125
+ * @param {string} [fingerprint] which key; omitted means the current one
126
+ */
127
+ async dataKeyFor(collectionId, fingerprint) {
128
+ const c = await this.get(collectionId);
129
+ const ring = c?.$keys;
130
+ if (!ring) return null;
131
+ const want = fingerprint || c.encryption?.fingerprint;
132
+ const hex = want && ring[want];
133
+ return hex ? fromHex(hex) : null;
134
+ }
135
+
136
+ /**
137
+ * Every key this collection can still open something with, current first.
138
+ *
139
+ * For rotation, and for anything that has to read objects it did not plan.
140
+ */
141
+ async keyRingFor(collectionId) {
142
+ const c = await this.get(collectionId);
143
+ if (!c?.$keys) return [];
144
+ const current = c.encryption?.fingerprint;
145
+ return Object.entries(c.$keys)
146
+ .map(([fp, hex]) => ({ fingerprint: fp, dataKey: fromHex(hex), current: fp === current }))
147
+ .sort((a, b) => Number(b.current) - Number(a.current));
148
+ }
149
+
150
+ /**
151
+ * Begin a rotation: mint a key, make it current, keep the old ones.
152
+ *
153
+ * Only makes the new key current. Nothing is re-encrypted here — objects move onto it
154
+ * incrementally, and until every one has, the old keys must stay or their objects become
155
+ * unreadable. `retireKey` is what finishes the job.
156
+ */
157
+ async beginRotation(collectionId, principal) {
158
+ const c = await this.assert(principal, collectionId, 'admin');
159
+ if (!c.encryption?.enabled) throw TroveError.invalid('This collection is not encrypted');
160
+ const { dataKey, config } = await newCollectionKey();
161
+ const next = {
162
+ ...c,
163
+ encryption: { ...c.encryption, fingerprint: config.fingerprint },
164
+ $keys: { ...(c.$keys || {}), [config.fingerprint]: toHex(dataKey) },
165
+ };
166
+ await this.kv.set(NS, collectionId, next);
167
+ return { fingerprint: config.fingerprint, previous: c.encryption.fingerprint };
168
+ }
169
+
170
+ /**
171
+ * Drop a key from the ring, once nothing is sealed with it any more.
172
+ *
173
+ * Refuses to drop the current key: that would leave the collection encrypting with
174
+ * something it cannot open.
175
+ */
176
+ async retireKey(collectionId, fingerprint, principal, { system = false } = {}) {
177
+ // `system` is for the rotation walker, which has no user behind it — it is finishing
178
+ // work an admin already authorized when they started the rotation. Named rather than
179
+ // done by passing a fake principal, so the bypass is visible at both ends.
180
+ const c = system ? await this.get(collectionId) : await this.assert(principal, collectionId, 'admin');
181
+ if (fingerprint === c.encryption?.fingerprint) {
182
+ throw TroveError.invalid('That is the collection\u2019s current key');
183
+ }
184
+ if (!c.$keys?.[fingerprint]) return { retired: false };
185
+ const keys = { ...c.$keys };
186
+ delete keys[fingerprint];
187
+ await this.kv.set(NS, collectionId, { ...c, $keys: keys });
188
+ return { retired: true };
189
+ }
190
+
191
+ /** What a collection encrypts, for the code that has to decide per item. */
192
+ async encryptionFor(collectionId) {
193
+ const c = await this.get(collectionId);
194
+ return c?.encryption || null;
195
+ }
196
+
80
197
  /**
81
198
  * Every collection record, with no principal and no ACL filtering.
82
199
  *
@@ -106,6 +223,11 @@ export class CollectionService {
106
223
  id: c.id, name: c.name, description: c.description || '',
107
224
  driver: c.store?.driver, system: !!c.system,
108
225
  capabilities: caps, createdAt: c.createdAt,
226
+ // Safe to hand to anyone who can see the collection: the salt, the KDF parameters
227
+ // and the fingerprint are what turn a passphrase into the key, and are useless
228
+ // without the passphrase. Null when the collection is not encrypted, so a client
229
+ // never has to ask a second question to find out.
230
+ encryption: describeEncryption(c.encryption),
109
231
  };
110
232
  }
111
233
 
@@ -269,7 +391,7 @@ export class CollectionService {
269
391
  return { record, created: true };
270
392
  }
271
393
 
272
- async create({ name, description, store, acl }, principal) {
394
+ async create({ name, description, store, acl, encryption }, principal) {
273
395
  if (!this.canCreate(principal)) throw TroveError.forbidden('You cannot create collections');
274
396
  if (!name?.trim()) throw TroveError.invalid('Collection name is required');
275
397
  if (!store?.driver) throw TroveError.invalid('A backing store (driver + config) is required');
@@ -283,6 +405,14 @@ export class CollectionService {
283
405
  const grants = acl?.grants ? [...acl.grants] : [];
284
406
  grants.push({ type: 'user', subject: principal.id, capabilities: ['admin'] });
285
407
  const record = { id, name: name.trim(), description: description || '', store, acl: { grants }, createdAt: Date.now(), createdBy: principal.id };
408
+ // Set at creation so the very first upload is covered. Enabling it later is allowed and
409
+ // only affects what arrives after — nothing retroactively encrypts what is already
410
+ // there, and pretending otherwise would be the more dangerous lie.
411
+ if (encryption !== undefined) {
412
+ const set = await this.#encryptionFor(encryption, null, null);
413
+ record.encryption = set.encryption;
414
+ if (set.keys) record.$keys = set.keys;
415
+ }
286
416
  await this.kv.set(NS, id, record);
287
417
  return this.describe(record, principal);
288
418
  }
@@ -297,6 +427,21 @@ export class CollectionService {
297
427
  next.store = patch.store;
298
428
  this._storage.delete(id); // rebuild backend next use
299
429
  }
430
+ // Turning encryption ON affects only what is uploaded from now on, and turning it OFF
431
+ // does not decrypt anything: every object records its own envelope, so what is already
432
+ // stored keeps working either way. Changing the FINGERPRINT is a different act — that
433
+ // is a key rotation, and rotating without re-encrypting would orphan every existing
434
+ // object. Refused here; the rotation job is what does it safely.
435
+ if (patch.encryption !== undefined) {
436
+ // Turning encryption ON affects only what is uploaded from now on, and turning it OFF
437
+ // decrypts nothing: every object records its own envelope, so what is already stored
438
+ // keeps working either way. The KEY is never replaced here — it is generated once and
439
+ // kept, because every stored object names the key it was sealed with. Replacing one
440
+ // is rotation, and rotation re-encrypts.
441
+ const set = await this.#encryptionFor(patch.encryption, c.encryption, c.$keys);
442
+ next.encryption = set.encryption;
443
+ if (set.keys) next.$keys = set.keys;
444
+ }
300
445
  await this.kv.set(NS, id, next);
301
446
  return this.describe(next, principal);
302
447
  }
@@ -0,0 +1,444 @@
1
+ // The wrapper around an encrypted object's bytes.
2
+ //
3
+ // An encrypted item is not a blob of ciphertext — it is a readable header followed by
4
+ // independently-encrypted chunks. Both halves of that are load-bearing.
5
+ //
6
+ // READABLE HEADER. Whoever holds the object must be able to learn what key it wants
7
+ // without having the key: which collection key encrypted it (the fingerprint), how it was
8
+ // encrypted, and how big it really is. That is what lets a sideloaded object — one copied
9
+ // into the bucket from somewhere else, or left behind by a half-finished key rotation —
10
+ // be matched to a key instead of being an unreadable mystery. It is also why the
11
+ // collection carries a fingerprint AND every object carries one: the collection's says
12
+ // which key to ask the user for, the object's says whether this particular object is
13
+ // actually encrypted with it.
14
+ //
15
+ // CHUNKS. Range requests are load-bearing in this drive already: the service worker slices
16
+ // ranges out of pinned files, the text viewer reads the first 512KB of a large file rather
17
+ // than pulling gigabytes to show a screenful, and media seeking is a range request per
18
+ // seek. A single AES-GCM blob has exactly one authentication tag over the whole message,
19
+ // so reading one byte means fetching and decrypting all of them. Fixed-size chunks turn a
20
+ // plaintext range into a chunk range, and only those chunks are fetched and decrypted.
21
+ //
22
+ // The cost is honest and small: 16 bytes of tag per chunk, and a header. At the default
23
+ // chunk size that is under 0.02% overhead.
24
+
25
+ import { TroveError } from '../errors.js';
26
+
27
+ /** "TRV1" — enough to recognise the format, and to refuse bytes that are not it. */
28
+ const MAGIC = new Uint8Array([0x54, 0x52, 0x56, 0x31]);
29
+
30
+ export const VERSION = 1;
31
+ /** AES-256-GCM. Recorded per object so the format can gain another without ambiguity. */
32
+ export const ALG_AES_256_GCM = 1;
33
+
34
+ /** AES-GCM's authentication tag. */
35
+ export const TAG_BYTES = 16;
36
+ /** Random per object; the per-chunk nonce is this followed by the chunk index. */
37
+ const NONCE_PREFIX_BYTES = 8;
38
+ const NONCE_BYTES = 12;
39
+ /** Truncated: 128 bits is far past collision risk for "which key is this". */
40
+ export const FINGERPRINT_BYTES = 16;
41
+
42
+ export const HEADER_BYTES = 44;
43
+
44
+ /**
45
+ * 1 MiB of plaintext per chunk.
46
+ *
47
+ * The trade is seek granularity against overhead and round trips. Too small and a large
48
+ * file becomes thousands of tags and a range read becomes many requests; too large and
49
+ * seeking to one second of audio drags megabytes. 1 MiB keeps overhead at 16 bytes per
50
+ * MiB — about 0.0015% — while keeping a seek to roughly one chunk.
51
+ */
52
+ export const DEFAULT_CHUNK_SIZE = 1024 * 1024;
53
+
54
+ /**
55
+ * How many bytes the ciphertext of a plaintext of this size occupies.
56
+ *
57
+ * Needed before a single byte is encrypted: the upload plan negotiates part boundaries and
58
+ * a per-file size limit against the size that will actually be STORED, and a plan computed
59
+ * against the plaintext size is wrong by a tag per chunk. On a multipart upload that is the
60
+ * difference between a final part that exists and one that does not.
61
+ */
62
+ export function cipherSize(plaintextSize, chunkSize = DEFAULT_CHUNK_SIZE) {
63
+ assertChunkSize(chunkSize);
64
+ if (!(plaintextSize >= 0)) throw TroveError.invalid('plaintextSize must be a non-negative number');
65
+ // An empty file still gets a header, and still has one (empty) chunk — so that "is this
66
+ // encrypted" has the same answer for an empty file as for any other.
67
+ const chunks = plaintextSize === 0 ? 1 : Math.ceil(plaintextSize / chunkSize);
68
+ return HEADER_BYTES + plaintextSize + chunks * TAG_BYTES;
69
+ }
70
+
71
+ /** The inverse, for reporting the real size of something already stored. */
72
+ export function plaintextSizeOf(cipherTotal, chunkSize = DEFAULT_CHUNK_SIZE) {
73
+ assertChunkSize(chunkSize);
74
+ const body = cipherTotal - HEADER_BYTES;
75
+ if (body < TAG_BYTES) throw TroveError.invalid('Ciphertext is too short to be an envelope');
76
+ const full = Math.floor(body / (chunkSize + TAG_BYTES));
77
+ const rest = body - full * (chunkSize + TAG_BYTES);
78
+ // A trailing partial chunk still carries a full tag.
79
+ return full * chunkSize + Math.max(0, rest - TAG_BYTES);
80
+ }
81
+
82
+ function assertChunkSize(chunkSize) {
83
+ if (!Number.isInteger(chunkSize) || chunkSize <= 0 || chunkSize > 0xffffffff) {
84
+ throw TroveError.invalid(`Invalid chunk size ${chunkSize}`);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * @typedef {object} EnvelopeHeader
90
+ * @property {number} version
91
+ * @property {number} algorithm
92
+ * @property {number} chunkSize plaintext bytes per chunk
93
+ * @property {number} plaintextSize the real size, which the ciphertext length does not give
94
+ * @property {Uint8Array} noncePrefix
95
+ * @property {Uint8Array} fingerprint which key this was encrypted with
96
+ */
97
+
98
+ /** @param {EnvelopeHeader} h */
99
+ export function encodeHeader(h) {
100
+ const out = new Uint8Array(HEADER_BYTES);
101
+ const view = new DataView(out.buffer);
102
+ out.set(MAGIC, 0);
103
+ out[4] = h.version ?? VERSION;
104
+ out[5] = h.algorithm ?? ALG_AES_256_GCM;
105
+ // 6..7 reserved: written as zero and required to BE zero on read, so a future flag
106
+ // cannot be silently ignored by a reader that predates it.
107
+ view.setUint32(8, h.chunkSize, true);
108
+ // A double holds an exact integer to 2^53, which is 9 petabytes — past any file, and
109
+ // past what the rest of this codebase handles as a JS number anyway.
110
+ view.setBigUint64(12, BigInt(h.plaintextSize), true);
111
+ out.set(h.noncePrefix, 20);
112
+ out.set(h.fingerprint, 28);
113
+ return out;
114
+ }
115
+
116
+ /** @returns {EnvelopeHeader} */
117
+ export function decodeHeader(bytes) {
118
+ if (bytes.length < HEADER_BYTES) throw TroveError.invalid('Not an encrypted object: too short');
119
+ for (let i = 0; i < MAGIC.length; i++) {
120
+ if (bytes[i] !== MAGIC[i]) throw TroveError.invalid('Not an encrypted object');
121
+ }
122
+ const version = bytes[4];
123
+ if (version !== VERSION) {
124
+ // Named rather than "corrupt". A reader that meets a newer envelope should say the
125
+ // drive is newer than it is, not that the file is broken.
126
+ throw TroveError.invalid(`This object uses envelope version ${version}, and this client understands ${VERSION}`);
127
+ }
128
+ const algorithm = bytes[5];
129
+ if (algorithm !== ALG_AES_256_GCM) throw TroveError.invalid(`Unknown encryption algorithm ${algorithm}`);
130
+ if (bytes[6] !== 0 || bytes[7] !== 0) {
131
+ throw TroveError.invalid('This object sets envelope flags this client does not understand');
132
+ }
133
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
134
+ const chunkSize = view.getUint32(8, true);
135
+ assertChunkSize(chunkSize);
136
+ return {
137
+ version,
138
+ algorithm,
139
+ chunkSize,
140
+ plaintextSize: Number(view.getBigUint64(12, true)),
141
+ noncePrefix: bytes.slice(20, 20 + NONCE_PREFIX_BYTES),
142
+ fingerprint: bytes.slice(28, 28 + FINGERPRINT_BYTES),
143
+ };
144
+ }
145
+
146
+ /** Does this look like one of ours? Cheap, and does not need the key. */
147
+ export function isEnvelope(bytes) {
148
+ if (!bytes || bytes.length < MAGIC.length) return false;
149
+ for (let i = 0; i < MAGIC.length; i++) if (bytes[i] !== MAGIC[i]) return false;
150
+ return true;
151
+ }
152
+
153
+ /**
154
+ * The nonce for one chunk: the object's random prefix, then the chunk index.
155
+ *
156
+ * Uniqueness per (key, nonce) is the one thing AES-GCM cannot survive losing — a repeat
157
+ * leaks the XOR of two plaintexts and, worse, the authentication key. The prefix is random
158
+ * per object so two objects never collide, and the counter makes chunks within an object
159
+ * distinct. This is why an object may never be re-encrypted in place under the same key
160
+ * with a fresh prefix omitted: rotation writes a NEW object.
161
+ */
162
+ function nonceFor(prefix, chunkIndex) {
163
+ const nonce = new Uint8Array(NONCE_BYTES);
164
+ nonce.set(prefix, 0);
165
+ new DataView(nonce.buffer).setUint32(NONCE_PREFIX_BYTES, chunkIndex, true);
166
+ return nonce;
167
+ }
168
+
169
+ /**
170
+ * Which ciphertext bytes are needed to answer a plaintext range, and how much of the
171
+ * decrypted result to discard at each end.
172
+ *
173
+ * This is the whole reason for chunking. A viewer asking for the first 512KB of a 4GB file
174
+ * gets one chunk fetched instead of four gigabytes.
175
+ *
176
+ * @param {{start: number, end: number}} range inclusive plaintext byte range
177
+ * @param {EnvelopeHeader} header
178
+ */
179
+ export function cipherRangeFor(range, header) {
180
+ const { chunkSize, plaintextSize } = header;
181
+ const start = Math.max(0, range.start);
182
+ const end = Math.min(range.end ?? plaintextSize - 1, plaintextSize - 1);
183
+ if (start > end) throw TroveError.invalid('Empty or reversed range');
184
+ const firstChunk = Math.floor(start / chunkSize);
185
+ const lastChunk = Math.floor(end / chunkSize);
186
+ const stride = chunkSize + TAG_BYTES;
187
+ return {
188
+ firstChunk,
189
+ lastChunk,
190
+ // Inclusive ciphertext byte range to fetch.
191
+ cipherStart: HEADER_BYTES + firstChunk * stride,
192
+ cipherEnd: Math.min(HEADER_BYTES + (lastChunk + 1) * stride, cipherSize(plaintextSize, chunkSize)) - 1,
193
+ // Once those chunks are decrypted and joined, the caller wants this slice of them.
194
+ trimStart: start - firstChunk * chunkSize,
195
+ trimEnd: end - firstChunk * chunkSize + 1,
196
+ };
197
+ }
198
+
199
+ async function importKey(rawKey) {
200
+ if (!(rawKey instanceof Uint8Array) || rawKey.length !== 32) {
201
+ throw TroveError.invalid('An AES-256 key must be 32 bytes');
202
+ }
203
+ return crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt', 'decrypt']);
204
+ }
205
+
206
+ /**
207
+ * Encrypt a whole object.
208
+ *
209
+ * @param {Uint8Array} rawKey 32-byte data key
210
+ * @param {Uint8Array} plaintext
211
+ * @param {{fingerprint: Uint8Array, chunkSize?: number}} opts
212
+ * @returns {Promise<Uint8Array>} header + chunks
213
+ */
214
+ export async function encrypt(rawKey, plaintext, { fingerprint, chunkSize = DEFAULT_CHUNK_SIZE } = {}) {
215
+ assertChunkSize(chunkSize);
216
+ if (!fingerprint || fingerprint.length !== FINGERPRINT_BYTES) {
217
+ throw TroveError.invalid(`A fingerprint must be ${FINGERPRINT_BYTES} bytes`);
218
+ }
219
+ const key = await importKey(rawKey);
220
+ const noncePrefix = crypto.getRandomValues(new Uint8Array(NONCE_PREFIX_BYTES));
221
+ const header = encodeHeader({
222
+ version: VERSION,
223
+ algorithm: ALG_AES_256_GCM,
224
+ chunkSize,
225
+ plaintextSize: plaintext.length,
226
+ noncePrefix,
227
+ fingerprint,
228
+ });
229
+
230
+ const out = new Uint8Array(cipherSize(plaintext.length, chunkSize));
231
+ out.set(header, 0);
232
+ let at = HEADER_BYTES;
233
+ const chunks = plaintext.length === 0 ? 1 : Math.ceil(plaintext.length / chunkSize);
234
+ for (let i = 0; i < chunks; i++) {
235
+ const slice = plaintext.subarray(i * chunkSize, Math.min((i + 1) * chunkSize, plaintext.length));
236
+ const sealed = new Uint8Array(await crypto.subtle.encrypt(
237
+ { name: 'AES-GCM', iv: nonceFor(noncePrefix, i) }, key, slice,
238
+ ));
239
+ out.set(sealed, at);
240
+ at += sealed.length;
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * Decrypt a whole object.
247
+ *
248
+ * A failure here is deliberately not distinguished into "wrong key" versus "tampered":
249
+ * AES-GCM cannot tell them apart, and a message that guessed would be guessing.
250
+ */
251
+ export async function decrypt(rawKey, envelope) {
252
+ const header = decodeHeader(envelope);
253
+ const key = await importKey(rawKey);
254
+ return decryptChunks(key, envelope.subarray(HEADER_BYTES), header, 0);
255
+ }
256
+
257
+ /**
258
+ * Decrypt a run of chunks that starts at `firstChunk` — the partner of `cipherRangeFor`,
259
+ * for the case where only part of the object was fetched.
260
+ */
261
+ export async function decryptRange(rawKey, cipherPart, header, firstChunk) {
262
+ const key = await importKey(rawKey);
263
+ return decryptChunks(key, cipherPart, header, firstChunk);
264
+ }
265
+
266
+ async function decryptChunks(key, body, header, firstChunk) {
267
+ const { chunkSize, noncePrefix } = header;
268
+ const stride = chunkSize + TAG_BYTES;
269
+ const pieces = [];
270
+ let total = 0;
271
+ for (let at = 0, i = firstChunk; at < body.length; at += stride, i++) {
272
+ const sealed = body.subarray(at, Math.min(at + stride, body.length));
273
+ if (sealed.length <= TAG_BYTES && sealed.length !== TAG_BYTES) {
274
+ throw TroveError.invalid('Truncated encrypted object');
275
+ }
276
+ let opened;
277
+ try {
278
+ opened = new Uint8Array(await crypto.subtle.decrypt(
279
+ { name: 'AES-GCM', iv: nonceFor(noncePrefix, i) }, key, sealed,
280
+ ));
281
+ } catch {
282
+ throw TroveError.invalid('Could not decrypt: wrong key, or the data has been altered');
283
+ }
284
+ pieces.push(opened);
285
+ total += opened.length;
286
+ }
287
+ const out = new Uint8Array(total);
288
+ let at = 0;
289
+ for (const p of pieces) { out.set(p, at); at += p.length; }
290
+ return out;
291
+ }
292
+
293
+ /**
294
+ * Decrypt a ciphertext stream chunk by chunk, without holding the object in memory.
295
+ *
296
+ * The buffering version is fine for a text preview and wrong for a two-hour video: a
297
+ * server that decrypted whole objects would hold one per concurrent viewer, and on a
298
+ * Worker that is the memory limit rather than a slowdown. This reassembles exactly one
299
+ * chunk at a time and emits its plaintext as soon as the tag verifies.
300
+ *
301
+ * Emitting per chunk does mean unverified bytes are never emitted, but earlier chunks are
302
+ * released before later ones are checked — which is inherent to streaming anything
303
+ * authenticated, and is why the chunk is the unit of trust rather than the file.
304
+ *
305
+ * @param {Uint8Array} rawKey
306
+ * @param {EnvelopeHeader} header
307
+ * @param {ReadableStream<Uint8Array>} cipherStream body only, no header
308
+ * @param {number} [firstChunk] index of the first chunk in the stream
309
+ */
310
+ export async function decryptStream(rawKey, header, cipherStream, firstChunk = 0) {
311
+ const key = await importKey(rawKey);
312
+ const stride = header.chunkSize + TAG_BYTES;
313
+ const reader = cipherStream.getReader();
314
+ let held = new Uint8Array(0);
315
+ let index = firstChunk;
316
+
317
+ const take = (n) => {
318
+ const out = held.subarray(0, n);
319
+ held = held.subarray(n);
320
+ return out;
321
+ };
322
+ const open = async (sealed) => {
323
+ try {
324
+ return new Uint8Array(await crypto.subtle.decrypt(
325
+ { name: 'AES-GCM', iv: nonceFor(header.noncePrefix, index++) }, key, sealed,
326
+ ));
327
+ } catch {
328
+ throw TroveError.invalid('Could not decrypt: wrong key, or the data has been altered');
329
+ }
330
+ };
331
+
332
+ return new ReadableStream({
333
+ async pull(controller) {
334
+ for (;;) {
335
+ if (held.length >= stride) {
336
+ controller.enqueue(await open(take(stride)));
337
+ return;
338
+ }
339
+ const { value, done } = await reader.read();
340
+ if (done) {
341
+ // Whatever is left is the final, partial chunk — still a whole tag, but fewer
342
+ // than a chunk of plaintext.
343
+ if (held.length) {
344
+ if (held.length < TAG_BYTES) throw TroveError.invalid('Truncated encrypted object');
345
+ controller.enqueue(await open(take(held.length)));
346
+ }
347
+ controller.close();
348
+ return;
349
+ }
350
+ const grown = new Uint8Array(held.length + value.length);
351
+ grown.set(held, 0);
352
+ grown.set(value, held.length);
353
+ held = grown;
354
+ }
355
+ },
356
+ cancel(reason) {
357
+ return reader.cancel(reason);
358
+ },
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Seal a plaintext stream into an envelope stream, a chunk at a time.
364
+ *
365
+ * The counterpart to `decryptStream`, and needed for the same reason: `encrypt` allocates
366
+ * the whole ciphertext, so re-encrypting a large object meant holding the file twice over —
367
+ * once decrypted and once sealed. On a Cloudflare isolate that is the memory limit rather
368
+ * than a slowdown, which capped key rotation at small files.
369
+ *
370
+ * The size has to be known up front because it goes in the header, which is written before
371
+ * any chunk. That is not a limitation in practice: every caller is re-sealing something
372
+ * whose size is already recorded.
373
+ *
374
+ * @param {Uint8Array} rawKey
375
+ * @param {ReadableStream<Uint8Array>} plaintext
376
+ * @param {{fingerprint: Uint8Array, plaintextSize: number, chunkSize?: number}} opts
377
+ */
378
+ export async function encryptStream(rawKey, plaintext, { fingerprint, plaintextSize, chunkSize = DEFAULT_CHUNK_SIZE } = {}) {
379
+ assertChunkSize(chunkSize);
380
+ if (!fingerprint || fingerprint.length !== FINGERPRINT_BYTES) {
381
+ throw TroveError.invalid(`A fingerprint must be ${FINGERPRINT_BYTES} bytes`);
382
+ }
383
+ const key = await importKey(rawKey);
384
+ const noncePrefix = crypto.getRandomValues(new Uint8Array(NONCE_PREFIX_BYTES));
385
+ const header = encodeHeader({
386
+ version: VERSION, algorithm: ALG_AES_256_GCM, chunkSize, plaintextSize, noncePrefix, fingerprint,
387
+ });
388
+
389
+ const reader = plaintext.getReader();
390
+ let held = new Uint8Array(0);
391
+ let index = 0;
392
+ let wroteHeader = false;
393
+ let seen = 0;
394
+
395
+ const seal = async (piece) => new Uint8Array(await crypto.subtle.encrypt(
396
+ { name: 'AES-GCM', iv: nonceFor(noncePrefix, index++) }, key, piece,
397
+ ));
398
+
399
+ return new ReadableStream({
400
+ async pull(controller) {
401
+ if (!wroteHeader) {
402
+ wroteHeader = true;
403
+ controller.enqueue(header);
404
+ return;
405
+ }
406
+ for (;;) {
407
+ if (held.length >= chunkSize) {
408
+ const piece = held.subarray(0, chunkSize);
409
+ held = held.subarray(chunkSize);
410
+ seen += piece.length;
411
+ controller.enqueue(await seal(piece));
412
+ return;
413
+ }
414
+ const { value, done } = await reader.read();
415
+ if (done) {
416
+ // The trailing partial chunk — and, for an empty file, the one empty chunk that
417
+ // makes "is this encrypted" answerable the same way at any size.
418
+ if (held.length || seen === 0) {
419
+ seen += held.length;
420
+ controller.enqueue(await seal(held));
421
+ held = new Uint8Array(0);
422
+ }
423
+ if (seen !== plaintextSize) {
424
+ // Refused rather than written: an envelope whose header disagrees with its body
425
+ // decrypts to the wrong length forever, and the header cannot be fixed later
426
+ // without re-encrypting.
427
+ throw TroveError.invalid(
428
+ `Expected ${plaintextSize} bytes to encrypt and received ${seen}`,
429
+ );
430
+ }
431
+ controller.close();
432
+ return;
433
+ }
434
+ const grown = new Uint8Array(held.length + value.length);
435
+ grown.set(held, 0);
436
+ grown.set(value, held.length);
437
+ held = grown;
438
+ }
439
+ },
440
+ cancel(reason) {
441
+ return reader.cancel(reason);
442
+ },
443
+ });
444
+ }