@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
package/lib/manager.js CHANGED
@@ -1,1081 +1,46 @@
1
- import { isKeyAgreementIdentity, normalizeDID } from '@kokuin/token';
2
- import { HLC } from '@kubun/hlc';
3
- import { createDeviceAuthority } from './authority.js';
4
- import { buildCredentialEntry, buildCredentialKey } from './builder.js';
5
- import { AmbiguousCredentialWrapping, CredentialKeyLocked, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialPinRejected, CredentialSecretRejected, CredentialSupersededBranch, CredentialTampered, CredentialUnopenable, InvalidCredentialFactors, NoAvailableFactor, UnsupportedCredentialSuite } from './errors.js';
6
- import { createKeyCache } from './key-cache.js';
7
- import { createCredentialOpSigner, selectWinningBranch } from './op.js';
8
- import { createPinThrottle } from './pin-throttle.js';
9
- import { entryProvenanceFields } from './provenance.js';
10
- import { getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
11
- import { wrappableDID } from './wrappable.js';
12
- /** Kinds a user can be prompted for, as opposed to ones held as key material. */ const SECRET_FACTOR_KINDS = [
13
- 'passphrase',
14
- 'pin'
15
- ];
16
- function isSecretFactorKind(kind) {
17
- return SECRET_FACTOR_KINDS.includes(kind);
18
- }
1
+ import { createManagerContext } from './manager-context.js';
2
+ import { createEntryFunctions } from './manager-entries.js';
3
+ import { createKeyMethods } from './manager-keys.js';
4
+ import { createOpenFunctions } from './manager-open.js';
5
+ import { createRecordFunctions } from './manager-records.js';
6
+ import { createRemovalRecoveryFunctions } from './manager-removal-recovery.js';
7
+ import { createRotateFunctions } from './manager-rotate.js';
19
8
  export function createCredentialManager(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 ops = createCredentialOpSigner(identity);
24
- const cache = createKeyCache({
25
- ttl: params.unlockTTL,
26
- now: params.now
27
- });
28
- const throttle = createPinThrottle({
29
- ...params.pinThrottle,
30
- now: params.now
31
- });
32
- // Concurrency guards for `openKey`:
33
- // - `inFlight` collapses a thundering herd of input-free opens of one key into
34
- // a single unwrap; each waiter then takes its own clone from the cache.
35
- // - `keyGeneration` (bumped by `lock`) and `lockAllEpoch` (bumped by `lockAll`)
36
- // let a lock landing mid-open suppress that open's `cache.set`, so an open
37
- // begun before the lock cannot re-warm the cache the lock just cleared. The
38
- // in-flight read still returns its key — it was already authorized; the lock
39
- // only means "do not stay warm."
40
- const inFlight = new Map();
41
- const keyGeneration = new Map();
42
- let lockAllEpoch = 0;
43
- // Input-bearing opens (a supplied pin/passphrase) never dedup: their outcome
44
- // and the PIN throttle are caller-specific, so one caller's attempt must not
45
- // stand in for another's.
46
- const hasSuppliedInputs = (inputs)=>inputs != null && Object.values(inputs).some((value)=>value != null);
47
- let floorPromise;
48
- /**
49
- * Floor the shared clock to the greatest stored entry HLC, once per manager.
50
- *
51
- * The engine floors from the graph mutation log, which credential writes never
52
- * touch — they advance the same clock and leave no trace there, so this is the
53
- * only read that can see them again after a restart where the OS clock stepped
54
- * backward. Memoized so concurrent first-writes await one read.
55
- */ async function ensureFloored() {
56
- floorPromise ??= (async ()=>{
57
- const max = await store.getMaxEntryHLC();
58
- // An applied grant stores the granter's HLC verbatim, so the maximum can be
59
- // a remote peer's string — and `HLC.parse` answers a malformed one with NaN
60
- // fields that `receive` would carry into the clock.
61
- const parsed = max == null ? null : HLC.tryParse(max);
62
- if (parsed != null) {
63
- hlc.receive(parsed);
64
- }
65
- })();
66
- await floorPromise;
67
- }
68
- async function nextHLC() {
69
- await ensureFloored();
70
- return HLC.serialize(hlc.now());
71
- }
72
- /**
73
- * Whether this device could ever satisfy a wrapping, given the chance to
74
- * prompt.
75
- *
76
- * This is what separates "locked" from "unopenable", and the two need opposite
77
- * responses from a caller. `recipient` is normalized at the comparison and
78
- * never at the write: it is a key-resolution input stored in peer:4 long form,
79
- * and folding it would make the wrapping unopenable.
80
- */ function satisfiableFactors(descriptors) {
81
- const needed = [];
82
- for (const descriptor of descriptors){
83
- if (isSecretFactorKind(descriptor.kind)) {
84
- needed.push(descriptor.kind);
85
- continue;
86
- }
87
- if (descriptor.kind !== 'did') {
88
- return null;
89
- }
90
- const recipient = descriptor.params.recipient;
91
- if (decrypting == null || typeof recipient !== 'string') {
92
- return null;
93
- }
94
- if (normalizeDID(recipient) !== normalizeDID(identity.id)) {
95
- return null;
96
- }
97
- }
98
- return needed;
99
- }
100
- /**
101
- * The wrapping this device holds by its own `did`.
102
- *
103
- * Not found by `recipient_did`: that column is populated only for the
104
- * single-`did` headless default, so a `[did, pin]` wrapping has it null. The
105
- * descriptors are the authority. More than one match throws rather than
106
- * picking — silently re-wrapping the wrong row would leave the other one
107
- * un-gated and look like success.
108
- */ async function ownWrapping(keyID, keyVersion) {
109
- const wrappings = await store.listWrappings(keyID, keyVersion);
110
- const mine = wrappings.filter((wrapping)=>satisfiableFactors(wrapping.factors) != null && wrapping.factors.some((descriptor)=>descriptor.kind === 'did' && typeof descriptor.params.recipient === 'string' && normalizeDID(descriptor.params.recipient) === normalizeDID(identity.id)));
111
- if (mine.length === 0) {
112
- throw new CredentialNotFound('Credential wrapping for this device on key', keyID);
113
- }
114
- if (mine.length > 1) {
115
- throw new AmbiguousCredentialWrapping(keyID, mine.length);
116
- }
117
- return mine[0];
118
- }
119
- /**
120
- * Rebuild wrap-time inputs from stored descriptors.
121
- *
122
- * A `did` descriptor carries its recipient, so it reconstructs itself. A
123
- * secret one carries a salt and a cost but never the secret, so the caller has
124
- * to supply it again — the new wrapping's key is derived from every surviving
125
- * factor, not only the one being changed.
126
- */ function factorsFromDescriptors(descriptors, inputs) {
127
- return descriptors.map((descriptor)=>{
128
- if (descriptor.kind === 'did') {
129
- const recipient = descriptor.params.recipient;
130
- if (typeof recipient !== 'string') {
131
- throw new CredentialTampered('did factor descriptor carries no recipient');
132
- }
133
- return {
134
- kind: 'did',
135
- recipientDID: recipient
136
- };
137
- }
138
- if (!isSecretFactorKind(descriptor.kind)) {
139
- throw new NoAvailableFactor(descriptor.kind);
140
- }
141
- const input = inputs?.[descriptor.kind];
142
- if (input == null) {
143
- throw new NoAvailableFactor(descriptor.kind);
144
- }
145
- const { m, t, p } = descriptor.params;
146
- // The cost travels with the factor: re-wrapping is not the moment to
147
- // silently reset a wrapping that was deliberately made expensive.
148
- const cost = typeof m === 'number' && typeof t === 'number' && typeof p === 'number' ? {
149
- m,
150
- t,
151
- p
152
- } : undefined;
153
- return {
154
- kind: descriptor.kind,
155
- input,
156
- cost
157
- };
158
- });
159
- }
160
- /**
161
- * Re-wrap this device's own wrapping under a new factor list.
162
- *
163
- * The new row is written before the old one is deleted. A crash between the
164
- * two leaves the key with two wrappings that both work; the reverse order
165
- * could leave it with none, which for a credential key is unrecoverable.
166
- */ async function rewrapOwn(keyID, inputs, change, methods) {
167
- const key = await loadKey(keyID);
168
- const existing = await ownWrapping(keyID, key.version);
169
- const { contentKey, branchID } = await openKey(keyID, inputs);
170
- const suite = getSuite(key.suite);
171
- const wrappingID = runtime.getRandomID();
172
- // `contentKey` is our own private clone from the cache; zero it once its use — this
173
- // rewrap — is done, on success or throw. The `try` opens right after the
174
- // acquisition so the caller-supplied `change` throwing also cannot leak it.
175
- let wrapped;
176
- try {
177
- const factors = change(factorsFromDescriptors(existing.factors, inputs));
178
- wrapped = await suite.wrap({
179
- runtime,
180
- contentKey,
181
- factors,
182
- wrappingID,
183
- keyID,
184
- keyVersion: key.version,
185
- methods
186
- });
187
- } finally{
188
- contentKey.fill(0);
189
- }
190
- const { descriptors, iv, wrappedKey } = wrapped;
191
- const replacement = {
192
- wrappingID,
193
- keyID,
194
- keyVersion: key.version,
195
- factors: descriptors,
196
- iv,
197
- wrappedKey,
198
- recipientDID: existing.recipient_did
199
- };
200
- await store.putWrapping(replacement, await ops.wrapping(replacement, await nextHLC(), branchID));
201
- await store.deleteWrapping(existing.wrapping_id, await ops.tombstone({
202
- subjectKind: 'wrapping',
203
- subjectID: existing.wrapping_id,
204
- keyID,
205
- keyVersion: existing.key_version
206
- }, await nextHLC()));
207
- }
208
- async function loadKey(keyID) {
209
- const key = await store.getKey(keyID);
210
- if (key == null) {
211
- throw new CredentialNotFound('Credential key', keyID);
212
- }
213
- if (!isSupportedSuite(key.suite)) {
214
- throw new UnsupportedCredentialSuite(key.suite);
215
- }
216
- return key;
217
- }
218
- /**
219
- * Recover a key's content key by trying its wrappings.
220
- *
221
- * The wrappings are an OR: any one opening is enough. If none opens, which
222
- * failure this is depends on whether any of them COULD have opened with input
223
- * this device is able to ask for — locked and unopenable are the same event
224
- * with opposite remedies.
225
- */ /**
226
- * Recover a key's content key. Wrapped by `openKey`, which adds the input-free
227
- * dedup; this is the actual open. Captures the lock generation and epoch at
228
- * entry so a `lock`/`lockAll` that lands before the terminal `cache.set` can
229
- * suppress it — the key is still returned to this caller, but the cache is not
230
- * re-warmed behind a lock.
231
- */ async function doOpenKey(keyID, inputs, genAtStart, epochAtStart) {
232
- // `genAtStart`/`epochAtStart` are captured by `openKey` at the moment the
233
- // caller entered — before any dedup wait — so a lock that lands while a
234
- // waiter is queued still suppresses that waiter's eventual re-warm.
235
- const lockedSince = ()=>(keyGeneration.get(keyID) ?? 0) !== genAtStart || lockAllEpoch !== epochAtStart;
236
- const key = await loadKey(keyID);
237
- const proven = new Set((await store.listKeyBranches(keyID, key.version)).map((branch)=>branch.branch_id));
238
- const winner = proven.size >= 2 ? selectWinningBranch([
239
- ...proven
240
- ]) : null;
241
- const cached = cache.get(keyID, key.version);
242
- if (cached != null) {
243
- if (winner == null || cached.branchID === winner) {
244
- return {
245
- contentKey: cached.contentKey,
246
- version: key.version,
247
- branchID: cached.branchID
248
- };
249
- }
250
- // A fork-losing hit: `cached.contentKey` is our private clone that
251
- // we are about to discard for a re-unwrap, so zero it rather than orphan it.
252
- cached.contentKey.fill(0);
253
- cache.evict(keyID);
254
- }
255
- const suite = getSuite(key.suite);
256
- const wrappings = await store.listWrappingsWithBranch(keyID, key.version);
257
- if (wrappings.length === 0) {
258
- throw new CredentialUnopenable(keyID, `no wrapping at version ${key.version}`);
259
- }
260
- // Concurrent rotations leave two content-key branches under one version. The
261
- // labels a keyless party could already partition by resolve, here, which one
262
- // this device opens — so every device converges on the same branch instead
263
- // of the first-listed wrapping, which is order-dependent and diverges. An
264
- // empty label is branch-agnostic (legacy/non-rotation state), not a branch of
265
- // its own, so it never counts toward a fork.
266
- // A label no key-op vouches for cannot be allowed to win or to open.
267
- const eligible = wrappings.filter((wrapping)=>wrapping.branch_id === '' || proven.has(wrapping.branch_id));
268
- // No fork: try every wrapping in listed order, unchanged. Fork: only the
269
- // winner-labelled wrappings, with empty-label ones as a last-resort fallback
270
- // — a loser-labelled wrapping opens the wrong content key and is skipped.
271
- const toTry = winner == null ? eligible : [
272
- ...eligible.filter((wrapping)=>wrapping.branch_id === winner),
273
- ...eligible.filter((wrapping)=>wrapping.branch_id === '')
274
- ];
275
- const reasons = [];
276
- const promptable = new Set();
277
- let rejected = null;
278
- for (const wrapping of toTry){
279
- const satisfiable = satisfiableFactors(wrapping.factors);
280
- if (satisfiable == null) {
281
- reasons.push(`wrapping ${wrapping.wrapping_id}: not addressed to this device`);
282
- continue;
283
- }
284
- try {
285
- const contentKey = await suite.unwrap({
286
- identity: decrypting,
287
- descriptors: wrapping.factors,
288
- inputs,
289
- iv: new Uint8Array(wrapping.iv),
290
- wrappedKey: new Uint8Array(wrapping.wrapped_key),
291
- wrappingID: wrapping.wrapping_id,
292
- keyID,
293
- keyVersion: wrapping.key_version
294
- });
295
- // In a fork every device names the same winner keylessly, so caching the
296
- // winner as the resolved branch converges post-fork writes even when an
297
- // empty-label fallback wrapping is what actually opened.
298
- const branchID = winner ?? wrapping.branch_id;
299
- // Suppress the re-warm if a lock landed while we were unwrapping: the
300
- // caller still gets its key, but the cache stays cleared.
301
- if (!lockedSince()) {
302
- cache.set(keyID, contentKey, key.version, branchID);
303
- }
304
- return {
305
- contentKey,
306
- version: key.version,
307
- branchID
308
- };
309
- } catch (error) {
310
- reasons.push(error instanceof Error ? error.message : String(error));
311
- if (error instanceof CredentialSecretRejected) {
312
- rejected = error;
313
- }
314
- for (const kind of satisfiable){
315
- promptable.add(kind);
316
- }
317
- }
318
- }
319
- // A secret WAS given and was wrong. Distinct from the case below, and it has
320
- // to survive: a caller retries on one and prompts on the other, and the PIN
321
- // throttle only counts an attempt that was actually made.
322
- if (rejected != null) {
323
- throw rejected;
324
- }
325
- // A wrapping this device is addressed by, that failed only for want of a
326
- // secret nobody was asked for, is a prompt — not a dead end.
327
- if (promptable.size > 0) {
328
- throw new CredentialKeyLocked(keyID, [
329
- ...promptable
330
- ]);
331
- }
332
- throw new CredentialUnopenable(keyID, reasons.join('; '));
333
- }
334
- /**
335
- * Open a key, collapsing concurrent input-free opens of the same key into one
336
- * unwrap. A waiter shares only the fact that an open ran, then takes its own
337
- * clone from the cache; on a miss — the leader hit a lock and skipped its set,
338
- * or failed — it falls through to its own open. Input-bearing opens never join
339
- * the herd (see `hasSuppliedInputs`).
340
- */ async function openKey(keyID, inputs) {
341
- // Captured once, at entry, so a lock landing while this call is queued behind
342
- // another open still counts against it (see `doOpenKey`).
343
- const genAtStart = keyGeneration.get(keyID) ?? 0;
344
- const epochAtStart = lockAllEpoch;
345
- if (hasSuppliedInputs(inputs)) {
346
- return doOpenKey(keyID, inputs, genAtStart, epochAtStart);
347
- }
348
- // Join any open already in flight for this key; on a miss, re-contend for
349
- // leadership rather than opening in parallel — so post-miss opens (a leader
350
- // that failed or was lock-suppressed) serialize into one leader at a time
351
- // instead of re-forming the herd.
352
- while(true){
353
- const pending = inFlight.get(keyID);
354
- if (pending == null) {
355
- break;
356
- }
357
- await pending;
358
- const key = await loadKey(keyID);
359
- const hit = cache.get(keyID, key.version);
360
- if (hit != null) {
361
- return {
362
- contentKey: hit.contentKey,
363
- version: key.version,
364
- branchID: hit.branchID
365
- };
366
- }
367
- }
368
- const run = doOpenKey(keyID, inputs, genAtStart, epochAtStart);
369
- // A gate that never rejects, so a waiter's `await pending` cannot throw the
370
- // leader's error into the wrong caller; the waiter re-derives its own result
371
- // from the cache (or its own open) after the gate settles.
372
- const gate = run.then(()=>undefined, ()=>undefined);
373
- inFlight.set(keyID, gate);
374
- try {
375
- return await run;
376
- } finally{
377
- // Only clear our own entry — a later leader may already have replaced it.
378
- if (inFlight.get(keyID) === gate) {
379
- inFlight.delete(keyID);
380
- }
381
- }
382
- }
383
- return {
384
- availableFactors () {
385
- // A passphrase or pin needs no key material, only someone to ask. They are
386
- // available on every device, including one that cannot decrypt at all.
387
- return decrypting == null ? [
388
- ...SECRET_FACTOR_KINDS
389
- ] : [
390
- 'did',
391
- ...SECRET_FACTOR_KINDS
392
- ];
393
- },
394
- async unlock (keyID, inputs) {
395
- if (inputs?.pin == null) {
396
- // Unlock only warms the cache; the returned clone is ours to zero.
397
- ;
398
- (await openKey(keyID, inputs)).contentKey.fill(0);
399
- return;
400
- }
401
- // Refused BEFORE `openKey`, so a throttled attempt never reaches Argon2.
402
- // Below the KDF the 340ms is paid either way and the counter buys the
403
- // defender nothing it does not also cost.
404
- const wait = throttle.retryAfter(keyID);
405
- if (wait > 0) {
406
- throw new CredentialPinRejected(keyID, {
407
- attemptsRemaining: throttle.attemptsRemaining(keyID),
408
- retryAfter: wait
409
- });
410
- }
411
- try {
412
- ;
413
- (await openKey(keyID, inputs)).contentKey.fill(0);
414
- } catch (error) {
415
- // Only a rejected secret advances the counter. A key that is simply not
416
- // addressed to this device, or whose suite is unknown, is not someone
417
- // guessing — counting it would lock a user out over a fault.
418
- if (error instanceof CredentialSecretRejected) {
419
- throttle.recordFailure(keyID);
420
- throw new CredentialPinRejected(keyID, {
421
- cause: error,
422
- attemptsRemaining: throttle.attemptsRemaining(keyID),
423
- retryAfter: throttle.retryAfter(keyID)
424
- });
425
- }
426
- throw error;
427
- }
428
- throttle.recordSuccess(keyID);
429
- },
430
- async addFactor (keyID, factor, params) {
431
- // Ownership, not possession of a wrapping. A rotation has to re-make every
432
- // retained wrapping at the new version, and a secret factor cannot be
433
- // re-made without its secret — so a recipient gating its own wrapping of
434
- // someone else's key would make that key un-rotatable, and revocation is
435
- // rotation.
436
- //
437
- // Asked for the verb rather than only for the secret kinds: a `did` added
438
- // to a foreign wrapping re-makes unattended, but it is still a change to a
439
- // key this identity does not administer, and the narrower rule buys the
440
- // caller nothing it could use.
441
- const key = await loadKey(keyID);
442
- if (!await (params?.authority ?? authority).authorize('administer', {
443
- key,
444
- store
445
- })) {
446
- throw new CredentialNotAuthorized('administer', keyID);
447
- }
448
- await rewrapOwn(keyID, params?.inputs, (factors)=>[
449
- ...factors,
450
- factor
451
- ], params?.methods);
452
- },
453
- async removeFactor (keyID, kind, params) {
454
- // Dropping a factor (a pin, say) weakens the key exactly as adding one
455
- // strengthens it, so it is administration too — see `addFactor`.
456
- const key = await loadKey(keyID);
457
- if (!await (params?.authority ?? authority).authorize('administer', {
458
- key,
459
- store
460
- })) {
461
- throw new CredentialNotAuthorized('administer', keyID);
462
- }
463
- await rewrapOwn(keyID, params?.inputs, (factors)=>{
464
- const remaining = factors.filter((factor)=>factor.kind !== kind);
465
- if (remaining.length === factors.length) {
466
- throw new CredentialNotFound(`Credential factor "${kind}" on this device's wrapping of`, keyID);
467
- }
468
- // Nothing here refuses a removal that would leave a pin without a did,
469
- // or nothing at all: `suite.wrap` validates the list it is given, so
470
- // the refusal happens once, where every path reaches it.
471
- return remaining;
472
- }, params?.methods);
473
- },
474
- lock (keyID) {
475
- // Bump before evicting so an `openKey` already past its generation capture
476
- // sees the change and skips its `cache.set`.
477
- keyGeneration.set(keyID, (keyGeneration.get(keyID) ?? 0) + 1);
478
- cache.evict(keyID);
479
- },
480
- lockAll () {
481
- lockAllEpoch += 1;
482
- cache.evictAll();
483
- },
484
- async status (keyID, version) {
485
- const key = await store.getKey(keyID);
486
- if (key == null) {
487
- throw new CredentialNotFound('Credential key', keyID);
488
- }
489
- // Reported, not thrown. `loadKey` raises here, which would make the call
490
- // that exists to say "this device cannot read that key" fail on exactly
491
- // the key it was asked about.
492
- if (!isSupportedSuite(key.suite)) {
493
- return {
494
- state: 'unopenable',
495
- reason: 'unsupported-suite'
496
- };
497
- }
498
- // Defaults to the key's current version; a caller checking a manifest
499
- // target passes the expected version so a stale wrapping is never
500
- // reported openable for a newer one, or vice versa.
501
- const targetVersion = version ?? key.version;
502
- if (cache.has(keyID, targetVersion)) {
503
- return {
504
- state: 'unlocked'
505
- };
506
- }
507
- const wrappings = await store.listWrappings(keyID, targetVersion);
508
- if (wrappings.length === 0) {
509
- return {
510
- state: 'unopenable',
511
- reason: 'no-wrapping'
512
- };
513
- }
514
- const unlockOptions = [];
515
- for (const wrapping of wrappings){
516
- const satisfiable = satisfiableFactors(wrapping.factors);
517
- if (satisfiable == null) {
518
- continue;
519
- }
520
- // Nothing to ask for: this one opens on the identity alone, so the key
521
- // needs no interaction however many other wrappings do.
522
- if (satisfiable.length === 0) {
523
- return {
524
- state: 'available'
525
- };
526
- }
527
- unlockOptions.push(satisfiable);
528
- }
529
- return unlockOptions.length > 0 ? {
530
- state: 'locked',
531
- unlockOptions
532
- } : {
533
- state: 'unopenable',
534
- reason: 'not-addressed'
535
- };
536
- },
537
- async createKey (createParams = {}) {
538
- const suiteID = createParams.suite ?? SUITE_KUBUN_CRED_1;
539
- if (createParams.factors != null && createParams.wrappings != null) {
540
- throw new InvalidCredentialFactors('pass either "factors" or "wrappings", not both');
541
- }
542
- // The headless default: one wrapping to this device's wrappable form. Taking
543
- // it without a decrypting identity would mint a key whose only wrapping
544
- // this device can never open, so it refuses here rather than at first read.
545
- const explicit = createParams.wrappings ?? (createParams.factors && [
546
- createParams.factors
547
- ]);
548
- const wrappings = explicit ?? [
549
- [
550
- {
551
- kind: 'did',
552
- recipientDID: wrappableDID(identity)
553
- }
554
- ]
555
- ];
556
- if (explicit == null && decrypting == null) {
557
- throw new NoAvailableFactor('did');
558
- }
559
- const built = await buildCredentialKey({
560
- runtime,
561
- ownerDID: createParams.ownerDID ?? identity.id,
562
- suite: suiteID,
563
- wrappings,
564
- methods: createParams.methods
565
- });
566
- // The wrappings already encapsulate the content key; createKey never reads
567
- // `built.contentKey` again, so zero it rather than leave the generated
568
- // secret live until collection.
569
- built.contentKey.fill(0);
570
- const keyParams = {
571
- keyID: built.keyID,
572
- ownerDID: built.ownerDID,
573
- suite: built.suite,
574
- version: built.version,
575
- state: 'active'
576
- };
577
- // The key-op opens the content-key branch every wrapping here joins, so
578
- // its label is threaded onto them.
579
- const keyOp = await ops.key(keyParams, await nextHLC());
580
- await store.recordKeyBranch({
581
- keyID: built.keyID,
582
- keyVersion: built.version,
583
- authorDID: keyOp.authorDID,
584
- hlc: keyOp.hlc,
585
- opJWT: keyOp.opJWT,
586
- opHash: keyOp.opHash,
587
- branchID: keyOp.branchID
588
- });
589
- await store.putKey(keyParams, keyOp);
590
- for (const wrapping of built.wrappings){
591
- await store.putWrapping(wrapping, await ops.wrapping(wrapping, await nextHLC(), keyOp.branchID));
592
- }
593
- return built.keyID;
594
- },
595
- async rotate (keyID, rotateParams) {
596
- const key = await loadKey(keyID);
597
- // Rotation is administration, not use: it retires a content key every
598
- // other recipient holds and re-makes their wrappings under one this
599
- // identity chose. Asked before the first read, because everything below
600
- // opens the key and decrypts every entry before it writes anything — a
601
- // gate after that has already done the work it exists to refuse.
602
- if (!await (rotateParams?.authority ?? authority).authorize('administer', {
603
- key,
604
- store
605
- })) {
606
- throw new CredentialNotAuthorized('administer', keyID);
607
- }
608
- const previousSuite = getSuite(key.suite);
609
- const suiteID = rotateParams?.suite ?? key.suite;
610
- const suite = getSuite(suiteID);
611
- const newVersion = key.version + 1;
612
- // Everything is read, and every plaintext recovered, before the first
613
- // write. A wrapping this device cannot re-make, or an entry it cannot
614
- // read, has to fail while the store is still untouched — past the first
615
- // write there is nothing here that could put it back.
616
- const previousKey = (await openKey(keyID, rotateParams?.inputs)).contentKey;
617
- // Both key buffers are our private clones. One try spans from here to the end so
618
- // a throw anywhere in the rotation zeros them, not only the success path.
619
- // `previousKey` is read only in the plaintext-recovery loop; the generated
620
- // key through the last write. It is declared here so the `finally` can zero
621
- // it whether or not generation was reached.
622
- let generatedKey;
623
- try {
624
- const retained = (await store.listWrappings(keyID, key.version)).map((wrapping)=>({
625
- recipientDID: wrapping.recipient_did,
626
- factors: factorsFromDescriptors(wrapping.factors, rotateParams?.inputs)
627
- }));
628
- const plaintexts = (await store.listEntries(keyID)).map((entry)=>{
629
- // An entry left behind by a rotation that did not finish. Carrying it
630
- // forward is impossible — its ciphertext is under a content key nothing
631
- // holds — and skipping it would quietly strand it one version further.
632
- if (entry.key_version !== key.version) {
633
- throw new CredentialKeyVersionMismatch(entry.entry_id, entry.key_version, key.version);
634
- }
635
- return {
636
- entryID: entry.entry_id,
637
- plaintext: previousSuite.decryptEntry({
638
- contentKey: previousKey,
639
- iv: new Uint8Array(entry.iv),
640
- ciphertext: new Uint8Array(entry.ciphertext),
641
- keyID,
642
- keyVersion: entry.key_version,
643
- entryID: entry.entry_id
644
- })
645
- };
646
- });
647
- const contentKey = suite.generateContentKey(runtime);
648
- generatedKey = contentKey;
649
- // Signed before the entries and wrappings it labels — its branch is what
650
- // they join — but persisted last, so the version bump still lands after
651
- // the rows a reader needs at it. Signing touches no store, so this only
652
- // moves the key-op's stamp ahead of the batch, which is where the branch
653
- // it introduces belongs.
654
- const rotatedKey = {
655
- keyID,
656
- ownerDID: key.owner_did,
657
- suite: suiteID,
658
- version: newVersion,
659
- state: key.state === 'retired' ? 'retired' : 'active'
660
- };
661
- const rotatedKeyOp = await ops.key(rotatedKey, await nextHLC());
662
- const branchID = rotatedKeyOp.branchID;
663
- await store.recordKeyBranch({
664
- keyID,
665
- keyVersion: newVersion,
666
- authorDID: rotatedKeyOp.authorDID,
667
- hlc: rotatedKeyOp.hlc,
668
- opJWT: rotatedKeyOp.opJWT,
669
- opHash: rotatedKeyOp.opHash,
670
- branchID: rotatedKeyOp.branchID
671
- });
672
- for (const { entryID, plaintext } of plaintexts){
673
- const { iv, ciphertext } = suite.encryptEntry({
674
- runtime,
675
- contentKey,
676
- plaintext,
677
- keyID,
678
- keyVersion: newVersion,
679
- entryID
680
- });
681
- const entryParams = {
682
- entryID,
683
- keyID,
684
- keyVersion: newVersion,
685
- iv,
686
- ciphertext,
687
- hlc: await nextHLC()
688
- };
689
- await store.putEntry(entryParams, await ops.entry(entryParams, branchID));
690
- }
691
- for (const { recipientDID, factors } of retained){
692
- const wrappingID = runtime.getRandomID();
693
- const { descriptors, iv, wrappedKey } = await suite.wrap({
694
- runtime,
695
- contentKey,
696
- factors,
697
- wrappingID,
698
- keyID,
699
- keyVersion: newVersion,
700
- methods: rotateParams?.methods
701
- });
702
- const wrappingParams = {
703
- wrappingID,
704
- keyID,
705
- keyVersion: newVersion,
706
- factors: descriptors,
707
- iv,
708
- wrappedKey,
709
- recipientDID
710
- };
711
- await store.putWrapping(wrappingParams, await ops.wrapping(wrappingParams, await nextHLC(), branchID));
712
- }
713
- // The version and the suite have to land together — an entry encrypted
714
- // under one and read under the other fails its tag — and the op speaks
715
- // for the whole row either way, so both branches sign the same shape. The
716
- // op was signed above so its label could tag the rows; it persists here.
717
- if (suiteID === key.suite) {
718
- await store.setKeyVersion(keyID, newVersion, rotatedKeyOp);
719
- } else {
720
- await store.putKey(rotatedKey, rotatedKeyOp);
721
- }
722
- // Listed before the sweep so every removed row gets a tombstone: the
723
- // delete predicate spans versions, and a row it takes with no tombstone
724
- // comes back the first time a peer that still holds it ships it over.
725
- const superseded = (await store.listWrappings(keyID)).filter((wrapping)=>wrapping.key_version < newVersion);
726
- const tombstones = [];
727
- for (const wrapping of superseded){
728
- tombstones.push(await ops.wrappingTombstone({
729
- subjectKind: 'wrapping',
730
- subjectID: wrapping.wrapping_id,
731
- keyID,
732
- keyVersion: wrapping.key_version
733
- }, await nextHLC()));
734
- }
735
- await store.deleteWrappingsBelowVersion(keyID, newVersion, tombstones);
736
- // The rotation's own key-op labels the branch it just made. `cache.set`
737
- // stores its own clone, so zeroing the local generated key in the
738
- // `finally` does not disturb the cached one.
739
- cache.set(keyID, contentKey, newVersion, branchID);
740
- return newVersion;
741
- } finally{
742
- previousKey.fill(0);
743
- generatedKey?.fill(0);
744
- }
9
+ const ctx = createManagerContext(params);
10
+ const { store } = ctx;
11
+ const internals = {
12
+ get records () {
13
+ return records;
745
14
  },
746
- async grant (keyID, recipientWrappableDID, params) {
747
- const key = await loadKey(keyID);
748
- // Administration, and by a stronger argument than `rotate`'s: this hands
749
- // the content key to a party that did not have it, which is the one change
750
- // no later rotation can take back from whoever received it.
751
- if (!await (params?.authority ?? authority).authorize('administer', {
752
- key,
753
- store
754
- })) {
755
- throw new CredentialNotAuthorized('administer', keyID);
756
- }
757
- // Answered before the key is opened, so a re-grant needs no prompt on a
758
- // locked device: it writes nothing either way.
759
- const wrappings = await store.listWrappings(keyID, key.version);
760
- const addressed = wrappings.some((wrapping)=>wrapping.factors.some((descriptor)=>descriptor.kind === 'did' && typeof descriptor.params.recipient === 'string' && normalizeDID(descriptor.params.recipient) === normalizeDID(recipientWrappableDID)));
761
- // A second wrapping to a recipient that already holds one is precisely the
762
- // state {@link AmbiguousCredentialWrapping} refuses, and it would break
763
- // that recipient's own `addFactor` rather than widening anything. So a
764
- // repeat grant — a push whose frame never arrived, retried — adds nothing.
765
- if (addressed) {
766
- return {
767
- version: key.version,
768
- created: false
769
- };
770
- }
771
- // Opened before the write, as everything here is: a granter that cannot
772
- // reach the content key has to fail against an untouched store rather than
773
- // leave a wrapping nobody can open.
774
- const { contentKey, branchID } = await openKey(keyID);
775
- const wrappingID = runtime.getRandomID();
776
- const suite = getSuite(key.suite);
777
- let wrapped;
778
- try {
779
- wrapped = await suite.wrap({
780
- runtime,
781
- contentKey,
782
- factors: [
783
- {
784
- kind: 'did',
785
- recipientDID: recipientWrappableDID
786
- }
787
- ],
788
- wrappingID,
789
- keyID,
790
- keyVersion: key.version,
791
- methods: params?.methods
792
- });
793
- } finally{
794
- contentKey.fill(0);
795
- }
796
- const { descriptors, iv, wrappedKey } = wrapped;
797
- const granted = {
798
- wrappingID,
799
- keyID,
800
- keyVersion: key.version,
801
- factors: descriptors,
802
- iv,
803
- wrappedKey,
804
- // Un-normalized, unlike `owner_did`: this column is a key-resolution
805
- // input, and folding a peer:4 long form would make it unwrappable.
806
- recipientDID: recipientWrappableDID
807
- };
808
- await store.putWrapping(granted, await ops.wrapping(granted, await nextHLC(), branchID));
809
- return {
810
- version: key.version,
811
- created: true
812
- };
15
+ get open () {
16
+ return open;
813
17
  },
814
- async listKeys () {
815
- return (await store.listKeys(normalizeDID(identity.id))).map((key)=>key.key_id);
18
+ get rotate () {
19
+ return rotate;
816
20
  },
817
- async countWrappings (keyID) {
818
- const key = await store.getKey(keyID);
819
- if (key == null) {
820
- throw new CredentialNotFound('Credential key', keyID);
821
- }
822
- return (await store.listWrappings(keyID, key.version)).length;
21
+ get removalRecovery () {
22
+ return removalRecovery;
823
23
  },
824
- async deleteKey (keyID) {
825
- // Read first, for the tombstone's version. A key that is not here leaves
826
- // no tombstone: this device has nothing to say about a row it never held,
827
- // and it could not name the version if it wanted to.
828
- const key = await store.getKey(keyID);
829
- if (key == null) {
830
- return;
831
- }
832
- await store.deleteKey(keyID, await ops.tombstone({
833
- subjectKind: 'key',
834
- subjectID: keyID,
835
- keyID,
836
- keyVersion: key.version
837
- }, await nextHLC()));
24
+ get manager () {
25
+ return manager;
838
26
  },
839
- async putEntry (keyID, plaintext) {
840
- const { contentKey, version, branchID } = await openKey(keyID);
841
- let entry;
842
- try {
843
- const key = await store.getKey(keyID);
844
- entry = buildCredentialEntry({
845
- runtime,
846
- key: {
847
- keyID,
848
- // The key's owner, not this device: an entry under a viewer's key
849
- // belongs to that viewer even when a server wrote it.
850
- ownerDID: key?.owner_did ?? identity.id,
851
- suite: key?.suite ?? SUITE_KUBUN_CRED_1,
852
- version,
853
- contentKey
854
- },
855
- plaintext
856
- });
857
- } finally{
858
- contentKey.fill(0);
859
- }
860
- const entryParams = {
861
- ...entry,
862
- hlc: await nextHLC()
863
- };
864
- await store.putEntry(entryParams, await ops.entry(entryParams, branchID));
865
- return entry.entryID;
866
- },
867
- async updateEntry (entryID, plaintext) {
868
- const existing = await store.getEntry(entryID);
869
- if (existing == null) {
870
- throw new CredentialNotFound('Credential entry', entryID);
871
- }
872
- const { contentKey, version, branchID } = await openKey(existing.key_id);
873
- // Re-encrypted under the key's CURRENT version, not the version the entry
874
- // was written at: an update is a fresh write, and leaving it behind would
875
- // keep a rotated-away version alive indefinitely. The suite lookup's store
876
- // read is inside the `try` so a rejection cannot leak the key copy.
877
- let encrypted;
878
- try {
879
- const suite = getSuite((await store.getKey(existing.key_id))?.suite ?? SUITE_KUBUN_CRED_1);
880
- encrypted = suite.encryptEntry({
881
- runtime,
882
- contentKey,
883
- plaintext,
884
- keyID: existing.key_id,
885
- keyVersion: version,
886
- entryID
887
- });
888
- } finally{
889
- contentKey.fill(0);
890
- }
891
- const { iv, ciphertext } = encrypted;
892
- const updated = {
893
- entryID,
894
- keyID: existing.key_id,
895
- keyVersion: version,
896
- iv,
897
- ciphertext,
898
- hlc: await nextHLC()
899
- };
900
- await store.putEntry(updated, await ops.entry(updated, branchID));
901
- },
902
- async updateEntryIfPresent (entryID, plaintext) {
903
- const existing = await store.getEntry(entryID);
904
- if (existing == null) {
905
- return 'absent';
906
- }
907
- const { contentKey, version, branchID } = await openKey(existing.key_id);
908
- // Same re-encrypt-under-current-version shape as `updateEntry`: the suite
909
- // lookup's store read stays inside the `try` so a rejection cannot leak the
910
- // key copy.
911
- let encrypted;
912
- try {
913
- const suite = getSuite((await store.getKey(existing.key_id))?.suite ?? SUITE_KUBUN_CRED_1);
914
- encrypted = suite.encryptEntry({
915
- runtime,
916
- contentKey,
917
- plaintext,
918
- keyID: existing.key_id,
919
- keyVersion: version,
920
- entryID
921
- });
922
- } finally{
923
- contentKey.fill(0);
924
- }
925
- const { iv, ciphertext } = encrypted;
926
- const updated = {
927
- entryID,
928
- keyID: existing.key_id,
929
- keyVersion: version,
930
- iv,
931
- ciphertext,
932
- // A fresh stamp every time, not `existing.hlc` carried forward: the store
933
- // primitive is op-first and gates the row write on this op winning LWW, so
934
- // the writeback needs a current stamp to land at all.
935
- hlc: await nextHLC()
936
- };
937
- return await store.updateEntryIfPresent(updated, await ops.entry(updated, branchID));
938
- },
939
- async readEntry (entryID) {
940
- const entry = await store.getEntry(entryID);
941
- if (entry == null) {
942
- throw new CredentialNotFound('Credential entry', entryID);
943
- }
944
- const key = await store.getKey(entry.key_id);
945
- if (key == null) {
946
- throw new CredentialNotFound('Credential key', entry.key_id);
947
- }
948
- // A rotation re-encrypts every entry, so a lagging one means it crashed
949
- // partway. Reading it under the older version would quietly return a value
950
- // the rotation was retiring, so this refuses instead.
951
- if (entry.key_version !== key.version) {
952
- throw new CredentialKeyVersionMismatch(entryID, entry.key_version, key.version);
953
- }
954
- const { contentKey, branchID } = await openKey(entry.key_id);
955
- // Zero our private clone on every exit — the branch-mismatch throw
956
- // and the decrypt alike.
957
- try {
958
- const entryBranchID = (await store.getOp('entry', entryID))?.branch_id ?? '';
959
- if (entryBranchID !== '' && branchID !== '' && entryBranchID !== branchID) {
960
- throw new CredentialSupersededBranch(entryID, entryBranchID, branchID);
961
- }
962
- return getSuite(key.suite).decryptEntry({
963
- contentKey,
964
- iv: new Uint8Array(entry.iv),
965
- ciphertext: new Uint8Array(entry.ciphertext),
966
- keyID: entry.key_id,
967
- keyVersion: entry.key_version,
968
- entryID
969
- });
970
- } finally{
971
- contentKey.fill(0);
972
- }
973
- },
974
- async getEntryProvenance (entryID) {
975
- const entry = await store.getEntryWithProvenance(entryID);
976
- if (entry == null) {
977
- return {
978
- updatedAt: null,
979
- writerDID: null
980
- };
981
- }
982
- return entryProvenanceFields(entry);
983
- },
984
- async getEntryKeyOwnerDID (entryID) {
985
- const entry = await store.getEntry(entryID);
986
- if (entry == null) {
987
- return null;
988
- }
989
- const key = await store.getKey(entry.key_id);
990
- return key == null ? null : key.owner_did;
991
- },
992
- async listEntries (keyID) {
993
- return (await store.listEntries(keyID)).map((entry)=>entry.entry_id);
994
- },
995
- async deleteEntry (entryID) {
996
- // Read first, as `deleteKey` does: the tombstone has to name the key the
997
- // entry sat under, and the entry row is the only thing that knows it.
998
- const entry = await store.getEntry(entryID);
999
- if (entry == null) {
1000
- return;
1001
- }
1002
- await store.deleteEntry(entryID, await ops.tombstone({
1003
- subjectKind: 'entry',
1004
- subjectID: entryID,
1005
- keyID: entry.key_id,
1006
- keyVersion: entry.key_version
1007
- }, await nextHLC()));
1008
- },
1009
- async reKeyEntry (entryID, toKeyID, params) {
1010
- const entry = await store.getEntry(entryID);
1011
- if (entry == null) {
1012
- throw new CredentialNotFound('Credential entry', entryID);
1013
- }
1014
- const fromKey = await loadKey(entry.key_id);
1015
- // Both keys resolved before either gate, so an absent target reports as
1016
- // missing rather than as something this identity may not administer.
1017
- const toKey = await loadKey(toKeyID);
1018
- for (const key of [
1019
- fromKey,
1020
- toKey
1021
- ]){
1022
- if (!await (params?.authority ?? authority).authorize('administer', {
1023
- key,
1024
- store
1025
- })) {
1026
- throw new CredentialNotAuthorized('administer', key.key_id);
1027
- }
1028
- }
1029
- // The refusal `readEntry` makes, for the same reason: the content key this
1030
- // entry was written under is gone with the rotation that did not finish,
1031
- // so it cannot be carried anywhere, and decrypting it under the current
1032
- // one would fail at the tag and read as tampering.
1033
- if (entry.key_version !== fromKey.version) {
1034
- throw new CredentialKeyVersionMismatch(entryID, entry.key_version, fromKey.version);
1035
- }
1036
- // Both keys opened, and the plaintext recovered, before the single write.
1037
- // A target this device cannot open has to fail while the entry is still
1038
- // where it was.
1039
- const { contentKey: fromContentKey } = await openKey(entry.key_id);
1040
- let plaintext;
1041
- try {
1042
- plaintext = getSuite(fromKey.suite).decryptEntry({
1043
- contentKey: fromContentKey,
1044
- iv: new Uint8Array(entry.iv),
1045
- ciphertext: new Uint8Array(entry.ciphertext),
1046
- keyID: entry.key_id,
1047
- keyVersion: entry.key_version,
1048
- entryID
1049
- });
1050
- } finally{
1051
- fromContentKey.fill(0);
1052
- }
1053
- const { contentKey: toContentKey, version, branchID } = await openKey(toKeyID);
1054
- // The target's CURRENT version, as an update is: landing on an older one
1055
- // would strand the entry the moment the key rotates.
1056
- let reEncrypted;
1057
- try {
1058
- reEncrypted = getSuite(toKey.suite).encryptEntry({
1059
- runtime,
1060
- contentKey: toContentKey,
1061
- plaintext,
1062
- keyID: toKeyID,
1063
- keyVersion: version,
1064
- entryID
1065
- });
1066
- } finally{
1067
- toContentKey.fill(0);
1068
- }
1069
- const { iv, ciphertext } = reEncrypted;
1070
- const moved = {
1071
- entryID,
1072
- keyID: toKeyID,
1073
- keyVersion: version,
1074
- iv,
1075
- ciphertext,
1076
- hlc: await nextHLC()
1077
- };
1078
- await store.moveEntry(moved, await ops.entry(moved, branchID));
1079
- }
27
+ createManager: createCredentialManager
28
+ };
29
+ const records = createRecordFunctions(ctx);
30
+ const open = createOpenFunctions(ctx, internals);
31
+ const rotate = createRotateFunctions(ctx, internals);
32
+ const removalRecovery = createRemovalRecoveryFunctions(ctx, internals);
33
+ const keyMethods = createKeyMethods(ctx, internals);
34
+ const entryFunctions = createEntryFunctions(ctx, internals);
35
+ const manager = {
36
+ get store () {
37
+ return store;
38
+ },
39
+ recoverRemovedDevices: removalRecovery.recoverRemovedDevices,
40
+ ensureRemovalRecovery: removalRecovery.checkRemovalRecovery,
41
+ repairFork: rotate.repairFork,
42
+ ...keyMethods,
43
+ ...entryFunctions
1080
44
  };
45
+ return manager;
1081
46
  }