@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.
- package/lib/api.d.ts +61 -30
- package/lib/api.js +455 -111
- package/lib/authority.d.ts +4 -0
- package/lib/authority.js +3 -0
- package/lib/branch.d.ts +4 -0
- package/lib/branch.js +24 -0
- package/lib/builder.d.ts +5 -0
- package/lib/builder.js +8 -6
- package/lib/canonical-token.d.ts +2 -0
- package/lib/canonical-token.js +10 -0
- package/lib/errors.d.ts +39 -1
- package/lib/errors.js +77 -0
- package/lib/fork-repair.d.ts +19 -0
- package/lib/fork-repair.js +3 -0
- package/lib/grantor-authority.d.ts +26 -0
- package/lib/grantor-authority.js +43 -0
- package/lib/index.d.ts +16 -6
- package/lib/index.js +8 -2
- package/lib/key-cache.d.ts +4 -3
- package/lib/key-cache.js +22 -15
- package/lib/manager-context.d.ts +50 -0
- package/lib/manager-context.js +82 -0
- package/lib/manager-entries.d.ts +3 -0
- package/lib/manager-entries.js +585 -0
- package/lib/manager-keys.d.ts +3 -0
- package/lib/manager-keys.js +413 -0
- package/lib/manager-open.d.ts +45 -0
- package/lib/manager-open.js +421 -0
- package/lib/manager-records.d.ts +94 -0
- package/lib/manager-records.js +291 -0
- package/lib/manager-removal-recovery.d.ts +7 -0
- package/lib/manager-removal-recovery.js +211 -0
- package/lib/manager-rotate.d.ts +15 -0
- package/lib/manager-rotate.js +536 -0
- package/lib/manager-types.d.ts +337 -0
- package/lib/manager-types.js +1 -0
- package/lib/manager.d.ts +2 -293
- package/lib/manager.js +38 -1072
- package/lib/manifest-token.d.ts +31 -0
- package/lib/manifest-token.js +51 -0
- package/lib/provider.d.ts +15 -2
- package/lib/provider.js +39 -0
- package/lib/record-types.d.ts +65 -0
- package/lib/record-types.js +865 -0
- package/lib/refreshing-credential-provider.d.ts +6 -2
- package/lib/refreshing-credential-provider.js +76 -13
- package/lib/suite.d.ts +5 -4
- package/lib/suite.js +2 -2
- package/lib/wrappable.d.ts +14 -0
- package/lib/wrappable.js +15 -0
- package/package.json +16 -15
- package/lib/op.d.ts +0 -128
- package/lib/op.js +0 -304
package/lib/manager.js
CHANGED
|
@@ -1,1080 +1,46 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
746
|
-
|
|
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
|
-
|
|
814
|
-
return
|
|
18
|
+
get rotate () {
|
|
19
|
+
return rotate;
|
|
815
20
|
},
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
824
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
}
|