@3sln/trove 0.0.7 → 0.0.9

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 (88) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/collections/index.js +146 -1
  3. package/packages/core/src/encryption/envelope.js +483 -0
  4. package/packages/core/src/encryption/exposure.js +101 -0
  5. package/packages/core/src/encryption/keys.js +88 -0
  6. package/packages/core/src/encryption/policy.js +112 -0
  7. package/packages/core/src/encryption/rotation.js +432 -0
  8. package/packages/core/src/index.js +17 -1
  9. package/packages/core/src/links.js +85 -0
  10. package/packages/core/src/metadata/memory.js +3 -1
  11. package/packages/core/src/metadata/sqlite.js +27 -7
  12. package/packages/core/src/scan.js +35 -1
  13. package/packages/core/src/storage/cost.js +228 -0
  14. package/packages/core/src/uploads.js +302 -19
  15. package/packages/core/src/vfs.js +161 -10
  16. package/packages/server/src/adapters/staticAssets.js +14 -5
  17. package/packages/server/src/engine/index.js +1 -1
  18. package/packages/server/src/engine/providers/core.js +21 -2
  19. package/packages/server/src/index.js +45 -2
  20. package/packages/server/src/routes.js +62 -1
  21. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  22. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  23. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  24. package/packages/web/dist/index.html +9 -3
  25. package/packages/web/dist/sw.js +1 -1
  26. package/packages/web/src/bl/actions.js +1471 -138
  27. package/packages/web/src/bl/commands.js +103 -284
  28. package/packages/web/src/bl/context.js +69 -0
  29. package/packages/web/src/bl/index.js +136 -40
  30. package/packages/web/src/bl/intern.js +129 -0
  31. package/packages/web/src/bl/launcher.js +251 -0
  32. package/packages/web/src/bl/links.js +1 -1
  33. package/packages/web/src/bl/match.js +71 -0
  34. package/packages/web/src/bl/mentions.js +39 -0
  35. package/packages/web/src/bl/offline.js +0 -4
  36. package/packages/web/src/bl/openers.js +65 -33
  37. package/packages/web/src/bl/pluginInstall.js +42 -21
  38. package/packages/web/src/bl/queries.js +669 -0
  39. package/packages/web/src/bl/services.js +141 -145
  40. package/packages/web/src/bl/social.js +3 -1
  41. package/packages/web/src/bl/state.js +144 -0
  42. package/packages/web/src/bl/status.js +96 -0
  43. package/packages/web/src/bl/trust.js +109 -0
  44. package/packages/web/src/bl/viewState.js +60 -0
  45. package/packages/web/src/bl/views.js +64 -0
  46. package/packages/web/src/bl/watchQuery.js +73 -0
  47. package/packages/web/src/platform/api.js +63 -11
  48. package/packages/web/src/platform/commands.js +49 -9
  49. package/packages/web/src/platform/context.js +146 -51
  50. package/packages/web/src/platform/index.js +27 -20
  51. package/packages/web/src/platform/navigation.js +34 -4
  52. package/packages/web/src/platform/pickers.js +46 -0
  53. package/packages/web/src/platform/pluginHost.js +43 -10
  54. package/packages/web/src/platform/pluginRpc.js +6 -2
  55. package/packages/web/src/platform/spatialNav.js +0 -2
  56. package/packages/web/src/platform/viewport.js +1 -6
  57. package/packages/web/src/platform/voiceSearch.js +1 -3
  58. package/packages/web/src/platform/whenclause.js +22 -3
  59. package/packages/web/src/styles.css +77 -4
  60. package/packages/web/src/ui/activate.js +22 -0
  61. package/packages/web/src/ui/components/activityBar.js +7 -3
  62. package/packages/web/src/ui/components/activityPanel.js +10 -11
  63. package/packages/web/src/ui/components/adminView.js +191 -0
  64. package/packages/web/src/ui/components/collectionGate.js +4 -3
  65. package/packages/web/src/ui/components/commandPalette.js +24 -55
  66. package/packages/web/src/ui/components/editorArea.js +16 -13
  67. package/packages/web/src/ui/components/launcher.js +39 -225
  68. package/packages/web/src/ui/components/openers/index.js +7 -4
  69. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  70. package/packages/web/src/ui/components/overlays.js +169 -43
  71. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  72. package/packages/web/src/ui/components/pluginReview.js +62 -23
  73. package/packages/web/src/ui/components/pluginsView.js +30 -28
  74. package/packages/web/src/ui/components/settingsView.js +151 -58
  75. package/packages/web/src/ui/components/social.js +42 -62
  76. package/packages/web/src/ui/components/statusBar.js +30 -74
  77. package/packages/web/src/ui/components/views/grid.js +3 -2
  78. package/packages/web/src/ui/components/views/index.js +7 -62
  79. package/packages/web/src/ui/components/views/list.js +3 -2
  80. package/packages/web/src/ui/components/views/parts.js +15 -4
  81. package/packages/web/src/ui/compositions/workbench.js +166 -58
  82. package/packages/web/src/ui/region.js +53 -0
  83. package/packages/web/src/workbench.js +17 -15
  84. package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
  85. package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
  86. package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
  87. package/packages/web/src/platform/overlay.js +0 -81
  88. package/packages/web/src/platform/workbench.js +0 -156
@@ -14,6 +14,12 @@
14
14
 
15
15
  import { TroveError, ErrorCode } from './errors.js';
16
16
  import { newId, isValidItemName } from './util.js';
17
+ import {
18
+ cipherSize, DEFAULT_CHUNK_SIZE, isEnvelope, decodeHeader, encodeHeader, encryptStream,
19
+ HEADER_BYTES as ENVELOPE_HEAD,
20
+ } from './encryption/envelope.js';
21
+ import { fromHex, toHex } from './encryption/keys.js';
22
+ import { shouldEncrypt } from './encryption/policy.js';
17
23
 
18
24
  export const DEFAULT_PART_SIZE = 8 * 1024 * 1024; // 8 MiB
19
25
  const MIN_MULTIPART_PART = 5 * 1024 * 1024; // S3 floor (except final part)
@@ -48,6 +54,92 @@ class MemorySessionStore {
48
54
  }
49
55
  }
50
56
 
57
+ /** The namespace upload sessions live under. */
58
+ const SESSION_NS = 'uploads';
59
+
60
+ /**
61
+ * Upload sessions in the KeyValueStore, so they outlive the process that made one.
62
+ *
63
+ * An upload is three or more separate requests — create, the bytes, complete — and the
64
+ * session is the only thing joining them. Held in a `Map`, that works exactly as long as
65
+ * every request happens to reach the same process: an assumption a long-lived server gets
66
+ * away with and a serverless one does not. On Cloudflare Workers an isolate can be
67
+ * discarded the moment a response resolves, and a cold drive fans a burst of requests
68
+ * across several isolates at once, so `create` writes to one Map and `complete` reads an
69
+ * empty one. The user is told their upload session does not exist while its own 24h TTL
70
+ * is nowhere near up — because it never expired, it was simply somewhere else.
71
+ *
72
+ * Retrying does not rescue that and must not: a missing session is `notFound`, correctly
73
+ * classified non-retryable, and one that genuinely expired is never coming back. The fix
74
+ * is for the session to live somewhere every request can see.
75
+ *
76
+ * Values are plain JSON, which is all a session ever was.
77
+ */
78
+ export class KvSessionStore {
79
+ /** @param {{kv: import('./kv.js').KeyValueStore, ns?: string}} deps */
80
+ constructor({ kv, ns = SESSION_NS } = {}) {
81
+ if (!kv) throw TroveError.invalid('KvSessionStore needs a KeyValueStore');
82
+ this.kv = kv;
83
+ this.ns = ns;
84
+ }
85
+ async get(id) {
86
+ return (await this.kv.get(this.ns, id)) || null;
87
+ }
88
+ async put(session) {
89
+ await this.kv.set(this.ns, session.id, session);
90
+ }
91
+ async delete(id) {
92
+ await this.kv.delete(this.ns, id);
93
+ }
94
+ /**
95
+ * Which sessions have expired — WITHOUT deleting them, for the reason given on
96
+ * MemorySessionStore.expired: the session holds the multipart `uploadId`, and dropping
97
+ * the record strands the uploaded parts in the bucket with nothing left to abort them.
98
+ */
99
+ async expired(now) {
100
+ const rows = await this.kv.list(this.ns, '');
101
+ return rows
102
+ .map((r) => r.value)
103
+ .filter(Boolean)
104
+ .filter((s) => now - s.createdAt > SESSION_TTL_MS)
105
+ .map((s) => s.id);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Whatever a caller handed us, as a stream.
111
+ *
112
+ * A part arrives as a stream over HTTP and as bytes from a test or an in-process caller;
113
+ * `encryptStream` takes only the former, and the sealing path has to accept both.
114
+ */
115
+ function asStream(body) {
116
+ if (body && typeof body.getReader === 'function') return body;
117
+ if (body && typeof body.stream === 'function') return body.stream(); // Blob
118
+ const bytes = typeof body === 'string'
119
+ ? new TextEncoder().encode(body)
120
+ : (body instanceof Uint8Array ? body : new Uint8Array(body || 0));
121
+ return new ReadableStream({
122
+ start(c) {
123
+ if (bytes.length) c.enqueue(bytes);
124
+ c.close();
125
+ },
126
+ });
127
+ }
128
+
129
+ /** A stream that emits `head`, then everything `rest` produces. */
130
+ function prependBytes(head, rest) {
131
+ const reader = rest.getReader();
132
+ return new ReadableStream({
133
+ start(c) { c.enqueue(head); },
134
+ async pull(c) {
135
+ const { value, done } = await reader.read();
136
+ if (done) c.close();
137
+ else c.enqueue(value);
138
+ },
139
+ cancel(reason) { return reader.cancel(reason); },
140
+ });
141
+ }
142
+
51
143
  export class UploadManager {
52
144
  /**
53
145
  * @param {object} deps
@@ -55,10 +147,14 @@ export class UploadManager {
55
147
  * @param {object} [deps.sessions] session store (defaults in-memory)
56
148
  * @param {number} [deps.partSize]
57
149
  */
58
- constructor({ storage, storageFor, sessions, partSize = DEFAULT_PART_SIZE, maxBytes = null }) {
150
+ constructor({ storage, storageFor, sessions, encryptionFor, partSize = DEFAULT_PART_SIZE, maxBytes = null }) {
59
151
  // Either a single backend, or a resolver keyed by collectionId (collections).
60
152
  this.storageFor = storageFor ?? (async () => storage);
61
153
  this.sessions = sessions ?? new MemorySessionStore();
154
+ // What a collection encrypts, and the key to do it with:
155
+ // `(collectionId) => { encryption, dataKey } | null`. Absent means nothing is
156
+ // encrypted, which is what every existing deployment is.
157
+ this.encryptionFor = encryptionFor ?? (async () => null);
62
158
  this.partSize = partSize;
63
159
  this.maxBytes = maxBytes || null; // per-file quota (null = unbounded)
64
160
  }
@@ -85,19 +181,50 @@ export class UploadManager {
85
181
  async create(req) {
86
182
  if (!isValidItemName(req.name)) throw TroveError.invalid(`Invalid file name "${req.name}"`);
87
183
  if (!(req.size >= 0)) throw TroveError.invalid('size must be a non-negative number');
88
- if (this.maxBytes && req.size > this.maxBytes) {
184
+ const collectionId = req.collectionId || 'default';
185
+ const storage = await this.#storage(collectionId);
186
+ const caps = storage.capabilities;
187
+ const storageKey = newId('obj');
188
+ const contentType = req.contentType || 'application/octet-stream';
189
+
190
+ // Does this item get encrypted, and with what?
191
+ //
192
+ // Decided here, once, and recorded on the session — not re-derived at `complete`,
193
+ // because the collection's rules can change between the two and an object half-planned
194
+ // as one thing and finished as another is unreadable either way.
195
+ //
196
+ // Everything downstream negotiates against the STORED size, which is larger: a header
197
+ // plus an authentication tag per chunk. Planning multipart boundaries against the
198
+ // plaintext size is short by exactly that, which is the difference between a final part
199
+ // that exists and one that does not.
200
+ const policy = await this.encryptionFor(collectionId);
201
+ const encrypting = !!policy && shouldEncrypt(policy.encryption, { name: req.name, contentType });
202
+ const chunkSize = policy?.encryption?.chunkSize || DEFAULT_CHUNK_SIZE;
203
+ const storedSize = encrypting ? cipherSize(req.size, chunkSize) : req.size;
204
+
205
+ // The per-file limit is checked against what will be STORED, and checked here rather
206
+ // than against `req.size` at the top of this method.
207
+ //
208
+ // `complete` compares the size read back from the store, which for an encrypted upload
209
+ // is the envelope. Checking the plaintext size at negotiation and the envelope size at
210
+ // completion meant a file just under the limit was accepted, transferred in full, and
211
+ // then DELETED by the too-large branch at the end — the user paying for the whole
212
+ // upload and losing the file. Both ends now measure the same thing.
213
+ if (this.maxBytes && storedSize > this.maxBytes) {
89
214
  // Deterministic per-file limit — retrying can't help, so it's non-retryable
90
215
  // (capacity/rate quotas stay retryable via the default).
91
216
  // TOO_LARGE (413), not QUOTA: the store has plenty of room, this file is simply
92
217
  // bigger than this deployment permits. Reporting it as a capacity problem would
93
218
  // send the user looking for space to free that would not help.
94
- throw TroveError.tooLarge(`File exceeds the maximum upload size of ${this.maxBytes} bytes`, { details: { maxBytes: this.maxBytes, size: req.size } });
219
+ throw TroveError.tooLarge(
220
+ encrypting && req.size <= this.maxBytes
221
+ // Naming the reason, because "your 10MB file exceeds the 10MB limit" is a
222
+ // maddening thing to be told.
223
+ ? `Encrypted, this file needs ${storedSize} bytes of storage, over the ${this.maxBytes}-byte limit`
224
+ : `File exceeds the maximum upload size of ${this.maxBytes} bytes`,
225
+ { details: { maxBytes: this.maxBytes, size: req.size, storedSize } },
226
+ );
95
227
  }
96
- const collectionId = req.collectionId || 'default';
97
- const storage = await this.#storage(collectionId);
98
- const caps = storage.capabilities;
99
- const storageKey = newId('obj');
100
- const contentType = req.contentType || 'application/octet-stream';
101
228
 
102
229
  const session = {
103
230
  id: newId('up'),
@@ -108,7 +235,17 @@ export class UploadManager {
108
235
  // the session because the decision is made when the upload STARTS but has to be
109
236
  // honoured when it COMPLETES, possibly much later.
110
237
  overwrite: !!req.overwrite,
238
+ // The size the USER sees, which is what the item is recorded as. `storedSize` is
239
+ // what actually occupies the bucket.
111
240
  size: req.size,
241
+ storedSize,
242
+ encrypted: encrypting,
243
+ chunkSize: encrypting ? chunkSize : null,
244
+ // Fixed for the object, at negotiation, because parts are sealed INDEPENDENTLY and
245
+ // may arrive in any order — see `#sealPart`. Generating it per part would reuse
246
+ // nonces across the object, which AES-GCM does not survive.
247
+ noncePrefix: encrypting ? toHex(crypto.getRandomValues(new Uint8Array(8))) : null,
248
+ keyFingerprint: encrypting ? policy.encryption.fingerprint : null,
112
249
  contentType,
113
250
  createdAt: Date.now(),
114
251
  strategy: null,
@@ -119,18 +256,35 @@ export class UploadManager {
119
256
 
120
257
  const limits = this.#limits();
121
258
 
259
+ // Presigned strategies hand the client a URL to the bucket, which is only safe when
260
+ // the client has something the bucket may hold. It seals nothing now, so for an
261
+ // encrypted collection a presigned PUT would put PLAINTEXT in the store — exactly what
262
+ // the encryption exists to prevent. Those go through us, and we seal on the way past.
263
+ const canPresign = caps.presignUpload && !encrypting;
264
+
122
265
  // Small file + presign → single PUT straight to storage (never through us).
123
- if (req.size <= SINGLE_PUT_LIMIT && caps.presignUpload) {
266
+ if (storedSize <= SINGLE_PUT_LIMIT && canPresign) {
124
267
  session.strategy = 'single';
125
268
  await this.sessions.put(session);
126
269
  const url = await storage.presignPut(storageKey, { contentType });
127
- return { ...planSummary(session), strategy: 'single', multipart: false, presigned: true, url, limits };
270
+ return { ...planSummary(session), strategy: 'single', multipart: false, presigned: true, url, limits, encryption: this.#planEncryption(session) };
128
271
  }
129
272
 
130
273
  // Multipart (presigned parts straight to storage, or streamed through us).
131
274
  if (caps.multipart) {
132
- session.strategy = caps.presignUpload ? 'presign' : 'direct';
133
- const partCount = Math.max(1, Math.ceil(req.size / this.partSize));
275
+ session.strategy = canPresign ? 'presign' : 'direct';
276
+ // Measured in PLAINTEXT for an encrypted upload: the client sends us the file as it
277
+ // is, and the envelope it becomes is bigger by a tag per chunk. Sizing parts against
278
+ // the stored size would ask the client for bytes it does not have.
279
+ const wireSize = encrypting ? req.size : storedSize;
280
+ const partCount = Math.max(1, Math.ceil(wireSize / this.partSize));
281
+ // A part must hold a whole number of chunks, or the chunk a part boundary lands
282
+ // inside would be sealed twice under two different indices.
283
+ if (encrypting && this.partSize % chunkSize !== 0) {
284
+ throw TroveError.internal(
285
+ `Part size ${this.partSize} is not a multiple of the ${chunkSize}-byte chunk size`,
286
+ );
287
+ }
134
288
  // `#limits()` advertises maxParts in the very same response, and nothing enforced
135
289
  // it: a 150 GiB file planned 19,200 parts against a ceiling of 10,000, which S3
136
290
  // rejects at part 10,001 — after the client has transferred 80 GiB. On a presign
@@ -139,7 +293,7 @@ export class UploadManager {
139
293
  if (partCount > MAX_PARTS) {
140
294
  throw TroveError.tooLarge(
141
295
  `This file needs ${partCount.toLocaleString()} parts of ${this.partSize} bytes, over the ${MAX_PARTS.toLocaleString()}-part limit`,
142
- { details: { maxParts: MAX_PARTS, partCount, partSize: this.partSize, size: req.size } },
296
+ { details: { maxParts: MAX_PARTS, partCount, partSize: this.partSize, size: storedSize } },
143
297
  );
144
298
  }
145
299
  // Only now open the multipart. Refusing AFTER creating it left an upload open in
@@ -153,13 +307,67 @@ export class UploadManager {
153
307
  parts.push({ partNumber: n, url: await storage.presignPart(storageKey, session.uploadId, n) });
154
308
  }
155
309
  }
156
- return { ...planSummary(session), strategy: session.strategy, multipart: true, presigned: session.strategy === 'presign', partCount, parts, limits };
310
+ return { ...planSummary(session), strategy: session.strategy, multipart: true, presigned: session.strategy === 'presign', partCount, parts, limits, encryption: this.#planEncryption(session) };
157
311
  }
158
312
 
159
313
  // Fallback: whole-object PUT streamed through us (tiny/simple backends).
160
314
  session.strategy = 'direct-single';
161
315
  await this.sessions.put(session);
162
- return { ...planSummary(session), strategy: 'direct-single', multipart: false, presigned: false, limits };
316
+ return { ...planSummary(session), strategy: 'direct-single', multipart: false, presigned: false, limits, encryption: this.#planEncryption(session) };
317
+ }
318
+
319
+ /**
320
+ * What a client is told about encryption: that it is happening, and nothing else.
321
+ *
322
+ * This used to hand over the collection's data key so the browser could seal before the
323
+ * bytes left it — which is what made a presigned direct-to-bucket PUT possible while the
324
+ * bucket only ever held ciphertext. That trade is off. Encrypted collections now pass
325
+ * through the drive in BOTH directions, so the key never leaves the server and a client
326
+ * has nothing it could leak.
327
+ *
328
+ * The cost is deliberate and worth naming: bytes for an encrypted collection are paid
329
+ * for twice, once to us and once to the store, and a presigned upload is unavailable for
330
+ * them. Unencrypted collections are untouched and still go straight to the bucket.
331
+ */
332
+ #planEncryption(session) {
333
+ if (!session.encrypted) return null;
334
+ return { algorithm: 'AES-256-GCM', sealedBy: 'server' };
335
+ }
336
+
337
+
338
+ /**
339
+ * Refuse an upload that was supposed to be encrypted and is not.
340
+ *
341
+ * Deleted rather than kept, like the over-size branch: an object that cannot be read is
342
+ * not worth the storage, and leaving it would also leave plaintext in a bucket the
343
+ * collection promises is ciphertext.
344
+ */
345
+ async #assertSealed(storage, s) {
346
+ let head;
347
+ try {
348
+ const got = await storage.get(s.storageKey, { range: { start: 0, end: ENVELOPE_HEAD - 1 } });
349
+ head = new Uint8Array(await new Response(got.stream).arrayBuffer());
350
+ } catch {
351
+ return; // a backend that cannot serve a range gets the benefit of the doubt
352
+ }
353
+ const sealed = isEnvelope(head);
354
+ const matches = sealed && (() => {
355
+ try {
356
+ return toHex(decodeHeader(head).fingerprint) === s.keyFingerprint;
357
+ } catch {
358
+ return false;
359
+ }
360
+ })();
361
+ if (sealed && matches) return;
362
+
363
+ await storage.delete(s.storageKey).catch(() => {});
364
+ await this.sessions.delete(s.id);
365
+ throw TroveError.invalid(
366
+ sealed
367
+ ? 'This upload was encrypted with the wrong key for this collection.'
368
+ : 'This collection encrypts its files, and this upload arrived unencrypted. The client '
369
+ + 'must seal the bytes using the key in the upload plan before sending them.',
370
+ );
163
371
  }
164
372
 
165
373
  /** Re-issue a signed URL for one part (resume after expiry). */
@@ -194,7 +402,8 @@ export class UploadManager {
194
402
  // deliberately doesn't apply — so this is the only place a client can be stopped
195
403
  // from writing an unbounded object through us.
196
404
  const capped = capStream(body, this.maxBytes);
197
- const info = await storage.put(s.storageKey, capped, { contentType: s.contentType, ...opts });
405
+ const toStore = s.encrypted ? await this.#sealPart(s, 1, capped, true) : capped;
406
+ const info = await storage.put(s.storageKey, toStore, { contentType: s.contentType, ...opts });
198
407
  s.parts[1] = { etag: info.etag || 'single' };
199
408
  await this.sessions.put(s);
200
409
  return { partNumber: 1, etag: s.parts[1].etag };
@@ -209,13 +418,62 @@ export class UploadManager {
209
418
  if (!Number.isInteger(partNumber) || partNumber < 1 || (s.partCount && partNumber > s.partCount)) {
210
419
  throw TroveError.invalid(`Part ${partNumber} is outside this upload's ${s.partCount} part(s)`);
211
420
  }
212
- // A part is exactly `partSize` bytes, except the last, which is smaller.
213
- const res = await storage.putPart(s.storageKey, s.uploadId, partNumber, capStream(body, s.partSize), opts);
421
+ // A part is exactly `partSize` bytes, except the last, which is smaller. Capped on the
422
+ // way IN that is the plaintext the client sends; what we write is bigger by a tag per
423
+ // chunk.
424
+ const capped = capStream(body, s.partSize);
425
+ const toStore = s.encrypted
426
+ ? await this.#sealPart(s, partNumber, capped, partNumber === (s.partCount ?? 1))
427
+ : capped;
428
+ const res = await storage.putPart(s.storageKey, s.uploadId, partNumber, toStore, opts);
214
429
  s.parts[partNumber] = { etag: res.etag };
215
430
  await this.sessions.put(s);
216
431
  return res;
217
432
  }
218
433
 
434
+ /**
435
+ * Seal one part on its way to the store.
436
+ *
437
+ * Parts are sealed INDEPENDENTLY, which is what lets a client keep sending them
438
+ * concurrently now that it no longer seals them itself. That is safe only because a
439
+ * part's position in the envelope is a function of its NUMBER: part n carries plaintext
440
+ * bytes [(n-1)·partSize, n·partSize), so it begins at chunk (n-1)·partSize/chunkSize, and
441
+ * the nonce prefix was fixed for the whole object at negotiation. Nothing depends on the
442
+ * order parts arrive in, and no two chunks can ever be sealed under the same nonce.
443
+ *
444
+ * The header goes on the front of part 1 and nowhere else — it is the first 44 bytes of
445
+ * the object. Every part but the last is `partial`, which is what makes `encryptStream`
446
+ * refuse to finish an envelope early and insist on stopping at a chunk boundary.
447
+ */
448
+ async #sealPart(s, partNumber, body, isLast) {
449
+ const chunksPerPart = s.partSize / s.chunkSize;
450
+ const startChunk = (partNumber - 1) * chunksPerPart;
451
+ const key = await this.#dataKeyFor(s);
452
+ const sealed = await encryptStream(key, asStream(body), {
453
+ fingerprint: fromHex(s.keyFingerprint),
454
+ plaintextSize: s.size,
455
+ chunkSize: s.chunkSize,
456
+ resume: { noncePrefix: fromHex(s.noncePrefix), index: startChunk },
457
+ partial: !isLast,
458
+ });
459
+ if (partNumber !== 1) return sealed;
460
+ const header = encodeHeader({
461
+ chunkSize: s.chunkSize,
462
+ plaintextSize: s.size,
463
+ noncePrefix: fromHex(s.noncePrefix),
464
+ fingerprint: fromHex(s.keyFingerprint),
465
+ });
466
+ return prependBytes(header, sealed);
467
+ }
468
+
469
+ /** The collection's key, for sealing. Never leaves this process. */
470
+ async #dataKeyFor(s) {
471
+ const policy = await this.encryptionFor(s.collectionId);
472
+ const key = policy?.dataKeyHex ? fromHex(policy.dataKeyHex) : null;
473
+ if (!key) throw TroveError.internal('This collection is encrypted but its key is unavailable');
474
+ return key;
475
+ }
476
+
219
477
  /** Which parts are still outstanding (resume support). */
220
478
  async status(uploadId) {
221
479
  const s = await this.#session(uploadId);
@@ -292,15 +550,40 @@ export class UploadManager {
292
550
  { details: { maxBytes: this.maxBytes, size } },
293
551
  );
294
552
  }
553
+ // An upload planned as encrypted must have ARRIVED encrypted.
554
+ //
555
+ // `complete` otherwise records what the session INTENDED, so a client that ignores
556
+ // `plan.encryption` — which is every client that has not implemented it yet — uploads
557
+ // plaintext and the item is stamped with a key fingerprint anyway. The result is the
558
+ // worst of both: permanently unreadable, because the read path looks for an envelope
559
+ // that is not there, and not actually protected, because the bytes are sitting in the
560
+ // bucket in the clear on a collection labelled encrypted.
561
+ //
562
+ // The envelope header is readable without the key, which is exactly what makes this
563
+ // checkable here. One small ranged read, once, at the end of an upload.
564
+ if (s.encrypted) {
565
+ await this.#assertSealed(storage, s);
566
+ }
567
+
295
568
  await this.sessions.delete(uploadId);
296
569
  return {
297
570
  storageKey: s.storageKey,
298
- size,
571
+ // For an encrypted object the store holds an envelope, which is larger than the
572
+ // file. The item records the file: that is the number a user recognises, the one
573
+ // search results and quotas are about, and the one a range request is against.
574
+ size: s.encrypted ? s.size : size,
575
+ storedSize: size,
299
576
  contentType: s.contentType,
300
577
  etag,
301
578
  collectionId: s.collectionId,
302
579
  name: s.name,
303
580
  overwrite: !!s.overwrite,
581
+ // Which key opens this object. Recorded on the item so reading it does not have to
582
+ // fetch the envelope header first, and so a rotation can find what it has not yet
583
+ // converted without opening every object in the bucket.
584
+ encryption: s.encrypted
585
+ ? { fingerprint: s.keyFingerprint, chunkSize: s.chunkSize }
586
+ : null,
304
587
  };
305
588
  }
306
589
 
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { TroveError, isOutOfSpace } from './errors.js';
14
14
  import { UploadManager } from './uploads.js';
15
+ import { toHex } from './encryption/keys.js';
16
+ import { decryptStream, decodeHeader, cipherRangeFor, cipherSize, HEADER_BYTES } from './encryption/envelope.js';
15
17
  import { IndexerRegistry } from './indexers/registry.js';
16
18
  import { ParsingSearchTransformer, matchTagFilters } from './search/transformer.js';
17
19
  import { extname } from './util.js';
@@ -36,8 +38,46 @@ const CONTENT_TYPES = {
36
38
  '.mp4': 'video/mp4', '.webm': 'video/webm', '.zip': 'application/zip',
37
39
  };
38
40
 
41
+
42
+ /** Drain a stream into one buffer. Only ever used for the fixed-size envelope header. */
43
+ async function bytesOf(stream) {
44
+ const reader = stream.getReader();
45
+ const parts = [];
46
+ let total = 0;
47
+ for (;;) {
48
+ const { value, done } = await reader.read();
49
+ if (done) break;
50
+ parts.push(value);
51
+ total += value.length;
52
+ }
53
+ const out = new Uint8Array(total);
54
+ let at = 0;
55
+ for (const p of parts) { out.set(p, at); at += p.length; }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Emit only the bytes between `start` and `end` of a stream.
61
+ *
62
+ * A plaintext range rarely lines up with a chunk boundary, so decryption yields whole
63
+ * chunks and this drops the overhang at each end — without collecting the middle, which is
64
+ * the entire point of streaming in the first place.
65
+ */
66
+ function trimStream(stream, start, end) {
67
+ let seen = 0;
68
+ return stream.pipeThrough(new TransformStream({
69
+ transform(chunk, controller) {
70
+ const from = Math.max(0, start - seen);
71
+ const to = Math.min(chunk.length, end - seen);
72
+ if (to > from) controller.enqueue(chunk.subarray(from, to));
73
+ seen += chunk.length;
74
+ if (seen >= end) controller.terminate();
75
+ },
76
+ }));
77
+ }
78
+
39
79
  export class Vfs {
40
- constructor({ storage, metadata, search, indexers, sidecar, collections, searchTransformer, issues, signedUrls = null, publicUrl = '', maxIndexBytes = 2 * 1024 * 1024, maxUploadBytes = null, uploadPartSize = undefined }) {
80
+ constructor({ storage, metadata, search, indexers, sidecar, collections, searchTransformer, issues, signedUrls = null, publicUrl = '', maxIndexBytes = 2 * 1024 * 1024, maxUploadBytes = null, uploadPartSize = undefined, uploadSessions = undefined }) {
41
81
  if (!storage && !collections) throw TroveError.invalid('Vfs requires a storage backend or a CollectionService');
42
82
  if (!metadata) throw TroveError.invalid('Vfs requires a metadata store');
43
83
  this.storage = storage; // primary backend (default collection + capability reporting)
@@ -49,7 +89,27 @@ export class Vfs {
49
89
  this.collections = collections ?? null;
50
90
  this.indexers = indexers ?? new IndexerRegistry();
51
91
  // One UploadManager; it resolves the right backend per session's collection.
52
- this.uploads = new UploadManager({ storageFor: (cid) => this.storageFor(cid), maxBytes: maxUploadBytes, partSize: uploadPartSize });
92
+ //
93
+ // `uploadSessions` is where the sessions live. It defaults to memory, which is right
94
+ // for a test and wrong for anything where two requests of one upload can be served by
95
+ // different processes — see KvSessionStore. Injected rather than built here because
96
+ // core does not otherwise need a KeyValueStore.
97
+ this.uploads = new UploadManager({
98
+ storageFor: (cid) => this.storageFor(cid),
99
+ sessions: uploadSessions,
100
+ // What a collection encrypts and the key for it. Only the CollectionService knows,
101
+ // and only it is allowed to hand the key out — see collections/index.js.
102
+ encryptionFor: async (cid) => {
103
+ if (!this.collections?.encryptionFor) return null;
104
+ const encryption = await this.collections.encryptionFor(cid);
105
+ if (!encryption?.enabled) return null;
106
+ const key = await this.collections.dataKeyFor(cid);
107
+ if (!key) return null;
108
+ return { encryption, dataKeyHex: toHex(key) };
109
+ },
110
+ maxBytes: maxUploadBytes,
111
+ partSize: uploadPartSize,
112
+ });
53
113
  this.maxIndexBytes = maxIndexBytes;
54
114
  // The indexing subsystem (run/backfill/purge/contributions) lives here.
55
115
  // Where a failure to index becomes a standing, retryable problem rather than a
@@ -223,17 +283,20 @@ export class Vfs {
223
283
  * — and an unconditional replace at completion silently destroys whichever landed
224
284
  * first. So an upload re-resolves the collision at the moment it commits.
225
285
  */
226
- async #upsertItem({ collectionId, name, storageKey, size, contentType, etag, overwrite = true }) {
286
+ async #upsertItem({ collectionId, name, storageKey, size, contentType, etag, overwrite = true, encryption = null }) {
227
287
  let finalName = name;
228
288
  const existing = await this.metadata.getByName(collectionId, name);
229
289
  if (existing && overwrite) {
230
290
  const oldKey = existing.storageKey;
231
- const updated = await this.metadata.update(existing.id, { storageKey, size, contentType, etag });
291
+ const updated = await this.metadata.update(existing.id, { storageKey, size, contentType, etag, encryption });
232
292
  if (oldKey && oldKey !== storageKey) (await this.storageFor(collectionId)).delete(oldKey).catch(() => {});
233
293
  return updated;
234
294
  }
235
295
  if (existing) finalName = await this.#uniqueName(collectionId, name);
236
- return this.metadata.create({ collectionId, name: finalName, storageKey, size, contentType, etag });
296
+ // `encryption` names which key opens this object. Carried on the item so a read does
297
+ // not have to fetch the envelope header to find out, and so a rotation can tell what it
298
+ // has not converted without opening every object in the bucket.
299
+ return this.metadata.create({ collectionId, name: finalName, storageKey, size, contentType, etag, encryption });
237
300
  }
238
301
 
239
302
  /**
@@ -399,15 +462,23 @@ export class Vfs {
399
462
 
400
463
  // --- download --------------------------------------------------------------
401
464
 
402
- async getDownload(id, { expiresIn, download } = {}) {
465
+ async getDownload(id, { expiresIn, download, ciphertext = false } = {}) {
403
466
  const node = await this.resolve(id);
404
467
  const storage = await this.storageFor(node.collectionId);
468
+ // An encrypted object is not redirected to by default. A redirect hands the caller raw
469
+ // ciphertext, and most callers of a download URL cannot do anything with it — an <img
470
+ // src>, a <video src>, a signed URL given to an external service. Proxying is the
471
+ // answer that is always correct, so it is the default; a client that holds the key and
472
+ // knows it can decrypt asks for `ciphertext` and gets the direct path back.
473
+ if (node.encryption && !ciphertext) return { mode: 'proxy', node };
405
474
  if (storage.capabilities.presignDownload) {
406
475
  const url = await storage.presignGet(node.storageKey, {
407
476
  expiresIn, responseContentType: node.contentType,
408
477
  downloadName: download ? node.name : undefined,
409
478
  });
410
- return { mode: 'redirect', url, node };
479
+ // Named on the way out so a client that asked for ciphertext knows which key opens
480
+ // what it is about to receive.
481
+ return { mode: 'redirect', url, node, encryption: node.encryption || null };
411
482
  }
412
483
  return { mode: 'proxy', node };
413
484
  }
@@ -431,7 +502,19 @@ export class Vfs {
431
502
  const node = await this.resolve(id);
432
503
  if (!node.storageKey) throw TroveError.notFound('File content');
433
504
  const storage = await this.storageFor(node.collectionId);
434
- if (storage.capabilities.presignDownload) {
505
+ // An ENCRYPTED object is never presigned to the store — the same rule `getDownload`
506
+ // already follows, and the one place it was missing. A bucket URL serves CIPHERTEXT,
507
+ // and everything a minted URL exists for (an <img src>, a <video src>, cache.add(), a
508
+ // URL handed to an external service) fetches bytes with nowhere to run decryption. So
509
+ // every thumbnail and preview in an encrypted collection on a presigning store pointed
510
+ // at ciphertext and rendered nothing.
511
+ //
512
+ // Worse than proxying would have been: a bucket URL is a DIFFERENT ORIGIN, so the
513
+ // service worker never saw it either — the one place that could have decrypted was
514
+ // routed around. Sending these through our own address is what lets the worker read
515
+ // straight from the store and decrypt (platform/directRead.js), and lets the server
516
+ // decrypt for anything without a worker.
517
+ if (storage.capabilities.presignDownload && !node.encryption) {
435
518
  const seconds = clampAge(op, expiresIn);
436
519
  const url = await storage.presignGet(node.storageKey, {
437
520
  expiresIn: seconds, responseContentType: node.contentType,
@@ -457,7 +540,75 @@ export class Vfs {
457
540
  async readStream(id, { range, signal } = {}) {
458
541
  const node = await this.resolve(id);
459
542
  if (!node.storageKey) throw TroveError.notFound('File content');
460
- return (await this.storageFor(node.collectionId)).get(node.storageKey, { range, signal });
543
+ const storage = await this.storageFor(node.collectionId);
544
+ if (!node.encryption) return storage.get(node.storageKey, { range, signal });
545
+
546
+ // An encrypted object is decrypted HERE, for everything that reads through the server.
547
+ //
548
+ // The browser can decrypt for itself when it fetches — and does, which is what keeps a
549
+ // presigned download direct. But an <img src>, a <video src>, a signed URL handed to an
550
+ // external service, and the service worker's own cache fills cannot: they are bare URLs
551
+ // that get bytes and have nowhere to run our code. Those all arrive here, so this is
552
+ // where correctness has to live.
553
+ //
554
+ // The plaintext range the caller asked for is mapped onto the chunks that hold it, so a
555
+ // seek into a video still fetches a chunk rather than the film.
556
+ // The ENVELOPE is read first, and it is authoritative.
557
+ //
558
+ // The item's own record says which key sealed it and at what chunk size, and that is
559
+ // what makes a listing cheap — but it is a copy, and a copy can be stale: an object
560
+ // restored from a backup, adopted by a scan, or written by another version. Deriving
561
+ // the byte ranges from the record and then decrypting with the object's real geometry
562
+ // is how you get chunk indices computed against one layout and nonces against another,
563
+ // which surfaces as "the data has been altered" on data nobody altered.
564
+ const head = await storage.get(node.storageKey, { range: { start: 0, end: HEADER_BYTES - 1 }, signal });
565
+ const header = decodeHeader(await bytesOf(head.stream));
566
+ // The plaintext size likewise: the envelope knows, the record remembers.
567
+ const plaintextSize = header.plaintextSize ?? node.size;
568
+ const key = await this.#keyForNode(node, header);
569
+
570
+ const want = range
571
+ ? cipherRangeFor({ start: range.start, end: range.end }, { ...header, plaintextSize })
572
+ : {
573
+ cipherStart: 0,
574
+ cipherEnd: cipherSize(plaintextSize, header.chunkSize) - 1,
575
+ firstChunk: 0,
576
+ trimStart: 0,
577
+ trimEnd: plaintextSize,
578
+ };
579
+
580
+ const body = await storage.get(node.storageKey, {
581
+ range: { start: Math.max(want.cipherStart, HEADER_BYTES), end: want.cipherEnd }, signal,
582
+ });
583
+ const plain = await decryptStream(key, header, body.stream, want.firstChunk);
584
+ return {
585
+ stream: trimStream(plain, want.trimStart, want.trimEnd),
586
+ size: want.trimEnd - want.trimStart,
587
+ contentType: node.contentType,
588
+ etag: head.etag,
589
+ range: range ? { start: range.start, end: range.start + (want.trimEnd - want.trimStart) - 1, total: plaintextSize } : null,
590
+ };
591
+ }
592
+
593
+ /**
594
+ * The key that opens this object, chosen by what the ENVELOPE names.
595
+ *
596
+ * The object is the authority on which key sealed it; the item's record is a copy kept
597
+ * for cheap listings. Trusting the record would mean a stale one sends us to the wrong
598
+ * key and the failure reads as corruption.
599
+ */
600
+ async #keyForNode(node, header) {
601
+ const want = toHex(header.fingerprint);
602
+ const key = this.collections?.dataKeyFor
603
+ ? await this.collections.dataKeyFor(node.collectionId, want)
604
+ : null;
605
+ if (!key) {
606
+ throw TroveError.invalid(
607
+ 'This item is encrypted with a key this collection no longer holds. It was probably '
608
+ + 'retired before a key rotation finished moving everything onto the new one.',
609
+ );
610
+ }
611
+ return key;
461
612
  }
462
613
 
463
614
  // --- uploads ---------------------------------------------------------------
@@ -511,7 +662,7 @@ export class Vfs {
511
662
  const node = await this.#upsertItem({
512
663
  collectionId: obj.collectionId, name: obj.name, storageKey: obj.storageKey,
513
664
  size: obj.size, contentType: obj.contentType, etag: obj.etag,
514
- overwrite: obj.overwrite,
665
+ overwrite: obj.overwrite, encryption: obj.encryption || null,
515
666
  });
516
667
  this.indexing.indexNode(node).catch((e) => console.error('index error', e));
517
668
  return node;