@interop/was-client 0.35.1 → 0.37.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 +29 -9
- package/dist/edv/EdvCodec.d.ts.map +1 -1
- package/dist/edv/EdvCodec.js +421 -40
- 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 +55 -4
- package/dist/edv/docCipher.d.ts.map +1 -1
- package/dist/edv/docCipher.js +51 -5
- package/dist/edv/docCipher.js.map +1 -1
- package/dist/edv/epochRoster.d.ts +53 -0
- package/dist/edv/epochRoster.d.ts.map +1 -0
- package/dist/edv/epochRoster.js +49 -0
- package/dist/edv/epochRoster.js.map +1 -0
- package/dist/edv/index.d.ts +6 -1
- package/dist/edv/index.d.ts.map +1 -1
- package/dist/edv/index.js +5 -0
- package/dist/edv/index.js.map +1 -1
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +11 -0
- 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/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
|
|
@@ -262,12 +296,20 @@ export class EdvCodec {
|
|
|
262
296
|
* into every envelope's `was.epoch`, which the decode side checks against
|
|
263
297
|
* the decrypting key's epoch unconditionally.
|
|
264
298
|
* @param options.contentType {string} stored envelope content type
|
|
265
|
-
* @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`)
|
|
266
306
|
* @param options.idDerivation {string} how `add()` mints a document
|
|
267
307
|
* id: `'random'` (classic `generateId()`) or `'content'` (derived from the
|
|
268
308
|
* JWE ciphertext, content-addressed)
|
|
269
309
|
* @param [options.version] {number} the EDV-over-WAS scheme version to bind
|
|
270
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
|
|
271
313
|
* @param options.collectionId {string} the Collection this codec reads and
|
|
272
314
|
* writes: bound into the Collection metadata envelope's `was.collection`
|
|
273
315
|
* (and checked on read), and it labels decrypt-routing errors
|
|
@@ -277,7 +319,7 @@ export class EdvCodec {
|
|
|
277
319
|
* @param [options.hmac] {BlindingKey} the collection's blinded-index key,
|
|
278
320
|
* where it declares one
|
|
279
321
|
*/
|
|
280
|
-
constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation, version, collectionId, epochIds, hmac }) {
|
|
322
|
+
constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, chunkSize, idDerivation, version, spaceId, collectionId, epochIds, hmac }) {
|
|
281
323
|
this.#edv = edv;
|
|
282
324
|
this.#recipients =
|
|
283
325
|
edv.documentCipher.createDefaultRecipients(keyAgreementKey);
|
|
@@ -285,8 +327,10 @@ export class EdvCodec {
|
|
|
285
327
|
this.#writeEpoch = writeEpoch;
|
|
286
328
|
this.#contentType = contentType;
|
|
287
329
|
this.#maxBlobBytes = maxBlobBytes;
|
|
330
|
+
this.#chunkSize = chunkSize;
|
|
288
331
|
this.#idDerivation = idDerivation;
|
|
289
332
|
this.#version = version ?? EDV_SCHEME_VERSION;
|
|
333
|
+
this.#spaceId = spaceId;
|
|
290
334
|
this.#collectionId = collectionId;
|
|
291
335
|
this.#epochIds = new Set(epochIds);
|
|
292
336
|
this.#blindingKey = hmac ?? null;
|
|
@@ -393,7 +437,25 @@ export class EdvCodec {
|
|
|
393
437
|
(this.#idDerivation === 'content'
|
|
394
438
|
? undefined
|
|
395
439
|
: (await this.#edv.generateId()));
|
|
396
|
-
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;
|
|
397
459
|
// When the write path pre-read a current envelope, advance `sequence` from
|
|
398
460
|
// its prior value (`encrypt({ update: true })` increments it) and pin the
|
|
399
461
|
// write to the server's current ETag with `If-Match`. With no prior envelope
|
|
@@ -472,6 +534,171 @@ export class EdvCodec {
|
|
|
472
534
|
epoch: this.#writeEpoch
|
|
473
535
|
};
|
|
474
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
|
+
}
|
|
475
702
|
/**
|
|
476
703
|
* The blinding key a content write should index with: the collection's key
|
|
477
704
|
* once the applied schema declares at least one attribute, else `undefined`
|
|
@@ -489,11 +716,89 @@ export class EdvCodec {
|
|
|
489
716
|
/**
|
|
490
717
|
* @inheritdoc
|
|
491
718
|
*/
|
|
492
|
-
async decode(response, expectedId) {
|
|
719
|
+
async decode(response, expectedId, context) {
|
|
493
720
|
const stored = await readJsonData(response);
|
|
494
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
|
+
}
|
|
495
747
|
return this.#fromDocument(decrypted.content, decrypted.meta);
|
|
496
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
|
+
}
|
|
497
802
|
/**
|
|
498
803
|
* Opens a stored envelope: asserts it IS an EDV envelope, decrypts it with
|
|
499
804
|
* whichever read key its JWE recipient names, and only then verifies the
|
|
@@ -509,19 +814,20 @@ export class EdvCodec {
|
|
|
509
814
|
* to a resource id is refused there, and one bound to this Collection's id
|
|
510
815
|
* is required (see {@link _verifyBinding}). Set only by the
|
|
511
816
|
* Collection-level metadata read
|
|
512
|
-
* @returns {Promise<
|
|
513
|
-
* 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)
|
|
514
820
|
*/
|
|
515
821
|
async #openEnvelope({ doc, expectedId, collectionSlot }) {
|
|
516
822
|
this.#assertEnvelope(doc, 'read');
|
|
517
823
|
const decrypted = await this.#decrypt(doc);
|
|
518
|
-
await this.#verifyBinding({
|
|
824
|
+
const resourceId = await this.#verifyBinding({
|
|
519
825
|
jwe: doc.jwe,
|
|
520
826
|
expectedId,
|
|
521
827
|
collectionSlot,
|
|
522
828
|
keyId: decrypted.keyId
|
|
523
829
|
});
|
|
524
|
-
return decrypted;
|
|
830
|
+
return { ...decrypted, ...(resourceId !== undefined && { resourceId }) };
|
|
525
831
|
}
|
|
526
832
|
/**
|
|
527
833
|
* Decrypts a stored EDV envelope, selecting which read key to use by matching
|
|
@@ -548,8 +854,7 @@ export class EdvCodec {
|
|
|
548
854
|
* binding against the epoch of the decrypting key.
|
|
549
855
|
*
|
|
550
856
|
* @param encryptedDoc {IEncryptedDocument}
|
|
551
|
-
* @returns {Promise<
|
|
552
|
-
* keyId: string }>}
|
|
857
|
+
* @returns {Promise<object>} the decrypted document plus `keyId`
|
|
553
858
|
*/
|
|
554
859
|
async #decrypt(encryptedDoc) {
|
|
555
860
|
const kids = envelopeRecipientKids(encryptedDoc);
|
|
@@ -669,7 +974,13 @@ export class EdvCodec {
|
|
|
669
974
|
* Collection metadata slot
|
|
670
975
|
* @param options.keyId {string} the id of the key that decrypted, for the
|
|
671
976
|
* epoch check
|
|
672
|
-
* @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.
|
|
673
984
|
*/
|
|
674
985
|
async #verifyBinding({ jwe, expectedId, collectionSlot, keyId }) {
|
|
675
986
|
const was = parseWasHeader(jwe);
|
|
@@ -754,6 +1065,7 @@ export class EdvCodec {
|
|
|
754
1065
|
`"${decryptedEpoch}". The server replayed it under a different ` +
|
|
755
1066
|
'epoch.');
|
|
756
1067
|
}
|
|
1068
|
+
return typeof was.resource === 'string' ? was.resource : undefined;
|
|
757
1069
|
}
|
|
758
1070
|
/**
|
|
759
1071
|
* @inheritdoc
|
|
@@ -884,6 +1196,13 @@ export class EdvCodec {
|
|
|
884
1196
|
* 3. Binary (any other `Blob`/`Uint8Array`) to `content = { bytes: base64 }`,
|
|
885
1197
|
* `meta = { contentType, encoding: 'base64' }`.
|
|
886
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
|
+
*
|
|
887
1206
|
* A bare primitive is rejected (mirroring the plaintext `prepareBody`
|
|
888
1207
|
* contract). The binary/text detection and content-type precedence are the
|
|
889
1208
|
* shared `resolvePayload` rules, so the plaintext and encrypted write paths
|
|
@@ -892,25 +1211,36 @@ export class EdvCodec {
|
|
|
892
1211
|
* @param data {ResourceData}
|
|
893
1212
|
* @param [contentType] {string} caller-supplied content type
|
|
894
1213
|
* @param [id] {string} resource id, for the extension guess
|
|
895
|
-
* @returns {Promise<{ content
|
|
896
|
-
*
|
|
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
|
|
897
1217
|
*/
|
|
898
1218
|
async #toDocument(data, contentType, id) {
|
|
899
1219
|
const payload = resolvePayload({ data, contentType, id });
|
|
900
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.
|
|
901
1241
|
const bytes = isBlob(payload.data)
|
|
902
1242
|
? new Uint8Array(await payload.data.arrayBuffer())
|
|
903
1243
|
: payload.data;
|
|
904
|
-
const resolvedType = payload.contentType;
|
|
905
|
-
if (bytes.length > this.#maxBlobBytes) {
|
|
906
|
-
throw new ValidationError(`Encrypted binary write of ${bytes.length} bytes exceeds the ` +
|
|
907
|
-
`single-document limit of ${this.#maxBlobBytes} bytes. The codec ` +
|
|
908
|
-
'seam is a single-request transform and cannot chunk. To store a ' +
|
|
909
|
-
'blob this large, write it through the chunked-stream path -- ' +
|
|
910
|
-
'`EdvClientCore.insert({ doc, stream, transport })` with a ' +
|
|
911
|
-
'`WasTransport`, read back with `getStream` -- against a server ' +
|
|
912
|
-
"whose backend advertises the 'chunked-streams' feature.");
|
|
913
|
-
}
|
|
914
1244
|
// Text-family AND valid UTF-8 to store as a legible string. The UTF-8 gate
|
|
915
1245
|
// guarantees the bytes survive the string round-trip exactly; anything
|
|
916
1246
|
// else falls through to base64, which is always byte-safe.
|
|
@@ -918,12 +1248,14 @@ export class EdvCodec {
|
|
|
918
1248
|
const text = decodeUtf8(bytes);
|
|
919
1249
|
if (text !== null) {
|
|
920
1250
|
return {
|
|
1251
|
+
kind: 'inline',
|
|
921
1252
|
content: { text },
|
|
922
1253
|
meta: { contentType: resolvedType, encoding: 'utf-8' }
|
|
923
1254
|
};
|
|
924
1255
|
}
|
|
925
1256
|
}
|
|
926
1257
|
return {
|
|
1258
|
+
kind: 'inline',
|
|
927
1259
|
content: { bytes: base64.encode(bytes) },
|
|
928
1260
|
meta: { contentType: resolvedType, encoding: 'base64' }
|
|
929
1261
|
};
|
|
@@ -933,6 +1265,7 @@ export class EdvCodec {
|
|
|
933
1265
|
// an absent `meta.encoding` as JSON). EDV models `content` as an object
|
|
934
1266
|
// record; a JSON array is also a valid encrypted value here, so widen it.
|
|
935
1267
|
return {
|
|
1268
|
+
kind: 'inline',
|
|
936
1269
|
content: data,
|
|
937
1270
|
meta: { contentType: contentType ?? 'application/json' }
|
|
938
1271
|
};
|
|
@@ -982,6 +1315,43 @@ export class EdvCodec {
|
|
|
982
1315
|
return content;
|
|
983
1316
|
}
|
|
984
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
|
+
}
|
|
985
1355
|
/**
|
|
986
1356
|
* Decodes bytes as strict UTF-8, returning `null` when they are not valid UTF-8
|
|
987
1357
|
* (so the caller can fall back to base64). Uses the shared fatal decoder.
|
|
@@ -1051,10 +1421,19 @@ const EDV_SCHEME = 'edv';
|
|
|
1051
1421
|
* defaults to `application/json`. Pass `JOSE_CONTENT_TYPE`
|
|
1052
1422
|
* (`application/jose+json`) against a server that registers an
|
|
1053
1423
|
* `application/*+json` parser.
|
|
1054
|
-
* @param [options.maxBlobBytes] {number}
|
|
1055
|
-
*
|
|
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
|
|
1056
1427
|
* server's ~1 MiB JSON body cap; raise it against a server with a larger
|
|
1057
|
-
* 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.
|
|
1058
1437
|
* @param [options.idDerivation] {string} how `add()` mints a document id.
|
|
1059
1438
|
* `'random'` (default) is the classic mutable-document model: a random
|
|
1060
1439
|
* `generateId()` id, updated in place via `sequence`. `'content'` derives the
|
|
@@ -1066,7 +1445,7 @@ const EDV_SCHEME = 'edv';
|
|
|
1066
1445
|
* `put(id, ...)` path is unaffected either way.
|
|
1067
1446
|
* @returns {EncryptionProvider}
|
|
1068
1447
|
*/
|
|
1069
|
-
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' }) {
|
|
1070
1449
|
return {
|
|
1071
1450
|
async codecFor({ spaceId, collectionId, scheme, encryption, keys }) {
|
|
1072
1451
|
if (scheme !== EDV_SCHEME) {
|
|
@@ -1136,8 +1515,10 @@ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT
|
|
|
1136
1515
|
writeEpoch: epochKeys.writeEpoch,
|
|
1137
1516
|
contentType,
|
|
1138
1517
|
maxBlobBytes,
|
|
1518
|
+
...(chunkSize !== undefined && { chunkSize }),
|
|
1139
1519
|
idDerivation,
|
|
1140
1520
|
version: descriptorVersion ?? EDV_SCHEME_VERSION,
|
|
1521
|
+
spaceId,
|
|
1141
1522
|
collectionId,
|
|
1142
1523
|
// Every epoch the descriptor lists, recipient of it or not, so
|
|
1143
1524
|
// decrypt routing can tell "not a recipient of this epoch" apart
|