@interop/was-client 0.34.0 → 0.35.1

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 (59) hide show
  1. package/README.md +112 -0
  2. package/dist/Collection.d.ts +172 -1
  3. package/dist/Collection.d.ts.map +1 -1
  4. package/dist/Collection.js +368 -2
  5. package/dist/Collection.js.map +1 -1
  6. package/dist/codec.d.ts +101 -3
  7. package/dist/codec.d.ts.map +1 -1
  8. package/dist/edv/EdvCodec.d.ts +38 -4
  9. package/dist/edv/EdvCodec.d.ts.map +1 -1
  10. package/dist/edv/EdvCodec.js +318 -54
  11. package/dist/edv/EdvCodec.js.map +1 -1
  12. package/dist/edv/docCipher.d.ts +4 -3
  13. package/dist/edv/docCipher.d.ts.map +1 -1
  14. package/dist/edv/docCipher.js +17 -17
  15. package/dist/edv/docCipher.js.map +1 -1
  16. package/dist/edv/hmacKey.d.ts +76 -0
  17. package/dist/edv/hmacKey.d.ts.map +1 -0
  18. package/dist/edv/hmacKey.js +105 -0
  19. package/dist/edv/hmacKey.js.map +1 -0
  20. package/dist/edv/index.d.ts +7 -0
  21. package/dist/edv/index.d.ts.map +1 -1
  22. package/dist/edv/index.js +6 -0
  23. package/dist/edv/index.js.map +1 -1
  24. package/dist/edv/recipients.d.ts +22 -3
  25. package/dist/edv/recipients.d.ts.map +1 -1
  26. package/dist/edv/recipients.js +197 -21
  27. package/dist/edv/recipients.js.map +1 -1
  28. package/dist/errors.d.ts +20 -10
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/errors.js +24 -14
  31. package/dist/errors.js.map +1 -1
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/internal/codec.d.ts.map +1 -1
  35. package/dist/internal/codec.js +64 -3
  36. package/dist/internal/codec.js.map +1 -1
  37. package/dist/internal/content.d.ts +12 -0
  38. package/dist/internal/content.d.ts.map +1 -1
  39. package/dist/internal/content.js +18 -0
  40. package/dist/internal/content.js.map +1 -1
  41. package/dist/internal/indexSchema.d.ts +95 -0
  42. package/dist/internal/indexSchema.d.ts.map +1 -0
  43. package/dist/internal/indexSchema.js +120 -0
  44. package/dist/internal/indexSchema.js.map +1 -0
  45. package/dist/internal/paths.d.ts +6 -0
  46. package/dist/internal/paths.d.ts.map +1 -1
  47. package/dist/internal/paths.js +8 -0
  48. package/dist/internal/paths.js.map +1 -1
  49. package/dist/paths.d.ts +1 -1
  50. package/dist/paths.d.ts.map +1 -1
  51. package/dist/paths.js +1 -1
  52. package/dist/paths.js.map +1 -1
  53. package/dist/sync/index.d.ts +9 -5
  54. package/dist/sync/index.d.ts.map +1 -1
  55. package/dist/sync/index.js +9 -5
  56. package/dist/sync/index.js.map +1 -1
  57. package/dist/types.d.ts +40 -1
  58. package/dist/types.d.ts.map +1 -1
  59. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  import { EdvClientCore } from '@interop/edv-client';
2
2
  import type { IKeyAgreementKey, IKeyResolver } from '@interop/data-integrity-core';
3
- import type { EncodedWrite, EncryptionProvider, ResourceCodec, ResponseLike } from '../codec.js';
3
+ import type { CodecIndexing, EncodedWrite, EncryptionProvider, ResourceCodec, ResponseLike } from '../codec.js';
4
+ import type { BlindingKey } from './hmacKey.js';
4
5
  import type { Json, ResourceData, ResourceMetadataCustom } from '../types.js';
5
6
  /**
6
7
  * A {@link ResourceCodec} that encrypts on write and decrypts on read using an
@@ -30,9 +31,16 @@ export declare class EdvCodec implements ResourceCodec {
30
31
  * JWE ciphertext, content-addressed)
31
32
  * @param [options.version] {number} the EDV-over-WAS scheme version to bind
32
33
  * into each envelope's `was.v` (defaults to {@link EDV_SCHEME_VERSION})
33
- * @param [options.collectionId] {string} labels decrypt-routing errors
34
+ * @param options.collectionId {string} the Collection this codec reads and
35
+ * writes: bound into the Collection metadata envelope's `was.collection`
36
+ * (and checked on read), and it labels decrypt-routing errors
37
+ * @param options.epochIds {string[]} the id of every epoch the descriptor
38
+ * lists (held by this reader or not); decrypt routing checks it to tell a
39
+ * not-a-recipient envelope apart from a stale-descriptor one
40
+ * @param [options.hmac] {BlindingKey} the collection's blinded-index key,
41
+ * where it declares one
34
42
  */
35
- constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation, version, collectionId }: {
43
+ constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation, version, collectionId, epochIds, hmac }: {
36
44
  edv: EdvClientCore;
37
45
  keyAgreementKey: IKeyAgreementKey;
38
46
  readKeys: IKeyAgreementKey[];
@@ -41,8 +49,20 @@ export declare class EdvCodec implements ResourceCodec {
41
49
  maxBlobBytes: number;
42
50
  idDerivation: 'random' | 'content';
43
51
  version?: number;
44
- collectionId?: string;
52
+ collectionId: string;
53
+ epochIds: string[];
54
+ hmac?: BlindingKey | null;
45
55
  });
56
+ /**
57
+ * The collection's blinded-index key, or `null` where it declares none.
58
+ *
59
+ * @returns {BlindingKey | null}
60
+ */
61
+ get blindingKey(): BlindingKey | null;
62
+ /**
63
+ * @inheritdoc
64
+ */
65
+ get indexing(): CodecIndexing | undefined;
46
66
  /**
47
67
  * @inheritdoc
48
68
  */
@@ -70,6 +90,7 @@ export declare class EdvCodec implements ResourceCodec {
70
90
  id?: string;
71
91
  }): Promise<{
72
92
  custom: object;
93
+ epoch: string;
73
94
  }>;
74
95
  /**
75
96
  * @inheritdoc
@@ -78,6 +99,11 @@ export declare class EdvCodec implements ResourceCodec {
78
99
  * absent `custom` (no metadata written yet, or cleared) decodes to `{}`; a
79
100
  * present value must be an EDV envelope (else {@link EncryptionError}, the
80
101
  * `_assertEnvelope` guard), so a foreign plaintext `custom` fails closed.
102
+ *
103
+ * An omitted `expectedId` means the Collection-level metadata slot (a
104
+ * Resource metadata read always passes its resource id), so an envelope bound
105
+ * to a resource is refused there as a server-side swap, and one that does not
106
+ * bind this Collection's own id is refused as an envelope of some other slot.
81
107
  */
82
108
  decodeMeta({ custom }: {
83
109
  custom?: unknown;
@@ -89,6 +115,14 @@ export declare class EdvCodec implements ResourceCodec {
89
115
  export interface EdvKeys {
90
116
  keyAgreementKey: IKeyAgreementKey;
91
117
  keyResolver: IKeyResolver;
118
+ /**
119
+ * The collection's blinded-index key, for a keystore that custodies the HMAC
120
+ * key directly rather than reading it off the descriptor. An explicitly
121
+ * supplied key wins over unwrapping the descriptor's `hmac` member; omit it
122
+ * to let the descriptor decide (and to get `null` when the collection
123
+ * declares no blinded index at all).
124
+ */
125
+ hmac?: BlindingKey;
92
126
  }
93
127
  /**
94
128
  * Builds an {@link EncryptionProvider} for the `edv` scheme: a pure **keystore**
@@ -1 +1 @@
1
- {"version":3,"file":"EdvCodec.d.ts","sourceRoot":"","sources":["../../src/edv/EdvCodec.ts"],"names":[],"mappings":"AA2DA,OAAO,EAAE,aAAa,EAAe,MAAM,qBAAqB,CAAA;AAChE,OAAO,KAAK,EAEV,gBAAgB,EAChB,YAAY,EAEb,MAAM,8BAA8B,CAAA;AACrC,OAAO,KAAK,EACV,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACb,YAAY,EACb,MAAM,aAAa,CAAA;AAkBpB,OAAO,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAA;AA8H7E;;;;GAIG;AACH,qBAAa,QAAS,YAAW,aAAa;;IAC5C,QAAQ,CAAC,iBAAiB,QAAO;IAiCjC;;;;;;;;;;;;;;;;;;;;;OAqBG;gBACS,EACV,GAAG,EACH,eAAe,EACf,QAAQ,EACR,UAAU,EACV,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,OAAO,EACP,YAAY,EACb,EAAE;QACD,GAAG,EAAE,aAAa,CAAA;QAClB,eAAe,EAAE,gBAAgB,CAAA;QACjC,QAAQ,EAAE,gBAAgB,EAAE,CAAA;QAC5B,UAAU,EAAE,MAAM,CAAA;QAClB,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,YAAY,EAAE,QAAQ,GAAG,SAAS,CAAA;QAClC,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,YAAY,CAAC,EAAE,MAAM,CAAA;KACtB;IAaD;;OAEG;IACG,MAAM,CAAC,EACX,EAAE,EACF,IAAI,EACJ,WAAW,EACX,OAAO,EACR,EAAE;QACD,EAAE,CAAC,EAAE,MAAM,CAAA;QACX,IAAI,EAAE,YAAY,CAAA;QAClB,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,OAAO,CAAC,EAAE,YAAY,GAAG,IAAI,CAAA;KAC9B,GAAG,OAAO,CAAC,YAAY,CAAC;IA0GzB;;OAEG;IACG,MAAM,CACV,QAAQ,EAAE,YAAY,EACtB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAgOvB;;;;;;;;OAQG;IACG,UAAU,CAAC,EACf,MAAM,EACN,EAAE,EAAE,UAAU,EACf,EAAE;QACD,MAAM,EAAE,sBAAsB,CAAA;QAC9B,EAAE,CAAC,EAAE,MAAM,CAAA;KACZ,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IA4B/B;;;;;;;OAOG;IACG,UAAU,CACd,EACE,MAAM,EACP,EAAE;QACD,MAAM,CAAC,EAAE,OAAO,CAAA;KACjB,EACD,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,sBAAsB,CAAC;CA4LnC;AAoDD;;GAEG;AACH,MAAM,WAAW,OAAO;IACtB,eAAe,EAAE,gBAAgB,CAAA;IACjC,WAAW,EAAE,YAAY,CAAA;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,WAAW,EACX,WAAkC,EAClC,YAAqC,EACrC,YAAuB,EACxB,EAAE;IACD,WAAW,EAAE,CAAC,GAAG,EAAE;QACjB,OAAO,EAAE,MAAM,CAAA;QACf,YAAY,EAAE,MAAM,CAAA;KACrB,KAAK,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAA;CACpC,GAAG,kBAAkB,CAyErB"}
1
+ {"version":3,"file":"EdvCodec.d.ts","sourceRoot":"","sources":["../../src/edv/EdvCodec.ts"],"names":[],"mappings":"AAgEA,OAAO,EAAE,aAAa,EAAe,MAAM,qBAAqB,CAAA;AAChE,OAAO,KAAK,EAEV,gBAAgB,EAChB,YAAY,EAEb,MAAM,8BAA8B,CAAA;AACrC,OAAO,KAAK,EAEV,aAAa,EACb,YAAY,EACZ,kBAAkB,EAElB,aAAa,EACb,YAAY,EACb,MAAM,aAAa,CAAA;AAiBpB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAO/C,OAAO,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAA;AAqI7E;;;;GAIG;AACH,qBAAa,QAAS,YAAW,aAAa;;IAC5C,QAAQ,CAAC,iBAAiB,QAAO;IAgEjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;gBACS,EACV,GAAG,EACH,eAAe,EACf,QAAQ,EACR,UAAU,EACV,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,OAAO,EACP,YAAY,EACZ,QAAQ,EACR,IAAI,EACL,EAAE;QACD,GAAG,EAAE,aAAa,CAAA;QAClB,eAAe,EAAE,gBAAgB,CAAA;QACjC,QAAQ,EAAE,gBAAgB,EAAE,CAAA;QAC5B,UAAU,EAAE,MAAM,CAAA;QAClB,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,YAAY,EAAE,QAAQ,GAAG,SAAS,CAAA;QAClC,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,YAAY,EAAE,MAAM,CAAA;QACpB,QAAQ,EAAE,MAAM,EAAE,CAAA;QAClB,IAAI,CAAC,EAAE,WAAW,GAAG,IAAI,CAAA;KAC1B;IAyBD;;;;OAIG;IACH,IAAI,WAAW,IAAI,WAAW,GAAG,IAAI,CAEpC;IAED;;OAEG;IACH,IAAI,QAAQ,IAAI,aAAa,GAAG,SAAS,CAExC;IAmED;;OAEG;IACG,MAAM,CAAC,EACX,EAAE,EACF,IAAI,EACJ,WAAW,EACX,OAAO,EACR,EAAE;QACD,EAAE,CAAC,EAAE,MAAM,CAAA;QACX,IAAI,EAAE,YAAY,CAAA;QAClB,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,OAAO,CAAC,EAAE,YAAY,GAAG,IAAI,CAAA;KAC9B,GAAG,OAAO,CAAC,YAAY,CAAC;IA6HzB;;OAEG;IACG,MAAM,CACV,QAAQ,EAAE,YAAY,EACtB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAoUvB;;;;;;;;OAQG;IACG,UAAU,CAAC,EACf,MAAM,EACN,EAAE,EAAE,UAAU,EACf,EAAE;QACD,MAAM,EAAE,sBAAsB,CAAA;QAC9B,EAAE,CAAC,EAAE,MAAM,CAAA;KACZ,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IA4C9C;;;;;;;;;;;;OAYG;IACG,UAAU,CACd,EACE,MAAM,EACP,EAAE;QACD,MAAM,CAAC,EAAE,OAAO,CAAA;KACjB,EACD,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,sBAAsB,CAAC;CAgMnC;AAoDD;;GAEG;AACH,MAAM,WAAW,OAAO;IACtB,eAAe,EAAE,gBAAgB,CAAA;IACjC,WAAW,EAAE,YAAY,CAAA;IACzB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,WAAW,CAAA;CACnB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,WAAW,EACX,WAAkC,EAClC,YAAqC,EACrC,YAAuB,EACxB,EAAE;IACD,WAAW,EAAE,CAAC,GAAG,EAAE;QACjB,OAAO,EAAE,MAAM,CAAA;QACf,YAAY,EAAE,MAAM,CAAA;KACrB,KAAK,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAA;CACpC,GAAG,kBAAkB,CAwFrB"}
@@ -54,15 +54,22 @@
54
54
  * encrypted into an EDV Document envelope with the same `documentCipher` used
55
55
  * for content and stored opaquely under `/meta`; the server never sees
56
56
  * plaintext `name`/`tags`. A reader with the keys decrypts it back
57
- * transparently via `meta()`.
57
+ * transparently via `meta()`. The Collection-level `/meta` surface
58
+ * (`Collection.meta()` / `setMeta()`) runs through the same pair, with no
59
+ * resource id to bind: its envelope binds the collection id
60
+ * (`was.collection`) instead, and the decode side refuses both a
61
+ * resource-bound envelope served into that slot and a collection-bound one
62
+ * served into a resource's.
58
63
  */
59
64
  import { base64, base64urlnopad } from '@scure/base';
60
65
  import { EdvClientCore, assertDocId } from '@interop/edv-client';
66
+ import { EMPTY_INDEX_SCHEMA, assertQueryAttributes } from '../internal/indexSchema.js';
61
67
  import { EncryptionError, IntegrityError, KeyUnwrapError, UnknownEpochError, ValidationError } from '../errors.js';
62
68
  import { readEtag } from '../internal/conditional.js';
63
69
  import { isEncryptedEnvelope } from '../sync/envelope.js';
64
70
  import { resolveEpochKeys } from './epochKeys.js';
65
71
  import { didKeyResolver } from './epochCrypto.js';
72
+ import { resolveHmacKey } from './hmacKey.js';
66
73
  import { isBlob, isTextContentType, readJsonData, resolvePayload } from '../internal/content.js';
67
74
  import { DEFAULT_CONTENT_TYPE, EDV_SCHEME_VERSION, envelopeBytes } from './constants.js';
68
75
  /**
@@ -95,21 +102,26 @@ const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
95
102
  const HEADER_DECODER = new TextDecoder();
96
103
  /**
97
104
  * Builds the AEAD-bound `was` protected-header parameter: the scheme version
98
- * and key epoch always, and the resource id when it is known at encrypt time
99
- * (absent for a content-derived id, which does not exist until after
100
- * encryption). The one shape both the content and the metadata write paths
101
- * bind.
105
+ * and key epoch always, and then exactly one slot marker -- the resource id
106
+ * when it is known at encrypt time (absent for a content-derived id, which
107
+ * does not exist until after encryption), or the collection id for the
108
+ * Collection metadata envelope, which belongs to no resource. The one shape
109
+ * both the content and the metadata write paths bind.
102
110
  *
103
111
  * @param options {object}
104
112
  * @param options.version {number} the EDV-over-WAS scheme version
105
113
  * @param [options.resource] {string} the resource id the envelope is bound to
114
+ * @param [options.collection] {string} the collection id the envelope is
115
+ * bound to (the Collection metadata slot only, never alongside `resource`)
106
116
  * @param [options.epoch] {string} the key epoch the write encrypts under
107
- * @returns {{ v: number, resource?: string, epoch?: string }}
117
+ * @returns {{ v: number, resource?: string, collection?: string,
118
+ * epoch?: string }}
108
119
  */
109
- function wasParam({ version, resource, epoch }) {
120
+ function wasParam({ version, resource, collection, epoch }) {
110
121
  return {
111
122
  v: version,
112
123
  ...(resource !== undefined && { resource }),
124
+ ...(collection !== undefined && { collection }),
113
125
  ...(epoch !== undefined && { epoch })
114
126
  };
115
127
  }
@@ -201,10 +213,41 @@ export class EdvCodec {
201
213
  */
202
214
  #version;
203
215
  /**
204
- * Labels decrypt-routing errors ({@link UnknownEpochError}); the codec is
205
- * otherwise collection-agnostic.
216
+ * The id of the Collection this codec was built for. It is bound into the
217
+ * Collection metadata envelope's `was.collection` on write and required to
218
+ * match on read, and it labels decrypt-routing errors
219
+ * ({@link UnknownEpochError}).
206
220
  */
207
221
  #collectionId;
222
+ /**
223
+ * The id of every epoch the descriptor lists -- held by this reader or not.
224
+ * Decrypt routing uses it to tell the two unroutable-envelope cases apart:
225
+ * an envelope kid whose epoch is listed here but matches no candidate key
226
+ * means this reader is not a recipient of that epoch
227
+ * ({@link KeyUnwrapError}); a kid whose epoch is not listed at all means the
228
+ * descriptor this codec was built from has never seen the epoch
229
+ * ({@link UnknownEpochError} -- the stale-descriptor signal).
230
+ */
231
+ #epochIds;
232
+ /**
233
+ * The collection's blinded-index key, or `null` where the collection
234
+ * declares none. It blinds attribute names and values both on write (the
235
+ * `indexed` entries an envelope carries) and on query, so equal plaintext
236
+ * yields equal tokens and the server can match without learning either.
237
+ */
238
+ #blindingKey;
239
+ /**
240
+ * The collection's persisted index schema, as last applied through
241
+ * {@link indexing}. Empty until the schema is loaded (at codec resolution) or
242
+ * a `declareIndex` installs one -- and while it is empty the write seam emits
243
+ * no `indexed` entries, since there is nothing declared to index.
244
+ */
245
+ #schema = EMPTY_INDEX_SCHEMA;
246
+ /**
247
+ * The scheme-agnostic search capability handed to the handle layer, present
248
+ * exactly when this collection declares a blinding key.
249
+ */
250
+ #indexing;
208
251
  /**
209
252
  * @param options {object}
210
253
  * @param options.edv {EdvClientCore} holds the cipher + key resolver
@@ -225,9 +268,16 @@ export class EdvCodec {
225
268
  * JWE ciphertext, content-addressed)
226
269
  * @param [options.version] {number} the EDV-over-WAS scheme version to bind
227
270
  * into each envelope's `was.v` (defaults to {@link EDV_SCHEME_VERSION})
228
- * @param [options.collectionId] {string} labels decrypt-routing errors
271
+ * @param options.collectionId {string} the Collection this codec reads and
272
+ * writes: bound into the Collection metadata envelope's `was.collection`
273
+ * (and checked on read), and it labels decrypt-routing errors
274
+ * @param options.epochIds {string[]} the id of every epoch the descriptor
275
+ * lists (held by this reader or not); decrypt routing checks it to tell a
276
+ * not-a-recipient envelope apart from a stale-descriptor one
277
+ * @param [options.hmac] {BlindingKey} the collection's blinded-index key,
278
+ * where it declares one
229
279
  */
230
- constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation, version, collectionId }) {
280
+ constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation, version, collectionId, epochIds, hmac }) {
231
281
  this.#edv = edv;
232
282
  this.#recipients =
233
283
  edv.documentCipher.createDefaultRecipients(keyAgreementKey);
@@ -237,7 +287,82 @@ export class EdvCodec {
237
287
  this.#maxBlobBytes = maxBlobBytes;
238
288
  this.#idDerivation = idDerivation;
239
289
  this.#version = version ?? EDV_SCHEME_VERSION;
240
- this.#collectionId = collectionId ?? '(unknown)';
290
+ this.#collectionId = collectionId;
291
+ this.#epochIds = new Set(epochIds);
292
+ this.#blindingKey = hmac ?? null;
293
+ if (this.#blindingKey !== null) {
294
+ this.#indexing = {
295
+ applySchema: (schema) => this.#applySchema(schema),
296
+ schema: () => this.#schema,
297
+ buildQuery: (input) => this.#buildQuery(input)
298
+ };
299
+ }
300
+ }
301
+ /**
302
+ * The collection's blinded-index key, or `null` where it declares none.
303
+ *
304
+ * @returns {BlindingKey | null}
305
+ */
306
+ get blindingKey() {
307
+ return this.#blindingKey;
308
+ }
309
+ /**
310
+ * @inheritdoc
311
+ */
312
+ get indexing() {
313
+ return this.#indexing;
314
+ }
315
+ /**
316
+ * Installs the persisted schema: records it, then registers each declared
317
+ * attribute with the EDV core, whose index helper is what actually blinds
318
+ * attributes on write and builds query terms. Registration is idempotent, so
319
+ * re-applying the same schema (or a superset of it) is safe.
320
+ *
321
+ * @param schema {IndexSchema}
322
+ * @returns {void}
323
+ */
324
+ #applySchema(schema) {
325
+ this.#schema = schema;
326
+ for (const entry of schema.indexes) {
327
+ this.#edv.ensureIndex({
328
+ attribute: entry.attribute,
329
+ unique: entry.unique === true
330
+ });
331
+ }
332
+ }
333
+ /**
334
+ * Blinds a caller's search terms with the collection's blinding key. Refuses
335
+ * an attribute the persisted schema does not declare (see
336
+ * `assertQueryAttributes`), and refuses a query that blinded to no terms at
337
+ * all -- the residual footgun the name check cannot see, e.g. a compound
338
+ * index queried by something other than a leading prefix of its members.
339
+ *
340
+ * @param input {object}
341
+ * @param [input.equals] {object | object[]}
342
+ * @param [input.has] {string | string[]}
343
+ * @returns {Promise<BlindedQuery>}
344
+ */
345
+ async #buildQuery({ equals, has }) {
346
+ const hmac = this.#blindingKey;
347
+ if (hmac === null) {
348
+ throw new EncryptionError(`Collection ${this.#collectionId} declares no blinded-index key, so ` +
349
+ 'its contents cannot be searched.');
350
+ }
351
+ assertQueryAttributes({ schema: this.#schema, equals, has });
352
+ const query = (await this.#edv.indexHelper.buildQuery({
353
+ hmac,
354
+ equals,
355
+ has
356
+ }));
357
+ const termless = (query.equals?.every(term => Object.keys(term).length === 0) ?? false) ||
358
+ query.has?.length === 0;
359
+ if (termless) {
360
+ throw new ValidationError('This search blinded to no index terms, so it would match nothing. ' +
361
+ 'A compound index can only be queried by a leading prefix of its ' +
362
+ "attributes; check the collection's declared indexes with " +
363
+ 'indexes().');
364
+ }
365
+ return query;
241
366
  }
242
367
  /**
243
368
  * @inheritdoc
@@ -303,7 +428,11 @@ export class EdvCodec {
303
428
  },
304
429
  recipients: this.#recipients,
305
430
  keyResolver: this.#edv.keyResolver,
306
- hmac: undefined,
431
+ // Blind the declared attributes into the envelope's cleartext `indexed`
432
+ // entries, so the server can match a search without decrypting anything.
433
+ // Absent a blinding key or a declared attribute there is nothing to
434
+ // index, and passing no hmac carries any prior entries through verbatim.
435
+ hmac: this.#writeBlindingKey(),
307
436
  update: priorDoc !== null,
308
437
  additionalProtectedParams: { was }
309
438
  });
@@ -343,12 +472,26 @@ export class EdvCodec {
343
472
  epoch: this.#writeEpoch
344
473
  };
345
474
  }
475
+ /**
476
+ * The blinding key a content write should index with: the collection's key
477
+ * once the applied schema declares at least one attribute, else `undefined`
478
+ * (the cipher then computes no `indexed` entries and passes any already
479
+ * stored on the prior envelope through unchanged).
480
+ *
481
+ * @returns {BlindingKey | undefined}
482
+ */
483
+ #writeBlindingKey() {
484
+ if (this.#blindingKey === null || this.#schema.indexes.length === 0) {
485
+ return undefined;
486
+ }
487
+ return this.#blindingKey;
488
+ }
346
489
  /**
347
490
  * @inheritdoc
348
491
  */
349
492
  async decode(response, expectedId) {
350
493
  const stored = await readJsonData(response);
351
- const decrypted = await this.#openEnvelope(stored, expectedId);
494
+ const decrypted = await this.#openEnvelope({ doc: stored, expectedId });
352
495
  return this.#fromDocument(decrypted.content, decrypted.meta);
353
496
  }
354
497
  /**
@@ -358,17 +501,24 @@ export class EdvCodec {
358
501
  * protected header authentic, so the order is load-bearing). The one opening
359
502
  * shared by {@link decode} and {@link decodeMeta}.
360
503
  *
361
- * @param doc {unknown} the stored document read from the server
362
- * @param [expectedId] {string} the resource id the read targeted
504
+ * @param options {object}
505
+ * @param options.doc {unknown} the stored document read from the server
506
+ * @param [options.expectedId] {string} the resource id the read targeted
507
+ * @param [options.collectionSlot] {boolean} the read addressed the
508
+ * Collection metadata slot, which belongs to no resource: an envelope bound
509
+ * to a resource id is refused there, and one bound to this Collection's id
510
+ * is required (see {@link _verifyBinding}). Set only by the
511
+ * Collection-level metadata read
363
512
  * @returns {Promise<{ content?: unknown; meta?: Record<string, unknown>;
364
513
  * keyId: string }>} the decrypted document
365
514
  */
366
- async #openEnvelope(doc, expectedId) {
515
+ async #openEnvelope({ doc, expectedId, collectionSlot }) {
367
516
  this.#assertEnvelope(doc, 'read');
368
517
  const decrypted = await this.#decrypt(doc);
369
518
  await this.#verifyBinding({
370
519
  jwe: doc.jwe,
371
520
  expectedId,
521
+ collectionSlot,
372
522
  keyId: decrypted.keyId
373
523
  });
374
524
  return decrypted;
@@ -380,12 +530,18 @@ export class EdvCodec {
380
530
  * selects that epoch's key, so history stays readable.
381
531
  *
382
532
  * A stored envelope naming only recipients this reader holds no candidate
383
- * key for fails fast with {@link UnknownEpochError} -- the signal that the
384
- * cached Collection Description may be stale (an epoch rotation emits no
533
+ * key for fails fast, and which error it raises depends on whether the
534
+ * descriptor lists the named epoch. An epoch the descriptor lists but wraps
535
+ * only to other recipients raises {@link KeyUnwrapError}: this reader is
536
+ * not a recipient of that epoch (it never was, or it was removed and the
537
+ * epoch rotated), so re-reading the descriptor cannot help. That is the
538
+ * read axis only; it says nothing about whether the server will still
539
+ * serve (pull) the ciphertext. An epoch the descriptor does not list at
540
+ * all raises {@link UnknownEpochError} -- the signal that the cached
541
+ * Collection Description may be stale (an epoch rotation emits no
385
542
  * change-feed entry) and the codec must be rebuilt from a re-read
386
- * descriptor. A candidate whose entry then fails to unwrap surfaces
387
- * {@link KeyUnwrapError} -- the **read** axis only; it says nothing about
388
- * whether the server will still serve (pull) the ciphertext.
543
+ * descriptor. A candidate whose entry then fails to unwrap also surfaces
544
+ * {@link KeyUnwrapError}.
389
545
  *
390
546
  * Also returns the `id` of the key that actually decrypted the envelope (its
391
547
  * JWE recipient `kid`), so {@link _verifyBinding} can check a `was.epoch`
@@ -400,14 +556,28 @@ export class EdvCodec {
400
556
  const kidSet = new Set(kids);
401
557
  // Prefer the read key whose id names a recipient of this envelope; for a
402
558
  // well-formed envelope the exact match always hits. A non-empty recipient
403
- // set that matches NO candidate is unroutable: the envelope was encrypted
404
- // under an epoch (or key) this codec's descriptor knows nothing about, so
405
- // fail fast with the stale-descriptor signal rather than burning ECDH
406
- // attempts that cannot succeed. The `rest` fallback below is then reached
407
- // only for a malformed envelope naming no recipient kid at all, letting a
408
- // candidate surface the cipher's own typed decrypt error.
559
+ // set that matches NO candidate is unroutable, so fail fast rather than
560
+ // burning ECDH attempts that cannot succeed. Which failure it is depends
561
+ // on whether the descriptor lists the named epoch: an epoch key's kid is
562
+ // `<epoch did:key>#<fingerprint>`, so the portion before the fragment
563
+ // names the epoch. Listed but wrapped only to others: this reader is not
564
+ // a recipient of that epoch (the membership signal, KeyUnwrapError). Not
565
+ // listed at all: the descriptor has never seen the epoch (the
566
+ // stale-descriptor signal, UnknownEpochError). The `rest` fallback below
567
+ // is then reached only for a malformed envelope naming no recipient kid
568
+ // at all, letting a candidate surface the cipher's own typed decrypt
569
+ // error.
409
570
  const preferred = this.#readKeys.filter(key => kidSet.has(key.id));
410
571
  if (preferred.length === 0 && kids.length > 0) {
572
+ const listed = kids.some(kid => this.#epochIds.has(kid.split('#')[0] ?? kid));
573
+ if (listed) {
574
+ throw new KeyUnwrapError('Cannot decrypt this resource: it was encrypted under a key epoch ' +
575
+ 'this reader holds no key for. The epoch is on the Collection ' +
576
+ 'Description, but none of its recipient entries name this reader ' +
577
+ '(it was never a recipient of that epoch, or it was removed and ' +
578
+ 'the epoch rotated). This is the read axis only -- the server ' +
579
+ 'may still serve the ciphertext (a separate zcap decision).');
580
+ }
411
581
  throw new UnknownEpochError({ collectionId: this.#collectionId, kids });
412
582
  }
413
583
  const rest = this.#readKeys.filter(key => !kidSet.has(key.id));
@@ -457,12 +627,34 @@ export class EdvCodec {
457
627
  * envelope stamps its scheme version) -- {@link EncryptionError}. Greater
458
628
  * than this codec's scheme version: a future-scheme envelope this client
459
629
  * does not implement -- {@link EncryptionError}.
630
+ * Then the slot markers, which declare positively which of the profile's
631
+ * slots the envelope was written for -- `was.resource` a resource slot,
632
+ * `was.collection` the Collection metadata slot, neither a content-derived
633
+ * content envelope. A read of the Collection metadata slot
634
+ * (`collectionSlot`) enforces:
635
+ *
636
+ * - `was.resource` present: refused outright -- a resource's envelope was
637
+ * served in the Collection's metadata slot, which belongs to no resource --
638
+ * {@link IntegrityError}.
639
+ * - `was.collection` missing or not a string: refused -- the envelope belongs
640
+ * to some other slot, notably a content-derived content envelope, whose
641
+ * member set is otherwise identical -- {@link IntegrityError}.
642
+ * - `was.collection` not this codec's collection id: one Collection's
643
+ * metadata served as another's -- {@link IntegrityError}.
644
+ *
645
+ * A read of a resource slot (content or resource metadata) enforces:
646
+ *
647
+ * - `was.collection` present: refused before any id comparison -- the
648
+ * Collection's metadata envelope was served in a resource slot --
649
+ * {@link IntegrityError}.
460
650
  * - `was.resource` present and the expected id known: a mismatch is a server-side
461
651
  * swap of two resources' envelopes -- {@link IntegrityError}.
462
652
  * - `resource` absent (a content-derived write) and the expected
463
653
  * id known: the envelope's ciphertext must re-derive to the expected id
464
654
  * ({@link EdvDocumentCipher.deriveId}); a mismatch means the envelope was
465
655
  * copied under a different id -- {@link IntegrityError}.
656
+ *
657
+ * Finally, unconditionally:
466
658
  * - `was.epoch` missing or not a string: refused like a missing `was` --
467
659
  * {@link EncryptionError}. Present, it must equal the epoch (the `did:key`
468
660
  * before the `#`) of the key that actually decrypted -- a mismatch is a
@@ -473,11 +665,13 @@ export class EdvCodec {
473
665
  * @param options.jwe {unknown} the envelope's JWE (its `protected` header is
474
666
  * parsed for `was`)
475
667
  * @param [options.expectedId] {string} the resource id the read targeted
668
+ * @param [options.collectionSlot] {boolean} the read addressed the
669
+ * Collection metadata slot
476
670
  * @param options.keyId {string} the id of the key that decrypted, for the
477
671
  * epoch check
478
672
  * @returns {Promise<void>}
479
673
  */
480
- async #verifyBinding({ jwe, expectedId, keyId }) {
674
+ async #verifyBinding({ jwe, expectedId, collectionSlot, keyId }) {
481
675
  const was = parseWasHeader(jwe);
482
676
  if (was === undefined) {
483
677
  throw new EncryptionError('Cannot decrypt this resource: its envelope carries no `was` binding ' +
@@ -496,24 +690,55 @@ export class EdvCodec {
496
690
  `EDV-over-WAS scheme version ${was.v}, which this client (version ` +
497
691
  `${this.#version}) does not implement. Upgrade the client.`);
498
692
  }
499
- if (typeof was.resource === 'string') {
500
- if (expectedId !== undefined && was.resource !== expectedId) {
501
- throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to a ` +
502
- `different resource id ("${was.resource}") than the one requested ` +
503
- `("${expectedId}"). The server swapped two resources' envelopes.`);
693
+ if (collectionSlot) {
694
+ if (typeof was.resource === 'string') {
695
+ throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
696
+ `bound to resource "${was.resource}", but the Collection metadata ` +
697
+ "slot belongs to no resource. The server swapped a resource's " +
698
+ "metadata envelope into the Collection's metadata slot.");
699
+ }
700
+ if (typeof was.collection !== 'string') {
701
+ throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope ` +
702
+ 'binds no `was.collection`, so it was written for some other slot ' +
703
+ '(a content envelope, whose binding is otherwise identical). The ' +
704
+ "server served a foreign envelope in the Collection's metadata slot.");
705
+ }
706
+ if (was.collection !== this.#collectionId) {
707
+ throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
708
+ `bound to collection "${was.collection}", not to the requested ` +
709
+ `collection ("${this.#collectionId}"). The server served one ` +
710
+ "Collection's metadata as another's.");
504
711
  }
505
712
  }
506
- else if (expectedId !== undefined) {
507
- // Content-derived write (no `resource`): the id is a function of the
508
- // ciphertext, so re-derive and compare.
509
- const { documentCipher } = this.#edv;
510
- const derived = await documentCipher.deriveId({
511
- jwe: jwe
512
- });
513
- if (derived !== expectedId) {
514
- throw new IntegrityError(`Cannot decrypt this resource: its content-derived id ("${derived}") ` +
515
- `does not match the requested id ("${expectedId}"). The server ` +
516
- 'served this envelope under an id it was not written for.');
713
+ else {
714
+ // A resource slot (content or resource metadata). The Collection's own
715
+ // metadata envelope has no business here, whatever id it names, so it is
716
+ // refused before any resource-id comparison.
717
+ if (was.collection !== undefined) {
718
+ throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to ` +
719
+ `collection "${String(was.collection)}", but a resource's slot ` +
720
+ 'belongs to no collection binding. The server served the ' +
721
+ "Collection's own metadata envelope in a resource's slot.");
722
+ }
723
+ if (typeof was.resource === 'string') {
724
+ if (expectedId !== undefined && was.resource !== expectedId) {
725
+ throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to a ` +
726
+ `different resource id ("${was.resource}") than the one requested ` +
727
+ `("${expectedId}"). The server swapped two resources' envelopes.`);
728
+ }
729
+ }
730
+ else if (expectedId !== undefined) {
731
+ // Content-derived write (no `resource`): the id is a function of the
732
+ // ciphertext, so re-derive and compare.
733
+ const { documentCipher } = this.#edv;
734
+ const derived = await documentCipher.deriveId({
735
+ jwe: jwe
736
+ });
737
+ if (derived !== expectedId) {
738
+ throw new IntegrityError(`Cannot decrypt this resource: its content-derived id ("${derived}") ` +
739
+ `does not match the requested id ("${expectedId}"). The server ` +
740
+ 'served this envelope under an id it was not written for.');
741
+ }
517
742
  }
518
743
  }
519
744
  if (typeof was.epoch !== 'string') {
@@ -548,23 +773,39 @@ export class EdvCodec {
548
773
  const id = (await this.#edv.generateId());
549
774
  // Bind the `was` parameter to the RESOURCE id (not the metadata envelope's
550
775
  // own random EDV id), so a server-side swap of two resources' metadata is
551
- // AEAD-detected on decode. The metadata envelope always knows the resource
552
- // id at encrypt time, so `resource` is always present here (never
553
- // content-derived). It seals to the current epoch key like every write,
554
- // so it binds `was.epoch` like every write.
776
+ // AEAD-detected on decode. A Resource-level write always knows that id at
777
+ // encrypt time (it is never content-derived here); a Collection-level write
778
+ // has no resource to bind and binds this collection's id instead, so its
779
+ // slot is declared positively -- a content envelope (which binds neither
780
+ // marker) served in the Collection metadata slot is then detected too. It
781
+ // seals to the current epoch key like every write, so it binds `was.epoch`
782
+ // like every write.
555
783
  const was = wasParam({
556
784
  version: this.#version,
557
- resource: resourceId,
785
+ ...(resourceId === undefined
786
+ ? { collection: this.#collectionId }
787
+ : { resource: resourceId }),
558
788
  epoch: this.#writeEpoch
559
789
  });
560
790
  const encrypted = await documentCipher.encrypt({
561
791
  doc: { id, content: custom },
562
792
  recipients: this.#recipients,
563
793
  keyResolver: this.#edv.keyResolver,
794
+ // Deliberately un-blinded, even on a searchable collection: this envelope
795
+ // is the WAS `/meta` value, not part of the resource's content document,
796
+ // and it is stored in a different slot the search endpoint never reads.
797
+ // The `meta.*` attribute paths a declared index may name address the
798
+ // *content document's* own `meta` (the content type and inline-encoding
799
+ // discriminator), which is a different object entirely -- so blinding
800
+ // here would emit entries that can never match a query and would leak the
801
+ // shape of the metadata into a slot with no index at all.
564
802
  hmac: undefined,
565
803
  additionalProtectedParams: { was }
566
804
  });
567
- return { custom: encrypted };
805
+ // Surface the epoch this envelope sealed under: a Collection-level `/meta`
806
+ // PUT carries it as the body's top-level `epoch` stamp (the server clears
807
+ // that stamp when it is omitted).
808
+ return { custom: encrypted, epoch: this.#writeEpoch };
568
809
  }
569
810
  /**
570
811
  * @inheritdoc
@@ -573,12 +814,21 @@ export class EdvCodec {
573
814
  * absent `custom` (no metadata written yet, or cleared) decodes to `{}`; a
574
815
  * present value must be an EDV envelope (else {@link EncryptionError}, the
575
816
  * `_assertEnvelope` guard), so a foreign plaintext `custom` fails closed.
817
+ *
818
+ * An omitted `expectedId` means the Collection-level metadata slot (a
819
+ * Resource metadata read always passes its resource id), so an envelope bound
820
+ * to a resource is refused there as a server-side swap, and one that does not
821
+ * bind this Collection's own id is refused as an envelope of some other slot.
576
822
  */
577
823
  async decodeMeta({ custom }, expectedId) {
578
824
  if (custom === undefined || custom === null) {
579
825
  return {};
580
826
  }
581
- const decrypted = await this.#openEnvelope(custom, expectedId);
827
+ const decrypted = await this.#openEnvelope({
828
+ doc: custom,
829
+ expectedId,
830
+ collectionSlot: expectedId === undefined
831
+ });
582
832
  return (decrypted.content ?? {});
583
833
  }
584
834
  /**
@@ -865,20 +1115,34 @@ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT
865
1115
  // resource's recipient (the epoch public key) resolves through the
866
1116
  // standard did:key resolver, independent of the reader's own keystore.
867
1117
  const keyAgreementKey = epochKeys.writeKey;
1118
+ // The collection's blinding key: an explicitly supplied one (a keystore
1119
+ // custodying the HMAC key itself) wins over unwrapping the descriptor's
1120
+ // `hmac` member; `null` means the collection declares no blinded index.
1121
+ const hmac = resolved.hmac ??
1122
+ (await resolveHmacKey({
1123
+ encryption,
1124
+ keyAgreementKey: resolved.keyAgreementKey
1125
+ }));
868
1126
  const edv = new EdvClientCore({
869
1127
  keyAgreementKey,
870
- keyResolver: didKeyResolver
1128
+ keyResolver: didKeyResolver,
1129
+ ...(hmac !== null && { hmac })
871
1130
  });
872
1131
  return new EdvCodec({
873
1132
  edv,
874
1133
  keyAgreementKey,
1134
+ hmac,
875
1135
  readKeys: epochKeys.readKeys,
876
1136
  writeEpoch: epochKeys.writeEpoch,
877
1137
  contentType,
878
1138
  maxBlobBytes,
879
1139
  idDerivation,
880
1140
  version: descriptorVersion ?? EDV_SCHEME_VERSION,
881
- collectionId
1141
+ collectionId,
1142
+ // Every epoch the descriptor lists, recipient of it or not, so
1143
+ // decrypt routing can tell "not a recipient of this epoch" apart
1144
+ // from "descriptor has never seen this epoch".
1145
+ epochIds: encryption.epochs.map(epoch => epoch.id)
882
1146
  });
883
1147
  }
884
1148
  };