@interop/was-client 0.41.0 → 0.43.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/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +27 -83
- package/dist/Collection.js.map +1 -1
- package/dist/Resource.d.ts +10 -7
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +32 -99
- package/dist/Resource.js.map +1 -1
- package/dist/codec.d.ts +18 -4
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js.map +1 -1
- package/dist/edv/EdvCodec.d.ts +77 -8
- package/dist/edv/EdvCodec.d.ts.map +1 -1
- package/dist/edv/EdvCodec.js +361 -224
- package/dist/edv/EdvCodec.js.map +1 -1
- package/dist/edv/WasTransport.js +2 -2
- package/dist/edv/WasTransport.js.map +1 -1
- package/dist/edv/constants.d.ts +0 -8
- package/dist/edv/constants.d.ts.map +1 -1
- package/dist/edv/constants.js +0 -8
- package/dist/edv/constants.js.map +1 -1
- package/dist/edv/descriptorStore.d.ts +10 -6
- package/dist/edv/descriptorStore.d.ts.map +1 -1
- package/dist/edv/descriptorStore.js +5 -3
- package/dist/edv/descriptorStore.js.map +1 -1
- package/dist/edv/didKeyRecipient.d.ts.map +1 -1
- package/dist/edv/didKeyRecipient.js +1 -1
- package/dist/edv/didKeyRecipient.js.map +1 -1
- package/dist/edv/docCipher.d.ts.map +1 -1
- package/dist/edv/docCipher.js +22 -38
- package/dist/edv/docCipher.js.map +1 -1
- package/dist/edv/epochCrypto.d.ts +7 -0
- package/dist/edv/epochCrypto.d.ts.map +1 -1
- package/dist/edv/epochCrypto.js +12 -5
- package/dist/edv/epochCrypto.js.map +1 -1
- package/dist/edv/epochKeys.d.ts.map +1 -1
- package/dist/edv/epochKeys.js +21 -37
- package/dist/edv/epochKeys.js.map +1 -1
- package/dist/edv/epochRoster.d.ts +24 -0
- package/dist/edv/epochRoster.d.ts.map +1 -1
- package/dist/edv/epochRoster.js +26 -0
- package/dist/edv/epochRoster.js.map +1 -1
- package/dist/edv/index.d.ts +2 -2
- package/dist/edv/index.d.ts.map +1 -1
- package/dist/edv/index.js +1 -1
- package/dist/edv/index.js.map +1 -1
- package/dist/edv/recipients.d.ts +2 -2
- package/dist/edv/recipients.d.ts.map +1 -1
- package/dist/edv/recipients.js +238 -202
- package/dist/edv/recipients.js.map +1 -1
- package/dist/internal/codec.d.ts.map +1 -1
- package/dist/internal/codec.js +5 -18
- package/dist/internal/codec.js.map +1 -1
- package/dist/internal/conditional.d.ts +33 -2
- package/dist/internal/conditional.d.ts.map +1 -1
- package/dist/internal/conditional.js +52 -0
- package/dist/internal/conditional.js.map +1 -1
- package/dist/internal/content.d.ts +7 -0
- package/dist/internal/content.d.ts.map +1 -1
- package/dist/internal/content.js +7 -0
- package/dist/internal/content.js.map +1 -1
- package/dist/internal/features.d.ts.map +1 -1
- package/dist/internal/features.js +9 -9
- package/dist/internal/features.js.map +1 -1
- package/dist/internal/indexSchema.d.ts +0 -10
- package/dist/internal/indexSchema.d.ts.map +1 -1
- package/dist/internal/indexSchema.js +1 -1
- package/dist/internal/indexSchema.js.map +1 -1
- package/dist/internal/memo.d.ts +39 -0
- package/dist/internal/memo.d.ts.map +1 -0
- package/dist/internal/memo.js +59 -0
- package/dist/internal/memo.js.map +1 -0
- package/dist/internal/meta.d.ts +92 -0
- package/dist/internal/meta.d.ts.map +1 -0
- package/dist/internal/meta.js +126 -0
- package/dist/internal/meta.js.map +1 -0
- package/dist/internal/pagination.js +1 -1
- package/dist/internal/pagination.js.map +1 -1
- package/dist/internal/withCodec.d.ts +26 -0
- package/dist/internal/withCodec.d.ts.map +1 -0
- package/dist/internal/withCodec.js +15 -0
- package/dist/internal/withCodec.js.map +1 -0
- package/dist/internal/write.d.ts +5 -8
- package/dist/internal/write.d.ts.map +1 -1
- package/dist/internal/write.js +25 -11
- package/dist/internal/write.js.map +1 -1
- package/dist/log/logStore.d.ts +6 -3
- package/dist/log/logStore.d.ts.map +1 -1
- package/dist/log/logStore.js +6 -3
- package/dist/log/logStore.js.map +1 -1
- package/dist/sync/cid.d.ts.map +1 -1
- package/dist/sync/cid.js +1 -6
- package/dist/sync/cid.js.map +1 -1
- package/dist/sync/index.d.ts +2 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +5 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/port.d.ts +0 -14
- package/dist/sync/port.d.ts.map +1 -1
- package/dist/sync/port.js +6 -19
- package/dist/sync/port.js.map +1 -1
- package/dist/sync/provisioning.d.ts.map +1 -1
- package/dist/sync/provisioning.js +23 -2
- package/dist/sync/provisioning.js.map +1 -1
- package/package.json +1 -1
package/dist/edv/EdvCodec.js
CHANGED
|
@@ -47,9 +47,13 @@
|
|
|
47
47
|
* `conditionalWrites`, so the write path pre-reads the current envelope and
|
|
48
48
|
* hands it to `encode`: an update advances `sequence` from its prior value and
|
|
49
49
|
* pins the write to the server's current ETag via `If-Match`, while a fresh
|
|
50
|
-
* insert (`sequence: 0`) is guarded by `If-None-Match: *`. A
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* insert (`sequence: 0`) is guarded by `If-None-Match: *`. A caller that
|
|
51
|
+
* named its own baseline (`put({ ifMatch })`) keeps it: the write is pinned
|
|
52
|
+
* to the revision the caller last saw rather than to the codec's pre-read, so
|
|
53
|
+
* a compare-and-swap loop works the same on an encrypted collection as on a
|
|
54
|
+
* plaintext one. A stale write surfaces as a `PreconditionFailedError` (412)
|
|
55
|
+
* -- the lost-update guard -- rather than the old advisory
|
|
56
|
+
* last-writer-wins. Against a backend that does
|
|
53
57
|
* not advertise `conditional-writes` (no ETag) an update degrades to
|
|
54
58
|
* advisory, and a create-by-put is refused by the write path (the masked-404
|
|
55
59
|
* pre-read could otherwise silently clobber; see `upsertResource`).
|
|
@@ -76,8 +80,8 @@ import { isEncryptedEnvelope } from '../sync/envelope.js';
|
|
|
76
80
|
import { resolveEpochKeys } from './epochKeys.js';
|
|
77
81
|
import { didKeyResolver, epochKeyIdFor } from './epochCrypto.js';
|
|
78
82
|
import { resolveHmacKey } from './hmacKey.js';
|
|
79
|
-
import { isBlob, isTextContentType, readJsonData, resolvePayload } from '../internal/content.js';
|
|
80
|
-
import { DEFAULT_CONTENT_TYPE, EDV_SCHEME_VERSION, envelopeBytes
|
|
83
|
+
import { DECODER, isBlob, isTextContentType, readJsonData, resolvePayload } from '../internal/content.js';
|
|
84
|
+
import { DEFAULT_CONTENT_TYPE, EDV_SCHEME_VERSION, envelopeBytes } from './constants.js';
|
|
81
85
|
/**
|
|
82
86
|
* Default threshold above which an encrypted binary write is routed to the
|
|
83
87
|
* chunked-stream path instead of being sealed into one document, measured in
|
|
@@ -99,6 +103,26 @@ const DEFAULT_MAX_BLOB_BYTES = 512 * 1024;
|
|
|
99
103
|
* decrypted document self-describing alongside `'utf-8'` and `'base64'`.
|
|
100
104
|
*/
|
|
101
105
|
const CHUNKED_ENCODING = 'chunked';
|
|
106
|
+
/**
|
|
107
|
+
* Builds the transport factory for a Collection reachable over WAS: the
|
|
108
|
+
* codec's route to its own document and chunk resources on the server.
|
|
109
|
+
*
|
|
110
|
+
* @param options {object}
|
|
111
|
+
* @param options.spaceId {string} the Space holding the Collection
|
|
112
|
+
* @param options.collectionId {string} the Collection
|
|
113
|
+
* @param options.contentType {string} stored envelope content type
|
|
114
|
+
* @returns {CodecTransportFactory}
|
|
115
|
+
*/
|
|
116
|
+
export function wasTransportFactory({ spaceId, collectionId, contentType }) {
|
|
117
|
+
return ({ context, documentHeaders }) => new WasTransport({
|
|
118
|
+
was: { request: input => context.request(input) },
|
|
119
|
+
spaceId,
|
|
120
|
+
collectionId,
|
|
121
|
+
contentType,
|
|
122
|
+
features: context.features,
|
|
123
|
+
...(documentHeaders !== undefined && { documentHeaders })
|
|
124
|
+
});
|
|
125
|
+
}
|
|
102
126
|
/**
|
|
103
127
|
* A shared strict UTF-8 decoder used to test whether a non-JSON payload is
|
|
104
128
|
* valid UTF-8 (so it can be stored legibly as text rather than base64).
|
|
@@ -109,13 +133,6 @@ const CHUNKED_ENCODING = 'chunked';
|
|
|
109
133
|
* instance is reused.
|
|
110
134
|
*/
|
|
111
135
|
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
112
|
-
/**
|
|
113
|
-
* A shared lenient UTF-8 decoder for a JWE protected header (already proven
|
|
114
|
-
* authentic by a successful decrypt). Deliberately NOT {@link UTF8_DECODER},
|
|
115
|
-
* whose `fatal: true` would turn malformed bytes into a throw rather than the
|
|
116
|
-
* documented `undefined` return.
|
|
117
|
-
*/
|
|
118
|
-
const HEADER_DECODER = new TextDecoder();
|
|
119
136
|
/**
|
|
120
137
|
* Builds the AEAD-bound `was` protected-header parameter: the scheme version
|
|
121
138
|
* and key epoch always, and then exactly one slot marker -- the resource id
|
|
@@ -242,11 +259,13 @@ export class EdvCodec {
|
|
|
242
259
|
*/
|
|
243
260
|
#version;
|
|
244
261
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
262
|
+
* Builds the `WasTransport` the chunked-stream paths drive, or `undefined`
|
|
263
|
+
* on a codec with no server behind it (the local-replica cipher builds).
|
|
264
|
+
* The factory is the codec's only way to address a resource by path, so a
|
|
265
|
+
* codec built without one cannot reach the network at all -- there is no id
|
|
266
|
+
* it could fabricate a route from.
|
|
248
267
|
*/
|
|
249
|
-
#
|
|
268
|
+
#transportFactory;
|
|
250
269
|
/**
|
|
251
270
|
* The id of the Collection this codec was built for. It is bound into the
|
|
252
271
|
* Collection metadata envelope's `was.collection` on write and required to
|
|
@@ -309,8 +328,9 @@ export class EdvCodec {
|
|
|
309
328
|
* JWE ciphertext, content-addressed)
|
|
310
329
|
* @param [options.version] {number} the EDV-over-WAS scheme version to bind
|
|
311
330
|
* into each envelope's `was.v` (defaults to {@link EDV_SCHEME_VERSION})
|
|
312
|
-
* @param options.
|
|
313
|
-
* chunked-stream path
|
|
331
|
+
* @param [options.transportFactory] {CodecTransportFactory} builds the
|
|
332
|
+
* transport the chunked-stream path drives; omitted by a build with no
|
|
333
|
+
* server behind it, which then refuses that path
|
|
314
334
|
* @param options.collectionId {string} the Collection this codec reads and
|
|
315
335
|
* writes: bound into the Collection metadata envelope's `was.collection`
|
|
316
336
|
* (and checked on read), and it labels decrypt-routing errors
|
|
@@ -320,7 +340,7 @@ export class EdvCodec {
|
|
|
320
340
|
* @param [options.hmac] {BlindingKey} the collection's blinded-index key,
|
|
321
341
|
* where it declares one
|
|
322
342
|
*/
|
|
323
|
-
constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, chunkSize, idDerivation, version,
|
|
343
|
+
constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, chunkSize, idDerivation, version, transportFactory, collectionId, epochIds, hmac }) {
|
|
324
344
|
this.#edv = edv;
|
|
325
345
|
this.#recipients =
|
|
326
346
|
edv.documentCipher.createDefaultRecipients(keyAgreementKey);
|
|
@@ -331,7 +351,7 @@ export class EdvCodec {
|
|
|
331
351
|
this.#chunkSize = chunkSize;
|
|
332
352
|
this.#idDerivation = idDerivation;
|
|
333
353
|
this.#version = version ?? EDV_SCHEME_VERSION;
|
|
334
|
-
this.#
|
|
354
|
+
this.#transportFactory = transportFactory;
|
|
335
355
|
this.#collectionId = collectionId;
|
|
336
356
|
this.#epochIds = new Set(epochIds);
|
|
337
357
|
this.#blindingKey = hmac ?? null;
|
|
@@ -382,6 +402,9 @@ export class EdvCodec {
|
|
|
382
402
|
* all -- the residual footgun the name check cannot see, e.g. a compound
|
|
383
403
|
* index queried by something other than a leading prefix of its members.
|
|
384
404
|
*
|
|
405
|
+
* Reached only through the `indexing` adapter, which exists only on a codec
|
|
406
|
+
* whose (readonly) blinding key is non-null, so the key is non-null here.
|
|
407
|
+
*
|
|
385
408
|
* @param input {object}
|
|
386
409
|
* @param [input.equals] {object | object[]}
|
|
387
410
|
* @param [input.has] {string | string[]}
|
|
@@ -389,10 +412,6 @@ export class EdvCodec {
|
|
|
389
412
|
*/
|
|
390
413
|
async #buildQuery({ equals, has }) {
|
|
391
414
|
const hmac = this.#blindingKey;
|
|
392
|
-
if (hmac === null) {
|
|
393
|
-
throw new EncryptionError(`Collection ${this.#collectionId} declares no blinded-index key, so ` +
|
|
394
|
-
'its contents cannot be searched.');
|
|
395
|
-
}
|
|
396
415
|
assertQueryAttributes({ schema: this.#schema, equals, has });
|
|
397
416
|
const query = (await this.#edv.indexHelper.buildQuery({
|
|
398
417
|
hmac,
|
|
@@ -412,7 +431,7 @@ export class EdvCodec {
|
|
|
412
431
|
/**
|
|
413
432
|
* @inheritdoc
|
|
414
433
|
*/
|
|
415
|
-
async encode({ id, data, contentType, current }) {
|
|
434
|
+
async encode({ id, data, contentType, current, precondition }) {
|
|
416
435
|
if (id !== undefined && !current) {
|
|
417
436
|
try {
|
|
418
437
|
// A full multibase decode + multihash length check (the same assertion
|
|
@@ -469,16 +488,6 @@ export class EdvCodec {
|
|
|
469
488
|
priorDoc = read;
|
|
470
489
|
}
|
|
471
490
|
const { documentCipher } = this.#edv;
|
|
472
|
-
// Bind an AEAD-authenticated `was` parameter into the JWE protected header:
|
|
473
|
-
// the scheme version, the resource id when known at encrypt time (omitted
|
|
474
|
-
// for a content-derived id, which does not exist until after encryption),
|
|
475
|
-
// and the write epoch. A server that swaps two envelopes between ids (or
|
|
476
|
-
// replays one under a rolled-back epoch) is then detected on decrypt.
|
|
477
|
-
const was = wasParam({
|
|
478
|
-
version: this.#version,
|
|
479
|
-
resource: docId,
|
|
480
|
-
epoch: this.#writeEpoch
|
|
481
|
-
});
|
|
482
491
|
const encrypted = await documentCipher.encrypt({
|
|
483
492
|
doc: {
|
|
484
493
|
...(docId !== undefined && { id: docId }),
|
|
@@ -496,15 +505,22 @@ export class EdvCodec {
|
|
|
496
505
|
// state that recomputation updates.
|
|
497
506
|
...(priorDoc?.indexed !== undefined && { indexed: priorDoc.indexed })
|
|
498
507
|
},
|
|
499
|
-
|
|
500
|
-
|
|
508
|
+
// Bind an AEAD-authenticated `was` parameter into the JWE protected
|
|
509
|
+
// header: the scheme version, the resource id when known at encrypt time
|
|
510
|
+
// (omitted for a content-derived id, which does not exist until after
|
|
511
|
+
// encryption), and the write epoch. A server that swaps two envelopes
|
|
512
|
+
// between ids (or replays one under a rolled-back epoch) is then detected
|
|
513
|
+
// on decrypt.
|
|
514
|
+
//
|
|
501
515
|
// Blind the declared attributes into the envelope's cleartext `indexed`
|
|
502
516
|
// entries, so the server can match a search without decrypting anything.
|
|
503
517
|
// Absent a blinding key or a declared attribute there is nothing to
|
|
504
518
|
// index, and the prior entries passed above are stored verbatim.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
519
|
+
...this.#sealParams({
|
|
520
|
+
...(docId !== undefined && { resource: docId }),
|
|
521
|
+
hmac: this.#writeBlindingKey()
|
|
522
|
+
}),
|
|
523
|
+
update: priorDoc !== null
|
|
508
524
|
});
|
|
509
525
|
if (docId === undefined) {
|
|
510
526
|
// Encrypt-then-stamp: the id lives in the cleartext envelope, outside the
|
|
@@ -513,30 +529,49 @@ export class EdvCodec {
|
|
|
513
529
|
docId = await documentCipher.deriveId({ jwe: encrypted.jwe });
|
|
514
530
|
encrypted.id = docId;
|
|
515
531
|
}
|
|
532
|
+
// Serialized on first read of `body` and kept for any later read, so the
|
|
533
|
+
// wire bytes are built once per write and only when someone asks for them.
|
|
534
|
+
let bytes;
|
|
516
535
|
return {
|
|
517
536
|
id: docId,
|
|
518
|
-
|
|
537
|
+
// Lazy: the HTTP write path reads `body` and pays the serialization, and
|
|
538
|
+
// a local-replica consumer reads `envelope` instead and pays nothing.
|
|
539
|
+
// Enumerable and forced by a spread or a structured clone, so a consumer
|
|
540
|
+
// that copies this object still sees the same bytes under the same key.
|
|
541
|
+
get body() {
|
|
542
|
+
bytes ??= envelopeBytes(encrypted);
|
|
543
|
+
return bytes;
|
|
544
|
+
},
|
|
519
545
|
// The same envelope in object form, so a consumer holding a local replica
|
|
520
|
-
// (the sync `DocCipher`) need not
|
|
521
|
-
// just serialized from. `body` remains the wire truth.
|
|
546
|
+
// (the sync `DocCipher`) need not force `body` and re-parse the bytes it
|
|
547
|
+
// was just serialized from. `body` remains the wire truth.
|
|
522
548
|
envelope: encrypted,
|
|
523
549
|
contentType: this.#contentType,
|
|
524
550
|
// Surface the plaintext content type (the server-opaque envelope type
|
|
525
551
|
// stays `contentType`) so `add()` reports the real resource type.
|
|
526
552
|
resourceContentType: meta.contentType,
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
553
|
+
// The caller's own compare-and-swap baseline wins when they named one:
|
|
554
|
+
// an `If-Match` derived from this codec's pre-read would pin the write to
|
|
555
|
+
// current server state rather than to the revision the caller last saw,
|
|
556
|
+
// which silently turns a lost-update guard into last-write-wins. The
|
|
557
|
+
// write path has already checked the caller's baseline against the
|
|
558
|
+
// pre-read, so the two agree by the time this runs.
|
|
559
|
+
//
|
|
560
|
+
// Otherwise pin an update to the server's current ETag and guard a fresh
|
|
561
|
+
// insert with create-if-absent. An update's `If-Match` carries a
|
|
562
|
+
// server-provided ETag, so it degrades to an advisory write against a
|
|
563
|
+
// backend without the conditional-writes feature (the ETag is absent). A
|
|
564
|
+
// fresh insert's `If-None-Match: *` needs no server-provided validator
|
|
565
|
+
// and so is emitted unconditionally by design -- it expresses the
|
|
566
|
+
// insert's intent (create-only-if-absent). A backend that does not honor
|
|
567
|
+
// it would ignore it, so the write path refuses the
|
|
568
|
+
// insert-after-null-pre-read up front on such a backend (see
|
|
569
|
+
// `upsertResource`) -- a masked-404 pre-read must not silently overwrite
|
|
570
|
+
// an existing document there.
|
|
571
|
+
...(precondition ??
|
|
572
|
+
(priorDoc
|
|
573
|
+
? { ifMatch: readEtag(current ?? null) }
|
|
574
|
+
: { ifNoneMatch: true })),
|
|
540
575
|
// Stamp the key epoch this write encrypted under (the `currentEpoch`), so
|
|
541
576
|
// the server records it and a reader can pick the epoch key.
|
|
542
577
|
epoch: this.#writeEpoch
|
|
@@ -544,9 +579,9 @@ export class EdvCodec {
|
|
|
544
579
|
}
|
|
545
580
|
/**
|
|
546
581
|
* Builds the `WasTransport` the chunked-stream paths drive, over the signed
|
|
547
|
-
* requester core supplied and this codec's
|
|
548
|
-
*
|
|
549
|
-
*
|
|
582
|
+
* requester core supplied and this codec's injected factory. The handle's
|
|
583
|
+
* memoized feature probe is passed straight through, so the transport's own
|
|
584
|
+
* affordance gates cost no extra descriptor read.
|
|
550
585
|
*
|
|
551
586
|
* @param context {CodecRequestContext}
|
|
552
587
|
* @param [documentHeaders] {Record<string, string>} extra headers for
|
|
@@ -554,14 +589,14 @@ export class EdvCodec {
|
|
|
554
589
|
* @returns {WasTransport}
|
|
555
590
|
*/
|
|
556
591
|
#transportFor(context, documentHeaders) {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
});
|
|
592
|
+
if (this.#transportFactory === undefined) {
|
|
593
|
+
throw new NotSupportedError(`Collection "${this.#collectionId}" has no server behind it: this ` +
|
|
594
|
+
'codec was built for a local replica and holds no route to address ' +
|
|
595
|
+
'a document and its chunk resources with. Chunked encrypted blobs ' +
|
|
596
|
+
'can only be read and written through a Collection handle bound to ' +
|
|
597
|
+
'a Space.');
|
|
598
|
+
}
|
|
599
|
+
return this.#transportFactory({ context, documentHeaders });
|
|
565
600
|
}
|
|
566
601
|
/**
|
|
567
602
|
* Refuses the operation unless the collection's backend advertises the
|
|
@@ -627,11 +662,6 @@ export class EdvCodec {
|
|
|
627
662
|
* @returns {ChunkedWrite}
|
|
628
663
|
*/
|
|
629
664
|
#chunkedWrite({ id, stream, meta }) {
|
|
630
|
-
const was = wasParam({
|
|
631
|
-
version: this.#version,
|
|
632
|
-
resource: id,
|
|
633
|
-
epoch: this.#writeEpoch
|
|
634
|
-
});
|
|
635
665
|
return {
|
|
636
666
|
chunked: true,
|
|
637
667
|
id,
|
|
@@ -658,10 +688,10 @@ export class EdvCodec {
|
|
|
658
688
|
...(this.#chunkSize !== undefined && {
|
|
659
689
|
chunkSize: this.#chunkSize
|
|
660
690
|
}),
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
691
|
+
...this.#sealParams({
|
|
692
|
+
resource: id,
|
|
693
|
+
hmac: this.#writeBlindingKey()
|
|
694
|
+
}),
|
|
665
695
|
transport
|
|
666
696
|
});
|
|
667
697
|
}
|
|
@@ -721,6 +751,39 @@ export class EdvCodec {
|
|
|
721
751
|
}
|
|
722
752
|
return this.#blindingKey;
|
|
723
753
|
}
|
|
754
|
+
/**
|
|
755
|
+
* The recipient/AAD wiring every write seals with: the epoch recipients, the
|
|
756
|
+
* key resolver that resolves them, the blinding key the caller decided on,
|
|
757
|
+
* and the AEAD-bound `was` protected-header parameter carrying this codec's
|
|
758
|
+
* scheme version, the write epoch, and the caller's slot marker. Spread into
|
|
759
|
+
* each `encrypt`/`insert` call so the single-document, chunked and metadata
|
|
760
|
+
* write paths cannot drift apart.
|
|
761
|
+
*
|
|
762
|
+
* @param options {object}
|
|
763
|
+
* @param [options.resource] {string} the resource id the envelope binds
|
|
764
|
+
* (absent for a content-derived id, which does not exist until after
|
|
765
|
+
* encryption)
|
|
766
|
+
* @param [options.collection] {string} the collection id the envelope binds
|
|
767
|
+
* (the Collection metadata slot only, never alongside `resource`)
|
|
768
|
+
* @param options.hmac {BlindingKey | undefined} the blinding key to index
|
|
769
|
+
* with, or `undefined` to emit no blinded entries
|
|
770
|
+
* @returns {{ recipients: IRecipientTemplate[], keyResolver: IKeyResolver,
|
|
771
|
+
* hmac: BlindingKey, additionalProtectedParams: { was: object } }}
|
|
772
|
+
*/
|
|
773
|
+
#sealParams({ resource, collection, hmac }) {
|
|
774
|
+
const was = wasParam({
|
|
775
|
+
version: this.#version,
|
|
776
|
+
...(resource !== undefined && { resource }),
|
|
777
|
+
...(collection !== undefined && { collection }),
|
|
778
|
+
epoch: this.#writeEpoch
|
|
779
|
+
});
|
|
780
|
+
return {
|
|
781
|
+
recipients: this.#recipients,
|
|
782
|
+
keyResolver: this.#edv.keyResolver,
|
|
783
|
+
hmac,
|
|
784
|
+
additionalProtectedParams: { was }
|
|
785
|
+
};
|
|
786
|
+
}
|
|
724
787
|
/**
|
|
725
788
|
* @inheritdoc
|
|
726
789
|
*/
|
|
@@ -940,39 +1003,18 @@ export class EdvCodec {
|
|
|
940
1003
|
* envelope stamps its scheme version) -- {@link EncryptionError}. Greater
|
|
941
1004
|
* than this codec's scheme version: a future-scheme envelope this client
|
|
942
1005
|
* does not implement -- {@link EncryptionError}.
|
|
943
|
-
* Then the slot markers, which declare positively which of the profile's
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
* - `was.
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
*
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
* - `was.collection` not this codec's collection id: one Collection's
|
|
956
|
-
* metadata served as another's -- {@link IntegrityError}.
|
|
957
|
-
*
|
|
958
|
-
* A read of a resource slot (content or resource metadata) enforces:
|
|
959
|
-
*
|
|
960
|
-
* - `was.collection` present: refused before any id comparison -- the
|
|
961
|
-
* Collection's metadata envelope was served in a resource slot --
|
|
962
|
-
* {@link IntegrityError}.
|
|
963
|
-
* - `was.resource` present and the expected id known: a mismatch is a server-side
|
|
964
|
-
* swap of two resources' envelopes -- {@link IntegrityError}.
|
|
965
|
-
* - `resource` absent (a content-derived write) and the expected
|
|
966
|
-
* id known: the envelope's ciphertext must re-derive to the expected id
|
|
967
|
-
* ({@link EdvDocumentCipher.deriveId}); a mismatch means the envelope was
|
|
968
|
-
* copied under a different id -- {@link IntegrityError}.
|
|
969
|
-
*
|
|
970
|
-
* Finally, unconditionally:
|
|
971
|
-
* - `was.epoch` missing or not a string: refused like a missing `was` --
|
|
972
|
-
* {@link EncryptionError}. Present, it must equal the epoch (the `did:key`
|
|
973
|
-
* before the `#`) of the key that actually decrypted -- a mismatch is a
|
|
974
|
-
* replay under a different epoch's key -- {@link IntegrityError}. The check
|
|
975
|
-
* is unconditional: there is no epoch-less envelope to carve out.
|
|
1006
|
+
* - Then the slot markers, which declare positively which of the profile's
|
|
1007
|
+
* slots the envelope was written for -- `was.resource` a resource slot,
|
|
1008
|
+
* `was.collection` the Collection metadata slot, neither a content-derived
|
|
1009
|
+
* content envelope. A read of the Collection metadata slot
|
|
1010
|
+
* (`collectionSlot`) is checked by `#verifyCollectionSlot`, a read of a
|
|
1011
|
+
* resource slot by `#verifyResourceSlot`.
|
|
1012
|
+
* - Finally, unconditionally: `was.epoch` missing or not a string: refused
|
|
1013
|
+
* like a missing `was` -- {@link EncryptionError}. Present, it must equal
|
|
1014
|
+
* the epoch (the `did:key` before the `#`) of the key that actually
|
|
1015
|
+
* decrypted -- a mismatch is a replay under a different epoch's key --
|
|
1016
|
+
* {@link IntegrityError}. The check is unconditional: there is no
|
|
1017
|
+
* epoch-less envelope to carve out.
|
|
976
1018
|
*
|
|
977
1019
|
* @param options {object}
|
|
978
1020
|
* @param options.jwe {unknown} the envelope's JWE (its `protected` header is
|
|
@@ -1010,55 +1052,10 @@ export class EdvCodec {
|
|
|
1010
1052
|
`${this.#version}) does not implement. Upgrade the client.`);
|
|
1011
1053
|
}
|
|
1012
1054
|
if (collectionSlot) {
|
|
1013
|
-
|
|
1014
|
-
throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
|
|
1015
|
-
`bound to resource "${was.resource}", but the Collection metadata ` +
|
|
1016
|
-
"slot belongs to no resource. The server swapped a resource's " +
|
|
1017
|
-
"metadata envelope into the Collection's metadata slot.");
|
|
1018
|
-
}
|
|
1019
|
-
if (typeof was.collection !== 'string') {
|
|
1020
|
-
throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope ` +
|
|
1021
|
-
'binds no `was.collection`, so it was written for some other slot ' +
|
|
1022
|
-
'(a content envelope, whose binding is otherwise identical). The ' +
|
|
1023
|
-
"server served a foreign envelope in the Collection's metadata slot.");
|
|
1024
|
-
}
|
|
1025
|
-
if (was.collection !== this.#collectionId) {
|
|
1026
|
-
throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
|
|
1027
|
-
`bound to collection "${was.collection}", not to the requested ` +
|
|
1028
|
-
`collection ("${this.#collectionId}"). The server served one ` +
|
|
1029
|
-
"Collection's metadata as another's.");
|
|
1030
|
-
}
|
|
1055
|
+
this.#verifyCollectionSlot(was);
|
|
1031
1056
|
}
|
|
1032
1057
|
else {
|
|
1033
|
-
|
|
1034
|
-
// metadata envelope has no business here, whatever id it names, so it is
|
|
1035
|
-
// refused before any resource-id comparison.
|
|
1036
|
-
if (was.collection !== undefined) {
|
|
1037
|
-
throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to ` +
|
|
1038
|
-
`collection "${String(was.collection)}", but a resource's slot ` +
|
|
1039
|
-
'belongs to no collection binding. The server served the ' +
|
|
1040
|
-
"Collection's own metadata envelope in a resource's slot.");
|
|
1041
|
-
}
|
|
1042
|
-
if (typeof was.resource === 'string') {
|
|
1043
|
-
if (expectedId !== undefined && was.resource !== expectedId) {
|
|
1044
|
-
throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to a ` +
|
|
1045
|
-
`different resource id ("${was.resource}") than the one requested ` +
|
|
1046
|
-
`("${expectedId}"). The server swapped two resources' envelopes.`);
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
else if (expectedId !== undefined) {
|
|
1050
|
-
// Content-derived write (no `resource`): the id is a function of the
|
|
1051
|
-
// ciphertext, so re-derive and compare.
|
|
1052
|
-
const { documentCipher } = this.#edv;
|
|
1053
|
-
const derived = await documentCipher.deriveId({
|
|
1054
|
-
jwe: jwe
|
|
1055
|
-
});
|
|
1056
|
-
if (derived !== expectedId) {
|
|
1057
|
-
throw new IntegrityError(`Cannot decrypt this resource: its content-derived id ("${derived}") ` +
|
|
1058
|
-
`does not match the requested id ("${expectedId}"). The server ` +
|
|
1059
|
-
'served this envelope under an id it was not written for.');
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1058
|
+
await this.#verifyResourceSlot({ was, expectedId, jwe });
|
|
1062
1059
|
}
|
|
1063
1060
|
if (typeof was.epoch !== 'string') {
|
|
1064
1061
|
throw new EncryptionError('Cannot decrypt this resource: its envelope binds no `was.epoch`. ' +
|
|
@@ -1075,6 +1072,96 @@ export class EdvCodec {
|
|
|
1075
1072
|
}
|
|
1076
1073
|
return typeof was.resource === 'string' ? was.resource : undefined;
|
|
1077
1074
|
}
|
|
1075
|
+
/**
|
|
1076
|
+
* The slot-marker half of `#verifyBinding` for a read of the Collection
|
|
1077
|
+
* metadata slot. Enforces:
|
|
1078
|
+
*
|
|
1079
|
+
* - `was.resource` present: refused outright -- a resource's envelope was
|
|
1080
|
+
* served in the Collection's metadata slot, which belongs to no resource --
|
|
1081
|
+
* {@link IntegrityError}.
|
|
1082
|
+
* - `was.collection` missing or not a string: refused -- the envelope belongs
|
|
1083
|
+
* to some other slot, notably a content-derived content envelope, whose
|
|
1084
|
+
* member set is otherwise identical -- {@link IntegrityError}.
|
|
1085
|
+
* - `was.collection` not this codec's collection id: one Collection's
|
|
1086
|
+
* metadata served as another's -- {@link IntegrityError}.
|
|
1087
|
+
*
|
|
1088
|
+
* @param was {Record<string, unknown>} the parsed `was` protected-header
|
|
1089
|
+
* parameter
|
|
1090
|
+
* @returns {void}
|
|
1091
|
+
*/
|
|
1092
|
+
#verifyCollectionSlot(was) {
|
|
1093
|
+
if (typeof was.resource === 'string') {
|
|
1094
|
+
throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
|
|
1095
|
+
`bound to resource "${was.resource}", but the Collection metadata ` +
|
|
1096
|
+
"slot belongs to no resource. The server swapped a resource's " +
|
|
1097
|
+
"metadata envelope into the Collection's metadata slot.");
|
|
1098
|
+
}
|
|
1099
|
+
if (typeof was.collection !== 'string') {
|
|
1100
|
+
throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope ` +
|
|
1101
|
+
'binds no `was.collection`, so it was written for some other slot ' +
|
|
1102
|
+
'(a content envelope, whose binding is otherwise identical). The ' +
|
|
1103
|
+
"server served a foreign envelope in the Collection's metadata slot.");
|
|
1104
|
+
}
|
|
1105
|
+
if (was.collection !== this.#collectionId) {
|
|
1106
|
+
throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
|
|
1107
|
+
`bound to collection "${was.collection}", not to the requested ` +
|
|
1108
|
+
`collection ("${this.#collectionId}"). The server served one ` +
|
|
1109
|
+
"Collection's metadata as another's.");
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* The slot-marker half of `#verifyBinding` for a read of a resource slot
|
|
1114
|
+
* (content or resource metadata). Enforces:
|
|
1115
|
+
*
|
|
1116
|
+
* - `was.collection` present: refused before any id comparison -- the
|
|
1117
|
+
* Collection's metadata envelope was served in a resource slot --
|
|
1118
|
+
* {@link IntegrityError}.
|
|
1119
|
+
* - `was.resource` present and the expected id known: a mismatch is a
|
|
1120
|
+
* server-side swap of two resources' envelopes -- {@link IntegrityError}.
|
|
1121
|
+
* - `resource` absent (a content-derived write) and the expected
|
|
1122
|
+
* id known: the envelope's ciphertext must re-derive to the expected id
|
|
1123
|
+
* ({@link EdvDocumentCipher.deriveId}); a mismatch means the envelope was
|
|
1124
|
+
* copied under a different id -- {@link IntegrityError}.
|
|
1125
|
+
*
|
|
1126
|
+
* @param options {object}
|
|
1127
|
+
* @param options.was {Record<string, unknown>} the parsed `was`
|
|
1128
|
+
* protected-header parameter
|
|
1129
|
+
* @param [options.expectedId] {string} the resource id the read targeted
|
|
1130
|
+
* @param options.jwe {unknown} the envelope's JWE, whose ciphertext the
|
|
1131
|
+
* content-derived branch re-derives the id from
|
|
1132
|
+
* @returns {Promise<void>}
|
|
1133
|
+
*/
|
|
1134
|
+
async #verifyResourceSlot({ was, expectedId, jwe }) {
|
|
1135
|
+
// A resource slot (content or resource metadata). The Collection's own
|
|
1136
|
+
// metadata envelope has no business here, whatever id it names, so it is
|
|
1137
|
+
// refused before any resource-id comparison.
|
|
1138
|
+
if (was.collection !== undefined) {
|
|
1139
|
+
throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to ` +
|
|
1140
|
+
`collection "${String(was.collection)}", but a resource's slot ` +
|
|
1141
|
+
'belongs to no collection binding. The server served the ' +
|
|
1142
|
+
"Collection's own metadata envelope in a resource's slot.");
|
|
1143
|
+
}
|
|
1144
|
+
if (typeof was.resource === 'string') {
|
|
1145
|
+
if (expectedId !== undefined && was.resource !== expectedId) {
|
|
1146
|
+
throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to a ` +
|
|
1147
|
+
`different resource id ("${was.resource}") than the one requested ` +
|
|
1148
|
+
`("${expectedId}"). The server swapped two resources' envelopes.`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
else if (expectedId !== undefined) {
|
|
1152
|
+
// Content-derived write (no `resource`): the id is a function of the
|
|
1153
|
+
// ciphertext, so re-derive and compare.
|
|
1154
|
+
const { documentCipher } = this.#edv;
|
|
1155
|
+
const derived = await documentCipher.deriveId({
|
|
1156
|
+
jwe: jwe
|
|
1157
|
+
});
|
|
1158
|
+
if (derived !== expectedId) {
|
|
1159
|
+
throw new IntegrityError(`Cannot decrypt this resource: its content-derived id ("${derived}") ` +
|
|
1160
|
+
`does not match the requested id ("${expectedId}"). The server ` +
|
|
1161
|
+
'served this envelope under an id it was not written for.');
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1078
1165
|
/**
|
|
1079
1166
|
* @inheritdoc
|
|
1080
1167
|
*
|
|
@@ -1100,27 +1187,23 @@ export class EdvCodec {
|
|
|
1100
1187
|
// marker) served in the Collection metadata slot is then detected too. It
|
|
1101
1188
|
// seals to the current epoch key like every write, so it binds `was.epoch`
|
|
1102
1189
|
// like every write.
|
|
1103
|
-
const was = wasParam({
|
|
1104
|
-
version: this.#version,
|
|
1105
|
-
...(resourceId === undefined
|
|
1106
|
-
? { collection: this.#collectionId }
|
|
1107
|
-
: { resource: resourceId }),
|
|
1108
|
-
epoch: this.#writeEpoch
|
|
1109
|
-
});
|
|
1110
1190
|
const encrypted = await documentCipher.encrypt({
|
|
1111
1191
|
doc: { id, content: custom },
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1192
|
+
...this.#sealParams({
|
|
1193
|
+
...(resourceId === undefined
|
|
1194
|
+
? { collection: this.#collectionId }
|
|
1195
|
+
: { resource: resourceId }),
|
|
1196
|
+
// Deliberately un-blinded, even on a searchable collection: this
|
|
1197
|
+
// envelope is the WAS `/meta` value, not part of the resource's content
|
|
1198
|
+
// document, and it is stored in a different slot the search endpoint
|
|
1199
|
+
// never reads. The `meta.*` attribute paths a declared index may name
|
|
1200
|
+
// address the *content document's* own `meta` (the content type and
|
|
1201
|
+
// inline-encoding discriminator), which is a different object entirely
|
|
1202
|
+
// -- so blinding here would emit entries that can never match a query
|
|
1203
|
+
// and would leak the shape of the metadata into a slot with no index at
|
|
1204
|
+
// all.
|
|
1205
|
+
hmac: undefined
|
|
1206
|
+
})
|
|
1124
1207
|
});
|
|
1125
1208
|
// Surface the epoch this envelope sealed under: a Collection-level `/meta`
|
|
1126
1209
|
// PUT carries it as the body's top-level `epoch` stamp (the server clears
|
|
@@ -1392,7 +1475,12 @@ function parseWasHeader(jwe) {
|
|
|
1392
1475
|
}
|
|
1393
1476
|
let parsed;
|
|
1394
1477
|
try {
|
|
1395
|
-
parsed = JSON.parse(
|
|
1478
|
+
parsed = JSON.parse(
|
|
1479
|
+
// The shared lenient decoder, deliberately NOT `UTF8_DECODER`: its
|
|
1480
|
+
// `fatal: true` would turn malformed bytes into a throw rather than the
|
|
1481
|
+
// documented `undefined` return. The header is already proven authentic
|
|
1482
|
+
// by a successful decrypt.
|
|
1483
|
+
DECODER.decode(base64urlnopad.decode(protectedHeader)));
|
|
1396
1484
|
}
|
|
1397
1485
|
catch {
|
|
1398
1486
|
return undefined;
|
|
@@ -1459,66 +1547,117 @@ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT
|
|
|
1459
1547
|
if (scheme !== EDV_SCHEME) {
|
|
1460
1548
|
return null;
|
|
1461
1549
|
}
|
|
1462
|
-
|
|
1550
|
+
// Guard the descriptor before consulting the keystore, so a collection
|
|
1551
|
+
// whose descriptor cannot be opened reports THAT rather than the vaguer
|
|
1552
|
+
// "holds no keys" the null return below would produce. `buildEdvCodec`
|
|
1553
|
+
// guards again for callers that reach it directly; the guard is pure, so
|
|
1554
|
+
// running it twice costs nothing.
|
|
1555
|
+
guardEncryptionDescriptor({
|
|
1463
1556
|
label: `${spaceId}/${collectionId}`,
|
|
1464
1557
|
encryption
|
|
1465
1558
|
});
|
|
1466
|
-
const descriptorVersion = descriptor.version;
|
|
1467
1559
|
// Prefer override-supplied keys; otherwise consult the keystore.
|
|
1468
1560
|
const resolved = keys ??
|
|
1469
1561
|
(await resolveKeys({ spaceId, collectionId }));
|
|
1470
1562
|
if (!resolved) {
|
|
1471
1563
|
return null;
|
|
1472
1564
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
encryption
|
|
1482
|
-
|
|
1483
|
-
}));
|
|
1484
|
-
// Epoch keys are self-describing did:key key-agreement keys, so a
|
|
1485
|
-
// resource's recipient (the epoch public key) resolves through the
|
|
1486
|
-
// standard did:key resolver, independent of the reader's own keystore.
|
|
1487
|
-
const keyAgreementKey = epochKeys.writeKey;
|
|
1488
|
-
// The collection's blinding key: an explicitly supplied one (a keystore
|
|
1489
|
-
// custodying the HMAC key itself) wins over unwrapping the descriptor's
|
|
1490
|
-
// `hmac` member; `null` means the collection declares no blinded index.
|
|
1491
|
-
const hmac = resolved.hmac ??
|
|
1492
|
-
(await resolveHmacKey({
|
|
1493
|
-
encryption: descriptor,
|
|
1494
|
-
keyAgreementKey: resolved.keyAgreementKey
|
|
1495
|
-
}));
|
|
1496
|
-
const edv = new EdvClientCore({
|
|
1497
|
-
keyAgreementKey,
|
|
1498
|
-
keyResolver: didKeyResolver,
|
|
1499
|
-
...(hmac !== null && { hmac })
|
|
1500
|
-
});
|
|
1501
|
-
return new EdvCodec({
|
|
1502
|
-
edv,
|
|
1503
|
-
keyAgreementKey,
|
|
1504
|
-
hmac,
|
|
1505
|
-
readKeys: epochKeys.readKeys,
|
|
1506
|
-
writeEpoch: epochKeys.writeEpoch,
|
|
1565
|
+
return buildEdvCodec({
|
|
1566
|
+
label: `${spaceId}/${collectionId}`,
|
|
1567
|
+
transportFactory: wasTransportFactory({
|
|
1568
|
+
spaceId,
|
|
1569
|
+
collectionId,
|
|
1570
|
+
contentType
|
|
1571
|
+
}),
|
|
1572
|
+
collectionId,
|
|
1573
|
+
encryption,
|
|
1574
|
+
keys: resolved,
|
|
1507
1575
|
contentType,
|
|
1508
1576
|
maxBlobBytes,
|
|
1509
1577
|
...(chunkSize !== undefined && { chunkSize }),
|
|
1510
|
-
idDerivation
|
|
1511
|
-
version: descriptorVersion ?? EDV_SCHEME_VERSION,
|
|
1512
|
-
spaceId,
|
|
1513
|
-
collectionId,
|
|
1514
|
-
// Every epoch the descriptor lists, recipient of it or not, so
|
|
1515
|
-
// decrypt routing can tell "not a recipient of this epoch" apart
|
|
1516
|
-
// from "descriptor has never seen this epoch".
|
|
1517
|
-
epochIds: descriptor.epochs.map(epoch => epoch.id)
|
|
1578
|
+
idDerivation
|
|
1518
1579
|
});
|
|
1519
1580
|
}
|
|
1520
1581
|
};
|
|
1521
1582
|
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Builds the {@link EdvCodec} for one encrypted collection from a reader's
|
|
1585
|
+
* keys and the collection's encryption descriptor. The whole of the codec
|
|
1586
|
+
* build that follows key resolution: the fail-closed descriptor guard, the
|
|
1587
|
+
* reader's per-epoch keys, the collection's blinding key, and the EDV core the
|
|
1588
|
+
* codec drives. Shared by the keystore provider (`createEdvEncryption`'s
|
|
1589
|
+
* `codecFor`, which resolves the keys first) and by the local-replica cipher,
|
|
1590
|
+
* which holds its keys already.
|
|
1591
|
+
*
|
|
1592
|
+
* @param options {object}
|
|
1593
|
+
* @param options.collectionId {string} the collection's WAS id
|
|
1594
|
+
* @param [options.label] {string} how the collection is named in errors;
|
|
1595
|
+
* defaults to its id alone, which is all a build with no Space knows
|
|
1596
|
+
* @param [options.transportFactory] {CodecTransportFactory} builds the
|
|
1597
|
+
* transport the chunked-stream path drives; omitted by the local-replica
|
|
1598
|
+
* build, which has no server to address
|
|
1599
|
+
* @param [options.encryption] {CollectionEncryption} the collection's
|
|
1600
|
+
* encryption descriptor; must carry the key-epoch roster
|
|
1601
|
+
* @param options.keys {EdvKeys} the reader's key material
|
|
1602
|
+
* @param options.idDerivation {'random' | 'content'} how `add()` mints ids
|
|
1603
|
+
* @param [options.contentType] {string} stored envelope content type
|
|
1604
|
+
* @param [options.maxBlobBytes] {number} the single-document threshold
|
|
1605
|
+
* @param [options.chunkSize] {number} the size of each encrypted chunk
|
|
1606
|
+
* @returns {Promise<EdvCodec>}
|
|
1607
|
+
*/
|
|
1608
|
+
export async function buildEdvCodec({ collectionId, label = `"${collectionId}"`, transportFactory, encryption, keys, idDerivation, contentType = DEFAULT_CONTENT_TYPE, maxBlobBytes = DEFAULT_MAX_BLOB_BYTES, chunkSize }) {
|
|
1609
|
+
const descriptor = guardEncryptionDescriptor({
|
|
1610
|
+
label,
|
|
1611
|
+
encryption
|
|
1612
|
+
});
|
|
1613
|
+
const descriptorVersion = descriptor.version;
|
|
1614
|
+
// Resolve the reader's per-epoch keys from the descriptor -- the
|
|
1615
|
+
// `currentEpoch` key pair for writes, every epoch key it can unwrap for
|
|
1616
|
+
// reads -- and drive the cipher with those. The reader's own
|
|
1617
|
+
// key-agreement key never encrypts or decrypts resources itself; it
|
|
1618
|
+
// only unwraps epoch keys. Non-null: the epochs guard above already
|
|
1619
|
+
// refused a descriptor without epochs, the one case resolveEpochKeys
|
|
1620
|
+
// resolves null for.
|
|
1621
|
+
const epochKeys = (await resolveEpochKeys({
|
|
1622
|
+
encryption: descriptor,
|
|
1623
|
+
keyAgreementKey: keys.keyAgreementKey
|
|
1624
|
+
}));
|
|
1625
|
+
// Epoch keys are self-describing did:key key-agreement keys, so a
|
|
1626
|
+
// resource's recipient (the epoch public key) resolves through the
|
|
1627
|
+
// standard did:key resolver, independent of the reader's own keystore.
|
|
1628
|
+
const keyAgreementKey = epochKeys.writeKey;
|
|
1629
|
+
// The collection's blinding key: an explicitly supplied one (a keystore
|
|
1630
|
+
// custodying the HMAC key itself) wins over unwrapping the descriptor's
|
|
1631
|
+
// `hmac` member; `null` means the collection declares no blinded index.
|
|
1632
|
+
const hmac = keys.hmac ??
|
|
1633
|
+
(await resolveHmacKey({
|
|
1634
|
+
encryption: descriptor,
|
|
1635
|
+
keyAgreementKey: keys.keyAgreementKey
|
|
1636
|
+
}));
|
|
1637
|
+
const edv = new EdvClientCore({
|
|
1638
|
+
keyAgreementKey,
|
|
1639
|
+
keyResolver: didKeyResolver,
|
|
1640
|
+
...(hmac !== null && { hmac })
|
|
1641
|
+
});
|
|
1642
|
+
return new EdvCodec({
|
|
1643
|
+
edv,
|
|
1644
|
+
keyAgreementKey,
|
|
1645
|
+
hmac,
|
|
1646
|
+
readKeys: epochKeys.readKeys,
|
|
1647
|
+
writeEpoch: epochKeys.writeEpoch,
|
|
1648
|
+
contentType,
|
|
1649
|
+
maxBlobBytes,
|
|
1650
|
+
...(chunkSize !== undefined && { chunkSize }),
|
|
1651
|
+
idDerivation,
|
|
1652
|
+
version: descriptorVersion ?? EDV_SCHEME_VERSION,
|
|
1653
|
+
...(transportFactory !== undefined && { transportFactory }),
|
|
1654
|
+
collectionId,
|
|
1655
|
+
// Every epoch the descriptor lists, recipient of it or not, so
|
|
1656
|
+
// decrypt routing can tell "not a recipient of this epoch" apart
|
|
1657
|
+
// from "descriptor has never seen this epoch".
|
|
1658
|
+
epochIds: descriptor.epochs.map(epoch => epoch.id)
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1522
1661
|
/**
|
|
1523
1662
|
* Refuses an encryption descriptor this client cannot operate on, fail-closed:
|
|
1524
1663
|
* a descriptor from a future scheme version (this client does not implement
|
|
@@ -1578,10 +1717,9 @@ function guardEncryptionDescriptor({ label, encryption }) {
|
|
|
1578
1717
|
* @param options.idDerivation {'content' | 'random'} how ids are minted
|
|
1579
1718
|
* @param options.encryption {CollectionEncryption} the collection's
|
|
1580
1719
|
* descriptor; must carry the key-epoch roster
|
|
1581
|
-
* @param [options.contentType] {string} stored envelope content type
|
|
1582
1720
|
* @returns {Promise<EdvCodec>}
|
|
1583
1721
|
*/
|
|
1584
|
-
export async function encryptOnlyEdvCodec({ collectionId, idDerivation, encryption
|
|
1722
|
+
export async function encryptOnlyEdvCodec({ collectionId, idDerivation, encryption }) {
|
|
1585
1723
|
const descriptor = guardEncryptionDescriptor({
|
|
1586
1724
|
label: `"${collectionId}"`,
|
|
1587
1725
|
encryption
|
|
@@ -1630,11 +1768,10 @@ export async function encryptOnlyEdvCodec({ collectionId, idDerivation, encrypti
|
|
|
1630
1768
|
keyAgreementKey: writeKey,
|
|
1631
1769
|
readKeys: [],
|
|
1632
1770
|
writeEpoch,
|
|
1633
|
-
contentType,
|
|
1771
|
+
contentType: DEFAULT_CONTENT_TYPE,
|
|
1634
1772
|
maxBlobBytes: DEFAULT_MAX_BLOB_BYTES,
|
|
1635
1773
|
idDerivation,
|
|
1636
1774
|
version: descriptor.version ?? EDV_SCHEME_VERSION,
|
|
1637
|
-
spaceId: LOCAL_SPACE_ID,
|
|
1638
1775
|
collectionId,
|
|
1639
1776
|
epochIds: epochs.map(epoch => epoch.id)
|
|
1640
1777
|
});
|