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