@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.
Files changed (96) hide show
  1. package/dist/Collection.d.ts.map +1 -1
  2. package/dist/Collection.js +27 -83
  3. package/dist/Collection.js.map +1 -1
  4. package/dist/Resource.d.ts +10 -7
  5. package/dist/Resource.d.ts.map +1 -1
  6. package/dist/Resource.js +32 -99
  7. package/dist/Resource.js.map +1 -1
  8. package/dist/codec.d.ts +12 -3
  9. package/dist/codec.d.ts.map +1 -1
  10. package/dist/codec.js.map +1 -1
  11. package/dist/edv/EdvCodec.d.ts +35 -4
  12. package/dist/edv/EdvCodec.d.ts.map +1 -1
  13. package/dist/edv/EdvCodec.js +296 -200
  14. package/dist/edv/EdvCodec.js.map +1 -1
  15. package/dist/edv/WasTransport.js +2 -2
  16. package/dist/edv/WasTransport.js.map +1 -1
  17. package/dist/edv/descriptorStore.d.ts +10 -6
  18. package/dist/edv/descriptorStore.d.ts.map +1 -1
  19. package/dist/edv/descriptorStore.js +5 -3
  20. package/dist/edv/descriptorStore.js.map +1 -1
  21. package/dist/edv/didKeyRecipient.d.ts.map +1 -1
  22. package/dist/edv/didKeyRecipient.js +1 -1
  23. package/dist/edv/didKeyRecipient.js.map +1 -1
  24. package/dist/edv/docCipher.d.ts.map +1 -1
  25. package/dist/edv/docCipher.js +11 -30
  26. package/dist/edv/docCipher.js.map +1 -1
  27. package/dist/edv/epochCrypto.d.ts +7 -0
  28. package/dist/edv/epochCrypto.d.ts.map +1 -1
  29. package/dist/edv/epochCrypto.js +12 -5
  30. package/dist/edv/epochCrypto.js.map +1 -1
  31. package/dist/edv/epochKeys.d.ts.map +1 -1
  32. package/dist/edv/epochKeys.js +21 -37
  33. package/dist/edv/epochKeys.js.map +1 -1
  34. package/dist/edv/epochRoster.d.ts +24 -0
  35. package/dist/edv/epochRoster.d.ts.map +1 -1
  36. package/dist/edv/epochRoster.js +26 -0
  37. package/dist/edv/epochRoster.js.map +1 -1
  38. package/dist/edv/recipients.d.ts +12 -10
  39. package/dist/edv/recipients.d.ts.map +1 -1
  40. package/dist/edv/recipients.js +254 -199
  41. package/dist/edv/recipients.js.map +1 -1
  42. package/dist/internal/codec.d.ts.map +1 -1
  43. package/dist/internal/codec.js +5 -18
  44. package/dist/internal/codec.js.map +1 -1
  45. package/dist/internal/conditional.d.ts +33 -2
  46. package/dist/internal/conditional.d.ts.map +1 -1
  47. package/dist/internal/conditional.js +52 -0
  48. package/dist/internal/conditional.js.map +1 -1
  49. package/dist/internal/content.d.ts +7 -0
  50. package/dist/internal/content.d.ts.map +1 -1
  51. package/dist/internal/content.js +7 -0
  52. package/dist/internal/content.js.map +1 -1
  53. package/dist/internal/features.d.ts.map +1 -1
  54. package/dist/internal/features.js +9 -9
  55. package/dist/internal/features.js.map +1 -1
  56. package/dist/internal/indexSchema.d.ts +0 -10
  57. package/dist/internal/indexSchema.d.ts.map +1 -1
  58. package/dist/internal/indexSchema.js +1 -1
  59. package/dist/internal/indexSchema.js.map +1 -1
  60. package/dist/internal/memo.d.ts +39 -0
  61. package/dist/internal/memo.d.ts.map +1 -0
  62. package/dist/internal/memo.js +59 -0
  63. package/dist/internal/memo.js.map +1 -0
  64. package/dist/internal/meta.d.ts +92 -0
  65. package/dist/internal/meta.d.ts.map +1 -0
  66. package/dist/internal/meta.js +126 -0
  67. package/dist/internal/meta.js.map +1 -0
  68. package/dist/internal/pagination.js +1 -1
  69. package/dist/internal/pagination.js.map +1 -1
  70. package/dist/internal/withCodec.d.ts +26 -0
  71. package/dist/internal/withCodec.d.ts.map +1 -0
  72. package/dist/internal/withCodec.js +15 -0
  73. package/dist/internal/withCodec.js.map +1 -0
  74. package/dist/internal/write.d.ts +5 -8
  75. package/dist/internal/write.d.ts.map +1 -1
  76. package/dist/internal/write.js +25 -11
  77. package/dist/internal/write.js.map +1 -1
  78. package/dist/log/logStore.d.ts +6 -3
  79. package/dist/log/logStore.d.ts.map +1 -1
  80. package/dist/log/logStore.js +6 -3
  81. package/dist/log/logStore.js.map +1 -1
  82. package/dist/sync/cid.d.ts.map +1 -1
  83. package/dist/sync/cid.js +1 -6
  84. package/dist/sync/cid.js.map +1 -1
  85. package/dist/sync/index.d.ts +2 -1
  86. package/dist/sync/index.d.ts.map +1 -1
  87. package/dist/sync/index.js +5 -1
  88. package/dist/sync/index.js.map +1 -1
  89. package/dist/sync/port.d.ts +0 -14
  90. package/dist/sync/port.d.ts.map +1 -1
  91. package/dist/sync/port.js +6 -19
  92. package/dist/sync/port.js.map +1 -1
  93. package/dist/sync/provisioning.d.ts.map +1 -1
  94. package/dist/sync/provisioning.js +23 -2
  95. package/dist/sync/provisioning.js.map +1 -1
  96. package/package.json +1 -1
@@ -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 stale write
51
- * surfaces as a `PreconditionFailedError` (412) -- the lost-update guard --
52
- * rather than the old advisory last-writer-wins. Against a backend that does
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
- recipients: this.#recipients,
500
- keyResolver: this.#edv.keyResolver,
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
- hmac: this.#writeBlindingKey(),
506
- update: priorDoc !== null,
507
- additionalProtectedParams: { was }
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
- // Pin an update to the server's current ETag; guard a fresh insert with
528
- // create-if-absent. An update's `If-Match` carries a server-provided ETag,
529
- // so it degrades to an advisory write against a backend without the
530
- // conditional-writes feature (the ETag is absent). A fresh insert's
531
- // `If-None-Match: *` needs no server-provided validator and so is emitted
532
- // unconditionally by design -- it expresses the insert's intent
533
- // (create-only-if-absent). A backend that does not honor it would ignore
534
- // it, so the write path refuses the insert-after-null-pre-read up front
535
- // on such a backend (see `upsertResource`) -- a masked-404 pre-read must
536
- // not silently overwrite an existing document there.
537
- ...(priorDoc
538
- ? { ifMatch: readEtag(current ?? null) }
539
- : { ifNoneMatch: true }),
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
- recipients: this.#recipients,
662
- keyResolver: this.#edv.keyResolver,
663
- hmac: this.#writeBlindingKey(),
664
- additionalProtectedParams: { was },
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
- * slots the envelope was written for -- `was.resource` a resource slot,
945
- * `was.collection` the Collection metadata slot, neither a content-derived
946
- * content envelope. A read of the Collection metadata slot
947
- * (`collectionSlot`) enforces:
948
- *
949
- * - `was.resource` present: refused outright -- a resource's envelope was
950
- * served in the Collection's metadata slot, which belongs to no resource --
951
- * {@link IntegrityError}.
952
- * - `was.collection` missing or not a string: refused -- the envelope belongs
953
- * to some other slot, notably a content-derived content envelope, whose
954
- * member set is otherwise identical -- {@link IntegrityError}.
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
- if (typeof was.resource === 'string') {
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
- // A resource slot (content or resource metadata). The Collection's own
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
- recipients: this.#recipients,
1113
- keyResolver: this.#edv.keyResolver,
1114
- // Deliberately un-blinded, even on a searchable collection: this envelope
1115
- // is the WAS `/meta` value, not part of the resource's content document,
1116
- // and it is stored in a different slot the search endpoint never reads.
1117
- // The `meta.*` attribute paths a declared index may name address the
1118
- // *content document's* own `meta` (the content type and inline-encoding
1119
- // discriminator), which is a different object entirely -- so blinding
1120
- // here would emit entries that can never match a query and would leak the
1121
- // shape of the metadata into a slot with no index at all.
1122
- hmac: undefined,
1123
- additionalProtectedParams: { was }
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(HEADER_DECODER.decode(base64urlnopad.decode(protectedHeader)));
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
- const descriptor = guardEncryptionDescriptor({
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
- // Resolve the reader's per-epoch keys from the descriptor -- the
1474
- // `currentEpoch` key pair for writes, every epoch key it can unwrap for
1475
- // reads -- and drive the cipher with those. The reader's own
1476
- // key-agreement key never encrypts or decrypts resources itself; it
1477
- // only unwraps epoch keys. Non-null: the epochs guard above already
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, contentType = DEFAULT_CONTENT_TYPE }) {
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,