@3sln/trove 0.0.7 → 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 (29) 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 +444 -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 +313 -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 +197 -15
  15. package/packages/core/src/vfs.js +148 -9
  16. package/packages/server/src/engine/providers/core.js +9 -2
  17. package/packages/server/src/routes.js +29 -1
  18. package/packages/web/dist/assets/main-y778bpte.js +356 -0
  19. package/packages/web/dist/assets/{main-f0f2tfhp.js.map → main-y778bpte.js.map} +10 -8
  20. package/packages/web/dist/assets/styles-nfy8t3n1.css +1 -0
  21. package/packages/web/dist/index.html +9 -3
  22. package/packages/web/dist/sw.js +1 -1
  23. package/packages/web/src/bl/actions.js +20 -4
  24. package/packages/web/src/bl/services.js +64 -4
  25. package/packages/web/src/platform/api.js +134 -11
  26. package/packages/web/src/styles.css +3 -0
  27. package/packages/web/src/ui/components/overlays.js +13 -0
  28. package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
  29. package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
@@ -14,6 +14,9 @@
14
14
 
15
15
  import { TroveError, ErrorCode } from './errors.js';
16
16
  import { newId, isValidItemName } from './util.js';
17
+ import { cipherSize, DEFAULT_CHUNK_SIZE, isEnvelope, decodeHeader, HEADER_BYTES as ENVELOPE_HEAD } from './encryption/envelope.js';
18
+ import { toHex } from './encryption/keys.js';
19
+ import { shouldEncrypt } from './encryption/policy.js';
17
20
 
18
21
  export const DEFAULT_PART_SIZE = 8 * 1024 * 1024; // 8 MiB
19
22
  const MIN_MULTIPART_PART = 5 * 1024 * 1024; // S3 floor (except final part)
@@ -48,6 +51,58 @@ class MemorySessionStore {
48
51
  }
49
52
  }
50
53
 
54
+ /** The namespace upload sessions live under. */
55
+ const SESSION_NS = 'uploads';
56
+
57
+ /**
58
+ * Upload sessions in the KeyValueStore, so they outlive the process that made one.
59
+ *
60
+ * An upload is three or more separate requests — create, the bytes, complete — and the
61
+ * session is the only thing joining them. Held in a `Map`, that works exactly as long as
62
+ * every request happens to reach the same process: an assumption a long-lived server gets
63
+ * away with and a serverless one does not. On Cloudflare Workers an isolate can be
64
+ * discarded the moment a response resolves, and a cold drive fans a burst of requests
65
+ * across several isolates at once, so `create` writes to one Map and `complete` reads an
66
+ * empty one. The user is told their upload session does not exist while its own 24h TTL
67
+ * is nowhere near up — because it never expired, it was simply somewhere else.
68
+ *
69
+ * Retrying does not rescue that and must not: a missing session is `notFound`, correctly
70
+ * classified non-retryable, and one that genuinely expired is never coming back. The fix
71
+ * is for the session to live somewhere every request can see.
72
+ *
73
+ * Values are plain JSON, which is all a session ever was.
74
+ */
75
+ export class KvSessionStore {
76
+ /** @param {{kv: import('./kv.js').KeyValueStore, ns?: string}} deps */
77
+ constructor({ kv, ns = SESSION_NS } = {}) {
78
+ if (!kv) throw TroveError.invalid('KvSessionStore needs a KeyValueStore');
79
+ this.kv = kv;
80
+ this.ns = ns;
81
+ }
82
+ async get(id) {
83
+ return (await this.kv.get(this.ns, id)) || null;
84
+ }
85
+ async put(session) {
86
+ await this.kv.set(this.ns, session.id, session);
87
+ }
88
+ async delete(id) {
89
+ await this.kv.delete(this.ns, id);
90
+ }
91
+ /**
92
+ * Which sessions have expired — WITHOUT deleting them, for the reason given on
93
+ * MemorySessionStore.expired: the session holds the multipart `uploadId`, and dropping
94
+ * the record strands the uploaded parts in the bucket with nothing left to abort them.
95
+ */
96
+ async expired(now) {
97
+ const rows = await this.kv.list(this.ns, '');
98
+ return rows
99
+ .map((r) => r.value)
100
+ .filter(Boolean)
101
+ .filter((s) => now - s.createdAt > SESSION_TTL_MS)
102
+ .map((s) => s.id);
103
+ }
104
+ }
105
+
51
106
  export class UploadManager {
52
107
  /**
53
108
  * @param {object} deps
@@ -55,10 +110,14 @@ export class UploadManager {
55
110
  * @param {object} [deps.sessions] session store (defaults in-memory)
56
111
  * @param {number} [deps.partSize]
57
112
  */
58
- constructor({ storage, storageFor, sessions, partSize = DEFAULT_PART_SIZE, maxBytes = null }) {
113
+ constructor({ storage, storageFor, sessions, encryptionFor, partSize = DEFAULT_PART_SIZE, maxBytes = null }) {
59
114
  // Either a single backend, or a resolver keyed by collectionId (collections).
60
115
  this.storageFor = storageFor ?? (async () => storage);
61
116
  this.sessions = sessions ?? new MemorySessionStore();
117
+ // What a collection encrypts, and the key to do it with:
118
+ // `(collectionId) => { encryption, dataKey } | null`. Absent means nothing is
119
+ // encrypted, which is what every existing deployment is.
120
+ this.encryptionFor = encryptionFor ?? (async () => null);
62
121
  this.partSize = partSize;
63
122
  this.maxBytes = maxBytes || null; // per-file quota (null = unbounded)
64
123
  }
@@ -85,19 +144,50 @@ export class UploadManager {
85
144
  async create(req) {
86
145
  if (!isValidItemName(req.name)) throw TroveError.invalid(`Invalid file name "${req.name}"`);
87
146
  if (!(req.size >= 0)) throw TroveError.invalid('size must be a non-negative number');
88
- if (this.maxBytes && req.size > this.maxBytes) {
147
+ const collectionId = req.collectionId || 'default';
148
+ const storage = await this.#storage(collectionId);
149
+ const caps = storage.capabilities;
150
+ const storageKey = newId('obj');
151
+ const contentType = req.contentType || 'application/octet-stream';
152
+
153
+ // Does this item get encrypted, and with what?
154
+ //
155
+ // Decided here, once, and recorded on the session — not re-derived at `complete`,
156
+ // because the collection's rules can change between the two and an object half-planned
157
+ // as one thing and finished as another is unreadable either way.
158
+ //
159
+ // Everything downstream negotiates against the STORED size, which is larger: a header
160
+ // plus an authentication tag per chunk. Planning multipart boundaries against the
161
+ // plaintext size is short by exactly that, which is the difference between a final part
162
+ // that exists and one that does not.
163
+ const policy = await this.encryptionFor(collectionId);
164
+ const encrypting = !!policy && shouldEncrypt(policy.encryption, { name: req.name, contentType });
165
+ const chunkSize = policy?.encryption?.chunkSize || DEFAULT_CHUNK_SIZE;
166
+ const storedSize = encrypting ? cipherSize(req.size, chunkSize) : req.size;
167
+
168
+ // The per-file limit is checked against what will be STORED, and checked here rather
169
+ // than against `req.size` at the top of this method.
170
+ //
171
+ // `complete` compares the size read back from the store, which for an encrypted upload
172
+ // is the envelope. Checking the plaintext size at negotiation and the envelope size at
173
+ // completion meant a file just under the limit was accepted, transferred in full, and
174
+ // then DELETED by the too-large branch at the end — the user paying for the whole
175
+ // upload and losing the file. Both ends now measure the same thing.
176
+ if (this.maxBytes && storedSize > this.maxBytes) {
89
177
  // Deterministic per-file limit — retrying can't help, so it's non-retryable
90
178
  // (capacity/rate quotas stay retryable via the default).
91
179
  // TOO_LARGE (413), not QUOTA: the store has plenty of room, this file is simply
92
180
  // bigger than this deployment permits. Reporting it as a capacity problem would
93
181
  // 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 } });
182
+ throw TroveError.tooLarge(
183
+ encrypting && req.size <= this.maxBytes
184
+ // Naming the reason, because "your 10MB file exceeds the 10MB limit" is a
185
+ // maddening thing to be told.
186
+ ? `Encrypted, this file needs ${storedSize} bytes of storage, over the ${this.maxBytes}-byte limit`
187
+ : `File exceeds the maximum upload size of ${this.maxBytes} bytes`,
188
+ { details: { maxBytes: this.maxBytes, size: req.size, storedSize } },
189
+ );
95
190
  }
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
191
 
102
192
  const session = {
103
193
  id: newId('up'),
@@ -108,7 +198,13 @@ export class UploadManager {
108
198
  // the session because the decision is made when the upload STARTS but has to be
109
199
  // honoured when it COMPLETES, possibly much later.
110
200
  overwrite: !!req.overwrite,
201
+ // The size the USER sees, which is what the item is recorded as. `storedSize` is
202
+ // what actually occupies the bucket.
111
203
  size: req.size,
204
+ storedSize,
205
+ encrypted: encrypting,
206
+ chunkSize: encrypting ? chunkSize : null,
207
+ keyFingerprint: encrypting ? policy.encryption.fingerprint : null,
112
208
  contentType,
113
209
  createdAt: Date.now(),
114
210
  strategy: null,
@@ -120,17 +216,17 @@ export class UploadManager {
120
216
  const limits = this.#limits();
121
217
 
122
218
  // Small file + presign → single PUT straight to storage (never through us).
123
- if (req.size <= SINGLE_PUT_LIMIT && caps.presignUpload) {
219
+ if (storedSize <= SINGLE_PUT_LIMIT && caps.presignUpload) {
124
220
  session.strategy = 'single';
125
221
  await this.sessions.put(session);
126
222
  const url = await storage.presignPut(storageKey, { contentType });
127
- return { ...planSummary(session), strategy: 'single', multipart: false, presigned: true, url, limits };
223
+ return { ...planSummary(session), strategy: 'single', multipart: false, presigned: true, url, limits, encryption: this.#planEncryption(session, policy) };
128
224
  }
129
225
 
130
226
  // Multipart (presigned parts straight to storage, or streamed through us).
131
227
  if (caps.multipart) {
132
228
  session.strategy = caps.presignUpload ? 'presign' : 'direct';
133
- const partCount = Math.max(1, Math.ceil(req.size / this.partSize));
229
+ const partCount = Math.max(1, Math.ceil(storedSize / this.partSize));
134
230
  // `#limits()` advertises maxParts in the very same response, and nothing enforced
135
231
  // it: a 150 GiB file planned 19,200 parts against a ceiling of 10,000, which S3
136
232
  // rejects at part 10,001 — after the client has transferred 80 GiB. On a presign
@@ -139,7 +235,7 @@ export class UploadManager {
139
235
  if (partCount > MAX_PARTS) {
140
236
  throw TroveError.tooLarge(
141
237
  `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 } },
238
+ { details: { maxParts: MAX_PARTS, partCount, partSize: this.partSize, size: storedSize } },
143
239
  );
144
240
  }
145
241
  // Only now open the multipart. Refusing AFTER creating it left an upload open in
@@ -153,13 +249,74 @@ export class UploadManager {
153
249
  parts.push({ partNumber: n, url: await storage.presignPart(storageKey, session.uploadId, n) });
154
250
  }
155
251
  }
156
- return { ...planSummary(session), strategy: session.strategy, multipart: true, presigned: session.strategy === 'presign', partCount, parts, limits };
252
+ return { ...planSummary(session), strategy: session.strategy, multipart: true, presigned: session.strategy === 'presign', partCount, parts, limits, encryption: this.#planEncryption(session, policy) };
157
253
  }
158
254
 
159
255
  // Fallback: whole-object PUT streamed through us (tiny/simple backends).
160
256
  session.strategy = 'direct-single';
161
257
  await this.sessions.put(session);
162
- return { ...planSummary(session), strategy: 'direct-single', multipart: false, presigned: false, limits };
258
+ return { ...planSummary(session), strategy: 'direct-single', multipart: false, presigned: false, limits, encryption: this.#planEncryption(session, policy) };
259
+ }
260
+
261
+ /**
262
+ * What the client needs in order to encrypt before the bytes leave the browser.
263
+ *
264
+ * The key travels, the bytes do not. That is what keeps a presigned direct-to-bucket
265
+ * upload possible while the bucket only ever sees ciphertext: the client seals the file
266
+ * locally and PUTs the envelope. Sending the key here is the explicit trade of this
267
+ * design — it defends the storage host, not the server, and the server had the key
268
+ * already in order to be able to index.
269
+ *
270
+ * Null for anything not being encrypted, so a client has one thing to check.
271
+ */
272
+ #planEncryption(session, policy) {
273
+ if (!session.encrypted) return null;
274
+ return {
275
+ algorithm: 'AES-256-GCM',
276
+ chunkSize: session.chunkSize,
277
+ fingerprint: policy.encryption.fingerprint,
278
+ // Hex rather than raw bytes: this rides in a JSON plan.
279
+ key: policy.dataKeyHex,
280
+ // What the client should end up PUTting, so it can check its own work before
281
+ // spending the bytes.
282
+ storedSize: session.storedSize,
283
+ };
284
+ }
285
+
286
+
287
+ /**
288
+ * Refuse an upload that was supposed to be encrypted and is not.
289
+ *
290
+ * Deleted rather than kept, like the over-size branch: an object that cannot be read is
291
+ * not worth the storage, and leaving it would also leave plaintext in a bucket the
292
+ * collection promises is ciphertext.
293
+ */
294
+ async #assertSealed(storage, s) {
295
+ let head;
296
+ try {
297
+ const got = await storage.get(s.storageKey, { range: { start: 0, end: ENVELOPE_HEAD - 1 } });
298
+ head = new Uint8Array(await new Response(got.stream).arrayBuffer());
299
+ } catch {
300
+ return; // a backend that cannot serve a range gets the benefit of the doubt
301
+ }
302
+ const sealed = isEnvelope(head);
303
+ const matches = sealed && (() => {
304
+ try {
305
+ return toHex(decodeHeader(head).fingerprint) === s.keyFingerprint;
306
+ } catch {
307
+ return false;
308
+ }
309
+ })();
310
+ if (sealed && matches) return;
311
+
312
+ await storage.delete(s.storageKey).catch(() => {});
313
+ await this.sessions.delete(s.id);
314
+ throw TroveError.invalid(
315
+ sealed
316
+ ? 'This upload was encrypted with the wrong key for this collection.'
317
+ : 'This collection encrypts its files, and this upload arrived unencrypted. The client '
318
+ + 'must seal the bytes using the key in the upload plan before sending them.',
319
+ );
163
320
  }
164
321
 
165
322
  /** Re-issue a signed URL for one part (resume after expiry). */
@@ -292,15 +449,40 @@ export class UploadManager {
292
449
  { details: { maxBytes: this.maxBytes, size } },
293
450
  );
294
451
  }
452
+ // An upload planned as encrypted must have ARRIVED encrypted.
453
+ //
454
+ // `complete` otherwise records what the session INTENDED, so a client that ignores
455
+ // `plan.encryption` — which is every client that has not implemented it yet — uploads
456
+ // plaintext and the item is stamped with a key fingerprint anyway. The result is the
457
+ // worst of both: permanently unreadable, because the read path looks for an envelope
458
+ // that is not there, and not actually protected, because the bytes are sitting in the
459
+ // bucket in the clear on a collection labelled encrypted.
460
+ //
461
+ // The envelope header is readable without the key, which is exactly what makes this
462
+ // checkable here. One small ranged read, once, at the end of an upload.
463
+ if (s.encrypted) {
464
+ await this.#assertSealed(storage, s);
465
+ }
466
+
295
467
  await this.sessions.delete(uploadId);
296
468
  return {
297
469
  storageKey: s.storageKey,
298
- size,
470
+ // For an encrypted object the store holds an envelope, which is larger than the
471
+ // file. The item records the file: that is the number a user recognises, the one
472
+ // search results and quotas are about, and the one a range request is against.
473
+ size: s.encrypted ? s.size : size,
474
+ storedSize: size,
299
475
  contentType: s.contentType,
300
476
  etag,
301
477
  collectionId: s.collectionId,
302
478
  name: s.name,
303
479
  overwrite: !!s.overwrite,
480
+ // Which key opens this object. Recorded on the item so reading it does not have to
481
+ // fetch the envelope header first, and so a rotation can find what it has not yet
482
+ // converted without opening every object in the bucket.
483
+ encryption: s.encrypted
484
+ ? { fingerprint: s.keyFingerprint, chunkSize: s.chunkSize }
485
+ : null,
304
486
  };
305
487
  }
306
488
 
@@ -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
  }
@@ -457,7 +528,75 @@ export class Vfs {
457
528
  async readStream(id, { range, signal } = {}) {
458
529
  const node = await this.resolve(id);
459
530
  if (!node.storageKey) throw TroveError.notFound('File content');
460
- return (await this.storageFor(node.collectionId)).get(node.storageKey, { range, signal });
531
+ const storage = await this.storageFor(node.collectionId);
532
+ if (!node.encryption) return storage.get(node.storageKey, { range, signal });
533
+
534
+ // An encrypted object is decrypted HERE, for everything that reads through the server.
535
+ //
536
+ // The browser can decrypt for itself when it fetches — and does, which is what keeps a
537
+ // presigned download direct. But an <img src>, a <video src>, a signed URL handed to an
538
+ // external service, and the service worker's own cache fills cannot: they are bare URLs
539
+ // that get bytes and have nowhere to run our code. Those all arrive here, so this is
540
+ // where correctness has to live.
541
+ //
542
+ // The plaintext range the caller asked for is mapped onto the chunks that hold it, so a
543
+ // seek into a video still fetches a chunk rather than the film.
544
+ // The ENVELOPE is read first, and it is authoritative.
545
+ //
546
+ // The item's own record says which key sealed it and at what chunk size, and that is
547
+ // what makes a listing cheap — but it is a copy, and a copy can be stale: an object
548
+ // restored from a backup, adopted by a scan, or written by another version. Deriving
549
+ // the byte ranges from the record and then decrypting with the object's real geometry
550
+ // is how you get chunk indices computed against one layout and nonces against another,
551
+ // which surfaces as "the data has been altered" on data nobody altered.
552
+ const head = await storage.get(node.storageKey, { range: { start: 0, end: HEADER_BYTES - 1 }, signal });
553
+ const header = decodeHeader(await bytesOf(head.stream));
554
+ // The plaintext size likewise: the envelope knows, the record remembers.
555
+ const plaintextSize = header.plaintextSize ?? node.size;
556
+ const key = await this.#keyForNode(node, header);
557
+
558
+ const want = range
559
+ ? cipherRangeFor({ start: range.start, end: range.end }, { ...header, plaintextSize })
560
+ : {
561
+ cipherStart: 0,
562
+ cipherEnd: cipherSize(plaintextSize, header.chunkSize) - 1,
563
+ firstChunk: 0,
564
+ trimStart: 0,
565
+ trimEnd: plaintextSize,
566
+ };
567
+
568
+ const body = await storage.get(node.storageKey, {
569
+ range: { start: Math.max(want.cipherStart, HEADER_BYTES), end: want.cipherEnd }, signal,
570
+ });
571
+ const plain = await decryptStream(key, header, body.stream, want.firstChunk);
572
+ return {
573
+ stream: trimStream(plain, want.trimStart, want.trimEnd),
574
+ size: want.trimEnd - want.trimStart,
575
+ contentType: node.contentType,
576
+ etag: head.etag,
577
+ range: range ? { start: range.start, end: range.start + (want.trimEnd - want.trimStart) - 1, total: plaintextSize } : null,
578
+ };
579
+ }
580
+
581
+ /**
582
+ * The key that opens this object, chosen by what the ENVELOPE names.
583
+ *
584
+ * The object is the authority on which key sealed it; the item's record is a copy kept
585
+ * for cheap listings. Trusting the record would mean a stale one sends us to the wrong
586
+ * key and the failure reads as corruption.
587
+ */
588
+ async #keyForNode(node, header) {
589
+ const want = toHex(header.fingerprint);
590
+ const key = this.collections?.dataKeyFor
591
+ ? await this.collections.dataKeyFor(node.collectionId, want)
592
+ : null;
593
+ if (!key) {
594
+ throw TroveError.invalid(
595
+ 'This item is encrypted with a key this collection no longer holds. It was probably '
596
+ + 'retired before a key rotation finished moving everything onto the new one.',
597
+ );
598
+ }
599
+ return key;
461
600
  }
462
601
 
463
602
  // --- uploads ---------------------------------------------------------------
@@ -511,7 +650,7 @@ export class Vfs {
511
650
  const node = await this.#upsertItem({
512
651
  collectionId: obj.collectionId, name: obj.name, storageKey: obj.storageKey,
513
652
  size: obj.size, contentType: obj.contentType, etag: obj.etag,
514
- overwrite: obj.overwrite,
653
+ overwrite: obj.overwrite, encryption: obj.encryption || null,
515
654
  });
516
655
  this.indexing.indexNode(node).catch((e) => console.error('index error', e));
517
656
  return node;
@@ -32,6 +32,7 @@ import {
32
32
  IdentityProvider, JwtIdentityProvider, HeaderIdentityProvider, AnonymousIdentityProvider,
33
33
  cloudflareAccess,
34
34
  KeyValueStore, MemoryKV, SqliteKV,
35
+ KvSessionStore,
35
36
  SqliteProvider, LocalSqliteProvider,
36
37
  SidecarService, NotificationCenter, WebPushService, WebPushChannel, NotificationChannel,
37
38
  ApiKeyService, CapabilityProvider, ApiKeyCapabilityProvider,
@@ -461,11 +462,17 @@ export function coreProviders(config, lifecycleState) {
461
462
  async (deps) => {
462
463
  const r = await need(deps, [
463
464
  'storage', 'metadata', 'search', 'indexers', 'sidecar', 'collections',
464
- 'searchTransformer', 'issues', 'signedUrls',
465
+ 'searchTransformer', 'issues', 'signedUrls', 'kv',
465
466
  ]);
466
467
  const vfs = new Vfs({
467
468
  ...r,
468
469
  maxUploadBytes: config.maxUploadBytes ?? null,
470
+ // Upload sessions in the KeyValueStore rather than this process's memory. An
471
+ // upload spans several requests and the session is the only thing joining them,
472
+ // so on a runtime that can serve those requests from different isolates an
473
+ // in-memory Map means "upload session not found" on a drive where nothing is
474
+ // wrong. See KvSessionStore.
475
+ uploadSessions: new KvSessionStore({ kv: r.kv }),
469
476
  // Where this server can be reached from outside, for the URLs that leave the
470
477
  // browser (an indexer hands one to an external API). Absent, those are refused
471
478
  // rather than handed out as links to nowhere.
@@ -475,7 +482,7 @@ export function coreProviders(config, lifecycleState) {
475
482
  return vfs;
476
483
  },
477
484
  null,
478
- { deps: ['storage', 'metadata', 'search', 'indexers', 'sidecar', 'collections', 'searchTransformer', 'issues', 'signedUrls'] },
485
+ { deps: ['storage', 'metadata', 'search', 'indexers', 'sidecar', 'collections', 'searchTransformer', 'issues', 'signedUrls', 'kv'] },
479
486
  ),
480
487
 
481
488
  // Bulk plugin package blobs. Defaults to the primary storage under a prefix;
@@ -5,6 +5,7 @@
5
5
  import { Router, json, parseRange } from './router.js';
6
6
  import {
7
7
  TroveError, assertSafePluginSql, concatBytes, metadataUrl, publicOrigin,
8
+ shouldEncrypt,
8
9
  } from '@3sln/trove/core';
9
10
  import { parseContribUri, CORE_DOMAIN } from '@3sln/trove/core/plugins/identity.js';
10
11
 
@@ -444,10 +445,20 @@ export function createRouter() {
444
445
 
445
446
  // --- uploads ---------------------------------------------------------------
446
447
 
447
- r.post('/api/collections/:collection/uploads', [], async (ctx) => {
448
+ r.post('/api/collections/:collection/uploads', ['collections', 'vfs'], async (ctx) => {
448
449
  const b = await body(ctx.req);
449
450
  if (!b.name) throw TroveError.invalid('name is required');
450
451
  const collection = await ctx.access.collection(scopedCollection(ctx), 'write');
452
+ // An upload onto an encrypted collection is handed the collection's key, so the client
453
+ // can seal the bytes before they reach the bucket. That key decrypts EVERYTHING in the
454
+ // collection, which makes it a read capability however it arrives — and `write` does
455
+ // not imply `read` here (only `admin` expands). Without this, a write-only API key,
456
+ // which the key model explicitly supports, could ask for a plan for a one-byte file and
457
+ // receive the means to decrypt the whole collection.
458
+ //
459
+ // Refused rather than quietly narrowed to a plaintext upload: silently storing in the
460
+ // clear on a collection someone set up to be encrypted is the worse failure.
461
+ await assertReadIfKeyed(ctx, b);
451
462
  return uploadDescriptor(await collection.createUpload({
452
463
  name: b.name, size: Number(b.size ?? 0), contentType: b.contentType,
453
464
  overwrite: b.overwrite === true,
@@ -1150,6 +1161,23 @@ async function assertTaskAccess(ctx, task, what) {
1150
1161
  * manage API keys, however broadly it was scoped, because a key that can mint keys can
1151
1162
  * outlive its own revocation. Then the ordinary admin check on the principal.
1152
1163
  */
1164
+
1165
+ /**
1166
+ * An upload plan that will carry the collection key needs `read`, not merely `write`.
1167
+ *
1168
+ * Checked before the session is created, so a refusal costs nothing and leaves no orphan.
1169
+ */
1170
+ async function assertReadIfKeyed(ctx, body) {
1171
+ if (!ctx.collections?.encryptionFor) return;
1172
+ const collectionId = scopedCollection(ctx);
1173
+ const encryption = await ctx.collections.encryptionFor(collectionId);
1174
+ if (!encryption?.enabled) return;
1175
+ const contentType = body.contentType || ctx.vfs?.guessContentType?.(body.name) || '';
1176
+ if (!shouldEncrypt(encryption, { name: body.name, contentType })) return;
1177
+ // Throws if the caller does not hold read on this collection.
1178
+ await ctx.access.collection(collectionId, 'read');
1179
+ }
1180
+
1153
1181
  function requireHumanAdmin(ctx, action) {
1154
1182
  if (ctx.grant) {
1155
1183
  throw TroveError.forbidden(`An API key cannot ${action} — sign in as an administrator`);