@kubun/credential 0.14.0 → 0.14.1

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.
@@ -27,6 +27,12 @@ export type KeyCache = {
27
27
  contentKey: Uint8Array;
28
28
  branchID: string;
29
29
  } | null;
30
+ /**
31
+ * Whether an unexpired, current-version key is cached, without materialising a
32
+ * copy of it. Presence-only callers (`status`) use this so a probe never mints
33
+ * a secret buffer it immediately discards.
34
+ */
35
+ has(keyID: string, keyVersion: number): boolean;
30
36
  set(keyID: string, contentKey: Uint8Array, keyVersion: number, branchID: string): void;
31
37
  evict(keyID: string): void;
32
38
  evictAll(): void;
package/lib/key-cache.js CHANGED
@@ -8,8 +8,8 @@ export function createKeyCache(params = {}) {
8
8
  *
9
9
  * Dropping the entry alone leaves the bytes wherever they are until a
10
10
  * collector reaches them. Zeroing is the part that can be guaranteed;
11
- * JavaScript cannot promise no interpreter-level copy survives, and the design
12
- * spec records that limit rather than implying hygiene it cannot deliver.
11
+ * JavaScript cannot promise no interpreter-level copy survives, so this is
12
+ * best-effort hygiene, not a guarantee that no copy of the key remains.
13
13
  */ function evict(keyID) {
14
14
  const cached = entries.get(keyID);
15
15
  if (cached != null) {
@@ -17,27 +17,48 @@ export function createKeyCache(params = {}) {
17
17
  entries.delete(keyID);
18
18
  }
19
19
  }
20
+ /**
21
+ * The still-valid cached entry for this key/version, or null — evicting a
22
+ * stale one on the way. A rotation invalidates the cache as surely as time
23
+ * does: serving the pre-rotation key would decrypt entries the rotation was
24
+ * retiring. Shared by `get` and `has` so both apply identical validity and
25
+ * lazy-eviction rules; neither exposes the buffer.
26
+ */ function live(keyID, keyVersion) {
27
+ const cached = entries.get(keyID);
28
+ if (cached == null) {
29
+ return null;
30
+ }
31
+ if (now() - cached.unlockedAt >= ttl || cached.keyVersion !== keyVersion) {
32
+ evict(keyID);
33
+ return null;
34
+ }
35
+ return cached;
36
+ }
20
37
  return {
21
38
  get (keyID, keyVersion) {
22
- const cached = entries.get(keyID);
39
+ const cached = live(keyID, keyVersion);
23
40
  if (cached == null) {
24
41
  return null;
25
42
  }
26
- // A rotation invalidates the cache as surely as time does: serving the
27
- // pre-rotation key would decrypt entries the rotation was retiring.
28
- if (now() - cached.unlockedAt >= ttl || cached.keyVersion !== keyVersion) {
29
- evict(keyID);
30
- return null;
31
- }
43
+ // The caller receives a private copy, never the cache's own buffer: the
44
+ // cache reserves the right to zero what it holds (on evict, replacement,
45
+ // expiry or lock), and a returned alias would let that zeroing corrupt a
46
+ // consumer still holding the key across an await.
32
47
  return {
33
- contentKey: cached.contentKey,
48
+ contentKey: cached.contentKey.slice(),
34
49
  branchID: cached.branchID
35
50
  };
36
51
  },
52
+ has (keyID, keyVersion) {
53
+ return live(keyID, keyVersion) != null;
54
+ },
37
55
  set (keyID, contentKey, keyVersion, branchID) {
38
56
  evict(keyID);
57
+ // Store a private clone so the caller's buffer is never the cached buffer:
58
+ // the caller may zero its own copy after use without disturbing the cache,
59
+ // and the cache may zero its clone on evict without disturbing the caller.
39
60
  entries.set(keyID, {
40
- contentKey,
61
+ contentKey: contentKey.slice(),
41
62
  keyVersion,
42
63
  branchID,
43
64
  unlockedAt: now()
package/lib/manager.js CHANGED
@@ -28,6 +28,21 @@ export function createCredentialManager(params) {
28
28
  ...params.pinThrottle,
29
29
  now: params.now
30
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);
31
46
  let floorPromise;
32
47
  /**
33
48
  * Floor the shared clock to the greatest stored entry HLC, once per manager.
@@ -151,18 +166,27 @@ export function createCredentialManager(params) {
151
166
  const key = await loadKey(keyID);
152
167
  const existing = await ownWrapping(keyID, key.version);
153
168
  const { contentKey, branchID } = await openKey(keyID, inputs);
154
- const factors = change(factorsFromDescriptors(existing.factors, inputs));
155
169
  const suite = getSuite(key.suite);
156
170
  const wrappingID = runtime.getRandomID();
157
- const { descriptors, iv, wrappedKey } = await suite.wrap({
158
- runtime,
159
- contentKey,
160
- factors,
161
- wrappingID,
162
- keyID,
163
- keyVersion: key.version,
164
- methods
165
- });
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;
166
190
  const replacement = {
167
191
  wrappingID,
168
192
  keyID,
@@ -197,7 +221,17 @@ export function createCredentialManager(params) {
197
221
  * failure this is depends on whether any of them COULD have opened with input
198
222
  * this device is able to ask for — locked and unopenable are the same event
199
223
  * with opposite remedies.
200
- */ async function openKey(keyID, inputs) {
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;
201
235
  const key = await loadKey(keyID);
202
236
  const proven = new Set((await store.listKeyBranches(keyID, key.version)).map((branch)=>branch.branch_id));
203
237
  const winner = proven.size >= 2 ? selectWinningBranch([
@@ -212,6 +246,9 @@ export function createCredentialManager(params) {
212
246
  branchID: cached.branchID
213
247
  };
214
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);
215
252
  cache.evict(keyID);
216
253
  }
217
254
  const suite = getSuite(key.suite);
@@ -258,7 +295,11 @@ export function createCredentialManager(params) {
258
295
  // winner as the resolved branch converges post-fork writes even when an
259
296
  // empty-label fallback wrapping is what actually opened.
260
297
  const branchID = winner ?? wrapping.branch_id;
261
- cache.set(keyID, contentKey, key.version, branchID);
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
+ }
262
303
  return {
263
304
  contentKey,
264
305
  version: key.version,
@@ -289,6 +330,55 @@ export function createCredentialManager(params) {
289
330
  }
290
331
  throw new CredentialUnopenable(keyID, reasons.join('; '));
291
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
+ }
292
382
  return {
293
383
  availableFactors () {
294
384
  // A passphrase or pin needs no key material, only someone to ask. They are
@@ -302,7 +392,9 @@ export function createCredentialManager(params) {
302
392
  },
303
393
  async unlock (keyID, inputs) {
304
394
  if (inputs?.pin == null) {
305
- await openKey(keyID, inputs);
395
+ // Unlock only warms the cache; the returned clone is ours to zero.
396
+ ;
397
+ (await openKey(keyID, inputs)).contentKey.fill(0);
306
398
  return;
307
399
  }
308
400
  // Refused BEFORE `openKey`, so a throttled attempt never reaches Argon2.
@@ -316,7 +408,8 @@ export function createCredentialManager(params) {
316
408
  });
317
409
  }
318
410
  try {
319
- await openKey(keyID, inputs);
411
+ ;
412
+ (await openKey(keyID, inputs)).contentKey.fill(0);
320
413
  } catch (error) {
321
414
  // Only a rejected secret advances the counter. A key that is simply not
322
415
  // addressed to this device, or whose suite is unknown, is not someone
@@ -378,9 +471,13 @@ export function createCredentialManager(params) {
378
471
  }, params?.methods);
379
472
  },
380
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);
381
477
  cache.evict(keyID);
382
478
  },
383
479
  lockAll () {
480
+ lockAllEpoch += 1;
384
481
  cache.evictAll();
385
482
  },
386
483
  async status (keyID) {
@@ -397,7 +494,7 @@ export function createCredentialManager(params) {
397
494
  reason: 'unsupported-suite'
398
495
  };
399
496
  }
400
- if (cache.get(keyID, key.version) != null) {
497
+ if (cache.has(keyID, key.version)) {
401
498
  return {
402
499
  state: 'unlocked'
403
500
  };
@@ -461,6 +558,10 @@ export function createCredentialManager(params) {
461
558
  wrappings,
462
559
  methods: createParams.methods
463
560
  });
561
+ // The wrappings already encapsulate the content key; createKey never reads
562
+ // `built.contentKey` again, so zero it rather than leave the generated
563
+ // secret live until collection.
564
+ built.contentKey.fill(0);
464
565
  const keyParams = {
465
566
  keyID: built.keyID,
466
567
  ownerDID: built.ownerDID,
@@ -508,121 +609,134 @@ export function createCredentialManager(params) {
508
609
  // read, has to fail while the store is still untouched — past the first
509
610
  // write there is nothing here that could put it back.
510
611
  const previousKey = (await openKey(keyID, rotateParams?.inputs)).contentKey;
511
- const retained = (await store.listWrappings(keyID, key.version)).map((wrapping)=>({
512
- recipientDID: wrapping.recipient_did,
513
- factors: factorsFromDescriptors(wrapping.factors, rotateParams?.inputs)
514
- }));
515
- const plaintexts = (await store.listEntries(keyID)).map((entry)=>{
516
- // An entry left behind by a rotation that did not finish. Carrying it
517
- // forward is impossible — its ciphertext is under a content key nothing
518
- // holds — and skipping it would quietly strand it one version further.
519
- if (entry.key_version !== key.version) {
520
- throw new CredentialKeyVersionMismatch(entry.entry_id, entry.key_version, key.version);
521
- }
522
- return {
523
- entryID: entry.entry_id,
524
- plaintext: previousSuite.decryptEntry({
525
- contentKey: previousKey,
526
- iv: new Uint8Array(entry.iv),
527
- ciphertext: new Uint8Array(entry.ciphertext),
528
- keyID,
529
- keyVersion: entry.key_version,
530
- entryID: entry.entry_id
531
- })
532
- };
533
- });
534
- const contentKey = suite.generateContentKey(runtime);
535
- // Signed before the entries and wrappings it labels — its branch is what
536
- // they join — but persisted last, so the version bump still lands after
537
- // the rows a reader needs at it. Signing touches no store, so this only
538
- // moves the key-op's stamp ahead of the batch, which is where the branch
539
- // it introduces belongs.
540
- const rotatedKey = {
541
- keyID,
542
- ownerDID: key.owner_did,
543
- suite: suiteID,
544
- version: newVersion,
545
- state: key.state === 'retired' ? 'retired' : 'active'
546
- };
547
- const rotatedKeyOp = await ops.key(rotatedKey, await nextHLC());
548
- const branchID = rotatedKeyOp.branchID;
549
- await store.recordKeyBranch({
550
- keyID,
551
- keyVersion: newVersion,
552
- authorDID: rotatedKeyOp.authorDID,
553
- hlc: rotatedKeyOp.hlc,
554
- opJWT: rotatedKeyOp.opJWT,
555
- opHash: rotatedKeyOp.opHash,
556
- branchID: rotatedKeyOp.branchID
557
- });
558
- for (const { entryID, plaintext } of plaintexts){
559
- const { iv, ciphertext } = suite.encryptEntry({
560
- runtime,
561
- contentKey,
562
- plaintext,
563
- keyID,
564
- keyVersion: newVersion,
565
- entryID
612
+ // Both key buffers are our private clones. One try spans from here to the end so
613
+ // a throw anywhere in the rotation zeros them, not only the success path.
614
+ // `previousKey` is read only in the plaintext-recovery loop; the generated
615
+ // key through the last write. It is declared here so the `finally` can zero
616
+ // it whether or not generation was reached.
617
+ let generatedKey;
618
+ try {
619
+ const retained = (await store.listWrappings(keyID, key.version)).map((wrapping)=>({
620
+ recipientDID: wrapping.recipient_did,
621
+ factors: factorsFromDescriptors(wrapping.factors, rotateParams?.inputs)
622
+ }));
623
+ const plaintexts = (await store.listEntries(keyID)).map((entry)=>{
624
+ // An entry left behind by a rotation that did not finish. Carrying it
625
+ // forward is impossible — its ciphertext is under a content key nothing
626
+ // holds — and skipping it would quietly strand it one version further.
627
+ if (entry.key_version !== key.version) {
628
+ throw new CredentialKeyVersionMismatch(entry.entry_id, entry.key_version, key.version);
629
+ }
630
+ return {
631
+ entryID: entry.entry_id,
632
+ plaintext: previousSuite.decryptEntry({
633
+ contentKey: previousKey,
634
+ iv: new Uint8Array(entry.iv),
635
+ ciphertext: new Uint8Array(entry.ciphertext),
636
+ keyID,
637
+ keyVersion: entry.key_version,
638
+ entryID: entry.entry_id
639
+ })
640
+ };
566
641
  });
567
- const entryParams = {
568
- entryID,
642
+ const contentKey = suite.generateContentKey(runtime);
643
+ generatedKey = contentKey;
644
+ // Signed before the entries and wrappings it labels — its branch is what
645
+ // they join — but persisted last, so the version bump still lands after
646
+ // the rows a reader needs at it. Signing touches no store, so this only
647
+ // moves the key-op's stamp ahead of the batch, which is where the branch
648
+ // it introduces belongs.
649
+ const rotatedKey = {
569
650
  keyID,
570
- keyVersion: newVersion,
571
- iv,
572
- ciphertext,
573
- hlc: await nextHLC()
651
+ ownerDID: key.owner_did,
652
+ suite: suiteID,
653
+ version: newVersion,
654
+ state: key.state === 'retired' ? 'retired' : 'active'
574
655
  };
575
- await store.putEntry(entryParams, await ops.entry(entryParams, branchID));
576
- }
577
- for (const { recipientDID, factors } of retained){
578
- const wrappingID = runtime.getRandomID();
579
- const { descriptors, iv, wrappedKey } = await suite.wrap({
580
- runtime,
581
- contentKey,
582
- factors,
583
- wrappingID,
656
+ const rotatedKeyOp = await ops.key(rotatedKey, await nextHLC());
657
+ const branchID = rotatedKeyOp.branchID;
658
+ await store.recordKeyBranch({
584
659
  keyID,
585
660
  keyVersion: newVersion,
586
- methods: rotateParams?.methods
661
+ authorDID: rotatedKeyOp.authorDID,
662
+ hlc: rotatedKeyOp.hlc,
663
+ opJWT: rotatedKeyOp.opJWT,
664
+ opHash: rotatedKeyOp.opHash,
665
+ branchID: rotatedKeyOp.branchID
587
666
  });
588
- const wrappingParams = {
589
- wrappingID,
590
- keyID,
591
- keyVersion: newVersion,
592
- factors: descriptors,
593
- iv,
594
- wrappedKey,
595
- recipientDID
596
- };
597
- await store.putWrapping(wrappingParams, await ops.wrapping(wrappingParams, await nextHLC(), branchID));
598
- }
599
- // The version and the suite have to land together — an entry encrypted
600
- // under one and read under the other fails its tag — and the op speaks for
601
- // the whole row either way, so both branches sign the same shape. The op
602
- // was signed above so its label could tag the rows; it persists here.
603
- if (suiteID === key.suite) {
604
- await store.setKeyVersion(keyID, newVersion, rotatedKeyOp);
605
- } else {
606
- await store.putKey(rotatedKey, rotatedKeyOp);
607
- }
608
- // Listed before the sweep so every removed row gets a tombstone: the delete
609
- // predicate spans versions, and a row it takes with no tombstone comes back
610
- // the first time a peer that still holds it ships it over.
611
- const superseded = (await store.listWrappings(keyID)).filter((wrapping)=>wrapping.key_version < newVersion);
612
- const tombstones = [];
613
- for (const wrapping of superseded){
614
- tombstones.push(await ops.wrappingTombstone({
615
- subjectKind: 'wrapping',
616
- subjectID: wrapping.wrapping_id,
617
- keyID,
618
- keyVersion: wrapping.key_version
619
- }, await nextHLC()));
667
+ for (const { entryID, plaintext } of plaintexts){
668
+ const { iv, ciphertext } = suite.encryptEntry({
669
+ runtime,
670
+ contentKey,
671
+ plaintext,
672
+ keyID,
673
+ keyVersion: newVersion,
674
+ entryID
675
+ });
676
+ const entryParams = {
677
+ entryID,
678
+ keyID,
679
+ keyVersion: newVersion,
680
+ iv,
681
+ ciphertext,
682
+ hlc: await nextHLC()
683
+ };
684
+ await store.putEntry(entryParams, await ops.entry(entryParams, branchID));
685
+ }
686
+ for (const { recipientDID, factors } of retained){
687
+ const wrappingID = runtime.getRandomID();
688
+ const { descriptors, iv, wrappedKey } = await suite.wrap({
689
+ runtime,
690
+ contentKey,
691
+ factors,
692
+ wrappingID,
693
+ keyID,
694
+ keyVersion: newVersion,
695
+ methods: rotateParams?.methods
696
+ });
697
+ const wrappingParams = {
698
+ wrappingID,
699
+ keyID,
700
+ keyVersion: newVersion,
701
+ factors: descriptors,
702
+ iv,
703
+ wrappedKey,
704
+ recipientDID
705
+ };
706
+ await store.putWrapping(wrappingParams, await ops.wrapping(wrappingParams, await nextHLC(), branchID));
707
+ }
708
+ // The version and the suite have to land together — an entry encrypted
709
+ // under one and read under the other fails its tag — and the op speaks
710
+ // for the whole row either way, so both branches sign the same shape. The
711
+ // op was signed above so its label could tag the rows; it persists here.
712
+ if (suiteID === key.suite) {
713
+ await store.setKeyVersion(keyID, newVersion, rotatedKeyOp);
714
+ } else {
715
+ await store.putKey(rotatedKey, rotatedKeyOp);
716
+ }
717
+ // Listed before the sweep so every removed row gets a tombstone: the
718
+ // delete predicate spans versions, and a row it takes with no tombstone
719
+ // comes back the first time a peer that still holds it ships it over.
720
+ const superseded = (await store.listWrappings(keyID)).filter((wrapping)=>wrapping.key_version < newVersion);
721
+ const tombstones = [];
722
+ for (const wrapping of superseded){
723
+ tombstones.push(await ops.wrappingTombstone({
724
+ subjectKind: 'wrapping',
725
+ subjectID: wrapping.wrapping_id,
726
+ keyID,
727
+ keyVersion: wrapping.key_version
728
+ }, await nextHLC()));
729
+ }
730
+ await store.deleteWrappingsBelowVersion(keyID, newVersion, tombstones);
731
+ // The rotation's own key-op labels the branch it just made. `cache.set`
732
+ // stores its own clone, so zeroing the local generated key in the
733
+ // `finally` does not disturb the cached one.
734
+ cache.set(keyID, contentKey, newVersion, branchID);
735
+ return newVersion;
736
+ } finally{
737
+ previousKey.fill(0);
738
+ generatedKey?.fill(0);
620
739
  }
621
- await store.deleteWrappingsBelowVersion(keyID, newVersion, tombstones);
622
- // Zeroes `previousKey` on the way in, which is why nothing above may read
623
- // it afterwards. The rotation's own key-op labels the branch it just made.
624
- cache.set(keyID, contentKey, newVersion, branchID);
625
- return newVersion;
626
740
  },
627
741
  async grant (keyID, recipientWrappableDID, params) {
628
742
  const key = await loadKey(keyID);
@@ -654,20 +768,27 @@ export function createCredentialManager(params) {
654
768
  // leave a wrapping nobody can open.
655
769
  const { contentKey, branchID } = await openKey(keyID);
656
770
  const wrappingID = runtime.getRandomID();
657
- const { descriptors, iv, wrappedKey } = await getSuite(key.suite).wrap({
658
- runtime,
659
- contentKey,
660
- factors: [
661
- {
662
- kind: 'did',
663
- recipientDID: recipientWrappableDID
664
- }
665
- ],
666
- wrappingID,
667
- keyID,
668
- keyVersion: key.version,
669
- methods: params?.methods
670
- });
771
+ const suite = getSuite(key.suite);
772
+ let wrapped;
773
+ try {
774
+ wrapped = await suite.wrap({
775
+ runtime,
776
+ contentKey,
777
+ factors: [
778
+ {
779
+ kind: 'did',
780
+ recipientDID: recipientWrappableDID
781
+ }
782
+ ],
783
+ wrappingID,
784
+ keyID,
785
+ keyVersion: key.version,
786
+ methods: params?.methods
787
+ });
788
+ } finally{
789
+ contentKey.fill(0);
790
+ }
791
+ const { descriptors, iv, wrappedKey } = wrapped;
671
792
  const granted = {
672
793
  wrappingID,
673
794
  keyID,
@@ -712,20 +833,25 @@ export function createCredentialManager(params) {
712
833
  },
713
834
  async putEntry (keyID, plaintext) {
714
835
  const { contentKey, version, branchID } = await openKey(keyID);
715
- const key = await store.getKey(keyID);
716
- const entry = buildCredentialEntry({
717
- runtime,
718
- key: {
719
- keyID,
720
- // The key's owner, not this device: an entry under a viewer's key
721
- // belongs to that viewer even when a server wrote it.
722
- ownerDID: key?.owner_did ?? identity.id,
723
- suite: key?.suite ?? SUITE_KUBUN_CRED_1,
724
- version,
725
- contentKey
726
- },
727
- plaintext
728
- });
836
+ let entry;
837
+ try {
838
+ const key = await store.getKey(keyID);
839
+ entry = buildCredentialEntry({
840
+ runtime,
841
+ key: {
842
+ keyID,
843
+ // The key's owner, not this device: an entry under a viewer's key
844
+ // belongs to that viewer even when a server wrote it.
845
+ ownerDID: key?.owner_did ?? identity.id,
846
+ suite: key?.suite ?? SUITE_KUBUN_CRED_1,
847
+ version,
848
+ contentKey
849
+ },
850
+ plaintext
851
+ });
852
+ } finally{
853
+ contentKey.fill(0);
854
+ }
729
855
  const entryParams = {
730
856
  ...entry,
731
857
  hlc: await nextHLC()
@@ -739,18 +865,25 @@ export function createCredentialManager(params) {
739
865
  throw new CredentialNotFound('Credential entry', entryID);
740
866
  }
741
867
  const { contentKey, version, branchID } = await openKey(existing.key_id);
742
- const suite = getSuite((await store.getKey(existing.key_id))?.suite ?? SUITE_KUBUN_CRED_1);
743
868
  // Re-encrypted under the key's CURRENT version, not the version the entry
744
869
  // was written at: an update is a fresh write, and leaving it behind would
745
- // keep a rotated-away version alive indefinitely.
746
- const { iv, ciphertext } = suite.encryptEntry({
747
- runtime,
748
- contentKey,
749
- plaintext,
750
- keyID: existing.key_id,
751
- keyVersion: version,
752
- entryID
753
- });
870
+ // keep a rotated-away version alive indefinitely. The suite lookup's store
871
+ // read is inside the `try` so a rejection cannot leak the key copy.
872
+ let encrypted;
873
+ try {
874
+ const suite = getSuite((await store.getKey(existing.key_id))?.suite ?? SUITE_KUBUN_CRED_1);
875
+ encrypted = suite.encryptEntry({
876
+ runtime,
877
+ contentKey,
878
+ plaintext,
879
+ keyID: existing.key_id,
880
+ keyVersion: version,
881
+ entryID
882
+ });
883
+ } finally{
884
+ contentKey.fill(0);
885
+ }
886
+ const { iv, ciphertext } = encrypted;
754
887
  const updated = {
755
888
  entryID,
756
889
  keyID: existing.key_id,
@@ -777,18 +910,24 @@ export function createCredentialManager(params) {
777
910
  throw new CredentialKeyVersionMismatch(entryID, entry.key_version, key.version);
778
911
  }
779
912
  const { contentKey, branchID } = await openKey(entry.key_id);
780
- const entryBranchID = (await store.getOp('entry', entryID))?.branch_id ?? '';
781
- if (entryBranchID !== '' && branchID !== '' && entryBranchID !== branchID) {
782
- throw new CredentialSupersededBranch(entryID, entryBranchID, branchID);
913
+ // Zero our private clone on every exit — the branch-mismatch throw
914
+ // and the decrypt alike.
915
+ try {
916
+ const entryBranchID = (await store.getOp('entry', entryID))?.branch_id ?? '';
917
+ if (entryBranchID !== '' && branchID !== '' && entryBranchID !== branchID) {
918
+ throw new CredentialSupersededBranch(entryID, entryBranchID, branchID);
919
+ }
920
+ return getSuite(key.suite).decryptEntry({
921
+ contentKey,
922
+ iv: new Uint8Array(entry.iv),
923
+ ciphertext: new Uint8Array(entry.ciphertext),
924
+ keyID: entry.key_id,
925
+ keyVersion: entry.key_version,
926
+ entryID
927
+ });
928
+ } finally{
929
+ contentKey.fill(0);
783
930
  }
784
- return getSuite(key.suite).decryptEntry({
785
- contentKey,
786
- iv: new Uint8Array(entry.iv),
787
- ciphertext: new Uint8Array(entry.ciphertext),
788
- keyID: entry.key_id,
789
- keyVersion: entry.key_version,
790
- entryID
791
- });
792
931
  },
793
932
  async getEntryProvenance (entryID) {
794
933
  const entry = await store.getEntryWithProvenance(entryID);
@@ -848,25 +987,36 @@ export function createCredentialManager(params) {
848
987
  // A target this device cannot open has to fail while the entry is still
849
988
  // where it was.
850
989
  const { contentKey: fromContentKey } = await openKey(entry.key_id);
851
- const plaintext = getSuite(fromKey.suite).decryptEntry({
852
- contentKey: fromContentKey,
853
- iv: new Uint8Array(entry.iv),
854
- ciphertext: new Uint8Array(entry.ciphertext),
855
- keyID: entry.key_id,
856
- keyVersion: entry.key_version,
857
- entryID
858
- });
990
+ let plaintext;
991
+ try {
992
+ plaintext = getSuite(fromKey.suite).decryptEntry({
993
+ contentKey: fromContentKey,
994
+ iv: new Uint8Array(entry.iv),
995
+ ciphertext: new Uint8Array(entry.ciphertext),
996
+ keyID: entry.key_id,
997
+ keyVersion: entry.key_version,
998
+ entryID
999
+ });
1000
+ } finally{
1001
+ fromContentKey.fill(0);
1002
+ }
859
1003
  const { contentKey: toContentKey, version, branchID } = await openKey(toKeyID);
860
1004
  // The target's CURRENT version, as an update is: landing on an older one
861
1005
  // would strand the entry the moment the key rotates.
862
- const { iv, ciphertext } = getSuite(toKey.suite).encryptEntry({
863
- runtime,
864
- contentKey: toContentKey,
865
- plaintext,
866
- keyID: toKeyID,
867
- keyVersion: version,
868
- entryID
869
- });
1006
+ let reEncrypted;
1007
+ try {
1008
+ reEncrypted = getSuite(toKey.suite).encryptEntry({
1009
+ runtime,
1010
+ contentKey: toContentKey,
1011
+ plaintext,
1012
+ keyID: toKeyID,
1013
+ keyVersion: version,
1014
+ entryID
1015
+ });
1016
+ } finally{
1017
+ toContentKey.fill(0);
1018
+ }
1019
+ const { iv, ciphertext } = reEncrypted;
870
1020
  const moved = {
871
1021
  entryID,
872
1022
  keyID: toKeyID,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/credential",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -23,8 +23,8 @@
23
23
  "@kubun/id": "^0.14.0",
24
24
  "@kubun/logger": "^0.14.0",
25
25
  "@kubun/store-credential": "^0.14.0",
26
- "@noble/ciphers": "^2.3.0",
27
- "@noble/hashes": "^2.3.0",
26
+ "@noble/ciphers": "^2.4.0",
27
+ "@noble/hashes": "^2.4.0",
28
28
  "@sozai/codec": "^0.4.0",
29
29
  "@sozai/runtime": "^0.1.0"
30
30
  },