@kubun/credential 0.17.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/lib/api.d.ts +61 -30
  2. package/lib/api.js +455 -111
  3. package/lib/authority.d.ts +4 -0
  4. package/lib/authority.js +3 -0
  5. package/lib/branch.d.ts +4 -0
  6. package/lib/branch.js +24 -0
  7. package/lib/builder.d.ts +5 -0
  8. package/lib/builder.js +8 -6
  9. package/lib/canonical-token.d.ts +2 -0
  10. package/lib/canonical-token.js +10 -0
  11. package/lib/errors.d.ts +39 -1
  12. package/lib/errors.js +77 -0
  13. package/lib/fork-repair.d.ts +19 -0
  14. package/lib/fork-repair.js +3 -0
  15. package/lib/grantor-authority.d.ts +26 -0
  16. package/lib/grantor-authority.js +43 -0
  17. package/lib/index.d.ts +15 -6
  18. package/lib/index.js +7 -2
  19. package/lib/key-cache.d.ts +4 -3
  20. package/lib/key-cache.js +22 -15
  21. package/lib/manager-context.d.ts +50 -0
  22. package/lib/manager-context.js +82 -0
  23. package/lib/manager-entries.d.ts +3 -0
  24. package/lib/manager-entries.js +585 -0
  25. package/lib/manager-keys.d.ts +3 -0
  26. package/lib/manager-keys.js +413 -0
  27. package/lib/manager-open.d.ts +45 -0
  28. package/lib/manager-open.js +421 -0
  29. package/lib/manager-records.d.ts +94 -0
  30. package/lib/manager-records.js +291 -0
  31. package/lib/manager-removal-recovery.d.ts +7 -0
  32. package/lib/manager-removal-recovery.js +211 -0
  33. package/lib/manager-rotate.d.ts +15 -0
  34. package/lib/manager-rotate.js +536 -0
  35. package/lib/manager-types.d.ts +337 -0
  36. package/lib/manager-types.js +1 -0
  37. package/lib/manager.d.ts +2 -293
  38. package/lib/manager.js +38 -1073
  39. package/lib/manifest-token.d.ts +31 -0
  40. package/lib/manifest-token.js +51 -0
  41. package/lib/provider.d.ts +15 -2
  42. package/lib/provider.js +39 -0
  43. package/lib/record-types.d.ts +65 -0
  44. package/lib/record-types.js +865 -0
  45. package/lib/refreshing-credential-provider.d.ts +6 -2
  46. package/lib/refreshing-credential-provider.js +76 -13
  47. package/lib/suite.d.ts +5 -4
  48. package/lib/suite.js +2 -2
  49. package/package.json +16 -15
  50. package/lib/op.d.ts +0 -128
  51. package/lib/op.js +0 -304
@@ -0,0 +1,413 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { buildCredentialKey } from './builder.js';
3
+ import { CredentialNotAuthorized, CredentialNotFound, CredentialPinRejected, CredentialRecipientRevoked, CredentialSecretRejected, InvalidCredentialFactors, NoAvailableFactor } from './errors.js';
4
+ import { SECRET_FACTOR_KINDS } from './manager-context.js';
5
+ import { getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
6
+ import { wrappableDID } from './wrappable.js';
7
+ export function createKeyMethods(ctx, internals) {
8
+ const { store, identity, runtime, authority, decrypting, cache, throttle, state, ensureFloored, nextHLC } = ctx;
9
+ const { putRecord, keyRecord, keyUnique, putWrappingRecord } = internals.records;
10
+ const { isSelfRevoked, satisfiableFactors, loadKey, openKey, rewrapOwn } = internals.open;
11
+ const { checkRemovalRecovery } = internals.removalRecovery;
12
+ const { rotateKey } = internals.rotate;
13
+ return {
14
+ availableFactors () {
15
+ // A passphrase or pin needs no key material, only someone to ask. They are
16
+ // available on every device, including one that cannot decrypt at all.
17
+ return decrypting == null ? [
18
+ ...SECRET_FACTOR_KINDS
19
+ ] : [
20
+ 'did',
21
+ ...SECRET_FACTOR_KINDS
22
+ ];
23
+ },
24
+ async unlock (keyID, inputs) {
25
+ if (inputs?.pin == null) {
26
+ // Unlock only warms the cache; the returned clone is ours to zero.
27
+ ;
28
+ (await openKey(keyID, inputs)).contentKey.fill(0);
29
+ return;
30
+ }
31
+ // Refused BEFORE `openKey`, so a throttled attempt never reaches Argon2.
32
+ // Below the KDF the 340ms is paid either way and the counter buys the
33
+ // defender nothing it does not also cost.
34
+ const wait = throttle.retryAfter(keyID);
35
+ if (wait > 0) {
36
+ throw new CredentialPinRejected(keyID, {
37
+ attemptsRemaining: throttle.attemptsRemaining(keyID),
38
+ retryAfter: wait
39
+ });
40
+ }
41
+ try {
42
+ ;
43
+ (await openKey(keyID, inputs)).contentKey.fill(0);
44
+ } catch (error) {
45
+ // Only a rejected secret advances the counter. A key that is simply not
46
+ // addressed to this device, or whose suite is unknown, is not someone
47
+ // guessing — counting it would lock a user out over a fault.
48
+ if (error instanceof CredentialSecretRejected) {
49
+ throttle.recordFailure(keyID);
50
+ throw new CredentialPinRejected(keyID, {
51
+ cause: error,
52
+ attemptsRemaining: throttle.attemptsRemaining(keyID),
53
+ retryAfter: throttle.retryAfter(keyID)
54
+ });
55
+ }
56
+ throw error;
57
+ }
58
+ throttle.recordSuccess(keyID);
59
+ },
60
+ async addFactor (keyID, factor, params) {
61
+ await checkRemovalRecovery();
62
+ // Ownership, not possession of a wrapping. A rotation has to re-make every
63
+ // retained wrapping at the new version, and a secret factor cannot be
64
+ // re-made without its secret — so a recipient gating its own wrapping of
65
+ // someone else's key would make that key un-rotatable, and revocation is
66
+ // rotation.
67
+ //
68
+ // Asked for the verb rather than only for the secret kinds: a `did` added
69
+ // to a foreign wrapping re-makes unattended, but it is still a change to a
70
+ // key this identity does not administer, and the narrower rule buys the
71
+ // caller nothing it could use.
72
+ const key = await loadKey(keyID);
73
+ if (!await (params?.authority ?? authority).authorize('administer', {
74
+ key,
75
+ store
76
+ })) {
77
+ throw new CredentialNotAuthorized('administer', keyID);
78
+ }
79
+ await rewrapOwn({
80
+ keyID,
81
+ inputs: params?.inputs,
82
+ change: (factors)=>[
83
+ ...factors,
84
+ factor
85
+ ],
86
+ methods: params?.methods,
87
+ authority: params?.authority ?? authority
88
+ });
89
+ },
90
+ async removeFactor (keyID, kind, params) {
91
+ await checkRemovalRecovery();
92
+ // Dropping a factor (a pin, say) weakens the key exactly as adding one
93
+ // strengthens it, so it is administration too — see `addFactor`.
94
+ const key = await loadKey(keyID);
95
+ if (!await (params?.authority ?? authority).authorize('administer', {
96
+ key,
97
+ store
98
+ })) {
99
+ throw new CredentialNotAuthorized('administer', keyID);
100
+ }
101
+ await rewrapOwn({
102
+ keyID,
103
+ inputs: params?.inputs,
104
+ change: (factors)=>{
105
+ const remaining = factors.filter((factor)=>factor.kind !== kind);
106
+ if (remaining.length === factors.length) {
107
+ throw new CredentialNotFound(`Credential factor "${kind}" on this device's wrapping of`, keyID);
108
+ }
109
+ // Nothing here refuses a removal that would leave a pin without a did,
110
+ // or nothing at all: `suite.wrap` validates the list it is given, so
111
+ // the refusal happens once, where every path reaches it.
112
+ return remaining;
113
+ },
114
+ methods: params?.methods,
115
+ authority: params?.authority ?? authority
116
+ });
117
+ },
118
+ lock (keyID) {
119
+ // Bump before evicting so an `openKey` already past its generation capture
120
+ // sees the change and skips its `cache.set`.
121
+ state.keyGeneration.set(keyID, (state.keyGeneration.get(keyID) ?? 0) + 1);
122
+ cache.evict(keyID);
123
+ },
124
+ lockAll () {
125
+ state.lockAllEpoch += 1;
126
+ cache.evictAll();
127
+ },
128
+ async status (keyID, version) {
129
+ const key = await store.getKey(keyID);
130
+ if (key == null) {
131
+ throw new CredentialNotFound('Credential key', keyID);
132
+ }
133
+ // Reported, not thrown. `loadKey` raises here, which would make the call
134
+ // that exists to say "this device cannot read that key" fail on exactly
135
+ // the key it was asked about.
136
+ if (!isSupportedSuite(key.suite)) {
137
+ return {
138
+ state: 'unopenable',
139
+ reason: 'unsupported-suite'
140
+ };
141
+ }
142
+ // Defaults to the key's current version; a caller checking a manifest
143
+ // target passes the expected version so a stale wrapping is never
144
+ // reported openable for a newer one, or vice versa.
145
+ const targetVersion = version ?? key.version;
146
+ if (await isSelfRevoked(keyID)) {
147
+ return {
148
+ state: 'unopenable',
149
+ reason: 'revoked'
150
+ };
151
+ }
152
+ if (cache.has(keyID, targetVersion)) {
153
+ return {
154
+ state: 'unlocked'
155
+ };
156
+ }
157
+ const wrappings = await store.listWrappings(keyID, targetVersion);
158
+ if (wrappings.length === 0) {
159
+ return {
160
+ state: 'unopenable',
161
+ reason: 'no-wrapping'
162
+ };
163
+ }
164
+ const unlockOptions = [];
165
+ for (const wrapping of wrappings){
166
+ const satisfiable = satisfiableFactors(wrapping.factors);
167
+ if (satisfiable == null) {
168
+ continue;
169
+ }
170
+ // Nothing to ask for: this one opens on the identity alone, so the key
171
+ // needs no interaction however many other wrappings do.
172
+ if (satisfiable.length === 0) {
173
+ return {
174
+ state: 'available'
175
+ };
176
+ }
177
+ unlockOptions.push(satisfiable);
178
+ }
179
+ return unlockOptions.length > 0 ? {
180
+ state: 'locked',
181
+ unlockOptions
182
+ } : {
183
+ state: 'unopenable',
184
+ reason: 'not-addressed'
185
+ };
186
+ },
187
+ async createKey (createParams = {}) {
188
+ await checkRemovalRecovery();
189
+ const suiteID = createParams.suite ?? SUITE_KUBUN_CRED_1;
190
+ if (createParams.factors != null && createParams.wrappings != null) {
191
+ throw new InvalidCredentialFactors('pass either "factors" or "wrappings", not both');
192
+ }
193
+ // The headless default: one wrapping to this device's wrappable form. Taking
194
+ // it without a decrypting identity would mint a key whose only wrapping
195
+ // this device can never open, so it refuses here rather than at first read.
196
+ const explicit = createParams.wrappings ?? (createParams.factors && [
197
+ createParams.factors
198
+ ]);
199
+ const wrappings = explicit ?? [
200
+ [
201
+ {
202
+ kind: 'did',
203
+ recipientDID: wrappableDID(identity)
204
+ }
205
+ ]
206
+ ];
207
+ if (explicit == null && decrypting == null) {
208
+ throw new NoAvailableFactor('did');
209
+ }
210
+ const built = await buildCredentialKey({
211
+ runtime,
212
+ ownerDID: createParams.ownerDID ?? identity.id,
213
+ suite: suiteID,
214
+ wrappings,
215
+ methods: createParams.methods
216
+ });
217
+ // The wrappings already encapsulate the content key; createKey never reads
218
+ // `built.contentKey` again, so zero it rather than leave the generated
219
+ // secret live until collection.
220
+ built.contentKey.fill(0);
221
+ const keyParams = {
222
+ keyID: built.keyID,
223
+ ownerDID: built.ownerDID,
224
+ suite: built.suite,
225
+ version: built.version,
226
+ state: 'active'
227
+ };
228
+ await ensureFloored();
229
+ // The key-op opens the content-key branch every wrapping here joins, so
230
+ // its label is threaded onto them.
231
+ await store.transaction(async (tx)=>{
232
+ const { branchID, branchRecordID: branchIDRecord } = await keyRecord({
233
+ key: keyParams,
234
+ keyUnique: built.keyUnique,
235
+ hlc: await nextHLC(),
236
+ authority,
237
+ target: tx
238
+ });
239
+ for (const wrapping of built.wrappings){
240
+ await putWrappingRecord({
241
+ wrapping,
242
+ owner: built.ownerDID,
243
+ branchID,
244
+ branchRecordID: branchIDRecord,
245
+ authority,
246
+ target: tx
247
+ });
248
+ }
249
+ });
250
+ return built.keyID;
251
+ },
252
+ async rotate (keyID, rotateParams) {
253
+ await checkRemovalRecovery();
254
+ return await rotateKey({
255
+ keyID,
256
+ rotateParams
257
+ });
258
+ },
259
+ async revoke (keyID, recipientDID, revokeParams) {
260
+ await checkRemovalRecovery();
261
+ const key = await loadKey(keyID);
262
+ const revocationAuthority = revokeParams?.authority ?? authority;
263
+ if (!await revocationAuthority.authorizeRevocation?.({
264
+ key,
265
+ store
266
+ }) && normalizeDID(key.owner_did) !== normalizeDID(identity.id)) {
267
+ throw new CredentialNotAuthorized('administer', keyID);
268
+ }
269
+ const revokedDID = normalizeDID(recipientDID);
270
+ if ((await store.listRevocations(keyID)).some((row)=>normalizeDID(row.recipient_did) === revokedDID)) {
271
+ return key.version;
272
+ }
273
+ const version = await rotateKey({
274
+ keyID,
275
+ rotateParams: revokeParams,
276
+ revokedDID
277
+ });
278
+ if (revokedDID === normalizeDID(identity.id)) cache.evict(keyID);
279
+ return version;
280
+ },
281
+ async grant (keyID, recipientWrappableDID, params) {
282
+ await checkRemovalRecovery();
283
+ await ensureFloored();
284
+ const key = await loadKey(keyID);
285
+ // Administration, and by a stronger argument than `rotate`'s: this hands
286
+ // the content key to a party that did not have it, which is the one change
287
+ // no later rotation can take back from whoever received it.
288
+ if (!await (params?.authority ?? authority).authorize('administer', {
289
+ key,
290
+ store
291
+ })) {
292
+ throw new CredentialNotAuthorized('administer', keyID);
293
+ }
294
+ // A repeat grant needs no key open or recipient resolution. Its answer is
295
+ // still taken under the same key lock as rotation.
296
+ const alreadyGranted = await store.transaction(async (tx)=>{
297
+ await tx.lockKeyRow(keyID);
298
+ const current = await tx.getKey(keyID);
299
+ if (current == null) throw new CredentialNotFound('Credential key', keyID);
300
+ if ((await tx.listRevocations(keyID)).some((row)=>normalizeDID(row.recipient_did) === normalizeDID(recipientWrappableDID))) {
301
+ throw new CredentialRecipientRevoked(keyID, recipientWrappableDID);
302
+ }
303
+ const addressed = (await tx.listWrappings(keyID, current.version)).some((wrapping)=>wrapping.factors.some((descriptor)=>descriptor.kind === 'did' && typeof descriptor.params.recipient === 'string' && normalizeDID(descriptor.params.recipient) === normalizeDID(recipientWrappableDID)));
304
+ return addressed ? {
305
+ version: current.version,
306
+ created: false
307
+ } : null;
308
+ });
309
+ if (alreadyGranted != null) return alreadyGranted;
310
+ // The key open and recipient key resolution run outside the transaction.
311
+ // The locked read decides whether this speculative wrapping is still at
312
+ // the current version; if rotation committed meanwhile, prepare again.
313
+ for(;;){
314
+ const candidate = await loadKey(keyID);
315
+ const { contentKey, version, branchID } = await openKey(keyID);
316
+ const wrappingID = runtime.getRandomID();
317
+ let wrapped;
318
+ try {
319
+ wrapped = await getSuite(candidate.suite).wrap({
320
+ runtime,
321
+ contentKey,
322
+ factors: [
323
+ {
324
+ kind: 'did',
325
+ recipientDID: recipientWrappableDID
326
+ }
327
+ ],
328
+ wrappingID,
329
+ keyID,
330
+ keyVersion: version,
331
+ methods: params?.methods
332
+ });
333
+ } finally{
334
+ contentKey.fill(0);
335
+ }
336
+ const result = await store.transaction(async (tx)=>{
337
+ await tx.lockKeyRow(keyID);
338
+ const current = await tx.getKey(keyID);
339
+ if (current == null) throw new CredentialNotFound('Credential key', keyID);
340
+ if ((await tx.listRevocations(keyID)).some((row)=>normalizeDID(row.recipient_did) === normalizeDID(recipientWrappableDID))) {
341
+ throw new CredentialRecipientRevoked(keyID, recipientWrappableDID);
342
+ }
343
+ if (current.version !== version || current.suite !== candidate.suite) return null;
344
+ const addressed = (await tx.listWrappings(keyID, version)).some((wrapping)=>wrapping.factors.some((descriptor)=>descriptor.kind === 'did' && typeof descriptor.params.recipient === 'string' && normalizeDID(descriptor.params.recipient) === normalizeDID(recipientWrappableDID)));
345
+ if (addressed) return {
346
+ version,
347
+ created: false
348
+ };
349
+ await putWrappingRecord({
350
+ wrapping: {
351
+ wrappingID,
352
+ keyID,
353
+ keyVersion: version,
354
+ factors: wrapped.descriptors,
355
+ iv: wrapped.iv,
356
+ wrappedKey: wrapped.wrappedKey,
357
+ recipientDID: recipientWrappableDID
358
+ },
359
+ owner: current.owner_did,
360
+ branchID,
361
+ authority: params?.authority ?? authority,
362
+ target: tx
363
+ });
364
+ return {
365
+ version,
366
+ created: true
367
+ };
368
+ });
369
+ if (result != null) return result;
370
+ }
371
+ },
372
+ async listKeys () {
373
+ return (await store.listKeys(normalizeDID(identity.id))).map((key)=>key.key_id);
374
+ },
375
+ async countWrappings (keyID) {
376
+ const key = await store.getKey(keyID);
377
+ if (key == null) {
378
+ throw new CredentialNotFound('Credential key', keyID);
379
+ }
380
+ return (await store.listWrappings(keyID, key.version)).length;
381
+ },
382
+ async deleteKey (keyID) {
383
+ await checkRemovalRecovery();
384
+ for(;;){
385
+ const key = await store.getKey(keyID);
386
+ if (key == null) return;
387
+ const unique = await keyUnique(keyID);
388
+ const deletionHLC = await nextHLC();
389
+ const result = await store.transaction(async (tx)=>{
390
+ await tx.lockKeyRow(keyID);
391
+ const current = await tx.getKey(keyID);
392
+ if (current == null) return 'done';
393
+ if (current.version !== key.version) return 'retry';
394
+ await putRecord({
395
+ type: 'credential.key',
396
+ id: keyID,
397
+ owner: current.owner_did,
398
+ hlc: deletionHLC,
399
+ payload: null,
400
+ meta: {
401
+ keyID,
402
+ keyUnique: unique,
403
+ version: current.version
404
+ },
405
+ target: tx
406
+ });
407
+ return 'done';
408
+ });
409
+ if (result === 'done') return;
410
+ }
411
+ }
412
+ };
413
+ }
@@ -0,0 +1,45 @@
1
+ import { type MethodRegistry } from '@kokuin/token';
2
+ import type { CredentialFactorDescriptor, CredentialKey, CredentialWrappingWithBranch } from '@kubun/store-credential';
3
+ import type { CredentialAuthority } from './authority.js';
4
+ import { type ManagerContext, type ManagerInternals } from './manager-context.js';
5
+ import { type FactorInput, type FactorInputs, type SecretFactorKind } from './suite.js';
6
+ type RewrapOwnParams = {
7
+ keyID: string;
8
+ inputs?: FactorInputs;
9
+ change: (factors: Array<FactorInput>) => Array<FactorInput>;
10
+ methods?: MethodRegistry;
11
+ authority?: CredentialAuthority;
12
+ };
13
+ type DoOpenKeyParams = {
14
+ keyID: string;
15
+ inputs?: FactorInputs;
16
+ genAtStart: number;
17
+ epochAtStart: number;
18
+ atVersion?: number;
19
+ };
20
+ type OpenSpecificBranchParams = {
21
+ keyID: string;
22
+ version: number;
23
+ branchID: string;
24
+ suiteID: number;
25
+ inputs?: FactorInputs;
26
+ };
27
+ export type OpenFunctions = {
28
+ isSelfRevoked: (keyID: string) => Promise<boolean>;
29
+ satisfiableFactors: (descriptors: Array<CredentialFactorDescriptor>) => Array<SecretFactorKind> | null;
30
+ ownWrapping: (keyID: string, keyVersion: number) => Promise<CredentialWrappingWithBranch>;
31
+ factorsFromDescriptors: (descriptors: Array<CredentialFactorDescriptor>, inputs: FactorInputs | undefined) => Array<FactorInput>;
32
+ loadKey: (keyID: string, atVersion?: number) => Promise<CredentialKey>;
33
+ doOpenKey: (params: DoOpenKeyParams) => Promise<OpenedKey>;
34
+ openKey: (keyID: string, inputs?: FactorInputs, atVersion?: number) => Promise<OpenedKey>;
35
+ openSpecificBranch: (params: OpenSpecificBranchParams) => Promise<Uint8Array | null>;
36
+ rewrapOwn: (params: RewrapOwnParams) => Promise<void>;
37
+ hasSuppliedInputs: (inputs?: FactorInputs) => boolean;
38
+ };
39
+ export type OpenedKey = {
40
+ contentKey: Uint8Array;
41
+ version: number;
42
+ branchID: string;
43
+ };
44
+ export declare function createOpenFunctions(ctx: ManagerContext, internals: ManagerInternals): OpenFunctions;
45
+ export {};