@interop/was-client 0.40.2 → 0.42.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 +12 -3
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js.map +1 -1
- package/dist/edv/EdvCodec.d.ts +35 -4
- package/dist/edv/EdvCodec.d.ts.map +1 -1
- package/dist/edv/EdvCodec.js +296 -200
- 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/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 +11 -30
- 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/recipients.d.ts +12 -10
- package/dist/edv/recipients.d.ts.map +1 -1
- package/dist/edv/recipients.js +254 -199
- 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,7 +80,7 @@ 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';
|
|
83
|
+
import { DECODER, isBlob, isTextContentType, readJsonData, resolvePayload } from '../internal/content.js';
|
|
80
84
|
import { DEFAULT_CONTENT_TYPE, EDV_SCHEME_VERSION, envelopeBytes, LOCAL_SPACE_ID } from './constants.js';
|
|
81
85
|
/**
|
|
82
86
|
* Default threshold above which an encrypted binary write is routed to the
|
|
@@ -109,13 +113,6 @@ const CHUNKED_ENCODING = 'chunked';
|
|
|
109
113
|
* instance is reused.
|
|
110
114
|
*/
|
|
111
115
|
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
116
|
/**
|
|
120
117
|
* Builds the AEAD-bound `was` protected-header parameter: the scheme version
|
|
121
118
|
* and key epoch always, and then exactly one slot marker -- the resource id
|
|
@@ -382,6 +379,9 @@ export class EdvCodec {
|
|
|
382
379
|
* all -- the residual footgun the name check cannot see, e.g. a compound
|
|
383
380
|
* index queried by something other than a leading prefix of its members.
|
|
384
381
|
*
|
|
382
|
+
* Reached only through the `indexing` adapter, which exists only on a codec
|
|
383
|
+
* whose (readonly) blinding key is non-null, so the key is non-null here.
|
|
384
|
+
*
|
|
385
385
|
* @param input {object}
|
|
386
386
|
* @param [input.equals] {object | object[]}
|
|
387
387
|
* @param [input.has] {string | string[]}
|
|
@@ -389,10 +389,6 @@ export class EdvCodec {
|
|
|
389
389
|
*/
|
|
390
390
|
async #buildQuery({ equals, has }) {
|
|
391
391
|
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
392
|
assertQueryAttributes({ schema: this.#schema, equals, has });
|
|
397
393
|
const query = (await this.#edv.indexHelper.buildQuery({
|
|
398
394
|
hmac,
|
|
@@ -412,7 +408,7 @@ export class EdvCodec {
|
|
|
412
408
|
/**
|
|
413
409
|
* @inheritdoc
|
|
414
410
|
*/
|
|
415
|
-
async encode({ id, data, contentType, current }) {
|
|
411
|
+
async encode({ id, data, contentType, current, precondition }) {
|
|
416
412
|
if (id !== undefined && !current) {
|
|
417
413
|
try {
|
|
418
414
|
// A full multibase decode + multihash length check (the same assertion
|
|
@@ -469,16 +465,6 @@ export class EdvCodec {
|
|
|
469
465
|
priorDoc = read;
|
|
470
466
|
}
|
|
471
467
|
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
468
|
const encrypted = await documentCipher.encrypt({
|
|
483
469
|
doc: {
|
|
484
470
|
...(docId !== undefined && { id: docId }),
|
|
@@ -496,15 +482,22 @@ export class EdvCodec {
|
|
|
496
482
|
// state that recomputation updates.
|
|
497
483
|
...(priorDoc?.indexed !== undefined && { indexed: priorDoc.indexed })
|
|
498
484
|
},
|
|
499
|
-
|
|
500
|
-
|
|
485
|
+
// Bind an AEAD-authenticated `was` parameter into the JWE protected
|
|
486
|
+
// header: the scheme version, the resource id when known at encrypt time
|
|
487
|
+
// (omitted for a content-derived id, which does not exist until after
|
|
488
|
+
// encryption), and the write epoch. A server that swaps two envelopes
|
|
489
|
+
// between ids (or replays one under a rolled-back epoch) is then detected
|
|
490
|
+
// on decrypt.
|
|
491
|
+
//
|
|
501
492
|
// Blind the declared attributes into the envelope's cleartext `indexed`
|
|
502
493
|
// entries, so the server can match a search without decrypting anything.
|
|
503
494
|
// Absent a blinding key or a declared attribute there is nothing to
|
|
504
495
|
// index, and the prior entries passed above are stored verbatim.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
496
|
+
...this.#sealParams({
|
|
497
|
+
...(docId !== undefined && { resource: docId }),
|
|
498
|
+
hmac: this.#writeBlindingKey()
|
|
499
|
+
}),
|
|
500
|
+
update: priorDoc !== null
|
|
508
501
|
});
|
|
509
502
|
if (docId === undefined) {
|
|
510
503
|
// Encrypt-then-stamp: the id lives in the cleartext envelope, outside the
|
|
@@ -524,19 +517,28 @@ export class EdvCodec {
|
|
|
524
517
|
// Surface the plaintext content type (the server-opaque envelope type
|
|
525
518
|
// stays `contentType`) so `add()` reports the real resource type.
|
|
526
519
|
resourceContentType: meta.contentType,
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
520
|
+
// The caller's own compare-and-swap baseline wins when they named one:
|
|
521
|
+
// an `If-Match` derived from this codec's pre-read would pin the write to
|
|
522
|
+
// current server state rather than to the revision the caller last saw,
|
|
523
|
+
// which silently turns a lost-update guard into last-write-wins. The
|
|
524
|
+
// write path has already checked the caller's baseline against the
|
|
525
|
+
// pre-read, so the two agree by the time this runs.
|
|
526
|
+
//
|
|
527
|
+
// Otherwise pin an update to the server's current ETag and guard a fresh
|
|
528
|
+
// insert with create-if-absent. An update's `If-Match` carries a
|
|
529
|
+
// server-provided ETag, so it degrades to an advisory write against a
|
|
530
|
+
// backend without the conditional-writes feature (the ETag is absent). A
|
|
531
|
+
// fresh insert's `If-None-Match: *` needs no server-provided validator
|
|
532
|
+
// and so is emitted unconditionally by design -- it expresses the
|
|
533
|
+
// insert's intent (create-only-if-absent). A backend that does not honor
|
|
534
|
+
// it would ignore it, so the write path refuses the
|
|
535
|
+
// insert-after-null-pre-read up front on such a backend (see
|
|
536
|
+
// `upsertResource`) -- a masked-404 pre-read must not silently overwrite
|
|
537
|
+
// an existing document there.
|
|
538
|
+
...(precondition ??
|
|
539
|
+
(priorDoc
|
|
540
|
+
? { ifMatch: readEtag(current ?? null) }
|
|
541
|
+
: { ifNoneMatch: true })),
|
|
540
542
|
// Stamp the key epoch this write encrypted under (the `currentEpoch`), so
|
|
541
543
|
// the server records it and a reader can pick the epoch key.
|
|
542
544
|
epoch: this.#writeEpoch
|
|
@@ -627,11 +629,6 @@ export class EdvCodec {
|
|
|
627
629
|
* @returns {ChunkedWrite}
|
|
628
630
|
*/
|
|
629
631
|
#chunkedWrite({ id, stream, meta }) {
|
|
630
|
-
const was = wasParam({
|
|
631
|
-
version: this.#version,
|
|
632
|
-
resource: id,
|
|
633
|
-
epoch: this.#writeEpoch
|
|
634
|
-
});
|
|
635
632
|
return {
|
|
636
633
|
chunked: true,
|
|
637
634
|
id,
|
|
@@ -658,10 +655,10 @@ export class EdvCodec {
|
|
|
658
655
|
...(this.#chunkSize !== undefined && {
|
|
659
656
|
chunkSize: this.#chunkSize
|
|
660
657
|
}),
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
658
|
+
...this.#sealParams({
|
|
659
|
+
resource: id,
|
|
660
|
+
hmac: this.#writeBlindingKey()
|
|
661
|
+
}),
|
|
665
662
|
transport
|
|
666
663
|
});
|
|
667
664
|
}
|
|
@@ -721,6 +718,39 @@ export class EdvCodec {
|
|
|
721
718
|
}
|
|
722
719
|
return this.#blindingKey;
|
|
723
720
|
}
|
|
721
|
+
/**
|
|
722
|
+
* The recipient/AAD wiring every write seals with: the epoch recipients, the
|
|
723
|
+
* key resolver that resolves them, the blinding key the caller decided on,
|
|
724
|
+
* and the AEAD-bound `was` protected-header parameter carrying this codec's
|
|
725
|
+
* scheme version, the write epoch, and the caller's slot marker. Spread into
|
|
726
|
+
* each `encrypt`/`insert` call so the single-document, chunked and metadata
|
|
727
|
+
* write paths cannot drift apart.
|
|
728
|
+
*
|
|
729
|
+
* @param options {object}
|
|
730
|
+
* @param [options.resource] {string} the resource id the envelope binds
|
|
731
|
+
* (absent for a content-derived id, which does not exist until after
|
|
732
|
+
* encryption)
|
|
733
|
+
* @param [options.collection] {string} the collection id the envelope binds
|
|
734
|
+
* (the Collection metadata slot only, never alongside `resource`)
|
|
735
|
+
* @param options.hmac {BlindingKey | undefined} the blinding key to index
|
|
736
|
+
* with, or `undefined` to emit no blinded entries
|
|
737
|
+
* @returns {{ recipients: IRecipientTemplate[], keyResolver: IKeyResolver,
|
|
738
|
+
* hmac: BlindingKey, additionalProtectedParams: { was: object } }}
|
|
739
|
+
*/
|
|
740
|
+
#sealParams({ resource, collection, hmac }) {
|
|
741
|
+
const was = wasParam({
|
|
742
|
+
version: this.#version,
|
|
743
|
+
...(resource !== undefined && { resource }),
|
|
744
|
+
...(collection !== undefined && { collection }),
|
|
745
|
+
epoch: this.#writeEpoch
|
|
746
|
+
});
|
|
747
|
+
return {
|
|
748
|
+
recipients: this.#recipients,
|
|
749
|
+
keyResolver: this.#edv.keyResolver,
|
|
750
|
+
hmac,
|
|
751
|
+
additionalProtectedParams: { was }
|
|
752
|
+
};
|
|
753
|
+
}
|
|
724
754
|
/**
|
|
725
755
|
* @inheritdoc
|
|
726
756
|
*/
|
|
@@ -940,39 +970,18 @@ export class EdvCodec {
|
|
|
940
970
|
* envelope stamps its scheme version) -- {@link EncryptionError}. Greater
|
|
941
971
|
* than this codec's scheme version: a future-scheme envelope this client
|
|
942
972
|
* 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.
|
|
973
|
+
* - Then the slot markers, which declare positively which of the profile's
|
|
974
|
+
* slots the envelope was written for -- `was.resource` a resource slot,
|
|
975
|
+
* `was.collection` the Collection metadata slot, neither a content-derived
|
|
976
|
+
* content envelope. A read of the Collection metadata slot
|
|
977
|
+
* (`collectionSlot`) is checked by `#verifyCollectionSlot`, a read of a
|
|
978
|
+
* resource slot by `#verifyResourceSlot`.
|
|
979
|
+
* - Finally, unconditionally: `was.epoch` missing or not a string: refused
|
|
980
|
+
* like a missing `was` -- {@link EncryptionError}. Present, it must equal
|
|
981
|
+
* the epoch (the `did:key` before the `#`) of the key that actually
|
|
982
|
+
* decrypted -- a mismatch is a replay under a different epoch's key --
|
|
983
|
+
* {@link IntegrityError}. The check is unconditional: there is no
|
|
984
|
+
* epoch-less envelope to carve out.
|
|
976
985
|
*
|
|
977
986
|
* @param options {object}
|
|
978
987
|
* @param options.jwe {unknown} the envelope's JWE (its `protected` header is
|
|
@@ -1010,55 +1019,10 @@ export class EdvCodec {
|
|
|
1010
1019
|
`${this.#version}) does not implement. Upgrade the client.`);
|
|
1011
1020
|
}
|
|
1012
1021
|
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
|
-
}
|
|
1022
|
+
this.#verifyCollectionSlot(was);
|
|
1031
1023
|
}
|
|
1032
1024
|
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
|
-
}
|
|
1025
|
+
await this.#verifyResourceSlot({ was, expectedId, jwe });
|
|
1062
1026
|
}
|
|
1063
1027
|
if (typeof was.epoch !== 'string') {
|
|
1064
1028
|
throw new EncryptionError('Cannot decrypt this resource: its envelope binds no `was.epoch`. ' +
|
|
@@ -1075,6 +1039,96 @@ export class EdvCodec {
|
|
|
1075
1039
|
}
|
|
1076
1040
|
return typeof was.resource === 'string' ? was.resource : undefined;
|
|
1077
1041
|
}
|
|
1042
|
+
/**
|
|
1043
|
+
* The slot-marker half of `#verifyBinding` for a read of the Collection
|
|
1044
|
+
* metadata slot. Enforces:
|
|
1045
|
+
*
|
|
1046
|
+
* - `was.resource` present: refused outright -- a resource's envelope was
|
|
1047
|
+
* served in the Collection's metadata slot, which belongs to no resource --
|
|
1048
|
+
* {@link IntegrityError}.
|
|
1049
|
+
* - `was.collection` missing or not a string: refused -- the envelope belongs
|
|
1050
|
+
* to some other slot, notably a content-derived content envelope, whose
|
|
1051
|
+
* member set is otherwise identical -- {@link IntegrityError}.
|
|
1052
|
+
* - `was.collection` not this codec's collection id: one Collection's
|
|
1053
|
+
* metadata served as another's -- {@link IntegrityError}.
|
|
1054
|
+
*
|
|
1055
|
+
* @param was {Record<string, unknown>} the parsed `was` protected-header
|
|
1056
|
+
* parameter
|
|
1057
|
+
* @returns {void}
|
|
1058
|
+
*/
|
|
1059
|
+
#verifyCollectionSlot(was) {
|
|
1060
|
+
if (typeof was.resource === 'string') {
|
|
1061
|
+
throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
|
|
1062
|
+
`bound to resource "${was.resource}", but the Collection metadata ` +
|
|
1063
|
+
"slot belongs to no resource. The server swapped a resource's " +
|
|
1064
|
+
"metadata envelope into the Collection's metadata slot.");
|
|
1065
|
+
}
|
|
1066
|
+
if (typeof was.collection !== 'string') {
|
|
1067
|
+
throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope ` +
|
|
1068
|
+
'binds no `was.collection`, so it was written for some other slot ' +
|
|
1069
|
+
'(a content envelope, whose binding is otherwise identical). The ' +
|
|
1070
|
+
"server served a foreign envelope in the Collection's metadata slot.");
|
|
1071
|
+
}
|
|
1072
|
+
if (was.collection !== this.#collectionId) {
|
|
1073
|
+
throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
|
|
1074
|
+
`bound to collection "${was.collection}", not to the requested ` +
|
|
1075
|
+
`collection ("${this.#collectionId}"). The server served one ` +
|
|
1076
|
+
"Collection's metadata as another's.");
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* The slot-marker half of `#verifyBinding` for a read of a resource slot
|
|
1081
|
+
* (content or resource metadata). Enforces:
|
|
1082
|
+
*
|
|
1083
|
+
* - `was.collection` present: refused before any id comparison -- the
|
|
1084
|
+
* Collection's metadata envelope was served in a resource slot --
|
|
1085
|
+
* {@link IntegrityError}.
|
|
1086
|
+
* - `was.resource` present and the expected id known: a mismatch is a
|
|
1087
|
+
* server-side swap of two resources' envelopes -- {@link IntegrityError}.
|
|
1088
|
+
* - `resource` absent (a content-derived write) and the expected
|
|
1089
|
+
* id known: the envelope's ciphertext must re-derive to the expected id
|
|
1090
|
+
* ({@link EdvDocumentCipher.deriveId}); a mismatch means the envelope was
|
|
1091
|
+
* copied under a different id -- {@link IntegrityError}.
|
|
1092
|
+
*
|
|
1093
|
+
* @param options {object}
|
|
1094
|
+
* @param options.was {Record<string, unknown>} the parsed `was`
|
|
1095
|
+
* protected-header parameter
|
|
1096
|
+
* @param [options.expectedId] {string} the resource id the read targeted
|
|
1097
|
+
* @param options.jwe {unknown} the envelope's JWE, whose ciphertext the
|
|
1098
|
+
* content-derived branch re-derives the id from
|
|
1099
|
+
* @returns {Promise<void>}
|
|
1100
|
+
*/
|
|
1101
|
+
async #verifyResourceSlot({ was, expectedId, jwe }) {
|
|
1102
|
+
// A resource slot (content or resource metadata). The Collection's own
|
|
1103
|
+
// metadata envelope has no business here, whatever id it names, so it is
|
|
1104
|
+
// refused before any resource-id comparison.
|
|
1105
|
+
if (was.collection !== undefined) {
|
|
1106
|
+
throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to ` +
|
|
1107
|
+
`collection "${String(was.collection)}", but a resource's slot ` +
|
|
1108
|
+
'belongs to no collection binding. The server served the ' +
|
|
1109
|
+
"Collection's own metadata envelope in a resource's slot.");
|
|
1110
|
+
}
|
|
1111
|
+
if (typeof was.resource === 'string') {
|
|
1112
|
+
if (expectedId !== undefined && was.resource !== expectedId) {
|
|
1113
|
+
throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to a ` +
|
|
1114
|
+
`different resource id ("${was.resource}") than the one requested ` +
|
|
1115
|
+
`("${expectedId}"). The server swapped two resources' envelopes.`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
else if (expectedId !== undefined) {
|
|
1119
|
+
// Content-derived write (no `resource`): the id is a function of the
|
|
1120
|
+
// ciphertext, so re-derive and compare.
|
|
1121
|
+
const { documentCipher } = this.#edv;
|
|
1122
|
+
const derived = await documentCipher.deriveId({
|
|
1123
|
+
jwe: jwe
|
|
1124
|
+
});
|
|
1125
|
+
if (derived !== expectedId) {
|
|
1126
|
+
throw new IntegrityError(`Cannot decrypt this resource: its content-derived id ("${derived}") ` +
|
|
1127
|
+
`does not match the requested id ("${expectedId}"). The server ` +
|
|
1128
|
+
'served this envelope under an id it was not written for.');
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1078
1132
|
/**
|
|
1079
1133
|
* @inheritdoc
|
|
1080
1134
|
*
|
|
@@ -1100,27 +1154,23 @@ export class EdvCodec {
|
|
|
1100
1154
|
// marker) served in the Collection metadata slot is then detected too. It
|
|
1101
1155
|
// seals to the current epoch key like every write, so it binds `was.epoch`
|
|
1102
1156
|
// 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
1157
|
const encrypted = await documentCipher.encrypt({
|
|
1111
1158
|
doc: { id, content: custom },
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1159
|
+
...this.#sealParams({
|
|
1160
|
+
...(resourceId === undefined
|
|
1161
|
+
? { collection: this.#collectionId }
|
|
1162
|
+
: { resource: resourceId }),
|
|
1163
|
+
// Deliberately un-blinded, even on a searchable collection: this
|
|
1164
|
+
// envelope is the WAS `/meta` value, not part of the resource's content
|
|
1165
|
+
// document, and it is stored in a different slot the search endpoint
|
|
1166
|
+
// never reads. The `meta.*` attribute paths a declared index may name
|
|
1167
|
+
// address the *content document's* own `meta` (the content type and
|
|
1168
|
+
// inline-encoding discriminator), which is a different object entirely
|
|
1169
|
+
// -- so blinding here would emit entries that can never match a query
|
|
1170
|
+
// and would leak the shape of the metadata into a slot with no index at
|
|
1171
|
+
// all.
|
|
1172
|
+
hmac: undefined
|
|
1173
|
+
})
|
|
1124
1174
|
});
|
|
1125
1175
|
// Surface the epoch this envelope sealed under: a Collection-level `/meta`
|
|
1126
1176
|
// PUT carries it as the body's top-level `epoch` stamp (the server clears
|
|
@@ -1392,7 +1442,12 @@ function parseWasHeader(jwe) {
|
|
|
1392
1442
|
}
|
|
1393
1443
|
let parsed;
|
|
1394
1444
|
try {
|
|
1395
|
-
parsed = JSON.parse(
|
|
1445
|
+
parsed = JSON.parse(
|
|
1446
|
+
// The shared lenient decoder, deliberately NOT `UTF8_DECODER`: its
|
|
1447
|
+
// `fatal: true` would turn malformed bytes into a throw rather than the
|
|
1448
|
+
// documented `undefined` return. The header is already proven authentic
|
|
1449
|
+
// by a successful decrypt.
|
|
1450
|
+
DECODER.decode(base64urlnopad.decode(protectedHeader)));
|
|
1396
1451
|
}
|
|
1397
1452
|
catch {
|
|
1398
1453
|
return undefined;
|
|
@@ -1459,66 +1514,108 @@ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT
|
|
|
1459
1514
|
if (scheme !== EDV_SCHEME) {
|
|
1460
1515
|
return null;
|
|
1461
1516
|
}
|
|
1462
|
-
|
|
1517
|
+
// Guard the descriptor before consulting the keystore, so a collection
|
|
1518
|
+
// whose descriptor cannot be opened reports THAT rather than the vaguer
|
|
1519
|
+
// "holds no keys" the null return below would produce. `buildEdvCodec`
|
|
1520
|
+
// guards again for callers that reach it directly; the guard is pure, so
|
|
1521
|
+
// running it twice costs nothing.
|
|
1522
|
+
guardEncryptionDescriptor({
|
|
1463
1523
|
label: `${spaceId}/${collectionId}`,
|
|
1464
1524
|
encryption
|
|
1465
1525
|
});
|
|
1466
|
-
const descriptorVersion = descriptor.version;
|
|
1467
1526
|
// Prefer override-supplied keys; otherwise consult the keystore.
|
|
1468
1527
|
const resolved = keys ??
|
|
1469
1528
|
(await resolveKeys({ spaceId, collectionId }));
|
|
1470
1529
|
if (!resolved) {
|
|
1471
1530
|
return null;
|
|
1472
1531
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
// refused a descriptor without epochs, the one case resolveEpochKeys
|
|
1479
|
-
// resolves null for.
|
|
1480
|
-
const epochKeys = (await resolveEpochKeys({
|
|
1481
|
-
encryption: descriptor,
|
|
1482
|
-
keyAgreementKey: resolved.keyAgreementKey
|
|
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,
|
|
1532
|
+
return buildEdvCodec({
|
|
1533
|
+
spaceId,
|
|
1534
|
+
collectionId,
|
|
1535
|
+
encryption,
|
|
1536
|
+
keys: resolved,
|
|
1507
1537
|
contentType,
|
|
1508
1538
|
maxBlobBytes,
|
|
1509
1539
|
...(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)
|
|
1540
|
+
idDerivation
|
|
1518
1541
|
});
|
|
1519
1542
|
}
|
|
1520
1543
|
};
|
|
1521
1544
|
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Builds the {@link EdvCodec} for one encrypted collection from a reader's
|
|
1547
|
+
* keys and the collection's encryption descriptor. The whole of the codec
|
|
1548
|
+
* build that follows key resolution: the fail-closed descriptor guard, the
|
|
1549
|
+
* reader's per-epoch keys, the collection's blinding key, and the EDV core the
|
|
1550
|
+
* codec drives. Shared by the keystore provider (`createEdvEncryption`'s
|
|
1551
|
+
* `codecFor`, which resolves the keys first) and by the local-replica cipher,
|
|
1552
|
+
* which holds its keys already.
|
|
1553
|
+
*
|
|
1554
|
+
* @param options {object}
|
|
1555
|
+
* @param options.spaceId {string} the collection's space id; labels errors
|
|
1556
|
+
* @param options.collectionId {string} the collection's WAS id
|
|
1557
|
+
* @param [options.encryption] {CollectionEncryption} the collection's
|
|
1558
|
+
* encryption descriptor; must carry the key-epoch roster
|
|
1559
|
+
* @param options.keys {EdvKeys} the reader's key material
|
|
1560
|
+
* @param options.idDerivation {'random' | 'content'} how `add()` mints ids
|
|
1561
|
+
* @param [options.contentType] {string} stored envelope content type
|
|
1562
|
+
* @param [options.maxBlobBytes] {number} the single-document threshold
|
|
1563
|
+
* @param [options.chunkSize] {number} the size of each encrypted chunk
|
|
1564
|
+
* @returns {Promise<EdvCodec>}
|
|
1565
|
+
*/
|
|
1566
|
+
export async function buildEdvCodec({ spaceId, collectionId, encryption, keys, idDerivation, contentType = DEFAULT_CONTENT_TYPE, maxBlobBytes = DEFAULT_MAX_BLOB_BYTES, chunkSize }) {
|
|
1567
|
+
const descriptor = guardEncryptionDescriptor({
|
|
1568
|
+
label: `${spaceId}/${collectionId}`,
|
|
1569
|
+
encryption
|
|
1570
|
+
});
|
|
1571
|
+
const descriptorVersion = descriptor.version;
|
|
1572
|
+
// Resolve the reader's per-epoch keys from the descriptor -- the
|
|
1573
|
+
// `currentEpoch` key pair for writes, every epoch key it can unwrap for
|
|
1574
|
+
// reads -- and drive the cipher with those. The reader's own
|
|
1575
|
+
// key-agreement key never encrypts or decrypts resources itself; it
|
|
1576
|
+
// only unwraps epoch keys. Non-null: the epochs guard above already
|
|
1577
|
+
// refused a descriptor without epochs, the one case resolveEpochKeys
|
|
1578
|
+
// resolves null for.
|
|
1579
|
+
const epochKeys = (await resolveEpochKeys({
|
|
1580
|
+
encryption: descriptor,
|
|
1581
|
+
keyAgreementKey: keys.keyAgreementKey
|
|
1582
|
+
}));
|
|
1583
|
+
// Epoch keys are self-describing did:key key-agreement keys, so a
|
|
1584
|
+
// resource's recipient (the epoch public key) resolves through the
|
|
1585
|
+
// standard did:key resolver, independent of the reader's own keystore.
|
|
1586
|
+
const keyAgreementKey = epochKeys.writeKey;
|
|
1587
|
+
// The collection's blinding key: an explicitly supplied one (a keystore
|
|
1588
|
+
// custodying the HMAC key itself) wins over unwrapping the descriptor's
|
|
1589
|
+
// `hmac` member; `null` means the collection declares no blinded index.
|
|
1590
|
+
const hmac = keys.hmac ??
|
|
1591
|
+
(await resolveHmacKey({
|
|
1592
|
+
encryption: descriptor,
|
|
1593
|
+
keyAgreementKey: keys.keyAgreementKey
|
|
1594
|
+
}));
|
|
1595
|
+
const edv = new EdvClientCore({
|
|
1596
|
+
keyAgreementKey,
|
|
1597
|
+
keyResolver: didKeyResolver,
|
|
1598
|
+
...(hmac !== null && { hmac })
|
|
1599
|
+
});
|
|
1600
|
+
return new EdvCodec({
|
|
1601
|
+
edv,
|
|
1602
|
+
keyAgreementKey,
|
|
1603
|
+
hmac,
|
|
1604
|
+
readKeys: epochKeys.readKeys,
|
|
1605
|
+
writeEpoch: epochKeys.writeEpoch,
|
|
1606
|
+
contentType,
|
|
1607
|
+
maxBlobBytes,
|
|
1608
|
+
...(chunkSize !== undefined && { chunkSize }),
|
|
1609
|
+
idDerivation,
|
|
1610
|
+
version: descriptorVersion ?? EDV_SCHEME_VERSION,
|
|
1611
|
+
spaceId,
|
|
1612
|
+
collectionId,
|
|
1613
|
+
// Every epoch the descriptor lists, recipient of it or not, so
|
|
1614
|
+
// decrypt routing can tell "not a recipient of this epoch" apart
|
|
1615
|
+
// from "descriptor has never seen this epoch".
|
|
1616
|
+
epochIds: descriptor.epochs.map(epoch => epoch.id)
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1522
1619
|
/**
|
|
1523
1620
|
* Refuses an encryption descriptor this client cannot operate on, fail-closed:
|
|
1524
1621
|
* a descriptor from a future scheme version (this client does not implement
|
|
@@ -1578,10 +1675,9 @@ function guardEncryptionDescriptor({ label, encryption }) {
|
|
|
1578
1675
|
* @param options.idDerivation {'content' | 'random'} how ids are minted
|
|
1579
1676
|
* @param options.encryption {CollectionEncryption} the collection's
|
|
1580
1677
|
* descriptor; must carry the key-epoch roster
|
|
1581
|
-
* @param [options.contentType] {string} stored envelope content type
|
|
1582
1678
|
* @returns {Promise<EdvCodec>}
|
|
1583
1679
|
*/
|
|
1584
|
-
export async function encryptOnlyEdvCodec({ collectionId, idDerivation, encryption
|
|
1680
|
+
export async function encryptOnlyEdvCodec({ collectionId, idDerivation, encryption }) {
|
|
1585
1681
|
const descriptor = guardEncryptionDescriptor({
|
|
1586
1682
|
label: `"${collectionId}"`,
|
|
1587
1683
|
encryption
|
|
@@ -1630,7 +1726,7 @@ export async function encryptOnlyEdvCodec({ collectionId, idDerivation, encrypti
|
|
|
1630
1726
|
keyAgreementKey: writeKey,
|
|
1631
1727
|
readKeys: [],
|
|
1632
1728
|
writeEpoch,
|
|
1633
|
-
contentType,
|
|
1729
|
+
contentType: DEFAULT_CONTENT_TYPE,
|
|
1634
1730
|
maxBlobBytes: DEFAULT_MAX_BLOB_BYTES,
|
|
1635
1731
|
idDerivation,
|
|
1636
1732
|
version: descriptor.version ?? EDV_SCHEME_VERSION,
|