@interop/was-client 0.35.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -5
- package/dist/Collection.d.ts +5 -0
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +37 -4
- package/dist/Collection.js.map +1 -1
- package/dist/Resource.d.ts +4 -3
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +31 -20
- package/dist/Resource.js.map +1 -1
- package/dist/codec.d.ts +91 -4
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +10 -1
- package/dist/codec.js.map +1 -1
- package/dist/edv/EdvCodec.d.ts +33 -9
- package/dist/edv/EdvCodec.d.ts.map +1 -1
- package/dist/edv/EdvCodec.js +471 -52
- package/dist/edv/EdvCodec.js.map +1 -1
- package/dist/edv/WasTransport.d.ts +39 -2
- package/dist/edv/WasTransport.d.ts.map +1 -1
- package/dist/edv/WasTransport.js +82 -26
- package/dist/edv/WasTransport.js.map +1 -1
- package/dist/edv/docCipher.d.ts +59 -7
- package/dist/edv/docCipher.d.ts.map +1 -1
- package/dist/edv/docCipher.js +63 -13
- package/dist/edv/docCipher.js.map +1 -1
- package/dist/edv/index.d.ts +1 -1
- package/dist/edv/index.d.ts.map +1 -1
- package/dist/errors.d.ts +31 -10
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +35 -14
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/features.d.ts +44 -1
- package/dist/internal/features.d.ts.map +1 -1
- package/dist/internal/features.js +39 -0
- package/dist/internal/features.js.map +1 -1
- package/dist/internal/write.d.ts +65 -12
- package/dist/internal/write.d.ts.map +1 -1
- package/dist/internal/write.js +71 -9
- package/dist/internal/write.js.map +1 -1
- package/dist/sync/index.d.ts +9 -5
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +9 -5
- package/dist/sync/index.js.map +1 -1
- package/package.json +1 -1
package/dist/edv/EdvCodec.js
CHANGED
|
@@ -36,9 +36,13 @@
|
|
|
36
36
|
* - **Inline non-JSON as a single JWE.** A `Blob`/`Uint8Array` under the size cap
|
|
37
37
|
* is encrypted as one document -- stored as a legible UTF-8 string for a
|
|
38
38
|
* text-family type (else base64) -- with the plaintext content type and the
|
|
39
|
-
* encoding carried in the document `meta`.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* encoding carried in the document `meta`. A blob over `maxBlobBytes` is
|
|
40
|
+
* auto-routed by `add()` to the chunked-stream path instead: `encode` returns
|
|
41
|
+
* a multi-request plan the write path executes, storing one document plus its
|
|
42
|
+
* chunk resources over a `WasTransport` of the codec's own. That needs the
|
|
43
|
+
* backend's `chunked-streams` affordance, checked before the first write.
|
|
44
|
+
* Reads reassemble transparently, so `get()` returns the same `Blob` either
|
|
45
|
+
* way.
|
|
42
46
|
* - **Enforced sequence (conditional writes).** The codec sets
|
|
43
47
|
* `conditionalWrites`, so the write path pre-reads the current envelope and
|
|
44
48
|
* hands it to `encode`: an update advances `sequence` from its prior value and
|
|
@@ -64,8 +68,9 @@
|
|
|
64
68
|
import { base64, base64urlnopad } from '@scure/base';
|
|
65
69
|
import { EdvClientCore, assertDocId } from '@interop/edv-client';
|
|
66
70
|
import { EMPTY_INDEX_SCHEMA, assertQueryAttributes } from '../internal/indexSchema.js';
|
|
67
|
-
import { EncryptionError, IntegrityError, KeyUnwrapError, UnknownEpochError, ValidationError } from '../errors.js';
|
|
68
|
-
import { readEtag } from '../internal/conditional.js';
|
|
71
|
+
import { EncryptionError, IntegrityError, KeyUnwrapError, NotSupportedError, UnknownEpochError, ValidationError } from '../errors.js';
|
|
72
|
+
import { readEtag, writeHeaders } from '../internal/conditional.js';
|
|
73
|
+
import { WasTransport } from './WasTransport.js';
|
|
69
74
|
import { isEncryptedEnvelope } from '../sync/envelope.js';
|
|
70
75
|
import { resolveEpochKeys } from './epochKeys.js';
|
|
71
76
|
import { didKeyResolver } from './epochCrypto.js';
|
|
@@ -73,16 +78,26 @@ import { resolveHmacKey } from './hmacKey.js';
|
|
|
73
78
|
import { isBlob, isTextContentType, readJsonData, resolvePayload } from '../internal/content.js';
|
|
74
79
|
import { DEFAULT_CONTENT_TYPE, EDV_SCHEME_VERSION, envelopeBytes } from './constants.js';
|
|
75
80
|
/**
|
|
76
|
-
* Default
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* (
|
|
83
|
-
*
|
|
81
|
+
* Default threshold above which an encrypted binary write is routed to the
|
|
82
|
+
* chunked-stream path instead of being sealed into one document, measured in
|
|
83
|
+
* raw (pre-base64) bytes. It is a routing threshold, not a hard cap: `add()`
|
|
84
|
+
* carries a larger blob as a document plus chunk resources, which needs the
|
|
85
|
+
* backend's `chunked-streams` feature. 512 KiB: a single-document envelope is
|
|
86
|
+
* stored as a JSON-family content type routed through the server's in-memory
|
|
87
|
+
* JSON body parser (a ~1 MiB cap), and a binary payload inflates ~33% inside
|
|
88
|
+
* the document (base64) and again ~33% in the JWE ciphertext (base64url) --
|
|
89
|
+
* ~1.78x total, so 512 KiB raw stays safely under the cap. Raise
|
|
90
|
+
* `maxBlobBytes` against a server with a larger JSON body limit.
|
|
84
91
|
*/
|
|
85
92
|
const DEFAULT_MAX_BLOB_BYTES = 512 * 1024;
|
|
93
|
+
/**
|
|
94
|
+
* The `meta.encoding` discriminator a chunked binary document carries: its
|
|
95
|
+
* bytes live in the document's chunk resources, not in `content`. It is sealed
|
|
96
|
+
* inside the JWE payload (the cipher encrypts `meta` alongside `content`), so
|
|
97
|
+
* it is the AEAD-authenticated signal the read side routes on, and it keeps the
|
|
98
|
+
* decrypted document self-describing alongside `'utf-8'` and `'base64'`.
|
|
99
|
+
*/
|
|
100
|
+
const CHUNKED_ENCODING = 'chunked';
|
|
86
101
|
/**
|
|
87
102
|
* A shared strict UTF-8 decoder used to test whether a non-JSON payload is
|
|
88
103
|
* valid UTF-8 (so it can be stored legibly as text rather than base64).
|
|
@@ -203,7 +218,20 @@ export class EdvCodec {
|
|
|
203
218
|
*/
|
|
204
219
|
#writeEpoch;
|
|
205
220
|
#contentType;
|
|
221
|
+
/**
|
|
222
|
+
* The size, in raw bytes, above which a binary write is routed to the
|
|
223
|
+
* chunked-stream path instead of being sealed into one document. A routing
|
|
224
|
+
* threshold, not a hard cap.
|
|
225
|
+
*/
|
|
206
226
|
#maxBlobBytes;
|
|
227
|
+
/**
|
|
228
|
+
* The size of each encrypted chunk a routed write emits, or `undefined` to
|
|
229
|
+
* take the EDV core's default (1 MiB). One chunk is one upload, so it has to
|
|
230
|
+
* stay under the backend's `maxUploadBytes`; nothing here can check that (the
|
|
231
|
+
* feature probe answers affordance tokens, not the backend's constraints), so
|
|
232
|
+
* a chunk over the limit surfaces as the server's 413.
|
|
233
|
+
*/
|
|
234
|
+
#chunkSize;
|
|
207
235
|
#idDerivation;
|
|
208
236
|
/**
|
|
209
237
|
* The EDV-over-WAS scheme version this codec binds into every envelope's
|
|
@@ -212,6 +240,12 @@ export class EdvCodec {
|
|
|
212
240
|
* stamped with a greater version.
|
|
213
241
|
*/
|
|
214
242
|
#version;
|
|
243
|
+
/**
|
|
244
|
+
* The id of the Space holding this codec's Collection. Needed only by the
|
|
245
|
+
* chunked-stream path, which addresses the document and its chunks through a
|
|
246
|
+
* `WasTransport` of its own.
|
|
247
|
+
*/
|
|
248
|
+
#spaceId;
|
|
215
249
|
/**
|
|
216
250
|
* The id of the Collection this codec was built for. It is bound into the
|
|
217
251
|
* Collection metadata envelope's `was.collection` on write and required to
|
|
@@ -219,6 +253,16 @@ export class EdvCodec {
|
|
|
219
253
|
* ({@link UnknownEpochError}).
|
|
220
254
|
*/
|
|
221
255
|
#collectionId;
|
|
256
|
+
/**
|
|
257
|
+
* The id of every epoch the descriptor lists -- held by this reader or not.
|
|
258
|
+
* Decrypt routing uses it to tell the two unroutable-envelope cases apart:
|
|
259
|
+
* an envelope kid whose epoch is listed here but matches no candidate key
|
|
260
|
+
* means this reader is not a recipient of that epoch
|
|
261
|
+
* ({@link KeyUnwrapError}); a kid whose epoch is not listed at all means the
|
|
262
|
+
* descriptor this codec was built from has never seen the epoch
|
|
263
|
+
* ({@link UnknownEpochError} -- the stale-descriptor signal).
|
|
264
|
+
*/
|
|
265
|
+
#epochIds;
|
|
222
266
|
/**
|
|
223
267
|
* The collection's blinded-index key, or `null` where the collection
|
|
224
268
|
* declares none. It blinds attribute names and values both on write (the
|
|
@@ -252,19 +296,30 @@ export class EdvCodec {
|
|
|
252
296
|
* into every envelope's `was.epoch`, which the decode side checks against
|
|
253
297
|
* the decrypting key's epoch unconditionally.
|
|
254
298
|
* @param options.contentType {string} stored envelope content type
|
|
255
|
-
* @param options.maxBlobBytes {number}
|
|
299
|
+
* @param options.maxBlobBytes {number} the size above which a binary write
|
|
300
|
+
* is routed to the chunked-stream path instead of one document
|
|
301
|
+
* @param [options.chunkSize] {number} the size of each encrypted chunk a
|
|
302
|
+
* routed write emits (defaults to the EDV core's 1 MiB). One chunk is one
|
|
303
|
+
* upload, so it must stay under the backend's `maxUploadBytes`; that
|
|
304
|
+
* constraint is not advertised through the feature probe, so it is the
|
|
305
|
+
* caller's to respect (see `createEdvEncryption`)
|
|
256
306
|
* @param options.idDerivation {string} how `add()` mints a document
|
|
257
307
|
* id: `'random'` (classic `generateId()`) or `'content'` (derived from the
|
|
258
308
|
* JWE ciphertext, content-addressed)
|
|
259
309
|
* @param [options.version] {number} the EDV-over-WAS scheme version to bind
|
|
260
310
|
* into each envelope's `was.v` (defaults to {@link EDV_SCHEME_VERSION})
|
|
311
|
+
* @param options.spaceId {string} the Space holding the Collection, for the
|
|
312
|
+
* chunked-stream path's own transport
|
|
261
313
|
* @param options.collectionId {string} the Collection this codec reads and
|
|
262
314
|
* writes: bound into the Collection metadata envelope's `was.collection`
|
|
263
315
|
* (and checked on read), and it labels decrypt-routing errors
|
|
316
|
+
* @param options.epochIds {string[]} the id of every epoch the descriptor
|
|
317
|
+
* lists (held by this reader or not); decrypt routing checks it to tell a
|
|
318
|
+
* not-a-recipient envelope apart from a stale-descriptor one
|
|
264
319
|
* @param [options.hmac] {BlindingKey} the collection's blinded-index key,
|
|
265
320
|
* where it declares one
|
|
266
321
|
*/
|
|
267
|
-
constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation, version, collectionId, hmac }) {
|
|
322
|
+
constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, chunkSize, idDerivation, version, spaceId, collectionId, epochIds, hmac }) {
|
|
268
323
|
this.#edv = edv;
|
|
269
324
|
this.#recipients =
|
|
270
325
|
edv.documentCipher.createDefaultRecipients(keyAgreementKey);
|
|
@@ -272,9 +327,12 @@ export class EdvCodec {
|
|
|
272
327
|
this.#writeEpoch = writeEpoch;
|
|
273
328
|
this.#contentType = contentType;
|
|
274
329
|
this.#maxBlobBytes = maxBlobBytes;
|
|
330
|
+
this.#chunkSize = chunkSize;
|
|
275
331
|
this.#idDerivation = idDerivation;
|
|
276
332
|
this.#version = version ?? EDV_SCHEME_VERSION;
|
|
333
|
+
this.#spaceId = spaceId;
|
|
277
334
|
this.#collectionId = collectionId;
|
|
335
|
+
this.#epochIds = new Set(epochIds);
|
|
278
336
|
this.#blindingKey = hmac ?? null;
|
|
279
337
|
if (this.#blindingKey !== null) {
|
|
280
338
|
this.#indexing = {
|
|
@@ -379,7 +437,25 @@ export class EdvCodec {
|
|
|
379
437
|
(this.#idDerivation === 'content'
|
|
380
438
|
? undefined
|
|
381
439
|
: (await this.#edv.generateId()));
|
|
382
|
-
const
|
|
440
|
+
const parts = await this.#toDocument(data, contentType, docId);
|
|
441
|
+
if (parts.kind === 'chunked') {
|
|
442
|
+
if (docId === undefined) {
|
|
443
|
+
throw new ValidationError(`Encrypted binary write of ${parts.size} bytes exceeds the ` +
|
|
444
|
+
`single-document threshold of ${this.#maxBlobBytes} bytes, so it ` +
|
|
445
|
+
'must be stored as a document plus chunk resources -- which a ' +
|
|
446
|
+
"content-addressed collection (idDerivation: 'content') cannot " +
|
|
447
|
+
'do: the document is written twice (once to reserve it, once to ' +
|
|
448
|
+
'record the chunk count), so no single ciphertext derives its id. ' +
|
|
449
|
+
'Store large blobs in a random-id collection, or keep the payload ' +
|
|
450
|
+
'under the threshold.');
|
|
451
|
+
}
|
|
452
|
+
return this.#chunkedWrite({
|
|
453
|
+
id: docId,
|
|
454
|
+
stream: parts.stream,
|
|
455
|
+
meta: parts.meta
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
const { content, meta } = parts;
|
|
383
459
|
// When the write path pre-read a current envelope, advance `sequence` from
|
|
384
460
|
// its prior value (`encrypt({ update: true })` increments it) and pin the
|
|
385
461
|
// write to the server's current ETag with `If-Match`. With no prior envelope
|
|
@@ -458,6 +534,171 @@ export class EdvCodec {
|
|
|
458
534
|
epoch: this.#writeEpoch
|
|
459
535
|
};
|
|
460
536
|
}
|
|
537
|
+
/**
|
|
538
|
+
* Builds the `WasTransport` the chunked-stream paths drive, over the signed
|
|
539
|
+
* requester core supplied and this codec's own Space/Collection. The
|
|
540
|
+
* handle's memoized feature probe is passed straight through, so the
|
|
541
|
+
* transport's own affordance gates cost no extra descriptor read.
|
|
542
|
+
*
|
|
543
|
+
* @param context {CodecRequestContext}
|
|
544
|
+
* @param [documentHeaders] {Record<string, string>} extra headers for
|
|
545
|
+
* document writes (the `Key-Epoch` stamp the codec seam applies)
|
|
546
|
+
* @returns {WasTransport}
|
|
547
|
+
*/
|
|
548
|
+
#transportFor(context, documentHeaders) {
|
|
549
|
+
return new WasTransport({
|
|
550
|
+
was: { request: input => context.request(input) },
|
|
551
|
+
spaceId: this.#spaceId,
|
|
552
|
+
collectionId: this.#collectionId,
|
|
553
|
+
contentType: this.#contentType,
|
|
554
|
+
features: context.features,
|
|
555
|
+
...(documentHeaders !== undefined && { documentHeaders })
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Refuses the operation unless the collection's backend advertises the
|
|
560
|
+
* `chunked-streams` affordance. Checked before the first write, so an
|
|
561
|
+
* unsupported server never ends up holding a document stub with no chunks.
|
|
562
|
+
*
|
|
563
|
+
* @param context {CodecRequestContext}
|
|
564
|
+
* @param what {string} the operation, for the message
|
|
565
|
+
* @returns {Promise<void>}
|
|
566
|
+
*/
|
|
567
|
+
async #assertChunkedStreams(context, what) {
|
|
568
|
+
if (await context.features.has('chunked-streams')) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
// "No features" has two causes, and only one of them is about the server's
|
|
572
|
+
// capabilities: a descriptor that was read and lists no `chunked-streams`,
|
|
573
|
+
// versus a descriptor that could not be read at all (no backend descriptor
|
|
574
|
+
// endpoint, a deleted collection, or a capability that cannot read it --
|
|
575
|
+
// WAS masks unauthorized reads as 404). Name the one that applies, so a
|
|
576
|
+
// capable server whose collection is gone does not look incapable.
|
|
577
|
+
if (await context.features.descriptorAbsent()) {
|
|
578
|
+
throw new NotSupportedError(`${what} needs the collection's backend to advertise the ` +
|
|
579
|
+
`'chunked-streams' feature, but the backend descriptor could not be ` +
|
|
580
|
+
'read at all: the collection may not exist, or this capability may ' +
|
|
581
|
+
'not be able to read its descriptor. Confirm the collection and the ' +
|
|
582
|
+
'capability, then retry.');
|
|
583
|
+
}
|
|
584
|
+
throw new NotSupportedError(`${what} needs the collection's backend to advertise the ` +
|
|
585
|
+
`'chunked-streams' feature, which it does not. Store the blob in a ` +
|
|
586
|
+
'collection on a backend that supports chunked streams, or keep the ' +
|
|
587
|
+
`payload under the ${this.#maxBlobBytes}-byte single-document ` +
|
|
588
|
+
"threshold (raise it with the provider's `maxBlobBytes` where the " +
|
|
589
|
+
'server accepts a larger body).');
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* The plan for a binary payload over the single-document threshold: one EDV
|
|
593
|
+
* document plus its chunk resources, written by `EdvClientCore.insert({ doc,
|
|
594
|
+
* stream, transport })` over a transport built from the write context. The
|
|
595
|
+
* document id is minted before the plan is returned, so the caller can report
|
|
596
|
+
* it without waiting for the write.
|
|
597
|
+
*
|
|
598
|
+
* The `was` binding, the recipients and the write epoch are exactly the
|
|
599
|
+
* single-document path's, and `additionalProtectedParams` carries the binding
|
|
600
|
+
* into both the document envelope and every chunk's AAD. `content` stays
|
|
601
|
+
* empty: the bytes are the chunks, and `meta` records the plaintext content
|
|
602
|
+
* type plus the chunked encoding discriminator so a read reconstructs the
|
|
603
|
+
* same `Blob` a small binary read returns. `meta` is sealed inside the JWE
|
|
604
|
+
* payload, so that discriminator is what the read side routes on: a server
|
|
605
|
+
* cannot mint it, and cannot suppress it to hide the chunks either.
|
|
606
|
+
*
|
|
607
|
+
* The write is two-phase (`EdvClientCore.insert` writes the document, then
|
|
608
|
+
* streams the chunks), so a failure partway leaves a document stub whose
|
|
609
|
+
* sealed stream state is still `{ pending: true }` -- undecryptable, listed,
|
|
610
|
+
* and never re-used, since a retry mints a fresh id. The plan therefore
|
|
611
|
+
* compensates: if the document was written and the write then failed, it
|
|
612
|
+
* best-effort deletes the stub before rethrowing.
|
|
613
|
+
*
|
|
614
|
+
* @param options {object}
|
|
615
|
+
* @param options.id {string} the minted document id
|
|
616
|
+
* @param options.stream {ReadableStream<Uint8Array>} the payload, as the
|
|
617
|
+
* stream the EDV core re-chunks (never buffered whole by this codec)
|
|
618
|
+
* @param options.meta {Record<string, unknown>} the document meta to seal
|
|
619
|
+
* @returns {ChunkedWrite}
|
|
620
|
+
*/
|
|
621
|
+
#chunkedWrite({ id, stream, meta }) {
|
|
622
|
+
const was = wasParam({
|
|
623
|
+
version: this.#version,
|
|
624
|
+
resource: id,
|
|
625
|
+
epoch: this.#writeEpoch
|
|
626
|
+
});
|
|
627
|
+
return {
|
|
628
|
+
chunked: true,
|
|
629
|
+
id,
|
|
630
|
+
resourceContentType: meta.contentType,
|
|
631
|
+
// What the scheme-agnostic write path appends when it refuses this plan
|
|
632
|
+
// for a write by id: only this codec knows why the payload needs several
|
|
633
|
+
// requests, and which low-level API writes one directly.
|
|
634
|
+
guidance: 'This payload is too large for a single encrypted document, so it is ' +
|
|
635
|
+
'stored as a document plus chunk resources. Drive the write yourself ' +
|
|
636
|
+
'with `EdvClientCore.update({ doc, stream, transport })` over a ' +
|
|
637
|
+
'`WasTransport`, against a server whose backend advertises the ' +
|
|
638
|
+
"'chunked-streams' feature.",
|
|
639
|
+
execute: async (context) => {
|
|
640
|
+
await this.#assertChunkedStreams(context, 'Writing a large blob');
|
|
641
|
+
// The EDV core owns the write and swallows the responses, so the
|
|
642
|
+
// transport reports the document write it made: whether one landed at
|
|
643
|
+
// all (the cleanup decision below) and the validator the server acked
|
|
644
|
+
// it with.
|
|
645
|
+
const transport = this.#transportFor(context, writeHeaders({ epoch: this.#writeEpoch }));
|
|
646
|
+
try {
|
|
647
|
+
await this.#edv.insert({
|
|
648
|
+
doc: { id, content: {}, meta },
|
|
649
|
+
stream,
|
|
650
|
+
...(this.#chunkSize !== undefined && {
|
|
651
|
+
chunkSize: this.#chunkSize
|
|
652
|
+
}),
|
|
653
|
+
recipients: this.#recipients,
|
|
654
|
+
keyResolver: this.#edv.keyResolver,
|
|
655
|
+
hmac: this.#writeBlindingKey(),
|
|
656
|
+
additionalProtectedParams: { was },
|
|
657
|
+
transport
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
catch (err) {
|
|
661
|
+
throw await this.#chunkedWriteFailed({ err, id, transport });
|
|
662
|
+
}
|
|
663
|
+
const etag = transport.lastDocumentWrite?.etag;
|
|
664
|
+
return { id, ...(etag !== undefined && { etag }) };
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Compensates a failed chunked write and builds the error to rethrow. The
|
|
670
|
+
* document stub is deleted only when the transport reports it actually wrote
|
|
671
|
+
* one: a write that failed before that (the id is freshly minted, so this is
|
|
672
|
+
* a server or network failure, not a collision) must not delete a resource
|
|
673
|
+
* this write never created. The delete is best effort -- it is a cleanup, and
|
|
674
|
+
* its own failure must not mask the failure that caused it -- so its outcome
|
|
675
|
+
* only shapes the message.
|
|
676
|
+
*
|
|
677
|
+
* @param options {object}
|
|
678
|
+
* @param options.err {unknown} the failure from the chunked write
|
|
679
|
+
* @param options.id {string} the document id the write minted
|
|
680
|
+
* @param options.transport {WasTransport} the transport the write ran on
|
|
681
|
+
* @returns {Promise<Error>} the error to throw, carrying `err` as its cause
|
|
682
|
+
*/
|
|
683
|
+
async #chunkedWriteFailed({ err, id, transport }) {
|
|
684
|
+
if (transport.lastDocumentWrite === undefined) {
|
|
685
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
686
|
+
}
|
|
687
|
+
let removed = true;
|
|
688
|
+
try {
|
|
689
|
+
await transport.deleteDocument({ id });
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
removed = false;
|
|
693
|
+
}
|
|
694
|
+
return new EncryptionError(`The chunked encrypted write of resource "${id}" failed partway: its ` +
|
|
695
|
+
'document was written but its chunks were not, so the stored ' +
|
|
696
|
+
'document cannot be read. ' +
|
|
697
|
+
(removed
|
|
698
|
+
? 'The incomplete document was deleted; retry the write.'
|
|
699
|
+
: 'The incomplete document could NOT be deleted and is still ' +
|
|
700
|
+
'stored; delete it and retry the write.'), { cause: err });
|
|
701
|
+
}
|
|
461
702
|
/**
|
|
462
703
|
* The blinding key a content write should index with: the collection's key
|
|
463
704
|
* once the applied schema declares at least one attribute, else `undefined`
|
|
@@ -475,11 +716,89 @@ export class EdvCodec {
|
|
|
475
716
|
/**
|
|
476
717
|
* @inheritdoc
|
|
477
718
|
*/
|
|
478
|
-
async decode(response, expectedId) {
|
|
719
|
+
async decode(response, expectedId, context) {
|
|
479
720
|
const stored = await readJsonData(response);
|
|
480
721
|
const decrypted = await this.#openEnvelope({ doc: stored, expectedId });
|
|
722
|
+
// A chunked document's bytes live in its chunk resources. Both routing
|
|
723
|
+
// inputs are AEAD-authenticated, never the cleartext copies on the
|
|
724
|
+
// envelope: the `meta.encoding` discriminator sealed in the JWE payload
|
|
725
|
+
// decides that this IS a chunked document (a server cannot bolt a
|
|
726
|
+
// cleartext `stream` onto an ordinary document to mask its sealed
|
|
727
|
+
// content), and the sealed `stream.chunks` count then says how many chunks
|
|
728
|
+
// to fetch (a server cannot lower it to truncate the read). A sealed
|
|
729
|
+
// discriminator with no sealed count is an interrupted write, whose state
|
|
730
|
+
// is still `{ pending: true }`: it fails loudly rather than decoding to an
|
|
731
|
+
// empty document.
|
|
732
|
+
if (decrypted.meta?.encoding === CHUNKED_ENCODING) {
|
|
733
|
+
return this.#readChunked({
|
|
734
|
+
// Address the chunk resources by the AEAD-bound `was.resource` id, not
|
|
735
|
+
// by the envelope's cleartext `id`: a server that serves document A's
|
|
736
|
+
// authentic envelope with the cleartext id swapped to B would
|
|
737
|
+
// otherwise have the read fetch (and cleanly decrypt) B's chunks,
|
|
738
|
+
// exactly the envelope swap the `was.resource` binding exists to
|
|
739
|
+
// detect.
|
|
740
|
+
id: decrypted.resourceId,
|
|
741
|
+
chunks: decrypted.stream?.chunks,
|
|
742
|
+
meta: decrypted.meta,
|
|
743
|
+
keyId: decrypted.keyId,
|
|
744
|
+
context
|
|
745
|
+
});
|
|
746
|
+
}
|
|
481
747
|
return this.#fromDocument(decrypted.content, decrypted.meta);
|
|
482
748
|
}
|
|
749
|
+
/**
|
|
750
|
+
* Reassembles a chunked binary document: drives `EdvClientCore.getStream`
|
|
751
|
+
* over a transport built from the read context, buffers the decrypt stream,
|
|
752
|
+
* and returns the same `Blob` a small binary read returns.
|
|
753
|
+
*
|
|
754
|
+
* Only AEAD-authenticated inputs are trusted -- the sealed chunk count and
|
|
755
|
+
* the `was.resource` id the envelope is bound to, never the envelope's
|
|
756
|
+
* cleartext `id` -- and the decrypt uses the very key that opened the
|
|
757
|
+
* document envelope, so a chunk sealed to some other epoch fails to
|
|
758
|
+
* authenticate rather than being accepted.
|
|
759
|
+
*
|
|
760
|
+
* @param options {object}
|
|
761
|
+
* @param [options.id] {string} the AEAD-bound resource id (= WAS resource
|
|
762
|
+
* id, the parent of the chunk resources)
|
|
763
|
+
* @param options.chunks {unknown} the sealed chunk count
|
|
764
|
+
* @param [options.meta] {Record<string, unknown>} the decrypted meta
|
|
765
|
+
* @param options.keyId {string} the id of the key that decrypted the
|
|
766
|
+
* document envelope
|
|
767
|
+
* @param [options.context] {CodecRequestContext} the signed-request context
|
|
768
|
+
* @returns {Promise<Blob>}
|
|
769
|
+
*/
|
|
770
|
+
async #readChunked({ id, chunks, meta, keyId, context }) {
|
|
771
|
+
if (typeof chunks !== 'number') {
|
|
772
|
+
throw new EncryptionError('Cannot read this resource: it is a chunked encrypted blob whose ' +
|
|
773
|
+
'sealed stream state records no chunk count, so the write that ' +
|
|
774
|
+
'created it never completed. Re-upload the blob.');
|
|
775
|
+
}
|
|
776
|
+
if (context === undefined) {
|
|
777
|
+
throw new EncryptionError('Cannot read this resource: it is a chunked encrypted blob, whose ' +
|
|
778
|
+
'bytes live in separate chunk resources, and this caller supplied no ' +
|
|
779
|
+
'request context to fetch them with. Read it through a Resource or ' +
|
|
780
|
+
'Collection handle (`resource.get()`), which supplies one.');
|
|
781
|
+
}
|
|
782
|
+
if (id === undefined) {
|
|
783
|
+
throw new EncryptionError('Cannot read this resource: the stored chunked document binds no ' +
|
|
784
|
+
'`was.resource` id, so its chunk resources cannot be addressed. ' +
|
|
785
|
+
"Only the envelope's AEAD-bound id may address them -- the cleartext " +
|
|
786
|
+
'id on the envelope is server-controlled and could point the read at ' +
|
|
787
|
+
"another document's chunks.");
|
|
788
|
+
}
|
|
789
|
+
await this.#assertChunkedStreams(context, 'Reading a large blob');
|
|
790
|
+
const keyAgreementKey = this.#readKeys.find(key => key.id === keyId);
|
|
791
|
+
const stream = (await this.#edv.getStream({
|
|
792
|
+
doc: { id, stream: { chunks } },
|
|
793
|
+
keyAgreementKey,
|
|
794
|
+
transport: this.#transportFor(context)
|
|
795
|
+
}));
|
|
796
|
+
const contentType = typeof meta?.contentType === 'string' ? meta.contentType : undefined;
|
|
797
|
+
return streamToBlob({
|
|
798
|
+
stream,
|
|
799
|
+
...(contentType !== undefined && { type: contentType })
|
|
800
|
+
});
|
|
801
|
+
}
|
|
483
802
|
/**
|
|
484
803
|
* Opens a stored envelope: asserts it IS an EDV envelope, decrypts it with
|
|
485
804
|
* whichever read key its JWE recipient names, and only then verifies the
|
|
@@ -495,19 +814,20 @@ export class EdvCodec {
|
|
|
495
814
|
* to a resource id is refused there, and one bound to this Collection's id
|
|
496
815
|
* is required (see {@link _verifyBinding}). Set only by the
|
|
497
816
|
* Collection-level metadata read
|
|
498
|
-
* @returns {Promise<
|
|
499
|
-
* keyId
|
|
817
|
+
* @returns {Promise<object>} the decrypted document (`content`, `meta`, the
|
|
818
|
+
* AEAD-authenticated `stream` state where one was sealed, `keyId`, and the
|
|
819
|
+
* AEAD-bound `resourceId` the envelope declares, where it binds one)
|
|
500
820
|
*/
|
|
501
821
|
async #openEnvelope({ doc, expectedId, collectionSlot }) {
|
|
502
822
|
this.#assertEnvelope(doc, 'read');
|
|
503
823
|
const decrypted = await this.#decrypt(doc);
|
|
504
|
-
await this.#verifyBinding({
|
|
824
|
+
const resourceId = await this.#verifyBinding({
|
|
505
825
|
jwe: doc.jwe,
|
|
506
826
|
expectedId,
|
|
507
827
|
collectionSlot,
|
|
508
828
|
keyId: decrypted.keyId
|
|
509
829
|
});
|
|
510
|
-
return decrypted;
|
|
830
|
+
return { ...decrypted, ...(resourceId !== undefined && { resourceId }) };
|
|
511
831
|
}
|
|
512
832
|
/**
|
|
513
833
|
* Decrypts a stored EDV envelope, selecting which read key to use by matching
|
|
@@ -516,34 +836,53 @@ export class EdvCodec {
|
|
|
516
836
|
* selects that epoch's key, so history stays readable.
|
|
517
837
|
*
|
|
518
838
|
* A stored envelope naming only recipients this reader holds no candidate
|
|
519
|
-
* key for fails fast
|
|
520
|
-
*
|
|
839
|
+
* key for fails fast, and which error it raises depends on whether the
|
|
840
|
+
* descriptor lists the named epoch. An epoch the descriptor lists but wraps
|
|
841
|
+
* only to other recipients raises {@link KeyUnwrapError}: this reader is
|
|
842
|
+
* not a recipient of that epoch (it never was, or it was removed and the
|
|
843
|
+
* epoch rotated), so re-reading the descriptor cannot help. That is the
|
|
844
|
+
* read axis only; it says nothing about whether the server will still
|
|
845
|
+
* serve (pull) the ciphertext. An epoch the descriptor does not list at
|
|
846
|
+
* all raises {@link UnknownEpochError} -- the signal that the cached
|
|
847
|
+
* Collection Description may be stale (an epoch rotation emits no
|
|
521
848
|
* change-feed entry) and the codec must be rebuilt from a re-read
|
|
522
|
-
* descriptor. A candidate whose entry then fails to unwrap surfaces
|
|
523
|
-
* {@link KeyUnwrapError}
|
|
524
|
-
* whether the server will still serve (pull) the ciphertext.
|
|
849
|
+
* descriptor. A candidate whose entry then fails to unwrap also surfaces
|
|
850
|
+
* {@link KeyUnwrapError}.
|
|
525
851
|
*
|
|
526
852
|
* Also returns the `id` of the key that actually decrypted the envelope (its
|
|
527
853
|
* JWE recipient `kid`), so {@link _verifyBinding} can check a `was.epoch`
|
|
528
854
|
* binding against the epoch of the decrypting key.
|
|
529
855
|
*
|
|
530
856
|
* @param encryptedDoc {IEncryptedDocument}
|
|
531
|
-
* @returns {Promise<
|
|
532
|
-
* keyId: string }>}
|
|
857
|
+
* @returns {Promise<object>} the decrypted document plus `keyId`
|
|
533
858
|
*/
|
|
534
859
|
async #decrypt(encryptedDoc) {
|
|
535
860
|
const kids = envelopeRecipientKids(encryptedDoc);
|
|
536
861
|
const kidSet = new Set(kids);
|
|
537
862
|
// Prefer the read key whose id names a recipient of this envelope; for a
|
|
538
863
|
// well-formed envelope the exact match always hits. A non-empty recipient
|
|
539
|
-
// set that matches NO candidate is unroutable
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
864
|
+
// set that matches NO candidate is unroutable, so fail fast rather than
|
|
865
|
+
// burning ECDH attempts that cannot succeed. Which failure it is depends
|
|
866
|
+
// on whether the descriptor lists the named epoch: an epoch key's kid is
|
|
867
|
+
// `<epoch did:key>#<fingerprint>`, so the portion before the fragment
|
|
868
|
+
// names the epoch. Listed but wrapped only to others: this reader is not
|
|
869
|
+
// a recipient of that epoch (the membership signal, KeyUnwrapError). Not
|
|
870
|
+
// listed at all: the descriptor has never seen the epoch (the
|
|
871
|
+
// stale-descriptor signal, UnknownEpochError). The `rest` fallback below
|
|
872
|
+
// is then reached only for a malformed envelope naming no recipient kid
|
|
873
|
+
// at all, letting a candidate surface the cipher's own typed decrypt
|
|
874
|
+
// error.
|
|
545
875
|
const preferred = this.#readKeys.filter(key => kidSet.has(key.id));
|
|
546
876
|
if (preferred.length === 0 && kids.length > 0) {
|
|
877
|
+
const listed = kids.some(kid => this.#epochIds.has(kid.split('#')[0] ?? kid));
|
|
878
|
+
if (listed) {
|
|
879
|
+
throw new KeyUnwrapError('Cannot decrypt this resource: it was encrypted under a key epoch ' +
|
|
880
|
+
'this reader holds no key for. The epoch is on the Collection ' +
|
|
881
|
+
'Description, but none of its recipient entries name this reader ' +
|
|
882
|
+
'(it was never a recipient of that epoch, or it was removed and ' +
|
|
883
|
+
'the epoch rotated). This is the read axis only -- the server ' +
|
|
884
|
+
'may still serve the ciphertext (a separate zcap decision).');
|
|
885
|
+
}
|
|
547
886
|
throw new UnknownEpochError({ collectionId: this.#collectionId, kids });
|
|
548
887
|
}
|
|
549
888
|
const rest = this.#readKeys.filter(key => !kidSet.has(key.id));
|
|
@@ -635,7 +974,13 @@ export class EdvCodec {
|
|
|
635
974
|
* Collection metadata slot
|
|
636
975
|
* @param options.keyId {string} the id of the key that decrypted, for the
|
|
637
976
|
* epoch check
|
|
638
|
-
* @returns {Promise<
|
|
977
|
+
* @returns {Promise<string | undefined>} the verified `was.resource` id the
|
|
978
|
+
* envelope binds, or `undefined` where it binds none (a content-derived
|
|
979
|
+
* content envelope, or the Collection metadata slot). It is the only
|
|
980
|
+
* trustworthy resource id on a stored document -- the envelope's top-level
|
|
981
|
+
* `id` is cleartext and server-controlled -- so a read that addresses
|
|
982
|
+
* anything under the document's path (the chunked-stream path) must use
|
|
983
|
+
* this one.
|
|
639
984
|
*/
|
|
640
985
|
async #verifyBinding({ jwe, expectedId, collectionSlot, keyId }) {
|
|
641
986
|
const was = parseWasHeader(jwe);
|
|
@@ -720,6 +1065,7 @@ export class EdvCodec {
|
|
|
720
1065
|
`"${decryptedEpoch}". The server replayed it under a different ` +
|
|
721
1066
|
'epoch.');
|
|
722
1067
|
}
|
|
1068
|
+
return typeof was.resource === 'string' ? was.resource : undefined;
|
|
723
1069
|
}
|
|
724
1070
|
/**
|
|
725
1071
|
* @inheritdoc
|
|
@@ -850,6 +1196,13 @@ export class EdvCodec {
|
|
|
850
1196
|
* 3. Binary (any other `Blob`/`Uint8Array`) to `content = { bytes: base64 }`,
|
|
851
1197
|
* `meta = { contentType, encoding: 'base64' }`.
|
|
852
1198
|
*
|
|
1199
|
+
* A binary payload over {@link #maxBlobBytes} is not an inline document at
|
|
1200
|
+
* all: it answers `kind: 'chunked'`, carrying a byte stream (plus its size,
|
|
1201
|
+
* for messages) and the `meta` the chunked-stream path seals, and the caller
|
|
1202
|
+
* routes the write there. The routing decision is made on the payload's size
|
|
1203
|
+
* alone, so a `Blob` over the threshold is never buffered here: it is handed
|
|
1204
|
+
* on as `blob.stream()`, and the EDV core re-chunks it as it reads.
|
|
1205
|
+
*
|
|
853
1206
|
* A bare primitive is rejected (mirroring the plaintext `prepareBody`
|
|
854
1207
|
* contract). The binary/text detection and content-type precedence are the
|
|
855
1208
|
* shared `resolvePayload` rules, so the plaintext and encrypted write paths
|
|
@@ -858,25 +1211,36 @@ export class EdvCodec {
|
|
|
858
1211
|
* @param data {ResourceData}
|
|
859
1212
|
* @param [contentType] {string} caller-supplied content type
|
|
860
1213
|
* @param [id] {string} resource id, for the extension guess
|
|
861
|
-
* @returns {Promise<{ content
|
|
862
|
-
*
|
|
1214
|
+
* @returns {Promise<object>} the inline document `{ content, meta }`, or
|
|
1215
|
+
* the `{ stream, size, meta }` of a payload to route to the chunked-stream
|
|
1216
|
+
* path
|
|
863
1217
|
*/
|
|
864
1218
|
async #toDocument(data, contentType, id) {
|
|
865
1219
|
const payload = resolvePayload({ data, contentType, id });
|
|
866
1220
|
if (payload.kind === 'binary') {
|
|
1221
|
+
const resolvedType = payload.contentType;
|
|
1222
|
+
// Route on the size alone (`Blob.size` is synchronous), so an
|
|
1223
|
+
// over-threshold blob is never read into memory here just to measure it.
|
|
1224
|
+
const size = isBlob(payload.data)
|
|
1225
|
+
? payload.data.size
|
|
1226
|
+
: payload.data.length;
|
|
1227
|
+
if (size > this.#maxBlobBytes) {
|
|
1228
|
+
// Too large for one document: route it to the chunked-stream path,
|
|
1229
|
+
// where the bytes live in the document's own chunk resources. Hand it
|
|
1230
|
+
// over as a stream -- a `Blob` streams itself, and bytes already in
|
|
1231
|
+
// hand become a one-value stream the same way -- so the payload is not
|
|
1232
|
+
// held twice while the EDV core re-chunks it.
|
|
1233
|
+
return {
|
|
1234
|
+
kind: 'chunked',
|
|
1235
|
+
stream: bytesToStream(payload.data),
|
|
1236
|
+
size,
|
|
1237
|
+
meta: { contentType: resolvedType, encoding: CHUNKED_ENCODING }
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
// Under the threshold the bytes are sealed inline, so buffer them now.
|
|
867
1241
|
const bytes = isBlob(payload.data)
|
|
868
1242
|
? new Uint8Array(await payload.data.arrayBuffer())
|
|
869
1243
|
: payload.data;
|
|
870
|
-
const resolvedType = payload.contentType;
|
|
871
|
-
if (bytes.length > this.#maxBlobBytes) {
|
|
872
|
-
throw new ValidationError(`Encrypted binary write of ${bytes.length} bytes exceeds the ` +
|
|
873
|
-
`single-document limit of ${this.#maxBlobBytes} bytes. The codec ` +
|
|
874
|
-
'seam is a single-request transform and cannot chunk. To store a ' +
|
|
875
|
-
'blob this large, write it through the chunked-stream path -- ' +
|
|
876
|
-
'`EdvClientCore.insert({ doc, stream, transport })` with a ' +
|
|
877
|
-
'`WasTransport`, read back with `getStream` -- against a server ' +
|
|
878
|
-
"whose backend advertises the 'chunked-streams' feature.");
|
|
879
|
-
}
|
|
880
1244
|
// Text-family AND valid UTF-8 to store as a legible string. The UTF-8 gate
|
|
881
1245
|
// guarantees the bytes survive the string round-trip exactly; anything
|
|
882
1246
|
// else falls through to base64, which is always byte-safe.
|
|
@@ -884,12 +1248,14 @@ export class EdvCodec {
|
|
|
884
1248
|
const text = decodeUtf8(bytes);
|
|
885
1249
|
if (text !== null) {
|
|
886
1250
|
return {
|
|
1251
|
+
kind: 'inline',
|
|
887
1252
|
content: { text },
|
|
888
1253
|
meta: { contentType: resolvedType, encoding: 'utf-8' }
|
|
889
1254
|
};
|
|
890
1255
|
}
|
|
891
1256
|
}
|
|
892
1257
|
return {
|
|
1258
|
+
kind: 'inline',
|
|
893
1259
|
content: { bytes: base64.encode(bytes) },
|
|
894
1260
|
meta: { contentType: resolvedType, encoding: 'base64' }
|
|
895
1261
|
};
|
|
@@ -899,6 +1265,7 @@ export class EdvCodec {
|
|
|
899
1265
|
// an absent `meta.encoding` as JSON). EDV models `content` as an object
|
|
900
1266
|
// record; a JSON array is also a valid encrypted value here, so widen it.
|
|
901
1267
|
return {
|
|
1268
|
+
kind: 'inline',
|
|
902
1269
|
content: data,
|
|
903
1270
|
meta: { contentType: contentType ?? 'application/json' }
|
|
904
1271
|
};
|
|
@@ -948,6 +1315,43 @@ export class EdvCodec {
|
|
|
948
1315
|
return content;
|
|
949
1316
|
}
|
|
950
1317
|
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Presents a binary payload as the `ReadableStream`
|
|
1320
|
+
* `EdvClientCore.insert({ stream })` consumes. A `Blob` streams itself; bytes
|
|
1321
|
+
* already in hand are wrapped in a `Blob` and stream the same way. The encrypt
|
|
1322
|
+
* stream re-chunks whatever it is fed at its own `chunkSize`, so the shape of
|
|
1323
|
+
* the source stream does not affect the stored chunks.
|
|
1324
|
+
*
|
|
1325
|
+
* @param data {Blob | Uint8Array}
|
|
1326
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
1327
|
+
*/
|
|
1328
|
+
function bytesToStream(data) {
|
|
1329
|
+
const blob = isBlob(data) ? data : new Blob([data]);
|
|
1330
|
+
return blob.stream();
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Drains a byte stream into one `Blob` of the given type. `Blob` does the
|
|
1334
|
+
* concatenation: its parts are the chunks the stream yielded, in order.
|
|
1335
|
+
*
|
|
1336
|
+
* @param options {object}
|
|
1337
|
+
* @param options.stream {ReadableStream<Uint8Array>}
|
|
1338
|
+
* @param [options.type] {string} the blob's content type
|
|
1339
|
+
* @returns {Promise<Blob>}
|
|
1340
|
+
*/
|
|
1341
|
+
async function streamToBlob({ stream, type }) {
|
|
1342
|
+
const reader = stream.getReader();
|
|
1343
|
+
const parts = [];
|
|
1344
|
+
for (;;) {
|
|
1345
|
+
const { value, done } = await reader.read();
|
|
1346
|
+
if (done) {
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
if (value !== undefined) {
|
|
1350
|
+
parts.push(value);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
return new Blob(parts, type !== undefined ? { type } : undefined);
|
|
1354
|
+
}
|
|
951
1355
|
/**
|
|
952
1356
|
* Decodes bytes as strict UTF-8, returning `null` when they are not valid UTF-8
|
|
953
1357
|
* (so the caller can fall back to base64). Uses the shared fatal decoder.
|
|
@@ -1017,10 +1421,19 @@ const EDV_SCHEME = 'edv';
|
|
|
1017
1421
|
* defaults to `application/json`. Pass `JOSE_CONTENT_TYPE`
|
|
1018
1422
|
* (`application/jose+json`) against a server that registers an
|
|
1019
1423
|
* `application/*+json` parser.
|
|
1020
|
-
* @param [options.maxBlobBytes] {number}
|
|
1021
|
-
*
|
|
1424
|
+
* @param [options.maxBlobBytes] {number} the size in raw bytes above which a
|
|
1425
|
+
* binary `add()` is routed to the chunked-stream path instead of one document
|
|
1426
|
+
* (default 512 KiB, sized so a single-document envelope stays under a
|
|
1022
1427
|
* server's ~1 MiB JSON body cap; raise it against a server with a larger
|
|
1023
|
-
* limit)
|
|
1428
|
+
* limit). A routing threshold, not a hard cap.
|
|
1429
|
+
* @param [options.chunkSize] {number} the size of each encrypted chunk a
|
|
1430
|
+
* routed write emits, in bytes (default 1 MiB). Each chunk is one upload, so
|
|
1431
|
+
* it must stay under the backend's `maxUploadBytes` constraint (the
|
|
1432
|
+
* encrypted chunk is somewhat larger than `chunkSize`, so leave headroom).
|
|
1433
|
+
* This is not checked client-side: the shared backend probe reads the
|
|
1434
|
+
* descriptor's affordance tokens, not its `constraints`, so a chunk over the
|
|
1435
|
+
* limit is rejected by the server with a `PayloadTooLargeError` (413) and
|
|
1436
|
+
* the failed write's document stub is then cleaned up.
|
|
1024
1437
|
* @param [options.idDerivation] {string} how `add()` mints a document id.
|
|
1025
1438
|
* `'random'` (default) is the classic mutable-document model: a random
|
|
1026
1439
|
* `generateId()` id, updated in place via `sequence`. `'content'` derives the
|
|
@@ -1032,7 +1445,7 @@ const EDV_SCHEME = 'edv';
|
|
|
1032
1445
|
* `put(id, ...)` path is unaffected either way.
|
|
1033
1446
|
* @returns {EncryptionProvider}
|
|
1034
1447
|
*/
|
|
1035
|
-
export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT_TYPE, maxBlobBytes = DEFAULT_MAX_BLOB_BYTES, idDerivation = 'random' }) {
|
|
1448
|
+
export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT_TYPE, maxBlobBytes = DEFAULT_MAX_BLOB_BYTES, chunkSize, idDerivation = 'random' }) {
|
|
1036
1449
|
return {
|
|
1037
1450
|
async codecFor({ spaceId, collectionId, scheme, encryption, keys }) {
|
|
1038
1451
|
if (scheme !== EDV_SCHEME) {
|
|
@@ -1102,9 +1515,15 @@ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT
|
|
|
1102
1515
|
writeEpoch: epochKeys.writeEpoch,
|
|
1103
1516
|
contentType,
|
|
1104
1517
|
maxBlobBytes,
|
|
1518
|
+
...(chunkSize !== undefined && { chunkSize }),
|
|
1105
1519
|
idDerivation,
|
|
1106
1520
|
version: descriptorVersion ?? EDV_SCHEME_VERSION,
|
|
1107
|
-
|
|
1521
|
+
spaceId,
|
|
1522
|
+
collectionId,
|
|
1523
|
+
// Every epoch the descriptor lists, recipient of it or not, so
|
|
1524
|
+
// decrypt routing can tell "not a recipient of this epoch" apart
|
|
1525
|
+
// from "descriptor has never seen this epoch".
|
|
1526
|
+
epochIds: encryption.epochs.map(epoch => epoch.id)
|
|
1108
1527
|
});
|
|
1109
1528
|
}
|
|
1110
1529
|
};
|