@kubun/credential 0.13.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/LICENSE.md +57 -0
- package/lib/authority.d.ts +29 -0
- package/lib/authority.js +26 -0
- package/lib/builder.d.ts +82 -0
- package/lib/builder.js +76 -0
- package/lib/digest.d.ts +53 -0
- package/lib/digest.js +82 -0
- package/lib/errors.d.ts +169 -0
- package/lib/errors.js +248 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.js +10 -0
- package/lib/key-cache.d.ts +34 -0
- package/lib/key-cache.js +55 -0
- package/lib/manager.d.ts +280 -0
- package/lib/manager.js +881 -0
- package/lib/op.d.ts +128 -0
- package/lib/op.js +304 -0
- package/lib/pin-throttle.d.ts +44 -0
- package/lib/pin-throttle.js +48 -0
- package/lib/provenance.d.ts +6 -0
- package/lib/provenance.js +8 -0
- package/lib/suite.d.ts +112 -0
- package/lib/suite.js +317 -0
- package/lib/vocabulary.d.ts +16 -0
- package/lib/vocabulary.js +17 -0
- package/package.json +45 -0
package/lib/manager.js
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
import { isKeyAgreementIdentity, normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { HLC } from '@kubun/hlc';
|
|
3
|
+
import { createDeviceAuthority } from './authority.js';
|
|
4
|
+
import { buildCredentialEntry, buildCredentialKey } from './builder.js';
|
|
5
|
+
import { AmbiguousCredentialWrapping, CredentialKeyLocked, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialPinRejected, CredentialSecretRejected, CredentialSupersededBranch, CredentialTampered, CredentialUnopenable, InvalidCredentialFactors, NoAvailableFactor, UnsupportedCredentialSuite } from './errors.js';
|
|
6
|
+
import { createKeyCache } from './key-cache.js';
|
|
7
|
+
import { createCredentialOpSigner, selectWinningBranch } from './op.js';
|
|
8
|
+
import { createPinThrottle } from './pin-throttle.js';
|
|
9
|
+
import { entryProvenanceFields } from './provenance.js';
|
|
10
|
+
import { getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
|
|
11
|
+
/** Kinds a user can be prompted for, as opposed to ones held as key material. */ const SECRET_FACTOR_KINDS = [
|
|
12
|
+
'passphrase',
|
|
13
|
+
'pin'
|
|
14
|
+
];
|
|
15
|
+
function isSecretFactorKind(kind) {
|
|
16
|
+
return SECRET_FACTOR_KINDS.includes(kind);
|
|
17
|
+
}
|
|
18
|
+
export function createCredentialManager(params) {
|
|
19
|
+
const { store, identity, runtime, hlc } = params;
|
|
20
|
+
const authority = params.authority ?? createDeviceAuthority(identity.id);
|
|
21
|
+
const decrypting = isKeyAgreementIdentity(identity) ? identity : null;
|
|
22
|
+
const ops = createCredentialOpSigner(identity);
|
|
23
|
+
const cache = createKeyCache({
|
|
24
|
+
ttl: params.unlockTTL,
|
|
25
|
+
now: params.now
|
|
26
|
+
});
|
|
27
|
+
const throttle = createPinThrottle({
|
|
28
|
+
...params.pinThrottle,
|
|
29
|
+
now: params.now
|
|
30
|
+
});
|
|
31
|
+
let floorPromise;
|
|
32
|
+
/**
|
|
33
|
+
* Floor the shared clock to the greatest stored entry HLC, once per manager.
|
|
34
|
+
*
|
|
35
|
+
* The engine floors from the graph mutation log, which credential writes never
|
|
36
|
+
* touch — they advance the same clock and leave no trace there, so this is the
|
|
37
|
+
* only read that can see them again after a restart where the OS clock stepped
|
|
38
|
+
* backward. Memoized so concurrent first-writes await one read.
|
|
39
|
+
*/ async function ensureFloored() {
|
|
40
|
+
floorPromise ??= (async ()=>{
|
|
41
|
+
const max = await store.getMaxEntryHLC();
|
|
42
|
+
// An applied grant stores the granter's HLC verbatim, so the maximum can be
|
|
43
|
+
// a remote peer's string — and `HLC.parse` answers a malformed one with NaN
|
|
44
|
+
// fields that `receive` would carry into the clock.
|
|
45
|
+
const parsed = max == null ? null : HLC.tryParse(max);
|
|
46
|
+
if (parsed != null) {
|
|
47
|
+
hlc.receive(parsed);
|
|
48
|
+
}
|
|
49
|
+
})();
|
|
50
|
+
await floorPromise;
|
|
51
|
+
}
|
|
52
|
+
async function nextHLC() {
|
|
53
|
+
await ensureFloored();
|
|
54
|
+
return HLC.serialize(hlc.now());
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Whether this device could ever satisfy a wrapping, given the chance to
|
|
58
|
+
* prompt.
|
|
59
|
+
*
|
|
60
|
+
* This is what separates "locked" from "unopenable", and the two need opposite
|
|
61
|
+
* responses from a caller. `recipient` is normalized at the comparison and
|
|
62
|
+
* never at the write: it is a key-resolution input stored in peer:4 long form,
|
|
63
|
+
* and folding it would make the wrapping unopenable.
|
|
64
|
+
*/ function satisfiableFactors(descriptors) {
|
|
65
|
+
const needed = [];
|
|
66
|
+
for (const descriptor of descriptors){
|
|
67
|
+
if (isSecretFactorKind(descriptor.kind)) {
|
|
68
|
+
needed.push(descriptor.kind);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (descriptor.kind !== 'did') {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const recipient = descriptor.params.recipient;
|
|
75
|
+
if (decrypting == null || typeof recipient !== 'string') {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (normalizeDID(recipient) !== normalizeDID(identity.id)) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return needed;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The wrapping this device holds by its own `did`.
|
|
86
|
+
*
|
|
87
|
+
* Not found by `recipient_did`: that column is populated only for the
|
|
88
|
+
* single-`did` headless default, so a `[did, pin]` wrapping has it null. The
|
|
89
|
+
* descriptors are the authority. More than one match throws rather than
|
|
90
|
+
* picking — silently re-wrapping the wrong row would leave the other one
|
|
91
|
+
* un-gated and look like success.
|
|
92
|
+
*/ async function ownWrapping(keyID, keyVersion) {
|
|
93
|
+
const wrappings = await store.listWrappings(keyID, keyVersion);
|
|
94
|
+
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)));
|
|
95
|
+
if (mine.length === 0) {
|
|
96
|
+
throw new CredentialNotFound('Credential wrapping for this device on key', keyID);
|
|
97
|
+
}
|
|
98
|
+
if (mine.length > 1) {
|
|
99
|
+
throw new AmbiguousCredentialWrapping(keyID, mine.length);
|
|
100
|
+
}
|
|
101
|
+
return mine[0];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Rebuild wrap-time inputs from stored descriptors.
|
|
105
|
+
*
|
|
106
|
+
* A `did` descriptor carries its recipient, so it reconstructs itself. A
|
|
107
|
+
* secret one carries a salt and a cost but never the secret, so the caller has
|
|
108
|
+
* to supply it again — the new wrapping's key is derived from every surviving
|
|
109
|
+
* factor, not only the one being changed.
|
|
110
|
+
*/ function factorsFromDescriptors(descriptors, inputs) {
|
|
111
|
+
return descriptors.map((descriptor)=>{
|
|
112
|
+
if (descriptor.kind === 'did') {
|
|
113
|
+
const recipient = descriptor.params.recipient;
|
|
114
|
+
if (typeof recipient !== 'string') {
|
|
115
|
+
throw new CredentialTampered('did factor descriptor carries no recipient');
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
kind: 'did',
|
|
119
|
+
recipientDID: recipient
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (!isSecretFactorKind(descriptor.kind)) {
|
|
123
|
+
throw new NoAvailableFactor(descriptor.kind);
|
|
124
|
+
}
|
|
125
|
+
const input = inputs?.[descriptor.kind];
|
|
126
|
+
if (input == null) {
|
|
127
|
+
throw new NoAvailableFactor(descriptor.kind);
|
|
128
|
+
}
|
|
129
|
+
const { m, t, p } = descriptor.params;
|
|
130
|
+
// The cost travels with the factor: re-wrapping is not the moment to
|
|
131
|
+
// silently reset a wrapping that was deliberately made expensive.
|
|
132
|
+
const cost = typeof m === 'number' && typeof t === 'number' && typeof p === 'number' ? {
|
|
133
|
+
m,
|
|
134
|
+
t,
|
|
135
|
+
p
|
|
136
|
+
} : undefined;
|
|
137
|
+
return {
|
|
138
|
+
kind: descriptor.kind,
|
|
139
|
+
input,
|
|
140
|
+
cost
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Re-wrap this device's own wrapping under a new factor list.
|
|
146
|
+
*
|
|
147
|
+
* The new row is written before the old one is deleted. A crash between the
|
|
148
|
+
* two leaves the key with two wrappings that both work; the reverse order
|
|
149
|
+
* could leave it with none, which for a credential key is unrecoverable.
|
|
150
|
+
*/ async function rewrapOwn(keyID, inputs, change, methods) {
|
|
151
|
+
const key = await loadKey(keyID);
|
|
152
|
+
const existing = await ownWrapping(keyID, key.version);
|
|
153
|
+
const { contentKey, branchID } = await openKey(keyID, inputs);
|
|
154
|
+
const factors = change(factorsFromDescriptors(existing.factors, inputs));
|
|
155
|
+
const suite = getSuite(key.suite);
|
|
156
|
+
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
|
+
});
|
|
166
|
+
const replacement = {
|
|
167
|
+
wrappingID,
|
|
168
|
+
keyID,
|
|
169
|
+
keyVersion: key.version,
|
|
170
|
+
factors: descriptors,
|
|
171
|
+
iv,
|
|
172
|
+
wrappedKey,
|
|
173
|
+
recipientDID: existing.recipient_did
|
|
174
|
+
};
|
|
175
|
+
await store.putWrapping(replacement, await ops.wrapping(replacement, await nextHLC(), branchID));
|
|
176
|
+
await store.deleteWrapping(existing.wrapping_id, await ops.tombstone({
|
|
177
|
+
subjectKind: 'wrapping',
|
|
178
|
+
subjectID: existing.wrapping_id,
|
|
179
|
+
keyID,
|
|
180
|
+
keyVersion: existing.key_version
|
|
181
|
+
}, await nextHLC()));
|
|
182
|
+
}
|
|
183
|
+
async function loadKey(keyID) {
|
|
184
|
+
const key = await store.getKey(keyID);
|
|
185
|
+
if (key == null) {
|
|
186
|
+
throw new CredentialNotFound('Credential key', keyID);
|
|
187
|
+
}
|
|
188
|
+
if (!isSupportedSuite(key.suite)) {
|
|
189
|
+
throw new UnsupportedCredentialSuite(key.suite);
|
|
190
|
+
}
|
|
191
|
+
return key;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Recover a key's content key by trying its wrappings.
|
|
195
|
+
*
|
|
196
|
+
* The wrappings are an OR: any one opening is enough. If none opens, which
|
|
197
|
+
* failure this is depends on whether any of them COULD have opened with input
|
|
198
|
+
* this device is able to ask for — locked and unopenable are the same event
|
|
199
|
+
* with opposite remedies.
|
|
200
|
+
*/ async function openKey(keyID, inputs) {
|
|
201
|
+
const key = await loadKey(keyID);
|
|
202
|
+
const proven = new Set((await store.listKeyBranches(keyID, key.version)).map((branch)=>branch.branch_id));
|
|
203
|
+
const winner = proven.size >= 2 ? selectWinningBranch([
|
|
204
|
+
...proven
|
|
205
|
+
]) : null;
|
|
206
|
+
const cached = cache.get(keyID, key.version);
|
|
207
|
+
if (cached != null) {
|
|
208
|
+
if (winner == null || cached.branchID === winner) {
|
|
209
|
+
return {
|
|
210
|
+
contentKey: cached.contentKey,
|
|
211
|
+
version: key.version,
|
|
212
|
+
branchID: cached.branchID
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
cache.evict(keyID);
|
|
216
|
+
}
|
|
217
|
+
const suite = getSuite(key.suite);
|
|
218
|
+
const wrappings = await store.listWrappingsWithBranch(keyID, key.version);
|
|
219
|
+
if (wrappings.length === 0) {
|
|
220
|
+
throw new CredentialUnopenable(keyID, `no wrapping at version ${key.version}`);
|
|
221
|
+
}
|
|
222
|
+
// Concurrent rotations leave two content-key branches under one version. The
|
|
223
|
+
// labels a keyless party could already partition by resolve, here, which one
|
|
224
|
+
// this device opens — so every device converges on the same branch instead
|
|
225
|
+
// of the first-listed wrapping, which is order-dependent and diverges. An
|
|
226
|
+
// empty label is branch-agnostic (legacy/non-rotation state), not a branch of
|
|
227
|
+
// its own, so it never counts toward a fork.
|
|
228
|
+
// A label no key-op vouches for cannot be allowed to win or to open.
|
|
229
|
+
const eligible = wrappings.filter((wrapping)=>wrapping.branch_id === '' || proven.has(wrapping.branch_id));
|
|
230
|
+
// No fork: try every wrapping in listed order, unchanged. Fork: only the
|
|
231
|
+
// winner-labelled wrappings, with empty-label ones as a last-resort fallback
|
|
232
|
+
// — a loser-labelled wrapping opens the wrong content key and is skipped.
|
|
233
|
+
const toTry = winner == null ? eligible : [
|
|
234
|
+
...eligible.filter((wrapping)=>wrapping.branch_id === winner),
|
|
235
|
+
...eligible.filter((wrapping)=>wrapping.branch_id === '')
|
|
236
|
+
];
|
|
237
|
+
const reasons = [];
|
|
238
|
+
const promptable = new Set();
|
|
239
|
+
let rejected = null;
|
|
240
|
+
for (const wrapping of toTry){
|
|
241
|
+
const satisfiable = satisfiableFactors(wrapping.factors);
|
|
242
|
+
if (satisfiable == null) {
|
|
243
|
+
reasons.push(`wrapping ${wrapping.wrapping_id}: not addressed to this device`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const contentKey = await suite.unwrap({
|
|
248
|
+
identity: decrypting,
|
|
249
|
+
descriptors: wrapping.factors,
|
|
250
|
+
inputs,
|
|
251
|
+
iv: new Uint8Array(wrapping.iv),
|
|
252
|
+
wrappedKey: new Uint8Array(wrapping.wrapped_key),
|
|
253
|
+
wrappingID: wrapping.wrapping_id,
|
|
254
|
+
keyID,
|
|
255
|
+
keyVersion: wrapping.key_version
|
|
256
|
+
});
|
|
257
|
+
// In a fork every device names the same winner keylessly, so caching the
|
|
258
|
+
// winner as the resolved branch converges post-fork writes even when an
|
|
259
|
+
// empty-label fallback wrapping is what actually opened.
|
|
260
|
+
const branchID = winner ?? wrapping.branch_id;
|
|
261
|
+
cache.set(keyID, contentKey, key.version, branchID);
|
|
262
|
+
return {
|
|
263
|
+
contentKey,
|
|
264
|
+
version: key.version,
|
|
265
|
+
branchID
|
|
266
|
+
};
|
|
267
|
+
} catch (error) {
|
|
268
|
+
reasons.push(error instanceof Error ? error.message : String(error));
|
|
269
|
+
if (error instanceof CredentialSecretRejected) {
|
|
270
|
+
rejected = error;
|
|
271
|
+
}
|
|
272
|
+
for (const kind of satisfiable){
|
|
273
|
+
promptable.add(kind);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// A secret WAS given and was wrong. Distinct from the case below, and it has
|
|
278
|
+
// to survive: a caller retries on one and prompts on the other, and the PIN
|
|
279
|
+
// throttle only counts an attempt that was actually made.
|
|
280
|
+
if (rejected != null) {
|
|
281
|
+
throw rejected;
|
|
282
|
+
}
|
|
283
|
+
// A wrapping this device is addressed by, that failed only for want of a
|
|
284
|
+
// secret nobody was asked for, is a prompt — not a dead end.
|
|
285
|
+
if (promptable.size > 0) {
|
|
286
|
+
throw new CredentialKeyLocked(keyID, [
|
|
287
|
+
...promptable
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
throw new CredentialUnopenable(keyID, reasons.join('; '));
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
availableFactors () {
|
|
294
|
+
// A passphrase or pin needs no key material, only someone to ask. They are
|
|
295
|
+
// available on every device, including one that cannot decrypt at all.
|
|
296
|
+
return decrypting == null ? [
|
|
297
|
+
...SECRET_FACTOR_KINDS
|
|
298
|
+
] : [
|
|
299
|
+
'did',
|
|
300
|
+
...SECRET_FACTOR_KINDS
|
|
301
|
+
];
|
|
302
|
+
},
|
|
303
|
+
async unlock (keyID, inputs) {
|
|
304
|
+
if (inputs?.pin == null) {
|
|
305
|
+
await openKey(keyID, inputs);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
// Refused BEFORE `openKey`, so a throttled attempt never reaches Argon2.
|
|
309
|
+
// Below the KDF the 340ms is paid either way and the counter buys the
|
|
310
|
+
// defender nothing it does not also cost.
|
|
311
|
+
const wait = throttle.retryAfter(keyID);
|
|
312
|
+
if (wait > 0) {
|
|
313
|
+
throw new CredentialPinRejected(keyID, {
|
|
314
|
+
attemptsRemaining: throttle.attemptsRemaining(keyID),
|
|
315
|
+
retryAfter: wait
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
await openKey(keyID, inputs);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// Only a rejected secret advances the counter. A key that is simply not
|
|
322
|
+
// addressed to this device, or whose suite is unknown, is not someone
|
|
323
|
+
// guessing — counting it would lock a user out over a fault.
|
|
324
|
+
if (error instanceof CredentialSecretRejected) {
|
|
325
|
+
throttle.recordFailure(keyID);
|
|
326
|
+
throw new CredentialPinRejected(keyID, {
|
|
327
|
+
cause: error,
|
|
328
|
+
attemptsRemaining: throttle.attemptsRemaining(keyID),
|
|
329
|
+
retryAfter: throttle.retryAfter(keyID)
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
throttle.recordSuccess(keyID);
|
|
335
|
+
},
|
|
336
|
+
async addFactor (keyID, factor, params) {
|
|
337
|
+
// Ownership, not possession of a wrapping. A rotation has to re-make every
|
|
338
|
+
// retained wrapping at the new version, and a secret factor cannot be
|
|
339
|
+
// re-made without its secret — so a recipient gating its own wrapping of
|
|
340
|
+
// someone else's key would make that key un-rotatable, and revocation is
|
|
341
|
+
// rotation.
|
|
342
|
+
//
|
|
343
|
+
// Asked for the verb rather than only for the secret kinds: a `did` added
|
|
344
|
+
// to a foreign wrapping re-makes unattended, but it is still a change to a
|
|
345
|
+
// key this identity does not administer, and the narrower rule buys the
|
|
346
|
+
// caller nothing it could use.
|
|
347
|
+
const key = await loadKey(keyID);
|
|
348
|
+
if (!await (params?.authority ?? authority).authorize('administer', {
|
|
349
|
+
key,
|
|
350
|
+
store
|
|
351
|
+
})) {
|
|
352
|
+
throw new CredentialNotAuthorized('administer', keyID);
|
|
353
|
+
}
|
|
354
|
+
await rewrapOwn(keyID, params?.inputs, (factors)=>[
|
|
355
|
+
...factors,
|
|
356
|
+
factor
|
|
357
|
+
], params?.methods);
|
|
358
|
+
},
|
|
359
|
+
async removeFactor (keyID, kind, params) {
|
|
360
|
+
// Dropping a factor (a pin, say) weakens the key exactly as adding one
|
|
361
|
+
// strengthens it, so it is administration too — see `addFactor`.
|
|
362
|
+
const key = await loadKey(keyID);
|
|
363
|
+
if (!await (params?.authority ?? authority).authorize('administer', {
|
|
364
|
+
key,
|
|
365
|
+
store
|
|
366
|
+
})) {
|
|
367
|
+
throw new CredentialNotAuthorized('administer', keyID);
|
|
368
|
+
}
|
|
369
|
+
await rewrapOwn(keyID, params?.inputs, (factors)=>{
|
|
370
|
+
const remaining = factors.filter((factor)=>factor.kind !== kind);
|
|
371
|
+
if (remaining.length === factors.length) {
|
|
372
|
+
throw new CredentialNotFound(`Credential factor "${kind}" on this device's wrapping of`, keyID);
|
|
373
|
+
}
|
|
374
|
+
// Nothing here refuses a removal that would leave a pin without a did,
|
|
375
|
+
// or nothing at all: `suite.wrap` validates the list it is given, so
|
|
376
|
+
// the refusal happens once, where every path reaches it.
|
|
377
|
+
return remaining;
|
|
378
|
+
}, params?.methods);
|
|
379
|
+
},
|
|
380
|
+
lock (keyID) {
|
|
381
|
+
cache.evict(keyID);
|
|
382
|
+
},
|
|
383
|
+
lockAll () {
|
|
384
|
+
cache.evictAll();
|
|
385
|
+
},
|
|
386
|
+
async status (keyID) {
|
|
387
|
+
const key = await store.getKey(keyID);
|
|
388
|
+
if (key == null) {
|
|
389
|
+
throw new CredentialNotFound('Credential key', keyID);
|
|
390
|
+
}
|
|
391
|
+
// Reported, not thrown. `loadKey` raises here, which would make the call
|
|
392
|
+
// that exists to say "this device cannot read that key" fail on exactly
|
|
393
|
+
// the key it was asked about.
|
|
394
|
+
if (!isSupportedSuite(key.suite)) {
|
|
395
|
+
return {
|
|
396
|
+
state: 'unopenable',
|
|
397
|
+
reason: 'unsupported-suite'
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (cache.get(keyID, key.version) != null) {
|
|
401
|
+
return {
|
|
402
|
+
state: 'unlocked'
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
const wrappings = await store.listWrappings(keyID, key.version);
|
|
406
|
+
if (wrappings.length === 0) {
|
|
407
|
+
return {
|
|
408
|
+
state: 'unopenable',
|
|
409
|
+
reason: 'no-wrapping'
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const unlockOptions = [];
|
|
413
|
+
for (const wrapping of wrappings){
|
|
414
|
+
const satisfiable = satisfiableFactors(wrapping.factors);
|
|
415
|
+
if (satisfiable == null) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
// Nothing to ask for: this one opens on the identity alone, so the key
|
|
419
|
+
// needs no interaction however many other wrappings do.
|
|
420
|
+
if (satisfiable.length === 0) {
|
|
421
|
+
return {
|
|
422
|
+
state: 'available'
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
unlockOptions.push(satisfiable);
|
|
426
|
+
}
|
|
427
|
+
return unlockOptions.length > 0 ? {
|
|
428
|
+
state: 'locked',
|
|
429
|
+
unlockOptions
|
|
430
|
+
} : {
|
|
431
|
+
state: 'unopenable',
|
|
432
|
+
reason: 'not-addressed'
|
|
433
|
+
};
|
|
434
|
+
},
|
|
435
|
+
async createKey (createParams = {}) {
|
|
436
|
+
const suiteID = createParams.suite ?? SUITE_KUBUN_CRED_1;
|
|
437
|
+
if (createParams.factors != null && createParams.wrappings != null) {
|
|
438
|
+
throw new InvalidCredentialFactors('pass either "factors" or "wrappings", not both');
|
|
439
|
+
}
|
|
440
|
+
// The headless default: one wrapping to this device's own identity. Taking
|
|
441
|
+
// it without a decrypting identity would mint a key whose only wrapping
|
|
442
|
+
// this device can never open, so it refuses here rather than at first read.
|
|
443
|
+
const explicit = createParams.wrappings ?? (createParams.factors && [
|
|
444
|
+
createParams.factors
|
|
445
|
+
]);
|
|
446
|
+
const wrappings = explicit ?? [
|
|
447
|
+
[
|
|
448
|
+
{
|
|
449
|
+
kind: 'did',
|
|
450
|
+
recipientDID: identity.id
|
|
451
|
+
}
|
|
452
|
+
]
|
|
453
|
+
];
|
|
454
|
+
if (explicit == null && decrypting == null) {
|
|
455
|
+
throw new NoAvailableFactor('did');
|
|
456
|
+
}
|
|
457
|
+
const built = await buildCredentialKey({
|
|
458
|
+
runtime,
|
|
459
|
+
ownerDID: createParams.ownerDID ?? identity.id,
|
|
460
|
+
suite: suiteID,
|
|
461
|
+
wrappings,
|
|
462
|
+
methods: createParams.methods
|
|
463
|
+
});
|
|
464
|
+
const keyParams = {
|
|
465
|
+
keyID: built.keyID,
|
|
466
|
+
ownerDID: built.ownerDID,
|
|
467
|
+
suite: built.suite,
|
|
468
|
+
version: built.version,
|
|
469
|
+
state: 'active'
|
|
470
|
+
};
|
|
471
|
+
// The key-op opens the content-key branch every wrapping here joins, so
|
|
472
|
+
// its label is threaded onto them.
|
|
473
|
+
const keyOp = await ops.key(keyParams, await nextHLC());
|
|
474
|
+
await store.recordKeyBranch({
|
|
475
|
+
keyID: built.keyID,
|
|
476
|
+
keyVersion: built.version,
|
|
477
|
+
authorDID: keyOp.authorDID,
|
|
478
|
+
hlc: keyOp.hlc,
|
|
479
|
+
opJWT: keyOp.opJWT,
|
|
480
|
+
opHash: keyOp.opHash,
|
|
481
|
+
branchID: keyOp.branchID
|
|
482
|
+
});
|
|
483
|
+
await store.putKey(keyParams, keyOp);
|
|
484
|
+
for (const wrapping of built.wrappings){
|
|
485
|
+
await store.putWrapping(wrapping, await ops.wrapping(wrapping, await nextHLC(), keyOp.branchID));
|
|
486
|
+
}
|
|
487
|
+
return built.keyID;
|
|
488
|
+
},
|
|
489
|
+
async rotate (keyID, rotateParams) {
|
|
490
|
+
const key = await loadKey(keyID);
|
|
491
|
+
// Rotation is administration, not use: it retires a content key every
|
|
492
|
+
// other recipient holds and re-makes their wrappings under one this
|
|
493
|
+
// identity chose. Asked before the first read, because everything below
|
|
494
|
+
// opens the key and decrypts every entry before it writes anything — a
|
|
495
|
+
// gate after that has already done the work it exists to refuse.
|
|
496
|
+
if (!await (rotateParams?.authority ?? authority).authorize('administer', {
|
|
497
|
+
key,
|
|
498
|
+
store
|
|
499
|
+
})) {
|
|
500
|
+
throw new CredentialNotAuthorized('administer', keyID);
|
|
501
|
+
}
|
|
502
|
+
const previousSuite = getSuite(key.suite);
|
|
503
|
+
const suiteID = rotateParams?.suite ?? key.suite;
|
|
504
|
+
const suite = getSuite(suiteID);
|
|
505
|
+
const newVersion = key.version + 1;
|
|
506
|
+
// Everything is read, and every plaintext recovered, before the first
|
|
507
|
+
// write. A wrapping this device cannot re-make, or an entry it cannot
|
|
508
|
+
// read, has to fail while the store is still untouched — past the first
|
|
509
|
+
// write there is nothing here that could put it back.
|
|
510
|
+
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
|
|
566
|
+
});
|
|
567
|
+
const entryParams = {
|
|
568
|
+
entryID,
|
|
569
|
+
keyID,
|
|
570
|
+
keyVersion: newVersion,
|
|
571
|
+
iv,
|
|
572
|
+
ciphertext,
|
|
573
|
+
hlc: await nextHLC()
|
|
574
|
+
};
|
|
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,
|
|
584
|
+
keyID,
|
|
585
|
+
keyVersion: newVersion,
|
|
586
|
+
methods: rotateParams?.methods
|
|
587
|
+
});
|
|
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()));
|
|
620
|
+
}
|
|
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
|
+
},
|
|
627
|
+
async grant (keyID, recipientWrappableDID, params) {
|
|
628
|
+
const key = await loadKey(keyID);
|
|
629
|
+
// Administration, and by a stronger argument than `rotate`'s: this hands
|
|
630
|
+
// the content key to a party that did not have it, which is the one change
|
|
631
|
+
// no later rotation can take back from whoever received it.
|
|
632
|
+
if (!await (params?.authority ?? authority).authorize('administer', {
|
|
633
|
+
key,
|
|
634
|
+
store
|
|
635
|
+
})) {
|
|
636
|
+
throw new CredentialNotAuthorized('administer', keyID);
|
|
637
|
+
}
|
|
638
|
+
// Answered before the key is opened, so a re-grant needs no prompt on a
|
|
639
|
+
// locked device: it writes nothing either way.
|
|
640
|
+
const wrappings = await store.listWrappings(keyID, key.version);
|
|
641
|
+
const addressed = wrappings.some((wrapping)=>wrapping.factors.some((descriptor)=>descriptor.kind === 'did' && typeof descriptor.params.recipient === 'string' && normalizeDID(descriptor.params.recipient) === normalizeDID(recipientWrappableDID)));
|
|
642
|
+
// A second wrapping to a recipient that already holds one is precisely the
|
|
643
|
+
// state {@link AmbiguousCredentialWrapping} refuses, and it would break
|
|
644
|
+
// that recipient's own `addFactor` rather than widening anything. So a
|
|
645
|
+
// repeat grant — a push whose frame never arrived, retried — adds nothing.
|
|
646
|
+
if (addressed) {
|
|
647
|
+
return {
|
|
648
|
+
version: key.version,
|
|
649
|
+
created: false
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
// Opened before the write, as everything here is: a granter that cannot
|
|
653
|
+
// reach the content key has to fail against an untouched store rather than
|
|
654
|
+
// leave a wrapping nobody can open.
|
|
655
|
+
const { contentKey, branchID } = await openKey(keyID);
|
|
656
|
+
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
|
+
});
|
|
671
|
+
const granted = {
|
|
672
|
+
wrappingID,
|
|
673
|
+
keyID,
|
|
674
|
+
keyVersion: key.version,
|
|
675
|
+
factors: descriptors,
|
|
676
|
+
iv,
|
|
677
|
+
wrappedKey,
|
|
678
|
+
// Un-normalized, unlike `owner_did`: this column is a key-resolution
|
|
679
|
+
// input, and folding a peer:4 long form would make it unwrappable.
|
|
680
|
+
recipientDID: recipientWrappableDID
|
|
681
|
+
};
|
|
682
|
+
await store.putWrapping(granted, await ops.wrapping(granted, await nextHLC(), branchID));
|
|
683
|
+
return {
|
|
684
|
+
version: key.version,
|
|
685
|
+
created: true
|
|
686
|
+
};
|
|
687
|
+
},
|
|
688
|
+
async listKeys () {
|
|
689
|
+
return (await store.listKeys(normalizeDID(identity.id))).map((key)=>key.key_id);
|
|
690
|
+
},
|
|
691
|
+
async countWrappings (keyID) {
|
|
692
|
+
const key = await store.getKey(keyID);
|
|
693
|
+
if (key == null) {
|
|
694
|
+
throw new CredentialNotFound('Credential key', keyID);
|
|
695
|
+
}
|
|
696
|
+
return (await store.listWrappings(keyID, key.version)).length;
|
|
697
|
+
},
|
|
698
|
+
async deleteKey (keyID) {
|
|
699
|
+
// Read first, for the tombstone's version. A key that is not here leaves
|
|
700
|
+
// no tombstone: this device has nothing to say about a row it never held,
|
|
701
|
+
// and it could not name the version if it wanted to.
|
|
702
|
+
const key = await store.getKey(keyID);
|
|
703
|
+
if (key == null) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
await store.deleteKey(keyID, await ops.tombstone({
|
|
707
|
+
subjectKind: 'key',
|
|
708
|
+
subjectID: keyID,
|
|
709
|
+
keyID,
|
|
710
|
+
keyVersion: key.version
|
|
711
|
+
}, await nextHLC()));
|
|
712
|
+
},
|
|
713
|
+
async putEntry (keyID, plaintext) {
|
|
714
|
+
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
|
+
});
|
|
729
|
+
const entryParams = {
|
|
730
|
+
...entry,
|
|
731
|
+
hlc: await nextHLC()
|
|
732
|
+
};
|
|
733
|
+
await store.putEntry(entryParams, await ops.entry(entryParams, branchID));
|
|
734
|
+
return entry.entryID;
|
|
735
|
+
},
|
|
736
|
+
async updateEntry (entryID, plaintext) {
|
|
737
|
+
const existing = await store.getEntry(entryID);
|
|
738
|
+
if (existing == null) {
|
|
739
|
+
throw new CredentialNotFound('Credential entry', entryID);
|
|
740
|
+
}
|
|
741
|
+
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
|
+
// Re-encrypted under the key's CURRENT version, not the version the entry
|
|
744
|
+
// 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
|
+
});
|
|
754
|
+
const updated = {
|
|
755
|
+
entryID,
|
|
756
|
+
keyID: existing.key_id,
|
|
757
|
+
keyVersion: version,
|
|
758
|
+
iv,
|
|
759
|
+
ciphertext,
|
|
760
|
+
hlc: await nextHLC()
|
|
761
|
+
};
|
|
762
|
+
await store.putEntry(updated, await ops.entry(updated, branchID));
|
|
763
|
+
},
|
|
764
|
+
async readEntry (entryID) {
|
|
765
|
+
const entry = await store.getEntry(entryID);
|
|
766
|
+
if (entry == null) {
|
|
767
|
+
throw new CredentialNotFound('Credential entry', entryID);
|
|
768
|
+
}
|
|
769
|
+
const key = await store.getKey(entry.key_id);
|
|
770
|
+
if (key == null) {
|
|
771
|
+
throw new CredentialNotFound('Credential key', entry.key_id);
|
|
772
|
+
}
|
|
773
|
+
// A rotation re-encrypts every entry, so a lagging one means it crashed
|
|
774
|
+
// partway. Reading it under the older version would quietly return a value
|
|
775
|
+
// the rotation was retiring, so this refuses instead.
|
|
776
|
+
if (entry.key_version !== key.version) {
|
|
777
|
+
throw new CredentialKeyVersionMismatch(entryID, entry.key_version, key.version);
|
|
778
|
+
}
|
|
779
|
+
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);
|
|
783
|
+
}
|
|
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
|
+
},
|
|
793
|
+
async getEntryProvenance (entryID) {
|
|
794
|
+
const entry = await store.getEntryWithProvenance(entryID);
|
|
795
|
+
if (entry == null) {
|
|
796
|
+
return {
|
|
797
|
+
updatedAt: null,
|
|
798
|
+
writerDID: null
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
return entryProvenanceFields(entry);
|
|
802
|
+
},
|
|
803
|
+
async listEntries (keyID) {
|
|
804
|
+
return (await store.listEntries(keyID)).map((entry)=>entry.entry_id);
|
|
805
|
+
},
|
|
806
|
+
async deleteEntry (entryID) {
|
|
807
|
+
// Read first, as `deleteKey` does: the tombstone has to name the key the
|
|
808
|
+
// entry sat under, and the entry row is the only thing that knows it.
|
|
809
|
+
const entry = await store.getEntry(entryID);
|
|
810
|
+
if (entry == null) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
await store.deleteEntry(entryID, await ops.tombstone({
|
|
814
|
+
subjectKind: 'entry',
|
|
815
|
+
subjectID: entryID,
|
|
816
|
+
keyID: entry.key_id,
|
|
817
|
+
keyVersion: entry.key_version
|
|
818
|
+
}, await nextHLC()));
|
|
819
|
+
},
|
|
820
|
+
async reKeyEntry (entryID, toKeyID, params) {
|
|
821
|
+
const entry = await store.getEntry(entryID);
|
|
822
|
+
if (entry == null) {
|
|
823
|
+
throw new CredentialNotFound('Credential entry', entryID);
|
|
824
|
+
}
|
|
825
|
+
const fromKey = await loadKey(entry.key_id);
|
|
826
|
+
// Both keys resolved before either gate, so an absent target reports as
|
|
827
|
+
// missing rather than as something this identity may not administer.
|
|
828
|
+
const toKey = await loadKey(toKeyID);
|
|
829
|
+
for (const key of [
|
|
830
|
+
fromKey,
|
|
831
|
+
toKey
|
|
832
|
+
]){
|
|
833
|
+
if (!await (params?.authority ?? authority).authorize('administer', {
|
|
834
|
+
key,
|
|
835
|
+
store
|
|
836
|
+
})) {
|
|
837
|
+
throw new CredentialNotAuthorized('administer', key.key_id);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// The refusal `readEntry` makes, for the same reason: the content key this
|
|
841
|
+
// entry was written under is gone with the rotation that did not finish,
|
|
842
|
+
// so it cannot be carried anywhere, and decrypting it under the current
|
|
843
|
+
// one would fail at the tag and read as tampering.
|
|
844
|
+
if (entry.key_version !== fromKey.version) {
|
|
845
|
+
throw new CredentialKeyVersionMismatch(entryID, entry.key_version, fromKey.version);
|
|
846
|
+
}
|
|
847
|
+
// Both keys opened, and the plaintext recovered, before the single write.
|
|
848
|
+
// A target this device cannot open has to fail while the entry is still
|
|
849
|
+
// where it was.
|
|
850
|
+
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
|
+
});
|
|
859
|
+
const { contentKey: toContentKey, version, branchID } = await openKey(toKeyID);
|
|
860
|
+
// The target's CURRENT version, as an update is: landing on an older one
|
|
861
|
+
// 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
|
+
});
|
|
870
|
+
const moved = {
|
|
871
|
+
entryID,
|
|
872
|
+
keyID: toKeyID,
|
|
873
|
+
keyVersion: version,
|
|
874
|
+
iv,
|
|
875
|
+
ciphertext,
|
|
876
|
+
hlc: await nextHLC()
|
|
877
|
+
};
|
|
878
|
+
await store.moveEntry(moved, await ops.entry(moved, branchID));
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
}
|