@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
@@ -2,6 +2,7 @@ import { EDV_SCHEME_VERSION } from './constants.js';
2
2
  import { EncryptionError, PreconditionFailedError, ValidationError } from '../errors.js';
3
3
  import { collectionDescriptorStore } from './descriptorStore.js';
4
4
  import { didKeyResolver, mintEpoch, unwrapEpochSecret, wrapEpochSecret } from './epochCrypto.js';
5
+ import { pickEpoch } from './epochRoster.js';
5
6
  import { mintHmacKey } from './hmacKey.js';
6
7
  /**
7
8
  * How many times a recipient CAS write retries a stale (`412`) description
@@ -9,11 +10,79 @@ import { mintHmacKey } from './hmacKey.js';
9
10
  */
10
11
  const MAX_CAS_ATTEMPTS = 3;
11
12
  /**
12
- * Escrows a recipient into every epoch it is not already a recipient of: for
13
- * each such epoch the caller unwraps the epoch key with its own key-agreement
14
- * key and re-wraps it to the incoming reader. Shared by {@link addRecipient}
15
- * (whose whole job this is) and {@link replaceRecipient} (which does it in the
16
- * same write as its rotation), so the two cannot drift.
13
+ * Escrows one or more readers into a single wrap roster (one epoch's
14
+ * `recipients`, or the blinded-index key's): the caller unwraps its OWN entry
15
+ * with its key-agreement key and re-wraps that secret to each reader the roster
16
+ * does not already carry. The one shape behind both {@link escrowIntoEpochs}
17
+ * and {@link escrowIntoHmac}, so an epoch key and the blinding key are escrowed
18
+ * by identical code.
19
+ *
20
+ * The owner's unwrap runs ONCE per roster no matter how many readers are being
21
+ * escrowed -- the secret depends on the roster and the owner's key, not on who
22
+ * receives it -- and the per-reader re-wraps then run concurrently. Each wrap
23
+ * still mints its own ephemeral key.
24
+ *
25
+ * Readers already carried by the roster are skipped, so an escrow is
26
+ * idempotent; `null` means every reader was already a recipient and the roster
27
+ * is unchanged.
28
+ *
29
+ * @param options {object}
30
+ * @param options.roster {CollectionEncryptionRecipient[]} the wrap entries to
31
+ * escrow into
32
+ * @param options.recipients {RecipientPublicKey[]} the readers being escrowed,
33
+ * appended in this order
34
+ * @param options.owner {object} the caller's own key material
35
+ * @param options.owner.keyAgreementKey {IKeyAgreementKey} unwraps the roster's
36
+ * secret for re-wrapping
37
+ * @param options.operation {EscrowOperation} the calling operation
38
+ * @param options.subject {object} how the error messages name this roster's
39
+ * secret, so both callers' messages read naturally
40
+ * @param options.subject.notRecipient {string} completes "the caller is not a
41
+ * recipient of ..." up to the trailing "to the <reader> reader."
42
+ * @param options.subject.unwrapping {string} names the secret in "unwrapping
43
+ * ... with the caller's key-agreement key failed."
44
+ * @returns {Promise<CollectionEncryptionRecipient[] | null>} the roster with
45
+ * the escrow wraps appended, or `null` when nothing changed
46
+ */
47
+ async function escrowInto({ roster, recipients, owner, operation, subject }) {
48
+ // Already a recipient of this roster (or named twice among `recipients`)?
49
+ // Leave it out (idempotent).
50
+ const missing = [];
51
+ for (const recipient of recipients) {
52
+ const present = roster.some(entry => entry.header.kid === recipient.id) ||
53
+ missing.some(pending => pending.id === recipient.id);
54
+ if (!present) {
55
+ missing.push(recipient);
56
+ }
57
+ }
58
+ if (missing.length === 0) {
59
+ return null;
60
+ }
61
+ const reader = operation === 'addRecipient' ? 'new' : 'incoming';
62
+ const ownEntry = roster.find(entry => entry.header.kid === owner.keyAgreementKey.id);
63
+ if (!ownEntry) {
64
+ throw new ValidationError(`Cannot ${operation}: the caller is not a recipient of ` +
65
+ `${subject.notRecipient} to the ${reader} reader.`);
66
+ }
67
+ const epochSecret = await unwrapEpochSecret({
68
+ entry: ownEntry,
69
+ keyAgreementKey: owner.keyAgreementKey
70
+ });
71
+ if (!epochSecret) {
72
+ throw new ValidationError(`Cannot ${operation}: unwrapping ${subject.unwrapping} with the ` +
73
+ "caller's key-agreement key failed.");
74
+ }
75
+ return [
76
+ ...roster,
77
+ ...(await Promise.all(missing.map(recipient => wrapEpochSecret({ epochSecret, recipient }))))
78
+ ];
79
+ }
80
+ /**
81
+ * Escrows one or more readers into every epoch they are not already a recipient
82
+ * of: for each such epoch the caller unwraps the epoch key with its own
83
+ * key-agreement key and re-wraps it to those readers. Shared by
84
+ * {@link addRecipient} (whose whole job this is) and {@link replaceRecipient}
85
+ * (which does it in the same write as its rotation), so the two cannot drift.
17
86
  *
18
87
  * Each epoch's unwrap + re-wrap is independent of the others', so they run
19
88
  * concurrently (order-preserving), like `initRecipients` and `removeRecipient`
@@ -21,46 +90,33 @@ const MAX_CAS_ATTEMPTS = 3;
21
90
  *
22
91
  * @param options {object}
23
92
  * @param options.epochs {CollectionEncryptionEpoch[]} the descriptor's epochs
24
- * @param options.recipient {RecipientPublicKey} the reader being escrowed
93
+ * @param options.recipients {RecipientPublicKey[]} the readers being escrowed
25
94
  * @param options.owner {object} the caller's own key material
26
95
  * @param options.owner.keyAgreementKey {IKeyAgreementKey} unwraps each epoch
27
96
  * key for re-wrapping
28
- * @param options.operation {string} the calling operation's name, as the error
29
- * messages name it (`addRecipient` / `replaceRecipient`)
30
- * @param options.reader {string} how those messages name the incoming reader
31
- * (`new` / `incoming`)
97
+ * @param options.operation {EscrowOperation} the calling operation
32
98
  * @returns {Promise<{ epochs: CollectionEncryptionEpoch[], changed: boolean }>}
33
99
  * the epochs with the escrow wraps applied, and whether any epoch changed
34
100
  */
35
- async function escrowIntoEpochs({ epochs, recipient, owner, operation, reader }) {
101
+ async function escrowIntoEpochs({ epochs, recipients, owner, operation }) {
36
102
  let changed = false;
37
103
  const escrowed = await Promise.all(epochs.map(async (epoch) => {
38
- // Already a recipient of this epoch? Leave it untouched (idempotent).
39
- if (epoch.recipients.some(entry => entry.header.kid === recipient.id)) {
40
- return epoch;
41
- }
42
- const ownEntry = epoch.recipients.find(entry => entry.header.kid === owner.keyAgreementKey.id);
43
- if (!ownEntry) {
44
- throw new ValidationError(`Cannot ${operation}: the caller is not a recipient of epoch ` +
45
- `"${epoch.id}", so it cannot unwrap that epoch key to escrow it ` +
46
- `to the ${reader} reader.`);
47
- }
48
- const epochSecret = await unwrapEpochSecret({
49
- entry: ownEntry,
50
- keyAgreementKey: owner.keyAgreementKey
104
+ const next = await escrowInto({
105
+ roster: epoch.recipients,
106
+ recipients,
107
+ owner,
108
+ operation,
109
+ subject: {
110
+ notRecipient: `epoch "${epoch.id}", so it cannot unwrap that epoch key ` +
111
+ 'to escrow it',
112
+ unwrapping: `epoch "${epoch.id}"`
113
+ }
51
114
  });
52
- if (!epochSecret) {
53
- throw new ValidationError(`Cannot ${operation}: unwrapping epoch "${epoch.id}" with the ` +
54
- "caller's key-agreement key failed.");
115
+ if (next === null) {
116
+ return epoch;
55
117
  }
56
118
  changed = true;
57
- return {
58
- ...epoch,
59
- recipients: [
60
- ...epoch.recipients,
61
- await wrapEpochSecret({ epochSecret, recipient })
62
- ]
63
- };
119
+ return { ...epoch, recipients: next };
64
120
  }));
65
121
  return { epochs: escrowed, changed };
66
122
  }
@@ -102,7 +158,7 @@ async function rotateEpoch({ epochId, secret, remaining, resolveRecipientKey })
102
158
  * @param [options.hmac] {CollectionEncryptionHmac} the blinded-index key to
103
159
  * install alongside the first epoch (provisioning-time only); a descriptor
104
160
  * that already carries one keeps it
105
- * @returns {EncryptionWithHmac} the descriptor to write
161
+ * @returns {CollectionEncryption} the descriptor to write
106
162
  */
107
163
  function withFirstEpoch({ descriptor, epoch, hmac }) {
108
164
  const installedHmac = descriptor.hmac ?? hmac;
@@ -134,51 +190,36 @@ async function mintHmacRoster({ recipients }) {
134
190
  };
135
191
  }
136
192
  /**
137
- * Escrows a recipient into the descriptor's blinded-index key: the caller
138
- * unwraps the HMAC secret with its own key-agreement key and re-wraps it to the
139
- * incoming reader, exactly as {@link escrowIntoEpochs} does per epoch. Returns
140
- * `null` when nothing changes -- the descriptor carries no `hmac`, or the
141
- * recipient already has an entry (idempotent).
193
+ * Escrows one or more readers into the descriptor's blinded-index key: the
194
+ * caller unwraps the HMAC secret with its own key-agreement key and re-wraps it
195
+ * to those readers, through the same {@link escrowInto} the epoch escrow uses.
196
+ * Returns `null` when nothing changes -- the descriptor carries no `hmac`, or
197
+ * every reader already has an entry (idempotent).
142
198
  *
143
199
  * @param options {object}
144
200
  * @param [options.hmac] {CollectionEncryptionHmac} the descriptor's key
145
- * @param options.recipient {RecipientPublicKey} the reader being escrowed
201
+ * @param options.recipients {RecipientPublicKey[]} the readers being escrowed
146
202
  * @param options.owner {object} the caller's own key material
147
203
  * @param options.owner.keyAgreementKey {IKeyAgreementKey} unwraps the HMAC
148
204
  * secret for re-wrapping
149
- * @param options.operation {string} the calling operation's name, as the
150
- * error messages name it
151
- * @param options.reader {string} how those messages name the incoming reader
205
+ * @param options.operation {EscrowOperation} the calling operation
152
206
  * @returns {Promise<CollectionEncryptionHmac | null>}
153
207
  */
154
- async function escrowIntoHmac({ hmac, recipient, owner, operation, reader }) {
208
+ async function escrowIntoHmac({ hmac, recipients, owner, operation }) {
155
209
  if (!hmac) {
156
210
  return null;
157
211
  }
158
- if (hmac.recipients.some(entry => entry.header.kid === recipient.id)) {
159
- return null;
160
- }
161
- const ownEntry = hmac.recipients.find(entry => entry.header.kid === owner.keyAgreementKey.id);
162
- if (!ownEntry) {
163
- throw new ValidationError(`Cannot ${operation}: the caller is not a recipient of this ` +
164
- "collection's blinded-index key, so it cannot escrow that key to the " +
165
- `${reader} reader.`);
166
- }
167
- const secret = await unwrapEpochSecret({
168
- entry: ownEntry,
169
- keyAgreementKey: owner.keyAgreementKey
212
+ const next = await escrowInto({
213
+ roster: hmac.recipients,
214
+ recipients,
215
+ owner,
216
+ operation,
217
+ subject: {
218
+ notRecipient: "this collection's blinded-index key, so it cannot escrow that key",
219
+ unwrapping: 'the blinded-index key'
220
+ }
170
221
  });
171
- if (!secret) {
172
- throw new ValidationError(`Cannot ${operation}: unwrapping the blinded-index key with the ` +
173
- "caller's key-agreement key failed.");
174
- }
175
- return {
176
- ...hmac,
177
- recipients: [
178
- ...hmac.recipients,
179
- await wrapEpochSecret({ epochSecret: secret, recipient })
180
- ]
181
- };
222
+ return next === null ? null : { ...hmac, recipients: next };
182
223
  }
183
224
  /**
184
225
  * Drops the departing kid(s) from the descriptor's blinded-index key roster.
@@ -278,8 +319,7 @@ export async function ensureFirstEpoch({ collection, store, recipients, blindedI
278
319
  const descriptor = await casUpdateDescriptor({
279
320
  store: descriptorStoreFor({ collection, store }),
280
321
  seed: { scheme: 'edv', version: EDV_SCHEME_VERSION },
281
- mutate: async (descriptor) => {
282
- const current = descriptor;
322
+ mutate: async (current) => {
283
323
  if (current.epochs && current.epochs.length > 0) {
284
324
  // Another provisioner's install already landed (possibly winning the
285
325
  // create/CAS race against this very call): adopt its roster as-is.
@@ -381,8 +421,7 @@ export async function initRecipients({ collection, store, recipients, epoch: pre
381
421
  export async function addRecipient({ collection, store, recipient, owner }) {
382
422
  return casUpdateDescriptor({
383
423
  store: descriptorStoreFor({ collection, store }),
384
- mutate: async (descriptor) => {
385
- const current = descriptor;
424
+ mutate: async (current) => {
386
425
  const epochs = current.epochs;
387
426
  if (!epochs || epochs.length === 0) {
388
427
  throw new ValidationError('Cannot addRecipient: this collection has no key epochs. Call ' +
@@ -390,20 +429,18 @@ export async function addRecipient({ collection, store, recipient, owner }) {
390
429
  }
391
430
  const { epochs: nextEpochs } = await escrowIntoEpochs({
392
431
  epochs,
393
- recipient,
432
+ recipients: [recipient],
394
433
  owner,
395
- operation: 'addRecipient',
396
- reader: 'new'
434
+ operation: 'addRecipient'
397
435
  });
398
436
  // The blinded-index key rides the same compare-and-swap write as the
399
437
  // epoch escrow -- never a second write. `null` means unchanged (no key,
400
438
  // or this recipient already has an entry).
401
439
  const nextHmac = await escrowIntoHmac({
402
440
  hmac: current.hmac,
403
- recipient,
441
+ recipients: [recipient],
404
442
  owner,
405
- operation: 'addRecipient',
406
- reader: 'new'
443
+ operation: 'addRecipient'
407
444
  });
408
445
  return {
409
446
  ...current,
@@ -478,75 +515,148 @@ export async function removeRecipient({ collection, store, space, recipientId, r
478
515
  // Resolve the pull axis up front, before any rotation, so a malformed call
479
516
  // fails before the descriptor is mutated.
480
517
  const pullAxis = resolvePullAxis({ space, revoke, pull });
518
+ // A removal is a replacement with nobody incoming: no escrow, the same
519
+ // rotation.
520
+ return rotateOffRecipients({
521
+ store: descriptorStore,
522
+ retiring: [recipientId],
523
+ operation: 'removeRecipient',
524
+ resolveRecipientKey,
525
+ pullAxis
526
+ });
527
+ }
528
+ /**
529
+ * The rotation shared by {@link removeRecipient} and {@link replaceRecipient}:
530
+ * rotate the current epoch off the retiring recipient kid(s), optionally
531
+ * escrowing incoming reader(s) into the whole history first, in ONE
532
+ * compare-and-swap write; then run the pull axis. A removal is exactly this
533
+ * with no `escrow`, so the two public operations cannot drift.
534
+ *
535
+ * The rotation runs first so it is durable before the irreversible pull: if the
536
+ * compare-and-swap keeps losing the race and throws, nothing is pulled and the
537
+ * caller's operation stays safely retryable. It is also idempotent -- when no
538
+ * retiring kid is still current (a prior attempt's rotation landed but its pull
539
+ * failed transiently), no fresh epoch is minted or appended, so a retry appends
540
+ * zero redundant epochs.
541
+ *
542
+ * @param options {object}
543
+ * @param options.store {EncryptionDescriptorStore} the descriptor host
544
+ * @param options.retiring {string[]} the retiring recipients' kids, dropped
545
+ * from the fresh epoch's roster and from the blinded-index roster
546
+ * @param [options.escrow] {object} the incoming half of a replacement; absent
547
+ * for a pure removal
548
+ * @param options.escrow.incoming {RecipientPublicKey[]} the incoming readers,
549
+ * escrowed into every epoch and into the blinded-index key
550
+ * @param options.escrow.owner {object} the caller's own key material
551
+ * @param options.escrow.owner.keyAgreementKey {IKeyAgreementKey} unwraps each
552
+ * epoch key for the escrow
553
+ * @param options.operation {string} the calling operation's name, as the error
554
+ * messages name it (`removeRecipient` / `replaceRecipient`)
555
+ * @param options.resolveRecipientKey {function} resolves a surviving
556
+ * recipient's kid for the fresh epoch, `null` to drop it
557
+ * @param options.pullAxis {function} the pull action, run once the rotation is
558
+ * durable
559
+ * @returns {Promise<CollectionEncryption>} the new descriptor
560
+ */
561
+ async function rotateOffRecipients({ store, retiring, escrow, operation, resolveRecipientKey, pullAxis }) {
562
+ const incoming = escrow?.incoming ?? [];
481
563
  // 1. Read axis: mint a fresh epoch, wrap it to every remaining recipient,
482
564
  // append it, and repoint `currentEpoch` (compare-and-swap, retried on race).
483
- // Rotate FIRST so the rotation is durable before any irreversible pull:
484
- // if the CAS keeps losing the race and throws, the reader is neither pulled
485
- // nor rotated, so `removeRecipient` is safely retryable to convergence.
486
565
  const { epochId, secret } = await mintEpoch();
487
566
  const rotatedDescriptor = await casUpdateDescriptor({
488
- store: descriptorStore,
489
- mutate: async (descriptor) => {
490
- const current = descriptor;
567
+ store,
568
+ mutate: async (current) => {
491
569
  const epochs = current.epochs;
492
570
  if (!epochs || epochs.length === 0) {
493
- throw new ValidationError('Cannot removeRecipient: this collection has no key epochs.');
571
+ throw new ValidationError(`Cannot ${operation}: this collection has no key epochs.`);
494
572
  }
495
573
  // Housekeeping on the blinded-index roster, in the same write as the
496
- // rotation: drop the leaver's wrap entry. The key itself does NOT rotate
497
- // (blinded tokens must compare across the collection's whole history),
498
- // so the removed recipient keeps the blinding key it already holds -- an
499
- // accepted revocation asymmetry.
574
+ // rotation: each incoming reader gains a wrap entry, the retiring kid(s)
575
+ // lose theirs. The key itself does NOT rotate (blinded tokens must
576
+ // compare across the collection's whole history), so a removed recipient
577
+ // keeps the blinding key it already holds -- an accepted revocation
578
+ // asymmetry. Only a replacement has an incoming half to escrow.
579
+ const escrowedHmac = escrow && operation === 'replaceRecipient'
580
+ ? await escrowIntoHmac({
581
+ hmac: current.hmac,
582
+ recipients: escrow.incoming,
583
+ owner: escrow.owner,
584
+ operation
585
+ })
586
+ : null;
500
587
  const nextHmac = withoutHmacRecipients({
501
- hmac: current.hmac,
502
- retiring: [recipientId]
503
- });
588
+ hmac: escrowedHmac ?? current.hmac,
589
+ retiring
590
+ }) ?? escrowedHmac;
591
+ // Escrow each incoming reader into every epoch it is missing from
592
+ // (addRecipient's escrow, so the whole replacement is one write).
593
+ let escrowed = epochs;
594
+ let escrowChanged = false;
595
+ if (escrow && operation === 'replaceRecipient') {
596
+ const next = await escrowIntoEpochs({
597
+ epochs,
598
+ recipients: escrow.incoming,
599
+ owner: escrow.owner,
600
+ operation
601
+ });
602
+ escrowed = next.epochs;
603
+ escrowChanged = next.changed;
604
+ }
504
605
  // Remaining recipients: the CURRENT epoch's recipients (the authoritative
505
- // roster by construction), minus the removed reader. Deliberately NOT the
506
- // union across all epochs -- a reader dropped in an earlier rotation is
507
- // still present in that older epoch, so unioning would silently re-escrow
508
- // it into the fresh epoch and hand it back read access. Older epochs exist
509
- // only so existing readers can decrypt history.
510
- const currentEpoch = epochs.find(epoch => epoch.id === descriptor.currentEpoch) ??
511
- epochs[epochs.length - 1];
512
- // Already excluded from the current epoch? A prior attempt's rotation
513
- // landed (its revoke step then failed transiently and the caller
514
- // retried), or the reader never held the current epoch. Nothing to
515
- // rotate -- signal no-op so the retry proceeds to the revoke step
516
- // instead of appending a redundant epoch per attempt.
517
- if (!currentEpoch.recipients.some(entry => entry.header.kid === recipientId)) {
518
- // Still write when only the blinded-index roster needs the leaver
519
- // dropped (e.g. a prior attempt rotated but its pull step failed).
520
- return nextHmac === null ? null : { ...current, hmac: nextHmac };
606
+ // roster by construction), minus the retiring reader(s). Deliberately NOT
607
+ // the union across all epochs -- a reader dropped in an earlier rotation
608
+ // is still present in that older epoch, so unioning would silently
609
+ // re-escrow it into the fresh epoch and hand it back read access. Older
610
+ // epochs exist only so existing readers can decrypt history.
611
+ const currentEpoch = pickEpoch(escrowed, current.currentEpoch);
612
+ // No retiring kid still current? A prior attempt's rotation landed (its
613
+ // pull step then failed transiently and the caller retried), or the
614
+ // reader never held the current epoch. Nothing to rotate -- write only
615
+ // what the escrow and the blinded-index housekeeping changed, so a retry
616
+ // proceeds to the pull step instead of appending a redundant epoch per
617
+ // attempt.
618
+ const rotating = currentEpoch.recipients.some(entry => retiring.includes(entry.header.kid));
619
+ if (!rotating) {
620
+ if (!escrowChanged && nextHmac === null) {
621
+ return null;
622
+ }
623
+ return {
624
+ ...current,
625
+ ...(escrowChanged && { epochs: escrowed }),
626
+ ...(nextHmac !== null && { hmac: nextHmac })
627
+ };
521
628
  }
522
629
  const remaining = new Set();
523
630
  for (const entry of currentEpoch.recipients) {
524
- if (entry.header.kid !== recipientId) {
631
+ if (!retiring.includes(entry.header.kid)) {
525
632
  remaining.add(entry.header.kid);
526
633
  }
527
634
  }
528
635
  if (remaining.size === 0) {
529
- throw new ValidationError('Cannot removeRecipient: no recipients would remain after the ' +
636
+ throw new ValidationError(`Cannot ${operation}: no recipients would remain after the ` +
530
637
  'removal (a collection with no readers cannot be rotated to).');
531
638
  }
532
639
  // Wrap the fresh epoch key to each remaining recipient. The resolver may
533
640
  // signal drop-this-kid by resolving `null` (e.g. a roster entry whose
534
641
  // key material is no longer resolvable), so the rotation excludes that
535
- // entry instead of throwing.
642
+ // entry instead of throwing. The escrow above already placed each
643
+ // incoming reader in the current epoch, so they are in `remaining`;
644
+ // short-circuit the resolver for them (their public keys are in hand) and
645
+ // route only the other survivors through the caller's resolver.
536
646
  const newEpoch = await rotateEpoch({
537
647
  epochId,
538
648
  secret,
539
649
  remaining,
540
- resolveRecipientKey
650
+ resolveRecipientKey: async (kid) => incoming.find(reader => reader.id === kid) ?? resolveRecipientKey(kid)
541
651
  });
542
652
  if (newEpoch.recipients.length === 0) {
543
- throw new ValidationError('Cannot removeRecipient: no recipients would remain after the ' +
653
+ throw new ValidationError(`Cannot ${operation}: no recipients would remain after the ` +
544
654
  'removal (resolveRecipientKey dropped every remaining entry, ' +
545
655
  'and a collection with no readers cannot be rotated to).');
546
656
  }
547
657
  return {
548
658
  ...current,
549
- epochs: [...epochs, newEpoch],
659
+ epochs: [...escrowed, newEpoch],
550
660
  currentEpoch: epochId,
551
661
  ...(nextHmac !== null && { hmac: nextHmac })
552
662
  };
@@ -558,20 +668,21 @@ export async function removeRecipient({ collection, store, space, recipientId, r
558
668
  return rotatedDescriptor;
559
669
  }
560
670
  /**
561
- * Replaces one reader (or several) with another in ONE descriptor write -- the
671
+ * Replaces one reader (or several) with another (or several) in ONE
672
+ * descriptor write -- the
562
673
  * shape of a key rotation cascading over a collection (e.g. a per-user key
563
- * replaced by its successor): the incoming recipient is escrowed into EVERY
674
+ * replaced by its successor): each incoming recipient is escrowed into EVERY
564
675
  * epoch (history included, {@link addRecipient}'s semantics) and the current
565
676
  * epoch is rotated off the retiring recipient(s) ({@link removeRecipient}'s
566
677
  * semantics), in a single compare-and-swap. Two requests total (the read and
567
678
  * the CAS write) against the four a compose of addRecipient + removeRecipient
568
679
  * would cost, and no intermediate state in which both keys are current.
569
680
  *
570
- * Idempotent to convergence like its two halves: an epoch already carrying the
681
+ * Idempotent to convergence like its two halves: an epoch already carrying an
571
682
  * incoming recipient is left untouched; when additionally no retiring
572
683
  * recipient remains in the current epoch, nothing is written at all -- a naive
573
684
  * re-run after a crash appends zero redundant epochs. An escrow-only state
574
- * (the incoming recipient missing from some epoch but no retiring recipient
685
+ * (an incoming recipient missing from some epoch but no retiring recipient
575
686
  * current) writes the escrow wraps without minting an epoch.
576
687
  *
577
688
  * The pull-axis contract is {@link removeRecipient}'s verbatim: the default
@@ -580,8 +691,8 @@ export async function removeRecipient({ collection, store, space, recipientId, r
580
691
  * already run elsewhere (e.g. a DID-document edit under a current-key-set
581
692
  * rule) passes a no-op `pull`.
582
693
  *
583
- * The rotation ceiling is unchanged: nothing is re-encrypted, so a retired
584
- * key still opens every pre-rotation epoch it was a recipient of.
694
+ * The limits of the rotation are unchanged: nothing is re-encrypted, so a
695
+ * retired key still opens every pre-rotation epoch it was a recipient of.
585
696
  *
586
697
  * A blinded-index HMAC key on the descriptor follows the same shape in the same
587
698
  * write: the incoming recipient gains a wrap entry, the retiring kid(s) lose
@@ -594,8 +705,9 @@ export async function removeRecipient({ collection, store, space, recipientId, r
594
705
  * @param [options.space] {Space} the default pull axis, with `revoke`
595
706
  * @param options.retire {string | string[]} the retiring recipient kid(s),
596
707
  * dropped from the fresh epoch's roster
597
- * @param options.recipient {RecipientPublicKey} the incoming reader's public
598
- * key-agreement key, escrowed into every epoch and wrapped into the fresh one
708
+ * @param options.recipient {RecipientPublicKey | RecipientPublicKey[]} the
709
+ * incoming reader(s)' public key-agreement key(s), each escrowed into every
710
+ * epoch and wrapped into the fresh one
599
711
  * @param options.owner {object} the caller's own key material
600
712
  * @param options.owner.keyAgreementKey {IKeyAgreementKey} unwraps each epoch
601
713
  * key for the escrow -- it must be a recipient of every epoch (a retiring
@@ -606,7 +718,7 @@ export async function removeRecipient({ collection, store, space, recipientId, r
606
718
  * exclusive with `space` / `revoke`
607
719
  * @param [options.resolveRecipientKey] {function} resolves a remaining
608
720
  * recipient's kid for the fresh epoch, `null` to drop it -- the
609
- * {@link removeRecipient} contract (the incoming recipient never routes
721
+ * {@link removeRecipient} contract (the incoming recipients never route
610
722
  * through it)
611
723
  * @returns {Promise<CollectionEncryption>} the new descriptor
612
724
  */
@@ -614,86 +726,26 @@ export async function replaceRecipient({ collection, store, space, retire, recip
614
726
  const descriptorStore = descriptorStoreFor({ collection, store });
615
727
  const pullAxis = resolvePullAxis({ space, revoke, pull });
616
728
  const retiring = Array.isArray(retire) ? retire : [retire];
729
+ const incoming = Array.isArray(recipient) ? recipient : [recipient];
617
730
  if (retiring.length === 0) {
618
731
  throw new ValidationError('replaceRecipient needs at least one retiring recipient kid; use ' +
619
732
  'addRecipient for a pure escrow.');
620
733
  }
621
- if (retiring.includes(recipient.id)) {
622
- throw new ValidationError('replaceRecipient cannot retire the incoming recipient itself.');
734
+ if (incoming.length === 0) {
735
+ throw new ValidationError('replaceRecipient needs at least one incoming recipient; use ' +
736
+ 'removeRecipient for a pure removal.');
623
737
  }
624
- const { epochId, secret } = await mintEpoch();
625
- const rotatedDescriptor = await casUpdateDescriptor({
738
+ if (incoming.some(reader => retiring.includes(reader.id))) {
739
+ throw new ValidationError('replaceRecipient cannot retire an incoming recipient itself.');
740
+ }
741
+ return rotateOffRecipients({
626
742
  store: descriptorStore,
627
- mutate: async (descriptor) => {
628
- const current = descriptor;
629
- const epochs = current.epochs;
630
- if (!epochs || epochs.length === 0) {
631
- throw new ValidationError('Cannot replaceRecipient: this collection has no key epochs.');
632
- }
633
- // The blinded-index roster mirrors the epoch semantics in the same
634
- // write: the successor gains a wrap entry, the retiring kid(s) lose
635
- // theirs. The key itself never rotates.
636
- const escrowedHmac = await escrowIntoHmac({
637
- hmac: current.hmac,
638
- recipient,
639
- owner,
640
- operation: 'replaceRecipient',
641
- reader: 'incoming'
642
- });
643
- const nextHmac = withoutHmacRecipients({
644
- hmac: escrowedHmac ?? current.hmac,
645
- retiring
646
- }) ?? escrowedHmac;
647
- // Escrow the incoming recipient into every epoch it is missing from
648
- // (addRecipient's escrow, so the whole replacement is one write).
649
- const { epochs: escrowed, changed: escrowChanged } = await escrowIntoEpochs({
650
- epochs,
651
- recipient,
652
- owner,
653
- operation: 'replaceRecipient',
654
- reader: 'incoming'
655
- });
656
- // Rotate only when a retiring kid is still current (the removeRecipient
657
- // no-op rule, so a naive re-run appends zero redundant epochs).
658
- const currentEpoch = escrowed.find(epoch => epoch.id === descriptor.currentEpoch) ??
659
- escrowed[escrowed.length - 1];
660
- const rotating = currentEpoch.recipients.some(entry => retiring.includes(entry.header.kid));
661
- if (!rotating) {
662
- if (!escrowChanged && nextHmac === null) {
663
- return null;
664
- }
665
- return {
666
- ...current,
667
- ...(escrowChanged && { epochs: escrowed }),
668
- ...(nextHmac !== null && { hmac: nextHmac })
669
- };
670
- }
671
- const remaining = new Set();
672
- for (const entry of currentEpoch.recipients) {
673
- if (!retiring.includes(entry.header.kid)) {
674
- remaining.add(entry.header.kid);
675
- }
676
- }
677
- // The escrow above already placed the incoming recipient in the current
678
- // epoch, so it is in `remaining`; short-circuit the resolver for it (its
679
- // public key is in hand) and route only the other survivors through the
680
- // caller's resolver.
681
- const newEpoch = await rotateEpoch({
682
- epochId,
683
- secret,
684
- remaining,
685
- resolveRecipientKey: async (kid) => kid === recipient.id ? recipient : resolveRecipientKey(kid)
686
- });
687
- return {
688
- ...current,
689
- epochs: [...escrowed, newEpoch],
690
- currentEpoch: epochId,
691
- ...(nextHmac !== null && { hmac: nextHmac })
692
- };
693
- }
743
+ retiring,
744
+ escrow: { incoming, owner },
745
+ operation: 'replaceRecipient',
746
+ resolveRecipientKey,
747
+ pullAxis
694
748
  });
695
- await pullAxis();
696
- return rotatedDescriptor;
697
749
  }
698
750
  /**
699
751
  * Resolves the pull axis of {@link removeRecipient} -- exactly one of the
@@ -725,19 +777,22 @@ function resolvePullAxis({ space, revoke, pull }) {
725
777
  // That same status also covers tampered/expired/foreign capabilities, which
726
778
  // the client cannot distinguish here, so this swallows only ValidationError
727
779
  // and re-throws anything else.
780
+ //
781
+ // Each revocation is an independent signed request against a different
782
+ // capability id, so they run concurrently.
728
783
  const toRevoke = Array.isArray(revoke) ? revoke : [revoke];
729
784
  return async function revokeZcaps() {
730
- for (const zcap of toRevoke) {
785
+ await Promise.all(toRevoke.map(async (zcap) => {
731
786
  try {
732
787
  await space.revoke(zcap);
733
788
  }
734
789
  catch (err) {
735
790
  if (err instanceof ValidationError) {
736
- continue;
791
+ return;
737
792
  }
738
793
  throw err;
739
794
  }
740
- }
795
+ }));
741
796
  };
742
797
  }
743
798
  /**