@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.
- 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 +12 -3
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js.map +1 -1
- package/dist/edv/EdvCodec.d.ts +35 -4
- package/dist/edv/EdvCodec.d.ts.map +1 -1
- package/dist/edv/EdvCodec.js +296 -200
- 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/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 +11 -30
- 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/recipients.d.ts +12 -10
- package/dist/edv/recipients.d.ts.map +1 -1
- package/dist/edv/recipients.js +254 -199
- 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
|
};
|
|
@@ -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
|
|
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):
|
|
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
|
|
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
|
-
* (
|
|
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
|
|
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
|
|
598
|
-
* key-agreement key, escrowed into every
|
|
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
|
|
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 (
|
|
622
|
-
throw new ValidationError('replaceRecipient
|
|
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
|
-
|
|
625
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
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
|
-
|
|
791
|
+
return;
|
|
737
792
|
}
|
|
738
793
|
throw err;
|
|
739
794
|
}
|
|
740
|
-
}
|
|
795
|
+
}));
|
|
741
796
|
};
|
|
742
797
|
}
|
|
743
798
|
/**
|