@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.
- package/lib/key-cache.d.ts +6 -0
- package/lib/key-cache.js +32 -11
- package/lib/manager.js +338 -188
- package/package.json +3 -3
package/lib/key-cache.d.ts
CHANGED
|
@@ -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,
|
|
12
|
-
*
|
|
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 =
|
|
39
|
+
const cached = live(keyID, keyVersion);
|
|
23
40
|
if (cached == null) {
|
|
24
41
|
return null;
|
|
25
42
|
}
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
*/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
entryID: entry.entry_id
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
651
|
+
ownerDID: key.owner_did,
|
|
652
|
+
suite: suiteID,
|
|
653
|
+
version: newVersion,
|
|
654
|
+
state: key.state === 'retired' ? 'retired' : 'active'
|
|
574
655
|
};
|
|
575
|
-
await
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
661
|
+
authorDID: rotatedKeyOp.authorDID,
|
|
662
|
+
hlc: rotatedKeyOp.hlc,
|
|
663
|
+
opJWT: rotatedKeyOp.opJWT,
|
|
664
|
+
opHash: rotatedKeyOp.opHash,
|
|
665
|
+
branchID: rotatedKeyOp.branchID
|
|
587
666
|
});
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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.
|
|
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.
|
|
27
|
-
"@noble/hashes": "^2.
|
|
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
|
},
|