@interop/was-client 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +27 -83
- package/dist/Collection.js.map +1 -1
- package/dist/Resource.d.ts +10 -7
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +32 -99
- package/dist/Resource.js.map +1 -1
- package/dist/codec.d.ts +18 -4
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js.map +1 -1
- package/dist/edv/EdvCodec.d.ts +77 -8
- package/dist/edv/EdvCodec.d.ts.map +1 -1
- package/dist/edv/EdvCodec.js +361 -224
- package/dist/edv/EdvCodec.js.map +1 -1
- package/dist/edv/WasTransport.js +2 -2
- package/dist/edv/WasTransport.js.map +1 -1
- package/dist/edv/constants.d.ts +0 -8
- package/dist/edv/constants.d.ts.map +1 -1
- package/dist/edv/constants.js +0 -8
- package/dist/edv/constants.js.map +1 -1
- package/dist/edv/descriptorStore.d.ts +10 -6
- package/dist/edv/descriptorStore.d.ts.map +1 -1
- package/dist/edv/descriptorStore.js +5 -3
- package/dist/edv/descriptorStore.js.map +1 -1
- package/dist/edv/didKeyRecipient.d.ts.map +1 -1
- package/dist/edv/didKeyRecipient.js +1 -1
- package/dist/edv/didKeyRecipient.js.map +1 -1
- package/dist/edv/docCipher.d.ts.map +1 -1
- package/dist/edv/docCipher.js +22 -38
- package/dist/edv/docCipher.js.map +1 -1
- package/dist/edv/epochCrypto.d.ts +7 -0
- package/dist/edv/epochCrypto.d.ts.map +1 -1
- package/dist/edv/epochCrypto.js +12 -5
- package/dist/edv/epochCrypto.js.map +1 -1
- package/dist/edv/epochKeys.d.ts.map +1 -1
- package/dist/edv/epochKeys.js +21 -37
- package/dist/edv/epochKeys.js.map +1 -1
- package/dist/edv/epochRoster.d.ts +24 -0
- package/dist/edv/epochRoster.d.ts.map +1 -1
- package/dist/edv/epochRoster.js +26 -0
- package/dist/edv/epochRoster.js.map +1 -1
- package/dist/edv/index.d.ts +2 -2
- package/dist/edv/index.d.ts.map +1 -1
- package/dist/edv/index.js +1 -1
- package/dist/edv/index.js.map +1 -1
- package/dist/edv/recipients.d.ts +2 -2
- package/dist/edv/recipients.d.ts.map +1 -1
- package/dist/edv/recipients.js +238 -202
- package/dist/edv/recipients.js.map +1 -1
- package/dist/internal/codec.d.ts.map +1 -1
- package/dist/internal/codec.js +5 -18
- package/dist/internal/codec.js.map +1 -1
- package/dist/internal/conditional.d.ts +33 -2
- package/dist/internal/conditional.d.ts.map +1 -1
- package/dist/internal/conditional.js +52 -0
- package/dist/internal/conditional.js.map +1 -1
- package/dist/internal/content.d.ts +7 -0
- package/dist/internal/content.d.ts.map +1 -1
- package/dist/internal/content.js +7 -0
- package/dist/internal/content.js.map +1 -1
- package/dist/internal/features.d.ts.map +1 -1
- package/dist/internal/features.js +9 -9
- package/dist/internal/features.js.map +1 -1
- package/dist/internal/indexSchema.d.ts +0 -10
- package/dist/internal/indexSchema.d.ts.map +1 -1
- package/dist/internal/indexSchema.js +1 -1
- package/dist/internal/indexSchema.js.map +1 -1
- package/dist/internal/memo.d.ts +39 -0
- package/dist/internal/memo.d.ts.map +1 -0
- package/dist/internal/memo.js +59 -0
- package/dist/internal/memo.js.map +1 -0
- package/dist/internal/meta.d.ts +92 -0
- package/dist/internal/meta.d.ts.map +1 -0
- package/dist/internal/meta.js +126 -0
- package/dist/internal/meta.js.map +1 -0
- package/dist/internal/pagination.js +1 -1
- package/dist/internal/pagination.js.map +1 -1
- package/dist/internal/withCodec.d.ts +26 -0
- package/dist/internal/withCodec.d.ts.map +1 -0
- package/dist/internal/withCodec.js +15 -0
- package/dist/internal/withCodec.js.map +1 -0
- package/dist/internal/write.d.ts +5 -8
- package/dist/internal/write.d.ts.map +1 -1
- package/dist/internal/write.js +25 -11
- package/dist/internal/write.js.map +1 -1
- package/dist/log/logStore.d.ts +6 -3
- package/dist/log/logStore.d.ts.map +1 -1
- package/dist/log/logStore.js +6 -3
- package/dist/log/logStore.js.map +1 -1
- package/dist/sync/cid.d.ts.map +1 -1
- package/dist/sync/cid.js +1 -6
- package/dist/sync/cid.js.map +1 -1
- package/dist/sync/index.d.ts +2 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +5 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/port.d.ts +0 -14
- package/dist/sync/port.d.ts.map +1 -1
- package/dist/sync/port.js +6 -19
- package/dist/sync/port.js.map +1 -1
- package/dist/sync/provisioning.d.ts.map +1 -1
- package/dist/sync/provisioning.js +23 -2
- package/dist/sync/provisioning.js.map +1 -1
- package/package.json +1 -1
package/dist/edv/recipients.js
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
* key and re-wraps
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
`
|
|
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 (
|
|
53
|
-
|
|
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 {
|
|
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
|
|
138
|
-
* unwraps the HMAC secret with its own key-agreement key and re-wraps it
|
|
139
|
-
*
|
|
140
|
-
* `null` when nothing changes -- the descriptor carries no `hmac`, or
|
|
141
|
-
*
|
|
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.
|
|
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 {
|
|
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,
|
|
208
|
+
async function escrowIntoHmac({ hmac, recipients, owner, operation }) {
|
|
155
209
|
if (!hmac) {
|
|
156
210
|
return null;
|
|
157
211
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
"collection's blinded-index key, so it cannot escrow that key
|
|
165
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
489
|
-
mutate: async (
|
|
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(
|
|
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:
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
// accepted revocation
|
|
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
|
|
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
|
|
506
|
-
// union across all epochs -- a reader dropped in an earlier rotation
|
|
507
|
-
// still present in that older epoch, so unioning would silently
|
|
508
|
-
// it into the fresh epoch and hand it back read access. Older
|
|
509
|
-
// only so existing readers can decrypt history.
|
|
510
|
-
const currentEpoch =
|
|
511
|
-
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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: [...
|
|
659
|
+
epochs: [...escrowed, newEpoch],
|
|
550
660
|
currentEpoch: epochId,
|
|
551
661
|
...(nextHmac !== null && { hmac: nextHmac })
|
|
552
662
|
};
|
|
@@ -581,8 +691,8 @@ export async function removeRecipient({ collection, store, space, recipientId, r
|
|
|
581
691
|
* already run elsewhere (e.g. a DID-document edit under a current-key-set
|
|
582
692
|
* rule) passes a no-op `pull`.
|
|
583
693
|
*
|
|
584
|
-
* The rotation
|
|
585
|
-
* 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.
|
|
586
696
|
*
|
|
587
697
|
* A blinded-index HMAC key on the descriptor follows the same shape in the same
|
|
588
698
|
* write: the incoming recipient gains a wrap entry, the retiring kid(s) lose
|
|
@@ -628,91 +738,14 @@ export async function replaceRecipient({ collection, store, space, retire, recip
|
|
|
628
738
|
if (incoming.some(reader => retiring.includes(reader.id))) {
|
|
629
739
|
throw new ValidationError('replaceRecipient cannot retire an incoming recipient itself.');
|
|
630
740
|
}
|
|
631
|
-
|
|
632
|
-
const rotatedDescriptor = await casUpdateDescriptor({
|
|
741
|
+
return rotateOffRecipients({
|
|
633
742
|
store: descriptorStore,
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
// The blinded-index roster mirrors the epoch semantics in the same
|
|
641
|
-
// write: each successor gains a wrap entry, the retiring kid(s) lose
|
|
642
|
-
// theirs. The key itself never rotates.
|
|
643
|
-
let escrowedHmac = null;
|
|
644
|
-
for (const reader of incoming) {
|
|
645
|
-
const next = await escrowIntoHmac({
|
|
646
|
-
hmac: escrowedHmac ?? current.hmac,
|
|
647
|
-
recipient: reader,
|
|
648
|
-
owner,
|
|
649
|
-
operation: 'replaceRecipient',
|
|
650
|
-
reader: 'incoming'
|
|
651
|
-
});
|
|
652
|
-
if (next !== null) {
|
|
653
|
-
escrowedHmac = next;
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
const nextHmac = withoutHmacRecipients({
|
|
657
|
-
hmac: escrowedHmac ?? current.hmac,
|
|
658
|
-
retiring
|
|
659
|
-
}) ?? escrowedHmac;
|
|
660
|
-
// Escrow each incoming recipient into every epoch it is missing from
|
|
661
|
-
// (addRecipient's escrow, so the whole replacement is one write).
|
|
662
|
-
let escrowed = epochs;
|
|
663
|
-
let escrowChanged = false;
|
|
664
|
-
for (const reader of incoming) {
|
|
665
|
-
const next = await escrowIntoEpochs({
|
|
666
|
-
epochs: escrowed,
|
|
667
|
-
recipient: reader,
|
|
668
|
-
owner,
|
|
669
|
-
operation: 'replaceRecipient',
|
|
670
|
-
reader: 'incoming'
|
|
671
|
-
});
|
|
672
|
-
escrowed = next.epochs;
|
|
673
|
-
escrowChanged = escrowChanged || next.changed;
|
|
674
|
-
}
|
|
675
|
-
// Rotate only when a retiring kid is still current (the removeRecipient
|
|
676
|
-
// no-op rule, so a naive re-run appends zero redundant epochs).
|
|
677
|
-
const currentEpoch = escrowed.find(epoch => epoch.id === descriptor.currentEpoch) ??
|
|
678
|
-
escrowed[escrowed.length - 1];
|
|
679
|
-
const rotating = currentEpoch.recipients.some(entry => retiring.includes(entry.header.kid));
|
|
680
|
-
if (!rotating) {
|
|
681
|
-
if (!escrowChanged && nextHmac === null) {
|
|
682
|
-
return null;
|
|
683
|
-
}
|
|
684
|
-
return {
|
|
685
|
-
...current,
|
|
686
|
-
...(escrowChanged && { epochs: escrowed }),
|
|
687
|
-
...(nextHmac !== null && { hmac: nextHmac })
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
const remaining = new Set();
|
|
691
|
-
for (const entry of currentEpoch.recipients) {
|
|
692
|
-
if (!retiring.includes(entry.header.kid)) {
|
|
693
|
-
remaining.add(entry.header.kid);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
// The escrow above already placed each incoming recipient in the
|
|
697
|
-
// current epoch, so they are in `remaining`; short-circuit the resolver
|
|
698
|
-
// for them (their public keys are in hand) and route only the other
|
|
699
|
-
// survivors through the caller's resolver.
|
|
700
|
-
const newEpoch = await rotateEpoch({
|
|
701
|
-
epochId,
|
|
702
|
-
secret,
|
|
703
|
-
remaining,
|
|
704
|
-
resolveRecipientKey: async (kid) => incoming.find(reader => reader.id === kid) ?? resolveRecipientKey(kid)
|
|
705
|
-
});
|
|
706
|
-
return {
|
|
707
|
-
...current,
|
|
708
|
-
epochs: [...escrowed, newEpoch],
|
|
709
|
-
currentEpoch: epochId,
|
|
710
|
-
...(nextHmac !== null && { hmac: nextHmac })
|
|
711
|
-
};
|
|
712
|
-
}
|
|
743
|
+
retiring,
|
|
744
|
+
escrow: { incoming, owner },
|
|
745
|
+
operation: 'replaceRecipient',
|
|
746
|
+
resolveRecipientKey,
|
|
747
|
+
pullAxis
|
|
713
748
|
});
|
|
714
|
-
await pullAxis();
|
|
715
|
-
return rotatedDescriptor;
|
|
716
749
|
}
|
|
717
750
|
/**
|
|
718
751
|
* Resolves the pull axis of {@link removeRecipient} -- exactly one of the
|
|
@@ -744,19 +777,22 @@ function resolvePullAxis({ space, revoke, pull }) {
|
|
|
744
777
|
// That same status also covers tampered/expired/foreign capabilities, which
|
|
745
778
|
// the client cannot distinguish here, so this swallows only ValidationError
|
|
746
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.
|
|
747
783
|
const toRevoke = Array.isArray(revoke) ? revoke : [revoke];
|
|
748
784
|
return async function revokeZcaps() {
|
|
749
|
-
|
|
785
|
+
await Promise.all(toRevoke.map(async (zcap) => {
|
|
750
786
|
try {
|
|
751
787
|
await space.revoke(zcap);
|
|
752
788
|
}
|
|
753
789
|
catch (err) {
|
|
754
790
|
if (err instanceof ValidationError) {
|
|
755
|
-
|
|
791
|
+
return;
|
|
756
792
|
}
|
|
757
793
|
throw err;
|
|
758
794
|
}
|
|
759
|
-
}
|
|
795
|
+
}));
|
|
760
796
|
};
|
|
761
797
|
}
|
|
762
798
|
/**
|