@kubun/credential 0.17.4 → 0.18.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 (51) hide show
  1. package/lib/api.d.ts +61 -30
  2. package/lib/api.js +455 -111
  3. package/lib/authority.d.ts +4 -0
  4. package/lib/authority.js +3 -0
  5. package/lib/branch.d.ts +4 -0
  6. package/lib/branch.js +24 -0
  7. package/lib/builder.d.ts +5 -0
  8. package/lib/builder.js +8 -6
  9. package/lib/canonical-token.d.ts +2 -0
  10. package/lib/canonical-token.js +10 -0
  11. package/lib/errors.d.ts +39 -1
  12. package/lib/errors.js +77 -0
  13. package/lib/fork-repair.d.ts +19 -0
  14. package/lib/fork-repair.js +3 -0
  15. package/lib/grantor-authority.d.ts +26 -0
  16. package/lib/grantor-authority.js +43 -0
  17. package/lib/index.d.ts +15 -6
  18. package/lib/index.js +7 -2
  19. package/lib/key-cache.d.ts +4 -3
  20. package/lib/key-cache.js +22 -15
  21. package/lib/manager-context.d.ts +50 -0
  22. package/lib/manager-context.js +82 -0
  23. package/lib/manager-entries.d.ts +3 -0
  24. package/lib/manager-entries.js +585 -0
  25. package/lib/manager-keys.d.ts +3 -0
  26. package/lib/manager-keys.js +413 -0
  27. package/lib/manager-open.d.ts +45 -0
  28. package/lib/manager-open.js +421 -0
  29. package/lib/manager-records.d.ts +94 -0
  30. package/lib/manager-records.js +291 -0
  31. package/lib/manager-removal-recovery.d.ts +7 -0
  32. package/lib/manager-removal-recovery.js +211 -0
  33. package/lib/manager-rotate.d.ts +15 -0
  34. package/lib/manager-rotate.js +536 -0
  35. package/lib/manager-types.d.ts +337 -0
  36. package/lib/manager-types.js +1 -0
  37. package/lib/manager.d.ts +2 -293
  38. package/lib/manager.js +38 -1073
  39. package/lib/manifest-token.d.ts +31 -0
  40. package/lib/manifest-token.js +51 -0
  41. package/lib/provider.d.ts +15 -2
  42. package/lib/provider.js +39 -0
  43. package/lib/record-types.d.ts +65 -0
  44. package/lib/record-types.js +865 -0
  45. package/lib/refreshing-credential-provider.d.ts +6 -2
  46. package/lib/refreshing-credential-provider.js +76 -13
  47. package/lib/suite.d.ts +5 -4
  48. package/lib/suite.js +2 -2
  49. package/package.json +16 -15
  50. package/lib/op.d.ts +0 -128
  51. package/lib/op.js +0 -304
@@ -0,0 +1,536 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { HLC } from '@kubun/hlc';
3
+ import { recordPayloadDigest, verifySignedRecord } from '@kubun/records';
4
+ import { credentialRevocationRecordID, decodeCredentialRecord } from '@kubun/store-credential';
5
+ import { CredentialInvalidEntry, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialRepairPending, CredentialUnopenable } from './errors.js';
6
+ import { isSecretFactorKind, wrappingNamesRemovedDevice } from './manager-context.js';
7
+ import { getSuite } from './suite.js';
8
+ export function createRotateFunctions(ctx, internals) {
9
+ const { store, identity, runtime, authority, cache, nextHLC, params } = ctx;
10
+ const { entryRecord, winningBranch, putRecord, keyRecord, keyUnique, putWrappingRecord, putEntryRecord, entryGrant, branchProofs, suiteForVersion } = internals.records;
11
+ const { factorsFromDescriptors, loadKey, openKey, openSpecificBranch } = internals.open;
12
+ async function rotateKey({ keyID, rotateParams, revokedDID, excludedRecipients = new Set() }) {
13
+ const key = await loadKey(keyID);
14
+ // Rotation is administration, not use: it retires a content key every
15
+ // other recipient holds and re-makes their wrappings under one this
16
+ // identity chose. Asked before the first read, because everything below
17
+ // opens the key and decrypts every entry before it writes anything — a
18
+ // gate after that has already done the work it exists to refuse.
19
+ if (revokedDID === undefined && !await (rotateParams?.authority ?? authority).authorize('administer', {
20
+ key,
21
+ store
22
+ })) {
23
+ throw new CredentialNotAuthorized('administer', keyID);
24
+ }
25
+ // An older partial rotation may have advanced the key row before writing
26
+ // this device's wrapping. Recover from the highest version we can open;
27
+ // locked factors and rejected secrets still require the caller's input.
28
+ let sourceVersion = key.version;
29
+ let previousKey;
30
+ for(let candidate = key.version; candidate >= 1; candidate -= 1){
31
+ try {
32
+ previousKey = (await openKey(keyID, rotateParams?.inputs, candidate)).contentKey;
33
+ sourceVersion = candidate;
34
+ break;
35
+ } catch (error) {
36
+ if (!(error instanceof CredentialUnopenable) || candidate === 1) throw error;
37
+ }
38
+ }
39
+ if (previousKey === undefined) throw new CredentialUnopenable(keyID, 'no openable version');
40
+ const previousSuite = getSuite(await suiteForVersion(keyID, sourceVersion, key.suite));
41
+ const suiteID = rotateParams?.suite ?? key.suite;
42
+ const suite = getSuite(suiteID);
43
+ const newVersion = key.version + 1;
44
+ // Open the old key before entering the transaction, then read every row
45
+ // being re-issued inside it. A concurrent grant or entry update must be
46
+ // included in the rotation, rather than replaced by an earlier snapshot.
47
+ // Both key buffers are our private clones. One try spans from here to the end so
48
+ // a throw anywhere in the rotation zeros them, not only the success path.
49
+ // Both keys are zeroed whether the transaction commits or rolls back.
50
+ let generatedKey;
51
+ try {
52
+ const contentKey = suite.generateContentKey(runtime);
53
+ generatedKey = contentKey;
54
+ // Mint the key-op stamp before the entries and wrappings it labels. All
55
+ // records commit together, so no reader sees its version bump alone.
56
+ const rotatedKey = {
57
+ keyID,
58
+ ownerDID: key.owner_did,
59
+ suite: suiteID,
60
+ version: newVersion,
61
+ state: key.state === 'retired' ? 'retired' : 'active'
62
+ };
63
+ const unique = await keyUnique(keyID);
64
+ const keyHLC = await nextHLC();
65
+ for(;;){
66
+ // Resolve recipient keys and derive any secret factors before taking a
67
+ // database lock. The locked read below validates this speculative set;
68
+ // a grant or rewrap that landed meanwhile makes us prepare again.
69
+ const observed = await store.listWrappings(keyID, sourceVersion);
70
+ const denied = new Set(excludedRecipients);
71
+ if (revokedDID !== undefined) denied.add(revokedDID);
72
+ const retained = observed.filter((wrapping)=>!wrappingNamesRemovedDevice(wrapping, denied)).map((wrapping)=>({
73
+ recipientDID: wrapping.recipient_did,
74
+ factors: factorsFromDescriptors(wrapping.factors, rotateParams?.inputs)
75
+ }));
76
+ const prepared = [];
77
+ for (const { recipientDID, factors } of retained){
78
+ const wrappingID = runtime.getRandomID();
79
+ const { descriptors, iv, wrappedKey } = await suite.wrap({
80
+ runtime,
81
+ contentKey,
82
+ factors,
83
+ wrappingID,
84
+ keyID,
85
+ keyVersion: newVersion,
86
+ methods: rotateParams?.methods
87
+ });
88
+ prepared.push({
89
+ wrappingID,
90
+ keyID,
91
+ keyVersion: newVersion,
92
+ factors: descriptors,
93
+ iv,
94
+ wrappedKey,
95
+ recipientDID
96
+ });
97
+ }
98
+ const branchID = await store.transaction(async (tx)=>{
99
+ await tx.lockKeyRow(keyID);
100
+ if ((await tx.getKey(keyID))?.version !== key.version) {
101
+ throw new CredentialKeyVersionMismatch(keyID, key.version, (await tx.getKey(keyID))?.version ?? -1);
102
+ }
103
+ if (JSON.stringify(await tx.listWrappings(keyID, sourceVersion)) !== JSON.stringify(observed)) {
104
+ return null;
105
+ }
106
+ const previousContentHLCs = new Map();
107
+ const entryGrants = new Map();
108
+ const plaintexts = [];
109
+ try {
110
+ for (const entry of (await tx.listEntries(keyID))){
111
+ const entryID = entry.entry_id;
112
+ const grant = await entryGrant(entryID, tx, entry.owner_did);
113
+ if (grant !== undefined && normalizeDID(grant.grantDeviceDID) !== normalizeDID(identity.id)) {
114
+ continue;
115
+ }
116
+ if (entry.key_version !== sourceVersion) {
117
+ throw new CredentialKeyVersionMismatch(entry.entry_id, entry.key_version, sourceVersion);
118
+ }
119
+ const previous = await entryRecord(entryID, tx, entry.owner_did);
120
+ const previousOp = previous === null ? null : await verifySignedRecord(previous);
121
+ const stamp = previousOp?.claim.meta.contentHLC;
122
+ if (typeof stamp === 'string' && (previousOp === null || !excludedRecipients.has(previousOp.issuer))) {
123
+ previousContentHLCs.set(entryID, stamp);
124
+ }
125
+ if (grant !== undefined) entryGrants.set(entryID, grant);
126
+ plaintexts.push({
127
+ entryID,
128
+ plaintext: previousSuite.decryptEntry({
129
+ contentKey: previousKey,
130
+ iv: new Uint8Array(entry.iv),
131
+ ciphertext: new Uint8Array(entry.ciphertext),
132
+ keyID,
133
+ keyVersion: entry.key_version,
134
+ entryID
135
+ })
136
+ });
137
+ }
138
+ const { branchID, branchRecordID: branchIDRecord } = await keyRecord({
139
+ key: rotatedKey,
140
+ keyUnique: unique,
141
+ hlc: keyHLC,
142
+ authority: rotateParams?.authority ?? authority,
143
+ target: tx
144
+ });
145
+ for (const wrappingParams of prepared){
146
+ await putWrappingRecord({
147
+ wrapping: wrappingParams,
148
+ owner: key.owner_did,
149
+ branchID,
150
+ branchRecordID: branchIDRecord,
151
+ authority: rotateParams?.authority ?? authority,
152
+ target: tx
153
+ });
154
+ }
155
+ for (const { entryID, plaintext } of plaintexts){
156
+ const grant = entryGrants.get(entryID);
157
+ let encrypted;
158
+ try {
159
+ encrypted = suite.encryptEntry({
160
+ runtime,
161
+ contentKey,
162
+ plaintext,
163
+ keyID,
164
+ keyVersion: newVersion,
165
+ entryID
166
+ });
167
+ } finally{
168
+ plaintext.fill(0);
169
+ }
170
+ const entryHLC = await nextHLC();
171
+ await putEntryRecord({
172
+ entry: {
173
+ entryID,
174
+ keyID,
175
+ keyVersion: newVersion,
176
+ ...encrypted,
177
+ hlc: entryHLC
178
+ },
179
+ owner: key.owner_did,
180
+ branchID,
181
+ contentHLC: previousContentHLCs.get(entryID) ?? entryHLC,
182
+ target: tx,
183
+ authority: rotateParams?.authority ?? authority,
184
+ grant
185
+ });
186
+ }
187
+ const revokedRecipients = new Set(excludedRecipients);
188
+ if (revokedDID !== undefined) revokedRecipients.add(revokedDID);
189
+ for (const recipientDID of revokedRecipients){
190
+ await putRecord({
191
+ type: 'credential.revocation',
192
+ id: credentialRevocationRecordID(keyID, recipientDID),
193
+ owner: key.owner_did,
194
+ hlc: await nextHLC(),
195
+ payload: {},
196
+ meta: {
197
+ keyID,
198
+ revokedDID: recipientDID
199
+ },
200
+ target: tx,
201
+ authority: rotateParams?.authority ?? authority
202
+ });
203
+ }
204
+ if (revokedRecipients.size > 0) {
205
+ for (const wrapping of (await tx.listWrappings(keyID))){
206
+ const recipients = [
207
+ wrapping.recipient_did,
208
+ ...wrapping.factors.filter((factor)=>factor.kind === 'did').map((factor)=>factor.params.recipient)
209
+ ];
210
+ if (recipients.some((recipient)=>typeof recipient === 'string' && revokedRecipients.has(normalizeDID(recipient)))) {
211
+ await tx.deleteWrapping(wrapping.wrapping_id);
212
+ }
213
+ }
214
+ }
215
+ return branchID;
216
+ } finally{
217
+ for (const { plaintext } of plaintexts)plaintext.fill(0);
218
+ }
219
+ });
220
+ if (branchID == null) continue;
221
+ // The rotation's own key-op labels the branch it just made. `cache.set`
222
+ // stores its own clone, so zeroing the local generated key in the
223
+ // `finally` does not disturb the cached one.
224
+ cache.set(keyID, contentKey, newVersion, branchID);
225
+ return newVersion;
226
+ }
227
+ } finally{
228
+ previousKey.fill(0);
229
+ generatedKey?.fill(0);
230
+ }
231
+ }
232
+ async function repairFork(keyID, inputs) {
233
+ await internals.removalRecovery.checkRemovalRecovery();
234
+ const key = await loadKey(keyID);
235
+ if (!await authority.authorize('administer', {
236
+ key,
237
+ store
238
+ })) {
239
+ throw new CredentialNotAuthorized('administer', keyID);
240
+ }
241
+ const branches = await store.listKeyBranches(keyID, key.version);
242
+ if (branches.length < 2) return {
243
+ status: 'not-needed'
244
+ };
245
+ const winner = await winningBranch(branches);
246
+ if (winner === null) throw new CredentialNotFound('Credential key branch', keyID);
247
+ const proofs = await branchProofs(keyID, key.version);
248
+ const digest = proofs.get(winner)?.repairDigest;
249
+ if (digest !== undefined && branches.every((branch)=>proofs.get(branch.branch_id)?.repairDigest === digest)) {
250
+ return {
251
+ status: 'not-needed'
252
+ };
253
+ }
254
+ const revocations = new Set((await store.listRevocations(keyID)).map((row)=>normalizeDID(row.recipient_did)));
255
+ const recovery = params.removalRecovery;
256
+ if (recovery !== undefined) {
257
+ for (const did of await recovery.removedDevices?.(key.owner_did) ?? []){
258
+ revocations.add(normalizeDID(did));
259
+ }
260
+ }
261
+ const wrappings = (await store.listWrappingsWithBranch(keyID, key.version)).filter((row)=>!wrappingNamesRemovedDevice(row, revocations));
262
+ const recipients = new Map();
263
+ const unaddressedWrappings = wrappings.filter((wrapping)=>wrapping.recipient_did === null);
264
+ const winnerRecipients = new Set();
265
+ for (const wrapping of wrappings){
266
+ const recipient = wrapping.recipient_did;
267
+ if (recipient === null) continue;
268
+ const normalized = normalizeDID(recipient);
269
+ if (wrapping.branch_id === winner) winnerRecipients.add(normalized);
270
+ if (!recipients.has(normalized) || wrapping.branch_id === winner) {
271
+ recipients.set(normalized, wrapping);
272
+ }
273
+ }
274
+ const candidates = [
275
+ ...await store.listEntryCandidates(keyID, key.version),
276
+ ...await store.listPriorEntryTombstones(keyID, key.version)
277
+ ];
278
+ const loserAddsRecipient = [
279
+ ...recipients.keys()
280
+ ].some((did)=>!winnerRecipients.has(did)) || unaddressedWrappings.some((wrapping)=>wrapping.branch_id !== winner);
281
+ const potentialLoserEntry = candidates.length > 0;
282
+ if (!potentialLoserEntry && !loserAddsRecipient) return {
283
+ status: 'not-needed'
284
+ };
285
+ const keys = new Map();
286
+ try {
287
+ for (const branch of branches){
288
+ const proof = proofs.get(branch.branch_id);
289
+ if (proof === undefined) return {
290
+ status: 'pending'
291
+ };
292
+ const contentKey = await openSpecificBranch({
293
+ keyID,
294
+ version: key.version,
295
+ branchID: branch.branch_id,
296
+ suiteID: proof.suite,
297
+ inputs
298
+ });
299
+ if (contentKey === null) return {
300
+ status: 'pending'
301
+ };
302
+ keys.set(branch.branch_id, contentKey);
303
+ }
304
+ const byEntry = new Map();
305
+ const verifiedCandidates = await Promise.all(candidates.map(async (record)=>({
306
+ record,
307
+ op: await verifySignedRecord(record)
308
+ })));
309
+ for (const { record, op } of verifiedCandidates){
310
+ if (op === null) return {
311
+ status: 'pending'
312
+ };
313
+ const branchID = op.claim.meta.branchID;
314
+ const contentHLC = op.claim.meta.contentHLC;
315
+ const keyVersion = op.claim.meta.keyVersion;
316
+ const priorTombstone = record.payload === null && typeof keyVersion === 'number' && keyVersion < key.version;
317
+ if (typeof branchID !== 'string' && !(branchID === undefined && record.payload === null) || typeof contentHLC !== 'string' || typeof keyVersion !== 'number' || typeof branchID === 'string' && !priorTombstone && !keys.has(branchID)) continue;
318
+ const list = byEntry.get(record.id) ?? [];
319
+ list.push({
320
+ record,
321
+ branchID: priorTombstone ? '' : branchID ?? '',
322
+ contentHLC,
323
+ keyVersion
324
+ });
325
+ byEntry.set(record.id, list);
326
+ }
327
+ const compareCandidates = (a, b)=>{
328
+ const contentOrder = HLC.compare(HLC.parse(a.contentHLC), HLC.parse(b.contentHLC));
329
+ if (contentOrder !== 0) return contentOrder;
330
+ if (a.keyVersion !== b.keyVersion) return a.keyVersion - b.keyVersion;
331
+ return a.record.opHash < b.record.opHash ? -1 : a.record.opHash > b.record.opHash ? 1 : 0;
332
+ };
333
+ const decryptCandidate = async (candidate)=>{
334
+ if (candidate.record.payload === null) return null;
335
+ const proof = proofs.get(candidate.branchID);
336
+ const sourceKey = keys.get(candidate.branchID);
337
+ const op = await verifySignedRecord(candidate.record);
338
+ if (proof === undefined || sourceKey === undefined || op === null) {
339
+ throw new CredentialRepairPending(keyID);
340
+ }
341
+ const decoded = decodeCredentialRecord(candidate.record, op.claim.meta);
342
+ if (decoded.type !== 'credential.entry') throw new CredentialInvalidEntry(candidate.record.id);
343
+ return getSuite(proof.suite).decryptEntry({
344
+ contentKey: sourceKey,
345
+ iv: decoded.payload.iv,
346
+ ciphertext: decoded.payload.ciphertext,
347
+ keyID,
348
+ keyVersion: candidate.keyVersion,
349
+ entryID: candidate.record.id
350
+ });
351
+ };
352
+ const selected = [];
353
+ try {
354
+ let loserAddsEntry = false;
355
+ let unpreservableGrant = false;
356
+ for (const [entryID, versions] of byEntry){
357
+ versions.sort(compareCandidates);
358
+ const chosen = versions[versions.length - 1];
359
+ if (chosen === undefined) continue;
360
+ const plaintext = await decryptCandidate(chosen);
361
+ const winnerCandidate = versions.filter((candidate)=>candidate.branchID === winner).sort(compareCandidates).at(-1);
362
+ if (chosen.branchID !== winner) {
363
+ const winnerPlaintext = winnerCandidate === undefined ? undefined : await decryptCandidate(winnerCandidate);
364
+ const same = winnerPlaintext !== undefined && winnerCandidate?.contentHLC === chosen.contentHLC && (plaintext === null && winnerPlaintext === null || plaintext !== null && winnerPlaintext !== null && plaintext.length === winnerPlaintext.length && plaintext.every((byte, index)=>byte === winnerPlaintext[index]));
365
+ winnerPlaintext?.fill(0);
366
+ if (!same) loserAddsEntry = true;
367
+ }
368
+ const grant = await entryGrant(entryID, store, key.owner_did);
369
+ if (grant !== undefined && normalizeDID(grant.grantDeviceDID) !== normalizeDID(identity.id)) {
370
+ unpreservableGrant = true;
371
+ plaintext?.fill(0);
372
+ continue;
373
+ }
374
+ selected.push({
375
+ entryID,
376
+ plaintext,
377
+ contentHLC: chosen.contentHLC,
378
+ opHash: chosen.record.opHash,
379
+ grant
380
+ });
381
+ }
382
+ if (!loserAddsEntry && !loserAddsRecipient) return {
383
+ status: 'not-needed'
384
+ };
385
+ if (unpreservableGrant) return {
386
+ status: 'pending'
387
+ };
388
+ // Secret factors need the caller's input. Preserve the fork for a prompted retry
389
+ // instead of dropping a recovery wrap or changing its secret without consent.
390
+ if (wrappings.some((wrapping)=>wrapping.factors.some((factor)=>factor.kind !== 'did' && (!isSecretFactorKind(factor.kind) || inputs?.[factor.kind] == null)))) return {
391
+ status: 'pending'
392
+ };
393
+ const stable = JSON.stringify({
394
+ recipients: [
395
+ ...recipients.keys()
396
+ ].sort(),
397
+ entries: selected.map(({ entryID, contentHLC, opHash })=>({
398
+ entryID,
399
+ contentHLC,
400
+ opHash
401
+ })).sort((a, b)=>a.entryID.localeCompare(b.entryID))
402
+ });
403
+ const repairDigest = recordPayloadDigest(new TextEncoder().encode(stable));
404
+ if (repairDigest === null) throw new Error('Missing repair digest');
405
+ const newVersion = key.version + 1;
406
+ const suite = getSuite(key.suite);
407
+ const contentKey = suite.generateContentKey(runtime);
408
+ try {
409
+ const prepared = [];
410
+ for (const wrapping of [
411
+ ...recipients.values(),
412
+ ...unaddressedWrappings
413
+ ]){
414
+ const wrappingID = runtime.getRandomID();
415
+ const wrapped = await suite.wrap({
416
+ runtime,
417
+ contentKey,
418
+ factors: factorsFromDescriptors(wrapping.factors, inputs),
419
+ wrappingID,
420
+ keyID,
421
+ keyVersion: newVersion
422
+ });
423
+ prepared.push({
424
+ wrappingID,
425
+ keyID,
426
+ keyVersion: newVersion,
427
+ factors: wrapped.descriptors,
428
+ iv: wrapped.iv,
429
+ wrappedKey: wrapped.wrappedKey,
430
+ recipientDID: wrapping.recipient_did
431
+ });
432
+ }
433
+ const unique = await keyUnique(keyID);
434
+ const keyHLC = await nextHLC();
435
+ const expectedBranches = branches.map((branch)=>branch.branch_id).sort();
436
+ await store.transaction(async (tx)=>{
437
+ await tx.lockKeyRow(keyID);
438
+ if ((await tx.getKey(keyID))?.version !== key.version) {
439
+ throw new CredentialKeyVersionMismatch(keyID, key.version, (await tx.getKey(keyID))?.version ?? -1);
440
+ }
441
+ if (JSON.stringify((await tx.listKeyBranches(keyID, key.version)).map((row)=>row.branch_id).sort()) !== JSON.stringify(expectedBranches)) {
442
+ throw new CredentialKeyVersionMismatch(keyID, key.version, -1);
443
+ }
444
+ const { branchID, branchRecordID } = await keyRecord({
445
+ key: {
446
+ keyID,
447
+ ownerDID: key.owner_did,
448
+ suite: key.suite,
449
+ version: newVersion,
450
+ state: key.state === 'retired' ? 'retired' : 'active'
451
+ },
452
+ keyUnique: unique,
453
+ hlc: keyHLC,
454
+ authority,
455
+ target: tx,
456
+ repairDigest
457
+ });
458
+ for (const wrapping of prepared){
459
+ await putWrappingRecord({
460
+ wrapping,
461
+ owner: key.owner_did,
462
+ branchID,
463
+ branchRecordID,
464
+ authority,
465
+ target: tx
466
+ });
467
+ }
468
+ for (const entry of selected){
469
+ if (entry.plaintext === null) {
470
+ await putRecord({
471
+ type: 'credential.entry',
472
+ id: entry.entryID,
473
+ owner: key.owner_did,
474
+ hlc: await nextHLC(),
475
+ payload: null,
476
+ meta: {
477
+ keyID,
478
+ keyVersion: newVersion,
479
+ contentHLC: entry.contentHLC,
480
+ branchID,
481
+ branchRecordID,
482
+ ...entry.grant === undefined ? {} : {
483
+ grantDeviceDID: normalizeDID(entry.grant.grantDeviceDID),
484
+ grantName: entry.grant.grantName,
485
+ grantOwnerDID: normalizeDID(entry.grant.grantOwnerDID ?? key.owner_did)
486
+ }
487
+ },
488
+ target: tx,
489
+ authority
490
+ });
491
+ continue;
492
+ }
493
+ const encrypted = suite.encryptEntry({
494
+ runtime,
495
+ contentKey,
496
+ plaintext: entry.plaintext,
497
+ keyID,
498
+ keyVersion: newVersion,
499
+ entryID: entry.entryID
500
+ });
501
+ await putEntryRecord({
502
+ entry: {
503
+ entryID: entry.entryID,
504
+ keyID,
505
+ keyVersion: newVersion,
506
+ ...encrypted,
507
+ hlc: await nextHLC()
508
+ },
509
+ owner: key.owner_did,
510
+ branchID,
511
+ contentHLC: entry.contentHLC,
512
+ target: tx,
513
+ authority,
514
+ grant: entry.grant
515
+ });
516
+ }
517
+ });
518
+ return {
519
+ status: 'rotated',
520
+ version: newVersion
521
+ };
522
+ } finally{
523
+ contentKey.fill(0);
524
+ }
525
+ } finally{
526
+ for (const entry of selected)entry.plaintext?.fill(0);
527
+ }
528
+ } finally{
529
+ for (const keyBytes of keys.values())keyBytes.fill(0);
530
+ }
531
+ }
532
+ return {
533
+ rotateKey,
534
+ repairFork
535
+ };
536
+ }