@interop/was-client 0.17.0 → 0.19.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 (94) hide show
  1. package/README.md +88 -0
  2. package/dist/Collection.d.ts +1 -46
  3. package/dist/Collection.d.ts.map +1 -1
  4. package/dist/Collection.js +77 -79
  5. package/dist/Collection.js.map +1 -1
  6. package/dist/Resource.d.ts +1 -51
  7. package/dist/Resource.d.ts.map +1 -1
  8. package/dist/Resource.js +61 -58
  9. package/dist/Resource.js.map +1 -1
  10. package/dist/Space.d.ts +1 -27
  11. package/dist/Space.d.ts.map +1 -1
  12. package/dist/Space.js +65 -65
  13. package/dist/Space.js.map +1 -1
  14. package/dist/WasClient.d.ts +1 -12
  15. package/dist/WasClient.d.ts.map +1 -1
  16. package/dist/WasClient.js +15 -15
  17. package/dist/WasClient.js.map +1 -1
  18. package/dist/codec.d.ts +15 -2
  19. package/dist/codec.d.ts.map +1 -1
  20. package/dist/edv/EdvCodec.d.ts +13 -95
  21. package/dist/edv/EdvCodec.d.ts.map +1 -1
  22. package/dist/edv/EdvCodec.js +221 -52
  23. package/dist/edv/EdvCodec.js.map +1 -1
  24. package/dist/edv/WasTransport.d.ts +1 -53
  25. package/dist/edv/WasTransport.d.ts.map +1 -1
  26. package/dist/edv/WasTransport.js +29 -29
  27. package/dist/edv/WasTransport.js.map +1 -1
  28. package/dist/edv/docCipher.d.ts +111 -0
  29. package/dist/edv/docCipher.d.ts.map +1 -0
  30. package/dist/edv/docCipher.js +236 -0
  31. package/dist/edv/docCipher.js.map +1 -0
  32. package/dist/edv/epochKeys.d.ts.map +1 -1
  33. package/dist/edv/epochKeys.js +46 -16
  34. package/dist/edv/epochKeys.js.map +1 -1
  35. package/dist/edv/epochMac.d.ts +32 -0
  36. package/dist/edv/epochMac.d.ts.map +1 -0
  37. package/dist/edv/epochMac.js +117 -0
  38. package/dist/edv/epochMac.js.map +1 -0
  39. package/dist/edv/index.d.ts +2 -0
  40. package/dist/edv/index.d.ts.map +1 -1
  41. package/dist/edv/index.js +1 -0
  42. package/dist/edv/index.js.map +1 -1
  43. package/dist/edv/recipients.d.ts.map +1 -1
  44. package/dist/edv/recipients.js +26 -3
  45. package/dist/edv/recipients.js.map +1 -1
  46. package/dist/errors.d.ts +22 -0
  47. package/dist/errors.d.ts.map +1 -1
  48. package/dist/errors.js +28 -0
  49. package/dist/errors.js.map +1 -1
  50. package/dist/index.d.ts +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/internal/codec.d.ts +1 -2
  55. package/dist/internal/codec.d.ts.map +1 -1
  56. package/dist/internal/codec.js +10 -10
  57. package/dist/internal/codec.js.map +1 -1
  58. package/dist/internal/features.d.ts +1 -12
  59. package/dist/internal/features.d.ts.map +1 -1
  60. package/dist/internal/features.js +8 -8
  61. package/dist/internal/features.js.map +1 -1
  62. package/dist/internal/pagination.d.ts +1 -1
  63. package/dist/internal/pagination.js +1 -1
  64. package/dist/sync/cid.d.ts +31 -0
  65. package/dist/sync/cid.d.ts.map +1 -0
  66. package/dist/sync/cid.js +55 -0
  67. package/dist/sync/cid.js.map +1 -0
  68. package/dist/sync/envelope.d.ts +20 -0
  69. package/dist/sync/envelope.d.ts.map +1 -0
  70. package/dist/sync/envelope.js +17 -0
  71. package/dist/sync/envelope.js.map +1 -0
  72. package/dist/sync/index.d.ts +29 -0
  73. package/dist/sync/index.d.ts.map +1 -0
  74. package/dist/sync/index.js +28 -0
  75. package/dist/sync/index.js.map +1 -0
  76. package/dist/sync/plaintextCipher.d.ts +16 -0
  77. package/dist/sync/plaintextCipher.d.ts.map +1 -0
  78. package/dist/sync/plaintextCipher.js +36 -0
  79. package/dist/sync/plaintextCipher.js.map +1 -0
  80. package/dist/sync/port.d.ts +78 -0
  81. package/dist/sync/port.d.ts.map +1 -0
  82. package/dist/sync/port.js +204 -0
  83. package/dist/sync/port.js.map +1 -0
  84. package/dist/sync/provisioning.d.ts +41 -0
  85. package/dist/sync/provisioning.d.ts.map +1 -0
  86. package/dist/sync/provisioning.js +44 -0
  87. package/dist/sync/provisioning.js.map +1 -0
  88. package/dist/sync/types.d.ts +166 -0
  89. package/dist/sync/types.d.ts.map +1 -0
  90. package/dist/sync/types.js +2 -0
  91. package/dist/sync/types.js.map +1 -0
  92. package/dist/types.d.ts +2 -2
  93. package/dist/types.d.ts.map +1 -1
  94. package/package.json +13 -5
@@ -9,21 +9,8 @@ import type { Json, ResourceData, ResourceMetadataCustom } from '../types.js';
9
9
  * collection handle.
10
10
  */
11
11
  export declare class EdvCodec implements ResourceCodec {
12
+ #private;
12
13
  readonly conditionalWrites = true;
13
- private readonly _edv;
14
- private readonly _writeKey;
15
- /**
16
- * The static JWE recipient descriptor every write encrypts to (the write
17
- * key's `{ kid, alg }` header; the ephemeral `epk` is minted later inside
18
- * `encrypt`). Deterministic per codec, so computed once here instead of per
19
- * write.
20
- */
21
- private readonly _recipients;
22
- private readonly _readKeys;
23
- private readonly _writeEpoch?;
24
- private readonly _contentType;
25
- private readonly _maxBlobBytes;
26
- private readonly _idDerivation;
27
14
  /**
28
15
  * @param options {object}
29
16
  * @param options.edv {EdvClientCore} holds the cipher + key resolver
@@ -44,8 +31,13 @@ export declare class EdvCodec implements ResourceCodec {
44
31
  * @param options.idDerivation {string} how `add()` mints a document
45
32
  * id: `'random'` (classic `generateId()`) or `'content'` (derived from the
46
33
  * JWE ciphertext, content-addressed)
34
+ * @param [options.version] {number} the EDV-over-WAS scheme version to bind
35
+ * into each envelope's `was.v` (defaults to `1`)
36
+ * @param [options.hasEpochs] {boolean} whether the collection has key
37
+ * epochs, so a decoded envelope's `was.epoch` binding is checked against the
38
+ * decrypting key's epoch (defaults to `false`)
47
39
  */
48
- constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation }: {
40
+ constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation, version, hasEpochs }: {
49
41
  edv: EdvClientCore;
50
42
  keyAgreementKey: IKeyAgreementKey;
51
43
  readKeys?: IKeyAgreementKey[];
@@ -53,6 +45,8 @@ export declare class EdvCodec implements ResourceCodec {
53
45
  contentType: string;
54
46
  maxBlobBytes: number;
55
47
  idDerivation: 'random' | 'content';
48
+ version?: number;
49
+ hasEpochs?: boolean;
56
50
  });
57
51
  /**
58
52
  * @inheritdoc
@@ -69,23 +63,7 @@ export declare class EdvCodec implements ResourceCodec {
69
63
  decode(response: {
70
64
  data?: unknown;
71
65
  json(): Promise<unknown>;
72
- }): Promise<Json | Blob>;
73
- /**
74
- * Decrypts a stored EDV envelope, selecting which read key to use by matching
75
- * the envelope's JWE recipient `kid` against this reader's candidate keys
76
- * (one per epoch it can unwrap). On a single-key collection there is exactly
77
- * one candidate; on a multi-recipient collection a resource written under an
78
- * older epoch selects that epoch's key, so history stays readable.
79
- *
80
- * A stored envelope naming an epoch this reader holds no key for (it was never
81
- * a recipient of that epoch, or was removed and the epoch rotated) fails with
82
- * {@link KeyUnwrapError} -- the **read** axis only; it says nothing about
83
- * whether the server will still serve (pull) the ciphertext.
84
- *
85
- * @param encryptedDoc {IEncryptedDocument}
86
- * @returns {Promise<{ content?: unknown; meta?: Record<string, unknown> }>}
87
- */
88
- private _decrypt;
66
+ }, expectedId?: string): Promise<Json | Blob>;
89
67
  /**
90
68
  * @inheritdoc
91
69
  *
@@ -95,8 +73,9 @@ export declare class EdvCodec implements ResourceCodec {
95
73
  * inert (metadata concurrency is the server's plaintext `metaVersion`, not the
96
74
  * envelope), so each write re-encrypts fresh with no `update`.
97
75
  */
98
- encodeMeta({ custom }: {
76
+ encodeMeta({ custom, id: resourceId }: {
99
77
  custom: ResourceMetadataCustom;
78
+ id?: string;
100
79
  }): Promise<{
101
80
  custom: object;
102
81
  }>;
@@ -110,68 +89,7 @@ export declare class EdvCodec implements ResourceCodec {
110
89
  */
111
90
  decodeMeta({ custom }: {
112
91
  custom?: unknown;
113
- }): Promise<ResourceMetadataCustom>;
114
- /**
115
- * Asserts that a document read from an encrypted collection is an EDV envelope
116
- * (`{ jwe, ... }`) before it is handed to the cipher. A plaintext or foreign
117
- * resource -- one written without this codec -- carries no `jwe`, which would
118
- * otherwise make the EDV core throw a raw `TypeError`. Surfacing a typed
119
- * `EncryptionError` keeps the fail-closed contract legible to callers.
120
- *
121
- * For an `update`, the envelope's `sequence` is also validated: the cipher
122
- * requires a non-negative safe integer to advance from, so a foreign envelope
123
- * without one (or with a malformed one) must fail here as a typed
124
- * `EncryptionError` rather than as the cipher's raw `Error`.
125
- *
126
- * @param doc {unknown}
127
- * @param context {string} the operation in progress (`read` / `update`),
128
- * for the message
129
- * @returns {asserts doc is IEncryptedDocument}
130
- */
131
- private _assertEnvelope;
132
- /**
133
- * Splits a caller value into a decrypted EDV document `{ content, meta }`,
134
- * carrying the plaintext content type and inline-encoding discriminator in
135
- * `meta`. Three cases:
136
- *
137
- * 1. JSON object/array to `content` verbatim, `meta = { contentType }` (no
138
- * `encoding`); the shape of `content` is never inspected on read, so a
139
- * caller object shaped like `{ text }` / `{ bytes }` round-trips as itself.
140
- * 2. Text (`Blob`/`Uint8Array` of a text-family type that is valid UTF-8)
141
- * to `content = { text }`, `meta = { contentType, encoding: 'utf-8' }`;
142
- * stored legibly with no base64 inflation.
143
- * 3. Binary (any other `Blob`/`Uint8Array`) to `content = { bytes: base64 }`,
144
- * `meta = { contentType, encoding: 'base64' }`.
145
- *
146
- * A bare primitive is rejected (mirroring the plaintext `prepareBody`
147
- * contract). The binary/text detection and content-type precedence are the
148
- * shared `resolvePayload` rules, so the plaintext and encrypted write paths
149
- * cannot drift.
150
- *
151
- * @param data {ResourceData}
152
- * @param [contentType] {string} caller-supplied content type
153
- * @param [id] {string} resource id, for the extension guess
154
- * @returns {Promise<{ content: Record<string, unknown>; meta:
155
- * Record<string, unknown> }>}
156
- */
157
- private _toDocument;
158
- /**
159
- * Reconstructs a caller value from a decrypted EDV document, discriminating
160
- * on `meta.encoding`:
161
- *
162
- * - `'utf-8'` to a `Blob` typed `meta.contentType` from `content.text`.
163
- * - `'base64'` to a `Blob` typed `meta.contentType` from `content.bytes`.
164
- * - absent (or `meta` absent) to `content` returned verbatim as JSON.
165
- *
166
- * A malformed inner shape (an encoding that does not match its container key's
167
- * type) throws {@link EncryptionError} -- the decrypted-document analogue of
168
- * `_assertEnvelope`'s outer guard.
169
- *
170
- * @param content {unknown}
171
- * @param [meta] {Record<string, unknown>}
172
- * @returns {Json | Blob}
173
- */
174
- private _fromDocument;
92
+ }, expectedId?: string): Promise<ResourceMetadataCustom>;
175
93
  }
176
94
  /**
177
95
  * The per-collection key material an EDV codec is built from.
@@ -1 +1 @@
1
- {"version":3,"file":"EdvCodec.d.ts","sourceRoot":"","sources":["../../src/edv/EdvCodec.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"EdvCodec.d.ts","sourceRoot":"","sources":["../../src/edv/EdvCodec.ts"],"names":[],"mappings":"AAuDA,OAAO,EAAE,aAAa,EAAe,MAAM,qBAAqB,CAAA;AAChE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACxD,OAAO,KAAK,EAEV,gBAAgB,EAChB,YAAY,EAEb,MAAM,8BAA8B,CAAA;AACrC,OAAO,KAAK,EACV,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACd,MAAM,aAAa,CAAA;AAiBpB,OAAO,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAA;AAyD7E;;;;GAIG;AACH,qBAAa,QAAS,YAAW,aAAa;;IAC5C,QAAQ,CAAC,iBAAiB,QAAO;IA4BjC;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;gBACS,EACV,GAAG,EACH,eAAe,EACf,QAAQ,EACR,UAAU,EACV,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,OAAO,EACP,SAAS,EACV,EAAE;QACD,GAAG,EAAE,aAAa,CAAA;QAClB,eAAe,EAAE,gBAAgB,CAAA;QACjC,QAAQ,CAAC,EAAE,gBAAgB,EAAE,CAAA;QAC7B,UAAU,CAAC,EAAE,MAAM,CAAA;QACnB,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,YAAY,EAAE,QAAQ,GAAG,SAAS,CAAA;QAClC,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,SAAS,CAAC,EAAE,OAAO,CAAA;KACpB;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;QACR,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;KACzB,EACD,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAwLvB;;;;;;;;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;IA0B/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;CAkMnC;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,CA+DrB"}
@@ -52,7 +52,7 @@
52
52
  * plaintext `name`/`tags`. A reader with the keys decrypts it back
53
53
  * transparently via `meta()`.
54
54
  */
55
- import { base64 } from '@scure/base';
55
+ import { base64, base64urlnopad } from '@scure/base';
56
56
  import { EdvClientCore, assertDocId } from '@interop/edv-client';
57
57
  import { EncryptionError, IntegrityError, KeyUnwrapError, ValidationError } from '../errors.js';
58
58
  import { readEtag } from '../internal/conditional.js';
@@ -116,20 +116,31 @@ function isKeyMiss(err) {
116
116
  */
117
117
  export class EdvCodec {
118
118
  conditionalWrites = true;
119
- _edv;
120
- _writeKey;
119
+ #edv;
121
120
  /**
122
121
  * The static JWE recipient descriptor every write encrypts to (the write
123
122
  * key's `{ kid, alg }` header; the ephemeral `epk` is minted later inside
124
123
  * `encrypt`). Deterministic per codec, so computed once here instead of per
125
124
  * write.
126
125
  */
127
- _recipients;
128
- _readKeys;
129
- _writeEpoch;
130
- _contentType;
131
- _maxBlobBytes;
132
- _idDerivation;
126
+ #recipients;
127
+ #readKeys;
128
+ #writeEpoch;
129
+ #contentType;
130
+ #maxBlobBytes;
131
+ #idDerivation;
132
+ /**
133
+ * The EDV-over-WAS scheme version this codec binds into every envelope's
134
+ * `was.v` protected-header parameter (the marker's `version`, `1` when
135
+ * absent). Read side rejects an envelope stamped with a greater version.
136
+ */
137
+ #version;
138
+ /**
139
+ * Whether this codec's collection has key epochs. When it does, an envelope's
140
+ * `was.epoch` binding is checked on decode against the epoch of the key that
141
+ * actually decrypted it.
142
+ */
143
+ #hasEpochs;
133
144
  /**
134
145
  * @param options {object}
135
146
  * @param options.edv {EdvClientCore} holds the cipher + key resolver
@@ -150,17 +161,23 @@ export class EdvCodec {
150
161
  * @param options.idDerivation {string} how `add()` mints a document
151
162
  * id: `'random'` (classic `generateId()`) or `'content'` (derived from the
152
163
  * JWE ciphertext, content-addressed)
164
+ * @param [options.version] {number} the EDV-over-WAS scheme version to bind
165
+ * into each envelope's `was.v` (defaults to `1`)
166
+ * @param [options.hasEpochs] {boolean} whether the collection has key
167
+ * epochs, so a decoded envelope's `was.epoch` binding is checked against the
168
+ * decrypting key's epoch (defaults to `false`)
153
169
  */
154
- constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation }) {
155
- this._edv = edv;
156
- this._writeKey = keyAgreementKey;
157
- this._recipients =
170
+ constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation, version, hasEpochs }) {
171
+ this.#edv = edv;
172
+ this.#recipients =
158
173
  edv.documentCipher.createDefaultRecipients(keyAgreementKey);
159
- this._readKeys = readKeys ?? [keyAgreementKey];
160
- this._writeEpoch = writeEpoch;
161
- this._contentType = contentType;
162
- this._maxBlobBytes = maxBlobBytes;
163
- this._idDerivation = idDerivation;
174
+ this.#readKeys = readKeys ?? [keyAgreementKey];
175
+ this.#writeEpoch = writeEpoch;
176
+ this.#contentType = contentType;
177
+ this.#maxBlobBytes = maxBlobBytes;
178
+ this.#idDerivation = idDerivation;
179
+ this.#version = version ?? 1;
180
+ this.#hasEpochs = hasEpochs ?? false;
164
181
  }
165
182
  /**
166
183
  * @inheritdoc
@@ -184,10 +201,10 @@ export class EdvCodec {
184
201
  // mode -- leave it unset and stamp the content-derived id after encryption
185
202
  // (the id is a function of the ciphertext, which does not exist yet).
186
203
  let docId = id ??
187
- (this._idDerivation === 'content'
204
+ (this.#idDerivation === 'content'
188
205
  ? undefined
189
- : (await this._edv.generateId()));
190
- const { content, meta } = await this._toDocument(data, contentType, docId);
206
+ : (await this.#edv.generateId()));
207
+ const { content, meta } = await this.#toDocument(data, contentType, docId);
191
208
  // When the write path pre-read a current envelope, advance `sequence` from
192
209
  // its prior value (`encrypt({ update: true })` increments it) and pin the
193
210
  // write to the server's current ETag with `If-Match`. With no prior envelope
@@ -196,10 +213,25 @@ export class EdvCodec {
196
213
  let priorDoc = null;
197
214
  if (current) {
198
215
  const read = await readJsonData(current);
199
- this._assertEnvelope(read, 'update');
216
+ this.#assertEnvelope(read, 'update');
200
217
  priorDoc = read;
201
218
  }
202
- const { documentCipher } = this._edv;
219
+ const { documentCipher } = this.#edv;
220
+ // Bind an AEAD-authenticated `was` parameter into the JWE protected header:
221
+ // the scheme version, the resource id when known at encrypt time (omitted
222
+ // for a content-derived id, which does not exist until after encryption),
223
+ // and the write epoch on a multi-recipient collection. A server that swaps
224
+ // two envelopes between ids (or replays one under a rolled-back epoch) is
225
+ // then detected on decrypt.
226
+ const was = {
227
+ v: this.#version
228
+ };
229
+ if (docId !== undefined) {
230
+ was.resource = docId;
231
+ }
232
+ if (this.#writeEpoch !== undefined) {
233
+ was.epoch = this.#writeEpoch;
234
+ }
203
235
  const encrypted = await documentCipher.encrypt({
204
236
  doc: {
205
237
  ...(docId !== undefined && { id: docId }),
@@ -210,10 +242,11 @@ export class EdvCodec {
210
242
  meta,
211
243
  ...(priorDoc && { sequence: priorDoc.sequence })
212
244
  },
213
- recipients: this._recipients,
214
- keyResolver: this._edv.keyResolver,
245
+ recipients: this.#recipients,
246
+ keyResolver: this.#edv.keyResolver,
215
247
  hmac: undefined,
216
- update: priorDoc !== null
248
+ update: priorDoc !== null,
249
+ additionalProtectedParams: { was }
217
250
  });
218
251
  if (docId === undefined) {
219
252
  // Encrypt-then-stamp: the id lives in the cleartext envelope, outside the
@@ -225,7 +258,7 @@ export class EdvCodec {
225
258
  return {
226
259
  id: docId,
227
260
  body: envelopeBytes(encrypted),
228
- contentType: this._contentType,
261
+ contentType: this.#contentType,
229
262
  // Surface the plaintext content type (the server-opaque envelope type stays
230
263
  // `contentType`) so `add()` reports the real resource type.
231
264
  resourceContentType: meta.contentType,
@@ -245,17 +278,22 @@ export class EdvCodec {
245
278
  // Stamp the key epoch this write encrypted under (the `currentEpoch`), so
246
279
  // the server records it and a reader can pick the epoch key. Absent on a
247
280
  // single-key collection.
248
- ...(this._writeEpoch !== undefined && { epoch: this._writeEpoch })
281
+ ...(this.#writeEpoch !== undefined && { epoch: this.#writeEpoch })
249
282
  };
250
283
  }
251
284
  /**
252
285
  * @inheritdoc
253
286
  */
254
- async decode(response) {
287
+ async decode(response, expectedId) {
255
288
  const encryptedDoc = await readJsonData(response);
256
- this._assertEnvelope(encryptedDoc, 'read');
257
- const decrypted = await this._decrypt(encryptedDoc);
258
- return this._fromDocument(decrypted.content, decrypted.meta);
289
+ this.#assertEnvelope(encryptedDoc, 'read');
290
+ const decrypted = await this.#decrypt(encryptedDoc);
291
+ await this.#verifyBinding({
292
+ jwe: encryptedDoc.jwe,
293
+ expectedId,
294
+ keyId: decrypted.keyId
295
+ });
296
+ return this.#fromDocument(decrypted.content, decrypted.meta);
259
297
  }
260
298
  /**
261
299
  * Decrypts a stored EDV envelope, selecting which read key to use by matching
@@ -269,10 +307,15 @@ export class EdvCodec {
269
307
  * {@link KeyUnwrapError} -- the **read** axis only; it says nothing about
270
308
  * whether the server will still serve (pull) the ciphertext.
271
309
  *
310
+ * Also returns the `id` of the key that actually decrypted the envelope (its
311
+ * JWE recipient `kid`), so {@link _verifyBinding} can check a `was.epoch`
312
+ * binding against the epoch of the decrypting key.
313
+ *
272
314
  * @param encryptedDoc {IEncryptedDocument}
273
- * @returns {Promise<{ content?: unknown; meta?: Record<string, unknown> }>}
315
+ * @returns {Promise<{ content?: unknown; meta?: Record<string, unknown>;
316
+ * keyId?: string }>}
274
317
  */
275
- async _decrypt(encryptedDoc) {
318
+ async #decrypt(encryptedDoc) {
276
319
  const recipients = encryptedDoc.jwe.recipients ?? [];
277
320
  const kids = new Set(recipients
278
321
  .map(recipient => recipient.header?.kid)
@@ -284,14 +327,15 @@ export class EdvCodec {
284
327
  // exact-match partition is empty even though a candidate can still unwrap it.
285
328
  // For a well-formed epoch envelope the exact match always hits, so `rest` is
286
329
  // normally unreached.
287
- const preferred = this._readKeys.filter(key => kids.has(key.id));
288
- const rest = this._readKeys.filter(key => !kids.has(key.id));
330
+ const preferred = this.#readKeys.filter(key => kids.has(key.id));
331
+ const rest = this.#readKeys.filter(key => !kids.has(key.id));
289
332
  for (const keyAgreementKey of [...preferred, ...rest]) {
290
333
  try {
291
- return await this._edv.documentCipher.decrypt({
334
+ const decrypted = await this.#edv.documentCipher.decrypt({
292
335
  encryptedDoc,
293
336
  keyAgreementKey
294
337
  });
338
+ return { ...decrypted, keyId: keyAgreementKey.id };
295
339
  }
296
340
  catch (err) {
297
341
  if (isKeyMiss(err)) {
@@ -318,6 +362,77 @@ export class EdvCodec {
318
362
  'collection and the epoch was rotated). This is the read axis only -- ' +
319
363
  'the server may still serve the ciphertext (a separate zcap decision).');
320
364
  }
365
+ /**
366
+ * Verifies the AEAD-authenticated `was` binding on a successfully-decrypted
367
+ * envelope (spec "Request Body Integrity"'s envelope half). Decrypt success
368
+ * proves the protected header authentic, so this runs only after a decrypt
369
+ * succeeds. Enforces, in order:
370
+ *
371
+ * - No `was` parameter at all: a legacy envelope, accepted unchanged (this
372
+ * client wrote it before the binding existed, or a foreign EDV writer did).
373
+ * - `was.v` greater than this codec's scheme version: a future-scheme envelope
374
+ * this client does not implement -- {@link EncryptionError}.
375
+ * - `was.resource` present and the expected id known: a mismatch is a server-side
376
+ * swap of two resources' envelopes -- {@link IntegrityError}.
377
+ * - `was` present but `resource` absent (a content-derived write) and the expected
378
+ * id known: the envelope's ciphertext must re-derive to the expected id
379
+ * ({@link EdvDocumentCipher.deriveId}); a mismatch means the envelope was
380
+ * copied under a different id -- {@link IntegrityError}. This check is NEVER
381
+ * applied to an envelope with no `was` at all (a legacy random-id envelope
382
+ * would fail it wrongly).
383
+ * - On a collection with epochs, `was.epoch` present: it must equal the epoch
384
+ * (the `did:key` before the `#`) of the key that actually decrypted -- a
385
+ * mismatch is a replay under a different epoch's key -- {@link IntegrityError}.
386
+ *
387
+ * @param options {object}
388
+ * @param options.jwe {unknown} the envelope's JWE (its `protected` header is
389
+ * parsed for `was`)
390
+ * @param [options.expectedId] {string} the resource id the read targeted
391
+ * @param [options.keyId] {string} the id of the key that decrypted, for the
392
+ * epoch check
393
+ * @returns {Promise<void>}
394
+ */
395
+ async #verifyBinding({ jwe, expectedId, keyId }) {
396
+ const was = parseWasHeader(jwe);
397
+ if (was === undefined) {
398
+ // Legacy envelope (no `was`): accept unchanged for back-compat.
399
+ return;
400
+ }
401
+ if (typeof was.v === 'number' && was.v > this.#version) {
402
+ throw new EncryptionError(`Cannot decrypt this resource: its envelope is stamped with ` +
403
+ `EDV-over-WAS scheme version ${was.v}, which this client (version ` +
404
+ `${this.#version}) does not implement. Upgrade the client.`);
405
+ }
406
+ if (typeof was.resource === 'string') {
407
+ if (expectedId !== undefined && was.resource !== expectedId) {
408
+ throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to a ` +
409
+ `different resource id ("${was.resource}") than the one requested ` +
410
+ `("${expectedId}"). The server swapped two resources' envelopes.`);
411
+ }
412
+ }
413
+ else if (expectedId !== undefined) {
414
+ // Content-derived write (no `resource`): the id is a function of the
415
+ // ciphertext, so re-derive and compare.
416
+ const { documentCipher } = this.#edv;
417
+ const derived = await documentCipher.deriveId({
418
+ jwe: jwe
419
+ });
420
+ if (derived !== expectedId) {
421
+ throw new IntegrityError(`Cannot decrypt this resource: its content-derived id ("${derived}") ` +
422
+ `does not match the requested id ("${expectedId}"). The server ` +
423
+ 'served this envelope under an id it was not written for.');
424
+ }
425
+ }
426
+ if (this.#hasEpochs && typeof was.epoch === 'string' && keyId) {
427
+ const decryptedEpoch = keyId.split('#')[0];
428
+ if (decryptedEpoch !== was.epoch) {
429
+ throw new IntegrityError(`Cannot decrypt this resource: its envelope is bound to key epoch ` +
430
+ `"${was.epoch}" but was decrypted with a key from epoch ` +
431
+ `"${decryptedEpoch}". The server replayed it under a different ` +
432
+ 'epoch.');
433
+ }
434
+ }
435
+ }
321
436
  /**
322
437
  * @inheritdoc
323
438
  *
@@ -327,18 +442,28 @@ export class EdvCodec {
327
442
  * inert (metadata concurrency is the server's plaintext `metaVersion`, not the
328
443
  * envelope), so each write re-encrypts fresh with no `update`.
329
444
  */
330
- async encodeMeta({ custom }) {
331
- const { documentCipher } = this._edv;
445
+ async encodeMeta({ custom, id: resourceId }) {
446
+ const { documentCipher } = this.#edv;
332
447
  // The document needs an EDV id (the cipher asserts one on decrypt). It is
333
448
  // opaque to the server -- carried inside the un-decryptable envelope -- and
334
449
  // minted fresh each write, since the metadata envelope is never updated in
335
450
  // place (concurrency is the server's plaintext `metaVersion`, Decision 3).
336
- const id = (await this._edv.generateId());
451
+ const id = (await this.#edv.generateId());
452
+ // Bind the `was` parameter to the RESOURCE id (not the metadata envelope's
453
+ // own random EDV id), so a server-side swap of two resources' metadata is
454
+ // AEAD-detected on decode. The metadata envelope always knows the resource
455
+ // id at encrypt time, so `resource` is always present here (never content-
456
+ // derived) and it carries no `epoch`.
457
+ const was = { v: this.#version };
458
+ if (resourceId !== undefined) {
459
+ was.resource = resourceId;
460
+ }
337
461
  const encrypted = await documentCipher.encrypt({
338
462
  doc: { id, content: custom },
339
- recipients: this._recipients,
340
- keyResolver: this._edv.keyResolver,
341
- hmac: undefined
463
+ recipients: this.#recipients,
464
+ keyResolver: this.#edv.keyResolver,
465
+ hmac: undefined,
466
+ additionalProtectedParams: { was }
342
467
  });
343
468
  return { custom: encrypted };
344
469
  }
@@ -350,12 +475,18 @@ export class EdvCodec {
350
475
  * present value must be an EDV envelope (else {@link EncryptionError}, the
351
476
  * `_assertEnvelope` guard), so a foreign plaintext `custom` fails closed.
352
477
  */
353
- async decodeMeta({ custom }) {
478
+ async decodeMeta({ custom }, expectedId) {
354
479
  if (custom === undefined || custom === null) {
355
480
  return {};
356
481
  }
357
- this._assertEnvelope(custom, 'read');
358
- const decrypted = await this._decrypt(custom);
482
+ this.#assertEnvelope(custom, 'read');
483
+ const encryptedDoc = custom;
484
+ const decrypted = await this.#decrypt(encryptedDoc);
485
+ await this.#verifyBinding({
486
+ jwe: encryptedDoc.jwe,
487
+ expectedId,
488
+ keyId: decrypted.keyId
489
+ });
359
490
  return (decrypted.content ?? {});
360
491
  }
361
492
  /**
@@ -375,7 +506,7 @@ export class EdvCodec {
375
506
  * for the message
376
507
  * @returns {asserts doc is IEncryptedDocument}
377
508
  */
378
- _assertEnvelope(doc, context) {
509
+ #assertEnvelope(doc, context) {
379
510
  const jwe = doc !== null && typeof doc === 'object'
380
511
  ? doc.jwe
381
512
  : undefined;
@@ -422,16 +553,16 @@ export class EdvCodec {
422
553
  * @returns {Promise<{ content: Record<string, unknown>; meta:
423
554
  * Record<string, unknown> }>}
424
555
  */
425
- async _toDocument(data, contentType, id) {
556
+ async #toDocument(data, contentType, id) {
426
557
  const payload = resolvePayload({ data, contentType, id });
427
558
  if (payload.kind === 'binary') {
428
559
  const bytes = isBlob(payload.data)
429
560
  ? new Uint8Array(await payload.data.arrayBuffer())
430
561
  : payload.data;
431
562
  const resolvedType = payload.contentType;
432
- if (bytes.length > this._maxBlobBytes) {
563
+ if (bytes.length > this.#maxBlobBytes) {
433
564
  throw new ValidationError(`Encrypted binary write of ${bytes.length} bytes exceeds the ` +
434
- `single-document limit of ${this._maxBlobBytes} bytes. The codec ` +
565
+ `single-document limit of ${this.#maxBlobBytes} bytes. The codec ` +
435
566
  'seam is a single-request transform and cannot chunk. To store a ' +
436
567
  'blob this large, write it through the chunked-stream path -- ' +
437
568
  '`EdvClientCore.insert({ doc, stream, transport })` with a ' +
@@ -483,7 +614,7 @@ export class EdvCodec {
483
614
  * @param [meta] {Record<string, unknown>}
484
615
  * @returns {Json | Blob}
485
616
  */
486
- _fromDocument(content, meta) {
617
+ #fromDocument(content, meta) {
487
618
  const encoding = meta?.encoding;
488
619
  const contentType = typeof meta?.contentType === 'string' ? meta.contentType : undefined;
489
620
  if (encoding === 'utf-8') {
@@ -522,6 +653,34 @@ function decodeUtf8(bytes) {
522
653
  return null;
523
654
  }
524
655
  }
656
+ /**
657
+ * Parses the `was` binding out of a JWE's protected header. The header is
658
+ * base64url (no padding) JSON; a successful decrypt has already proven it
659
+ * authentic, so this parse is trusted. Returns the `was` object, or `undefined`
660
+ * when the header is absent/unparseable or carries no `was` member (a legacy
661
+ * envelope that predates the binding).
662
+ *
663
+ * @param jwe {unknown}
664
+ * @returns {Record<string, unknown> | undefined}
665
+ */
666
+ function parseWasHeader(jwe) {
667
+ const protectedHeader = jwe?.protected;
668
+ if (typeof protectedHeader !== 'string') {
669
+ return undefined;
670
+ }
671
+ let parsed;
672
+ try {
673
+ parsed = JSON.parse(new TextDecoder().decode(base64urlnopad.decode(protectedHeader)));
674
+ }
675
+ catch {
676
+ return undefined;
677
+ }
678
+ const was = parsed?.was;
679
+ if (was === null || typeof was !== 'object') {
680
+ return undefined;
681
+ }
682
+ return was;
683
+ }
525
684
  /**
526
685
  * The EDV scheme tag this provider handles (matches the Collection marker).
527
686
  */
@@ -569,6 +728,14 @@ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT
569
728
  if (scheme !== EDV_SCHEME) {
570
729
  return null;
571
730
  }
731
+ // Refuse a marker from a future scheme version: this client does not
732
+ // implement it, and silently operating on it could mis-handle the data.
733
+ const markerVersion = encryption?.version;
734
+ if (typeof markerVersion === 'number' && markerVersion > 1) {
735
+ throw new EncryptionError(`Collection ${spaceId}/${collectionId} declares EDV-over-WAS scheme ` +
736
+ `version ${markerVersion}, which this client (version 1) does not ` +
737
+ 'implement. Upgrade the client.');
738
+ }
572
739
  // Prefer override-supplied keys; otherwise consult the keystore.
573
740
  const resolved = keys ??
574
741
  (await resolveKeys({ spaceId, collectionId }));
@@ -609,7 +776,9 @@ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT
609
776
  writeEpoch,
610
777
  contentType,
611
778
  maxBlobBytes,
612
- idDerivation
779
+ idDerivation,
780
+ version: markerVersion ?? 1,
781
+ hasEpochs: !!(encryption?.epochs && encryption.epochs.length > 0)
613
782
  });
614
783
  }
615
784
  };