@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,82 @@
1
+ import { isKeyAgreementIdentity, normalizeDID } from '@kokuin/token';
2
+ import { HLC } from '@kubun/hlc';
3
+ import { createDeviceAuthority } from './authority.js';
4
+ import { createKeyCache } from './key-cache.js';
5
+ import { createPinThrottle } from './pin-throttle.js';
6
+ /** Kinds a user can be prompted for, as opposed to ones held as key material. */ export const SECRET_FACTOR_KINDS = [
7
+ 'passphrase',
8
+ 'pin'
9
+ ];
10
+ export function wrappingNamesRemovedDevice(wrapping, removed) {
11
+ return [
12
+ wrapping.recipient_did,
13
+ ...wrapping.factors.filter((factor)=>factor.kind === 'did').map((factor)=>factor.params.recipient)
14
+ ].some((recipient)=>typeof recipient === 'string' && removed.has(normalizeDID(recipient)));
15
+ }
16
+ export function isSecretFactorKind(kind) {
17
+ return SECRET_FACTOR_KINDS.includes(kind);
18
+ }
19
+ export function createManagerContext(params) {
20
+ const { store, identity, runtime, hlc } = params;
21
+ const authority = params.authority ?? createDeviceAuthority(identity.id);
22
+ const decrypting = isKeyAgreementIdentity(identity) ? identity : null;
23
+ const cache = createKeyCache({
24
+ ttl: params.unlockTTL,
25
+ now: params.now
26
+ });
27
+ const throttle = createPinThrottle({
28
+ ...params.pinThrottle,
29
+ now: params.now
30
+ });
31
+ // Concurrency guards for `openKey`:
32
+ // - `inFlight` collapses a thundering herd of input-free opens of one key into
33
+ // a single unwrap; each waiter then takes its own clone from the cache.
34
+ // - `keyGeneration` (bumped by `lock`) and `lockAllEpoch` (bumped by `lockAll`)
35
+ // let a lock landing mid-open suppress that open's `cache.set`, so an open
36
+ // begun before the lock cannot re-warm the cache the lock just cleared. The
37
+ // in-flight read still returns its key — it was already authorized; the lock
38
+ // only means "do not stay warm."
39
+ const state = {
40
+ inFlight: new Map(),
41
+ keyGeneration: new Map(),
42
+ lockAllEpoch: 0
43
+ };
44
+ /**
45
+ * Floor the shared clock to the greatest stored entry HLC, once per manager.
46
+ *
47
+ * The engine floors from the graph mutation log, which credential writes never
48
+ * touch — they advance the same clock and leave no trace there, so this is the
49
+ * only read that can see them again after a restart where the OS clock stepped
50
+ * backward. Memoized so concurrent first-writes await one read.
51
+ */ async function ensureFloored() {
52
+ state.floorPromise ??= (async ()=>{
53
+ const max = await store.getMaxEntryHLC();
54
+ // An applied grant stores the granter's HLC verbatim, so the maximum can be
55
+ // a remote peer's string — and `HLC.parse` answers a malformed one with NaN
56
+ // fields that `receive` would carry into the clock.
57
+ const parsed = max == null ? null : HLC.tryParse(max);
58
+ if (parsed != null) {
59
+ hlc.receive(parsed);
60
+ }
61
+ })();
62
+ await state.floorPromise;
63
+ }
64
+ async function nextHLC() {
65
+ await ensureFloored();
66
+ return HLC.serialize(hlc.now());
67
+ }
68
+ return {
69
+ params,
70
+ store,
71
+ identity,
72
+ runtime,
73
+ hlc,
74
+ authority,
75
+ decrypting,
76
+ cache,
77
+ throttle,
78
+ state,
79
+ ensureFloored,
80
+ nextHLC
81
+ };
82
+ }
@@ -0,0 +1,3 @@
1
+ import type { ManagerContext, ManagerInternals } from './manager-context.js';
2
+ import type { CredentialManager } from './manager-types.js';
3
+ export declare function createEntryFunctions(ctx: ManagerContext, internals: ManagerInternals): Pick<CredentialManager, 'putEntry' | 'updateEntry' | 'updateEntryIfPresent' | 'updateEntryIfUnchanged' | 'readEntry' | 'getEntryProvenance' | 'getEntryKeyOwnerDID' | 'listEntries' | 'reencryptGrantEntries' | 'deleteEntry' | 'reKeyEntry'>;
@@ -0,0 +1,585 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { verifySignedRecord } from '@kubun/records';
3
+ import { buildCredentialEntry } from './builder.js';
4
+ import { CredentialEntryConflict, CredentialEntryOwnerMismatch, CredentialInvalidEntry, CredentialKeyLocked, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialRepairPending, CredentialSupersededBranch, CredentialUnopenable, UnsupportedCredentialSuite } from './errors.js';
5
+ import { entryProvenanceFields } from './provenance.js';
6
+ import { credentialGrantEntryID } from './record-types.js';
7
+ import { getSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
8
+ export function createEntryFunctions(ctx, internals) {
9
+ const { store, identity, runtime, authority, nextHLC } = ctx;
10
+ const { entryRecord, winningBranch, putRecord, branchRecordID, putEntryRecord, entryGrant, writableGrant, branchProofs, suiteForVersion } = internals.records;
11
+ const { loadKey, openKey, openSpecificBranch } = internals.open;
12
+ const { checkRemovalRecovery } = internals.removalRecovery;
13
+ const requireEntryAddress = (address)=>{
14
+ if (typeof address?.entryID !== 'string' || typeof address.ownerDID !== 'string' || !address.ownerDID) {
15
+ throw new CredentialInvalidEntry('credential entry address requires an ownerDID and entryID');
16
+ }
17
+ return address;
18
+ };
19
+ // The shared body of updateEntryIfPresent and updateEntryIfUnchanged; a null
20
+ // `expectedHLC` is the unconditional presence-gated write.
21
+ async function updateEntryConditionally({ entryID, plaintext, expectedHLC, attempt = 1, ownerDID }) {
22
+ const existing = await store.getEntry(entryID, ownerDID);
23
+ if (existing == null) {
24
+ return 'absent';
25
+ }
26
+ // Already rewritten since the caller read it: skip the key open and the
27
+ // re-encrypt. The store repeats the check atomically with the write.
28
+ if (expectedHLC != null && existing.hlc !== expectedHLC) {
29
+ return 'superseded';
30
+ }
31
+ const grant = await writableGrant(entryID, existing.owner_did);
32
+ const { contentKey, version, branchID } = await openKey(existing.key_id);
33
+ // Same re-encrypt-under-current-version shape as `updateEntry`: the suite
34
+ // lookup's store read stays inside the `try` so a rejection cannot leak the
35
+ // key copy.
36
+ let encrypted;
37
+ try {
38
+ const suite = getSuite((await store.getKey(existing.key_id))?.suite ?? SUITE_KUBUN_CRED_1);
39
+ encrypted = suite.encryptEntry({
40
+ runtime,
41
+ contentKey,
42
+ plaintext,
43
+ keyID: existing.key_id,
44
+ keyVersion: version,
45
+ entryID
46
+ });
47
+ } finally{
48
+ contentKey.fill(0);
49
+ }
50
+ const { iv, ciphertext } = encrypted;
51
+ const updated = {
52
+ entryID,
53
+ keyID: existing.key_id,
54
+ keyVersion: version,
55
+ iv,
56
+ ciphertext,
57
+ // A fresh stamp every time, not `existing.hlc` carried forward: the store
58
+ // primitive is op-first and gates the row write on this op winning LWW, so
59
+ // the writeback needs a current stamp to land at all.
60
+ hlc: await nextHLC()
61
+ };
62
+ const outcome = await store.transaction(async (tx)=>{
63
+ await tx.lockKeyRow(updated.keyID);
64
+ const current = await tx.getEntry(entryID, existing.owner_did);
65
+ if (current == null) return 'absent';
66
+ if (expectedHLC != null && current.hlc !== expectedHLC) return 'superseded';
67
+ const key = await tx.getKey(updated.keyID);
68
+ if (key == null) throw new CredentialNotFound('Credential key', updated.keyID);
69
+ if (key.version !== version || current.key_id !== updated.keyID) return 'retry';
70
+ await putEntryRecord({
71
+ entry: updated,
72
+ owner: key.owner_did,
73
+ branchID,
74
+ contentHLC: updated.hlc,
75
+ target: tx,
76
+ authority,
77
+ grant
78
+ });
79
+ return 'updated';
80
+ });
81
+ if (outcome !== 'retry') return outcome;
82
+ if (attempt === 5) throw new CredentialEntryConflict(entryID);
83
+ return await updateEntryConditionally({
84
+ entryID,
85
+ plaintext,
86
+ expectedHLC,
87
+ attempt: attempt + 1,
88
+ ownerDID
89
+ });
90
+ }
91
+ async function reencryptGrantEntries(keyID) {
92
+ await checkRemovalRecovery();
93
+ for (const listed of (await store.listEntries(keyID))){
94
+ for(let attempt = 0; attempt < 5; attempt += 1){
95
+ const key = await loadKey(keyID);
96
+ const entry = await store.getEntry(listed.entry_id, key.owner_did);
97
+ if (entry == null || entry.key_id !== keyID || entry.key_version >= key.version) break;
98
+ const grant = await entryGrant(entry.entry_id, store, entry.owner_did);
99
+ if (grant === undefined || normalizeDID(grant.grantDeviceDID) !== normalizeDID(identity.id)) {
100
+ break;
101
+ }
102
+ const previousRecord = await entryRecord(entry.entry_id, store, entry.owner_did);
103
+ const previousOp = previousRecord === null ? null : await verifySignedRecord(previousRecord);
104
+ const contentHLC = previousOp?.claim.meta.contentHLC;
105
+ if (typeof contentHLC !== 'string') break;
106
+ const old = await openKey(keyID, undefined, entry.key_version);
107
+ let plaintext;
108
+ try {
109
+ plaintext = getSuite(await suiteForVersion(keyID, entry.key_version, key.suite)).decryptEntry({
110
+ contentKey: old.contentKey,
111
+ iv: new Uint8Array(entry.iv),
112
+ ciphertext: new Uint8Array(entry.ciphertext),
113
+ keyID,
114
+ keyVersion: entry.key_version,
115
+ entryID: entry.entry_id
116
+ });
117
+ } finally{
118
+ old.contentKey.fill(0);
119
+ }
120
+ let retry;
121
+ try {
122
+ const expectedKey = await store.getKey(keyID);
123
+ const current = await openKey(keyID);
124
+ try {
125
+ const stamp = await nextHLC();
126
+ retry = await store.transaction(async (tx)=>{
127
+ await tx.lockKeyRows([
128
+ keyID
129
+ ]);
130
+ const latestKey = await tx.getKey(keyID);
131
+ const latestEntry = await tx.getEntry(entry.entry_id, entry.owner_did);
132
+ if (latestKey == null || expectedKey == null || latestKey.version !== expectedKey.version || latestKey.suite !== expectedKey.suite || latestKey.version !== current.version || latestEntry?.hlc !== entry.hlc || latestEntry.key_version !== entry.key_version) {
133
+ return true;
134
+ }
135
+ const encrypted = getSuite(latestKey.suite).encryptEntry({
136
+ runtime,
137
+ contentKey: current.contentKey,
138
+ plaintext,
139
+ keyID,
140
+ keyVersion: latestKey.version,
141
+ entryID: entry.entry_id
142
+ });
143
+ await putEntryRecord({
144
+ entry: {
145
+ entryID: entry.entry_id,
146
+ keyID,
147
+ keyVersion: latestKey.version,
148
+ ...encrypted,
149
+ hlc: stamp
150
+ },
151
+ owner: latestKey.owner_did,
152
+ branchID: current.branchID,
153
+ contentHLC,
154
+ target: tx,
155
+ authority,
156
+ grant
157
+ });
158
+ return false;
159
+ });
160
+ } finally{
161
+ current.contentKey.fill(0);
162
+ }
163
+ } finally{
164
+ plaintext.fill(0);
165
+ }
166
+ if (!retry) break;
167
+ }
168
+ }
169
+ }
170
+ return {
171
+ async putEntry (keyID, plaintext, grant) {
172
+ await checkRemovalRecovery();
173
+ if (grant !== undefined && grant.grantName === '') {
174
+ throw new CredentialInvalidEntry('Grant name is empty');
175
+ }
176
+ if (grant !== undefined && normalizeDID(grant.grantDeviceDID) !== normalizeDID(identity.id)) {
177
+ throw new CredentialNotAuthorized('administer', keyID);
178
+ }
179
+ for(;;){
180
+ const { contentKey, version, branchID } = await openKey(keyID);
181
+ let entry;
182
+ let candidateSuite;
183
+ try {
184
+ const key = await loadKey(keyID);
185
+ candidateSuite = key.suite;
186
+ if (grant === undefined) {
187
+ entry = buildCredentialEntry({
188
+ runtime,
189
+ key: {
190
+ keyID,
191
+ ownerDID: key.owner_did,
192
+ suite: key.suite,
193
+ version,
194
+ contentKey
195
+ },
196
+ plaintext
197
+ });
198
+ } else {
199
+ const entryID = credentialGrantEntryID(grant.grantOwnerDID ?? key.owner_did, grant.grantDeviceDID, grant.grantName);
200
+ const encrypted = getSuite(key.suite).encryptEntry({
201
+ runtime,
202
+ contentKey,
203
+ plaintext,
204
+ keyID,
205
+ keyVersion: version,
206
+ entryID
207
+ });
208
+ entry = {
209
+ entryID,
210
+ keyID,
211
+ keyVersion: version,
212
+ ...encrypted
213
+ };
214
+ }
215
+ } finally{
216
+ contentKey.fill(0);
217
+ }
218
+ const entryParams = {
219
+ ...entry,
220
+ hlc: await nextHLC()
221
+ };
222
+ const written = await store.transaction(async (tx)=>{
223
+ await tx.lockKeyRow(keyID);
224
+ const key = await tx.getKey(keyID);
225
+ if (key == null) throw new CredentialNotFound('Credential key', keyID);
226
+ if (key.version !== version || key.suite !== candidateSuite) return false;
227
+ await putEntryRecord({
228
+ entry: entryParams,
229
+ owner: key.owner_did,
230
+ branchID,
231
+ contentHLC: entryParams.hlc,
232
+ target: tx,
233
+ authority,
234
+ grant
235
+ });
236
+ return true;
237
+ });
238
+ if (written) return entry.entryID;
239
+ }
240
+ },
241
+ async updateEntry (address, plaintext) {
242
+ const { entryID, ownerDID } = requireEntryAddress(address);
243
+ await checkRemovalRecovery();
244
+ for(;;){
245
+ const existing = await store.getEntry(entryID, ownerDID);
246
+ if (existing == null) throw new CredentialNotFound('Credential entry', entryID);
247
+ const grant = await writableGrant(entryID, existing.owner_did);
248
+ const { contentKey, version, branchID } = await openKey(existing.key_id);
249
+ let encrypted;
250
+ let candidateSuite;
251
+ try {
252
+ candidateSuite = (await loadKey(existing.key_id)).suite;
253
+ encrypted = getSuite(candidateSuite).encryptEntry({
254
+ runtime,
255
+ contentKey,
256
+ plaintext,
257
+ keyID: existing.key_id,
258
+ keyVersion: version,
259
+ entryID
260
+ });
261
+ } finally{
262
+ contentKey.fill(0);
263
+ }
264
+ const updated = {
265
+ entryID,
266
+ keyID: existing.key_id,
267
+ keyVersion: version,
268
+ ...encrypted,
269
+ hlc: await nextHLC()
270
+ };
271
+ const written = await store.transaction(async (tx)=>{
272
+ await tx.lockKeyRow(existing.key_id);
273
+ const key = await tx.getKey(existing.key_id);
274
+ if (key == null) throw new CredentialNotFound('Credential key', existing.key_id);
275
+ if (key.version !== version || key.suite !== candidateSuite) return false;
276
+ const current = await tx.getEntry(entryID, existing.owner_did);
277
+ if (current == null) throw new CredentialNotFound('Credential entry', entryID);
278
+ if (current.key_id !== existing.key_id) return false;
279
+ await putEntryRecord({
280
+ entry: updated,
281
+ owner: key.owner_did,
282
+ branchID,
283
+ contentHLC: updated.hlc,
284
+ target: tx,
285
+ authority,
286
+ grant
287
+ });
288
+ return true;
289
+ });
290
+ if (written) return;
291
+ }
292
+ },
293
+ async updateEntryIfPresent (address, plaintext) {
294
+ const { entryID, ownerDID } = requireEntryAddress(address);
295
+ await checkRemovalRecovery();
296
+ // Unconditional, so never superseded.
297
+ return await updateEntryConditionally({
298
+ entryID,
299
+ plaintext,
300
+ expectedHLC: null,
301
+ attempt: 1,
302
+ ownerDID
303
+ });
304
+ },
305
+ async updateEntryIfUnchanged (input) {
306
+ const { entryID, ownerDID } = requireEntryAddress(input);
307
+ const { plaintext, expectedHLC } = input;
308
+ await checkRemovalRecovery();
309
+ return await updateEntryConditionally({
310
+ entryID,
311
+ plaintext,
312
+ expectedHLC,
313
+ attempt: 1,
314
+ ownerDID
315
+ });
316
+ },
317
+ async readEntry (address) {
318
+ const { entryID, ownerDID } = requireEntryAddress(address);
319
+ const entry = await store.getEntry(entryID, ownerDID);
320
+ if (entry == null) {
321
+ throw new CredentialNotFound('Credential entry', entryID);
322
+ }
323
+ if (entryID.startsWith('grant:')) {
324
+ try {
325
+ await reencryptGrantEntries(entry.key_id);
326
+ } catch (error) {
327
+ if (!(error instanceof UnsupportedCredentialSuite) && !(error instanceof CredentialUnopenable) && !(error instanceof CredentialKeyLocked)) {
328
+ throw error;
329
+ }
330
+ }
331
+ }
332
+ const currentEntry = await store.getEntry(entryID, entry.owner_did);
333
+ if (currentEntry == null) throw new CredentialNotFound('Credential entry', entryID);
334
+ const key = await store.getKey(currentEntry.key_id);
335
+ if (key == null) {
336
+ throw new CredentialNotFound('Credential key', entry.key_id);
337
+ }
338
+ if (currentEntry.key_version > key.version) {
339
+ throw new CredentialKeyVersionMismatch(entryID, currentEntry.key_version, key.version);
340
+ }
341
+ const { contentKey, branchID } = await openKey(currentEntry.key_id, undefined, currentEntry.key_version);
342
+ // Zero our private clone on every exit — the branch-mismatch throw
343
+ // and the decrypt alike.
344
+ try {
345
+ // The row's own label: a replicated entry record has no op row to read it from.
346
+ const entryBranchID = currentEntry.branch_id;
347
+ if (entryBranchID !== '' && branchID !== '' && entryBranchID !== branchID) {
348
+ const proofs = await branchProofs(currentEntry.key_id, currentEntry.key_version);
349
+ const entryProof = proofs.get(entryBranchID);
350
+ const winnerProof = proofs.get(branchID);
351
+ if (entryProof?.repairDigest === undefined || entryProof.repairDigest !== winnerProof?.repairDigest) {
352
+ throw new CredentialSupersededBranch(entryID, entryBranchID, branchID);
353
+ }
354
+ const ownBranchKey = await openSpecificBranch({
355
+ keyID: currentEntry.key_id,
356
+ version: currentEntry.key_version,
357
+ branchID: entryBranchID,
358
+ suiteID: entryProof.suite
359
+ });
360
+ if (ownBranchKey === null) throw new CredentialRepairPending(currentEntry.key_id);
361
+ try {
362
+ return getSuite(entryProof.suite).decryptEntry({
363
+ contentKey: ownBranchKey,
364
+ iv: new Uint8Array(currentEntry.iv),
365
+ ciphertext: new Uint8Array(currentEntry.ciphertext),
366
+ keyID: currentEntry.key_id,
367
+ keyVersion: currentEntry.key_version,
368
+ entryID
369
+ });
370
+ } finally{
371
+ ownBranchKey.fill(0);
372
+ }
373
+ }
374
+ return getSuite(await suiteForVersion(currentEntry.key_id, currentEntry.key_version, key.suite)).decryptEntry({
375
+ contentKey,
376
+ iv: new Uint8Array(currentEntry.iv),
377
+ ciphertext: new Uint8Array(currentEntry.ciphertext),
378
+ keyID: currentEntry.key_id,
379
+ keyVersion: currentEntry.key_version,
380
+ entryID
381
+ });
382
+ } finally{
383
+ contentKey.fill(0);
384
+ }
385
+ },
386
+ async getEntryProvenance (address) {
387
+ const { entryID, ownerDID } = requireEntryAddress(address);
388
+ const entry = await store.getEntryWithProvenance(entryID, ownerDID);
389
+ if (entry == null) {
390
+ return {
391
+ updatedAt: null,
392
+ writerDID: null
393
+ };
394
+ }
395
+ const record = await entryRecord(entryID, store, entry.owner_did);
396
+ const op = record === null ? null : await verifySignedRecord(record);
397
+ return entryProvenanceFields({
398
+ hlc: entry.hlc,
399
+ author_did: op?.issuer ?? entry.author_did
400
+ });
401
+ },
402
+ async getEntryKeyOwnerDID (address) {
403
+ const { entryID, ownerDID } = requireEntryAddress(address);
404
+ const entry = await store.getEntry(entryID, ownerDID);
405
+ if (entry == null) {
406
+ return null;
407
+ }
408
+ const key = await store.getKey(entry.key_id, entry.owner_did);
409
+ return key == null ? null : key.owner_did;
410
+ },
411
+ async listEntries (keyID) {
412
+ await reencryptGrantEntries(keyID);
413
+ return (await store.listEntries(keyID)).map((entry)=>entry.entry_id);
414
+ },
415
+ reencryptGrantEntries,
416
+ async deleteEntry (address) {
417
+ const { entryID, ownerDID } = requireEntryAddress(address);
418
+ await checkRemovalRecovery();
419
+ for(;;){
420
+ const entry = await store.getEntry(entryID, ownerDID);
421
+ if (entry == null) return;
422
+ const key = await loadKey(entry.key_id);
423
+ if (!Number.isSafeInteger(entry.key_version) || entry.key_version < 0) {
424
+ throw new CredentialKeyVersionMismatch(entryID, entry.key_version, key.version);
425
+ }
426
+ const grant = await entryGrant(entryID, store, entry.owner_did);
427
+ const deletionHLC = await nextHLC();
428
+ const result = await store.transaction(async (tx)=>{
429
+ await tx.lockKeyRows([
430
+ entry.key_id
431
+ ]);
432
+ const currentKey = await tx.getKey(entry.key_id);
433
+ if (currentKey == null) throw new CredentialNotFound('Credential key', entry.key_id);
434
+ const currentEntry = await tx.getEntry(entryID, entry.owner_did);
435
+ if (currentEntry == null) return 'done';
436
+ if (currentKey.version !== key.version || currentEntry.key_id !== entry.key_id || currentEntry.hlc !== entry.hlc) {
437
+ return 'retry';
438
+ }
439
+ const currentBranches = await tx.listKeyBranches(entry.key_id, key.version);
440
+ const deletionBranchID = currentEntry.key_version === key.version && currentEntry.branch_id !== '' ? currentEntry.branch_id : currentBranches.length > 0 ? await winningBranch(currentBranches) ?? '' : '';
441
+ await putRecord({
442
+ type: 'credential.entry',
443
+ id: entryID,
444
+ owner: currentKey.owner_did,
445
+ hlc: deletionHLC,
446
+ payload: null,
447
+ meta: {
448
+ keyID: entry.key_id,
449
+ keyVersion: key.version,
450
+ contentHLC: deletionHLC,
451
+ ...deletionBranchID === '' ? {} : {
452
+ branchID: deletionBranchID,
453
+ branchRecordID: await branchRecordID({
454
+ keyID: entry.key_id,
455
+ version: key.version,
456
+ branchID: deletionBranchID,
457
+ target: tx
458
+ })
459
+ },
460
+ ...grant === undefined ? {} : {
461
+ grantDeviceDID: normalizeDID(grant.grantDeviceDID),
462
+ grantName: grant.grantName,
463
+ grantOwnerDID: normalizeDID(grant.grantOwnerDID ?? currentKey.owner_did)
464
+ }
465
+ },
466
+ target: tx
467
+ });
468
+ return 'done';
469
+ });
470
+ if (result === 'done') return;
471
+ }
472
+ },
473
+ async reKeyEntry (input) {
474
+ const { entryID, ownerDID } = requireEntryAddress(input);
475
+ const { toKeyID } = input;
476
+ const params = input;
477
+ await checkRemovalRecovery();
478
+ const entry = await store.getEntry(entryID, ownerDID);
479
+ if (entry == null) {
480
+ throw new CredentialNotFound('Credential entry', entryID);
481
+ }
482
+ const grant = await writableGrant(entryID, entry.owner_did);
483
+ const fromKey = await loadKey(entry.key_id);
484
+ // Both keys resolved before either gate, so an absent target reports as
485
+ // missing rather than as something this identity may not administer.
486
+ const toKey = await loadKey(toKeyID);
487
+ for (const key of [
488
+ fromKey,
489
+ toKey
490
+ ]){
491
+ if (!await (params?.authority ?? authority).authorize('administer', {
492
+ key,
493
+ store
494
+ })) {
495
+ throw new CredentialNotAuthorized('administer', key.key_id);
496
+ }
497
+ }
498
+ if (normalizeDID(fromKey.owner_did) !== normalizeDID(toKey.owner_did)) {
499
+ throw new CredentialEntryOwnerMismatch(entryID, fromKey.owner_did, toKey.owner_did);
500
+ }
501
+ // The refusal `readEntry` makes, for the same reason: the content key this
502
+ // entry was written under is gone with the rotation that did not finish,
503
+ // so it cannot be carried anywhere, and decrypting it under the current
504
+ // one would fail at the tag and read as tampering.
505
+ if (entry.key_version !== fromKey.version) {
506
+ throw new CredentialKeyVersionMismatch(entryID, entry.key_version, fromKey.version);
507
+ }
508
+ // Both keys opened, and the plaintext recovered, before the single write.
509
+ // A target this device cannot open has to fail while the entry is still
510
+ // where it was.
511
+ const { contentKey: fromContentKey } = await openKey(entry.key_id, undefined, entry.key_version);
512
+ let plaintext;
513
+ try {
514
+ plaintext = getSuite(await suiteForVersion(entry.key_id, entry.key_version, fromKey.suite)).decryptEntry({
515
+ contentKey: fromContentKey,
516
+ iv: new Uint8Array(entry.iv),
517
+ ciphertext: new Uint8Array(entry.ciphertext),
518
+ keyID: entry.key_id,
519
+ keyVersion: entry.key_version,
520
+ entryID
521
+ });
522
+ } finally{
523
+ fromContentKey.fill(0);
524
+ }
525
+ let movedNow;
526
+ try {
527
+ const { contentKey: toContentKey, version, branchID } = await openKey(toKeyID);
528
+ // The target's CURRENT version, as an update is: landing on an older one
529
+ // would strand the entry the moment the key rotates.
530
+ let reEncrypted;
531
+ try {
532
+ reEncrypted = getSuite(toKey.suite).encryptEntry({
533
+ runtime,
534
+ contentKey: toContentKey,
535
+ plaintext,
536
+ keyID: toKeyID,
537
+ keyVersion: version,
538
+ entryID
539
+ });
540
+ } finally{
541
+ toContentKey.fill(0);
542
+ }
543
+ const { iv, ciphertext } = reEncrypted;
544
+ const moved = {
545
+ entryID,
546
+ keyID: toKeyID,
547
+ keyVersion: version,
548
+ iv,
549
+ ciphertext,
550
+ hlc: await nextHLC()
551
+ };
552
+ movedNow = await store.transaction(async (tx)=>{
553
+ // A move touches both key scopes; the store orders advisory IDs.
554
+ await tx.lockKeyRows([
555
+ entry.key_id,
556
+ toKeyID
557
+ ]);
558
+ const currentFrom = await tx.getKey(entry.key_id);
559
+ const currentTo = await tx.getKey(toKeyID);
560
+ const currentEntry = await tx.getEntry(entryID, entry.owner_did);
561
+ if (currentFrom == null || currentTo == null || currentEntry == null) return false;
562
+ if (currentFrom.version !== fromKey.version || currentTo.version !== version || currentEntry.key_id !== entry.key_id || currentEntry.hlc !== entry.hlc) {
563
+ return false;
564
+ }
565
+ await putEntryRecord({
566
+ entry: moved,
567
+ owner: currentTo.owner_did,
568
+ branchID,
569
+ contentHLC: moved.hlc,
570
+ target: tx,
571
+ authority: params?.authority ?? authority,
572
+ grant
573
+ });
574
+ return true;
575
+ });
576
+ } finally{
577
+ plaintext.fill(0);
578
+ }
579
+ if (!movedNow) await internals.manager.reKeyEntry({
580
+ ...input,
581
+ ownerDID: entry.owner_did
582
+ });
583
+ }
584
+ };
585
+ }
@@ -0,0 +1,3 @@
1
+ import { type ManagerContext, type ManagerInternals } from './manager-context.js';
2
+ import type { CredentialManager } from './manager-types.js';
3
+ export declare function createKeyMethods(ctx: ManagerContext, internals: ManagerInternals): Pick<CredentialManager, 'availableFactors' | 'unlock' | 'addFactor' | 'removeFactor' | 'lock' | 'lockAll' | 'status' | 'createKey' | 'rotate' | 'revoke' | 'grant' | 'listKeys' | 'countWrappings' | 'deleteKey'>;