@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.
Files changed (53) 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 +16 -6
  18. package/lib/index.js +8 -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 -1072
  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/lib/wrappable.d.ts +14 -0
  50. package/lib/wrappable.js +15 -0
  51. package/package.json +16 -15
  52. package/lib/op.d.ts +0 -128
  53. package/lib/op.js +0 -304
@@ -0,0 +1,865 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { HLC } from '@kubun/hlc';
3
+ import { CredentialKeyID } from '@kubun/id';
4
+ import { RecordContentRefusalError, recordPayloadDigest, recordPayloadMatches, verifyRecordOp, verifySignedRecord, visibilityRules, visibleTo } from '@kubun/records';
5
+ import { credentialStoreOf, decodeCredentialRecord, encodeCredentialRecordPayload } from '@kubun/store-credential';
6
+ import { fromB64U, toB64U } from '@sozai/codec';
7
+ import { isCanonicalCompactToken } from './canonical-token.js';
8
+ import { digestParts } from './digest.js';
9
+ import { verifyGrantorAuthority } from './grantor-authority.js';
10
+ import { verifyCredentialManifest } from './manifest-token.js';
11
+ /** Thrown when a credential hook runs against a store it cannot reach the indexed tables of. */ export class CredentialRecordHookError extends Error {
12
+ constructor(message){
13
+ super(message);
14
+ this.name = 'CredentialRecordHookError';
15
+ }
16
+ }
17
+ const KEY = 'credential.key';
18
+ const KEY_BRANCH = 'credential.key-branch';
19
+ const WRAPPING = 'credential.wrapping';
20
+ const WRAPPING_AUTHORITY = 'credential.wrapping-authority';
21
+ const ENTRY = 'credential.entry';
22
+ const MANIFEST = 'credential.manifest';
23
+ const REVOCATION = 'credential.revocation';
24
+ function isCount(value) {
25
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
26
+ }
27
+ function isText(value) {
28
+ return typeof value === 'string' && value !== '';
29
+ }
30
+ function normalizedOrNull(value) {
31
+ if (typeof value !== 'string') {
32
+ return null;
33
+ }
34
+ try {
35
+ return normalizeDID(value);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+ /** Only these keys, each passing its check; a missing optional key is `undefined`. */ function hasShape(meta, required, optional = {}) {
41
+ for (const key of Object.keys(meta)){
42
+ if (!(key in required) && !(key in optional)) {
43
+ return false;
44
+ }
45
+ }
46
+ for (const [key, check] of Object.entries(required)){
47
+ if (!(key in meta) || !check(meta[key])) {
48
+ return false;
49
+ }
50
+ }
51
+ for (const [key, check] of Object.entries(optional)){
52
+ if (key in meta && !check(meta[key])) {
53
+ return false;
54
+ }
55
+ }
56
+ return true;
57
+ }
58
+ function isTokenList(value) {
59
+ return Array.isArray(value) && value.length > 0 && value.every(isText);
60
+ }
61
+ function isDID(value) {
62
+ return normalizedOrNull(value) !== null;
63
+ }
64
+ function isRecipient(value) {
65
+ return value === null || isDID(value);
66
+ }
67
+ /** Canonical unpadded base64url of exactly `length` bytes. */ function isBase64Of(value, length) {
68
+ if (typeof value !== 'string') {
69
+ return false;
70
+ }
71
+ try {
72
+ const bytes = fromB64U(value);
73
+ return bytes.length === length && toB64U(bytes) === value;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+ /** A record digest or op hash: 32 bytes, as `recordPayloadDigest` spells them. */ function isDigest(value) {
79
+ return isBase64Of(value, 32);
80
+ }
81
+ /** A credential key id in its one string form. */ function isKeyID(value) {
82
+ if (typeof value !== 'string') {
83
+ return false;
84
+ }
85
+ try {
86
+ return CredentialKeyID.fromString(value).toString() === value;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+ /** The random half of a key id, as the builder draws it. */ const KEY_UNIQUE_BYTES = 16;
92
+ function isKeyUnique(value) {
93
+ return isBase64Of(value, KEY_UNIQUE_BYTES);
94
+ }
95
+ /**
96
+ * Whether `keyID` is the id `owner` mints from `unique`. The id commits to its owner, so no one
97
+ * can hold another owner's key id as their own, even on a device that has not yet seen it.
98
+ */ function keyIDNames(keyID, owner, unique) {
99
+ try {
100
+ return CredentialKeyID.create(owner, fromB64U(unique)).toString() === keyID;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+ const GRANT_ENTRY_PREFIX = 'grant:';
106
+ const GRANT_ENTRY_DOMAIN = 'kubun/credential-grant-entry';
107
+ /**
108
+ * The id of the entry holding the OAuth grant `name` that `grantDeviceDID` spends for `owner`.
109
+ * Derived, so the device that may write an entry is a property of the entry's id, whatever
110
+ * ops a peer has or has not seen for it.
111
+ */ export function credentialGrantEntryID(owner, grantDeviceDID, name) {
112
+ const parts = [
113
+ GRANT_ENTRY_DOMAIN,
114
+ normalizeDID(owner),
115
+ normalizeDID(grantDeviceDID),
116
+ name
117
+ ];
118
+ return `${GRANT_ENTRY_PREFIX}${digestParts(parts)}`;
119
+ }
120
+ const AUTHORITY = {
121
+ delegationTokens: isTokenList
122
+ };
123
+ function keyMeta(claim) {
124
+ const meta = claim.meta;
125
+ const identity = {
126
+ keyID: isKeyID,
127
+ keyUnique: isKeyUnique,
128
+ version: isCount
129
+ };
130
+ const ok = claim.deleted ? hasShape(meta, identity, AUTHORITY) : hasShape(meta, {
131
+ ...identity,
132
+ state: (v)=>v === 'active' || v === 'retired'
133
+ }, AUTHORITY);
134
+ return ok && meta.keyID === claim.id && keyIDNames(claim.id, claim.owner, meta.keyUnique) ? meta : null;
135
+ }
136
+ function keyBranchMeta(claim) {
137
+ const ok = hasShape(claim.meta, {
138
+ keyID: isKeyID,
139
+ keyVersion: isCount,
140
+ branchID: isText
141
+ }, {
142
+ ...AUTHORITY,
143
+ repairDigest: isText
144
+ });
145
+ return ok ? claim.meta : null;
146
+ }
147
+ function wrappingMeta(claim) {
148
+ const ok = claim.deleted ? hasShape(claim.meta, {
149
+ keyID: isKeyID,
150
+ keyVersion: isCount
151
+ }, {
152
+ recipient: isRecipient,
153
+ ...AUTHORITY
154
+ }) : hasShape(claim.meta, {
155
+ keyID: isKeyID,
156
+ keyVersion: isCount,
157
+ branchID: isText,
158
+ branchRecordID: isDigest
159
+ }, {
160
+ recipient: isRecipient,
161
+ ...AUTHORITY
162
+ });
163
+ return ok ? claim.meta : null;
164
+ }
165
+ function wrappingAuthorityMeta(claim) {
166
+ const ok = hasShape(claim.meta, {
167
+ wrappingID: isText,
168
+ refreshSeq: isCount,
169
+ wrappingOpHash: isDigest
170
+ });
171
+ return ok && claim.meta.wrappingID === claim.id ? claim.meta : null;
172
+ }
173
+ function entryMeta(claim) {
174
+ const grant = {
175
+ grantDeviceDID: isDID,
176
+ grantName: isText,
177
+ grantOwnerDID: isDID,
178
+ ...AUTHORITY
179
+ };
180
+ const ok = claim.deleted ? hasShape(claim.meta, {
181
+ keyID: isKeyID,
182
+ keyVersion: isCount,
183
+ contentHLC: isText
184
+ }, {
185
+ ...grant,
186
+ branchID: isText,
187
+ branchRecordID: isDigest
188
+ }) : hasShape(claim.meta, {
189
+ keyID: isKeyID,
190
+ keyVersion: isCount,
191
+ contentHLC: isText,
192
+ branchID: isText,
193
+ branchRecordID: isDigest
194
+ }, grant);
195
+ // A grant names its account, device and provider together.
196
+ const grantFields = [
197
+ 'grantDeviceDID',
198
+ 'grantName',
199
+ 'grantOwnerDID'
200
+ ].filter((key)=>key in claim.meta);
201
+ return ok && 'branchID' in claim.meta === 'branchRecordID' in claim.meta && (grantFields.length === 0 || grantFields.length === 3) ? claim.meta : null;
202
+ }
203
+ /** Content intent precedes key version; an encryption refresh preserves its content stamp. */ function entryOrder(claim) {
204
+ const meta = entryMeta(claim);
205
+ const stamp = meta === null ? null : HLC.tryParse(meta.contentHLC);
206
+ if (stamp == null || HLC.serialize(stamp) !== meta?.contentHLC) {
207
+ throw new RecordContentRefusalError('credential.entry content stamp is malformed');
208
+ }
209
+ const node = Array.from({
210
+ length: stamp.nodeID.length
211
+ }, (_, index)=>stamp.nodeID.charCodeAt(index));
212
+ return [
213
+ stamp.wallTime,
214
+ stamp.counter,
215
+ ...node,
216
+ -1,
217
+ meta.keyVersion
218
+ ];
219
+ }
220
+ function validEntryContentStamp(claim) {
221
+ const meta = entryMeta(claim);
222
+ const content = meta === null ? null : HLC.tryParse(meta.contentHLC);
223
+ const operation = HLC.tryParse(claim.hlc);
224
+ return content !== null && operation !== null && HLC.serialize(content) === meta?.contentHLC && HLC.serialize(operation) === claim.hlc && HLC.compare(content, operation) <= 0 && /^[0-9]{4}-/.test(meta.contentHLC);
225
+ }
226
+ function manifestMeta(claim) {
227
+ const ok = hasShape(claim.meta, {
228
+ recipient: isDID,
229
+ grantor: isDID,
230
+ epoch: isCount,
231
+ sequence: isCount
232
+ });
233
+ return ok ? claim.meta : null;
234
+ }
235
+ function revocationMeta(claim) {
236
+ const ok = hasShape(claim.meta, {
237
+ keyID: isKeyID,
238
+ revokedDID: isDID
239
+ }, AUTHORITY);
240
+ return ok ? claim.meta : null;
241
+ }
242
+ /** A key version and state as one order: a retirement at a version follows its activation. */ function keyOrder(claim) {
243
+ const meta = keyMeta(claim);
244
+ if (meta === null) {
245
+ throw new RecordContentRefusalError('credential.key meta is malformed');
246
+ }
247
+ // A deletion follows both, so a later live op at the same version cannot revive it.
248
+ const rank = claim.deleted ? 2 : meta.state === 'retired' ? 1 : 0;
249
+ return [
250
+ meta.version,
251
+ rank
252
+ ];
253
+ }
254
+ // --- shared checks ---
255
+ /**
256
+ * The live payload of a record, decoded by the store's own codec, or `null` when the bytes are
257
+ * not the signed ones or not of the type's shape. Checked here rather than left to the store,
258
+ * whose write would throw and roll back the whole chunk.
259
+ */ function decodedPayload(record, op) {
260
+ if (op.claim.deleted || !recordPayloadMatches(record, op.claim)) {
261
+ return null;
262
+ }
263
+ try {
264
+ return decodeCredentialRecord(record, op.claim.meta);
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
269
+ function decodedAs(record, op, type) {
270
+ const decoded = decodedPayload(record, op);
271
+ return decoded?.type === type ? decoded : null;
272
+ }
273
+ /** The owner of the stored key `keyID`, or `null` when the store holds no verified key record. */ async function storedKeyOwner(store, owner, keyID, methods) {
274
+ const stored = await store.get({
275
+ type: KEY,
276
+ owner,
277
+ id: keyID
278
+ });
279
+ const copy = stored?.copies[0];
280
+ const op = copy === undefined ? null : await verifySignedRecord(copy, methods);
281
+ return op?.claim.owner ?? null;
282
+ }
283
+ function keyAtLeast(owner, keyID, keyVersion) {
284
+ return {
285
+ type: KEY,
286
+ owner,
287
+ id: keyID,
288
+ satisfiedBy: (key)=>{
289
+ const meta = keyMeta(key.claim);
290
+ return key.claim.owner === owner && meta !== null && meta.version >= keyVersion;
291
+ }
292
+ };
293
+ }
294
+ function authorityOf(deps) {
295
+ const self = normalizedOrNull(deps.selfDID);
296
+ const controllerOf = async (did)=>normalizedOrNull(await deps.resolveController(did));
297
+ return {
298
+ methods: deps.methods,
299
+ async actsFor (issuer, owner, tokens) {
300
+ if (issuer === owner) return true;
301
+ if (issuer.startsWith('did:kokuin:')) return false;
302
+ if (await controllerOf(issuer) === owner) return true;
303
+ if (owner.startsWith('did:kokuin:')) return false;
304
+ return tokens !== undefined && await deps.verifyAdministerChain(tokens, owner, issuer);
305
+ },
306
+ async isControllerHead (issuer, owner) {
307
+ return issuer === owner || !issuer.startsWith('did:kokuin:') && await controllerOf(issuer) === owner;
308
+ },
309
+ async receives (owner, recipient) {
310
+ if (self === null) {
311
+ return false;
312
+ }
313
+ if (normalizedOrNull(recipient) === self) {
314
+ return true;
315
+ }
316
+ return self === owner || await controllerOf(self) === owner;
317
+ }
318
+ };
319
+ }
320
+ // --- the seven types ---
321
+ function keyType(authority) {
322
+ return {
323
+ type: KEY,
324
+ policy: {
325
+ kind: 'monotonic',
326
+ version: keyOrder
327
+ },
328
+ dependsOn: [],
329
+ // Two ops at one version and state are concurrent rotations: both stay proven through
330
+ // their branch records, and the store keeps the greater `opHash`, the branch readers open.
331
+ onEqual: ()=>'fork',
332
+ precheck: (op)=>authority.actsFor(op.issuer, op.claim.owner, keyMeta(op.claim)?.delegationTokens),
333
+ async authorize (op, ctx) {
334
+ const meta = keyMeta(op.claim);
335
+ if (meta === null) {
336
+ return false;
337
+ }
338
+ if (!op.claim.deleted) {
339
+ const decoded = decodedAs(ctx.record, op, KEY);
340
+ if (decoded === null || decoded.payload.state !== meta.state) {
341
+ return false;
342
+ }
343
+ }
344
+ return await authority.actsFor(op.issuer, op.claim.owner, meta.delegationTokens);
345
+ },
346
+ visibleTo: visibilityRules([
347
+ 'ownerDevices',
348
+ 'keyHolder'
349
+ ]),
350
+ dependencies: ()=>[]
351
+ };
352
+ }
353
+ /** Revocation removes every indexed wrapping for that recipient, at every key version. */ export function credentialStoreForHooks(adapter) {
354
+ const credentials = credentialStoreOf(adapter.adapterForType?.(KEY) ?? adapter);
355
+ if (credentials === null) throw new CredentialRecordHookError('Credential store unavailable');
356
+ return credentials;
357
+ }
358
+ async function dropRevokedWrappings(store, owner, keyID) {
359
+ const credentials = credentialStoreForHooks(store);
360
+ const revoked = new Set((await credentials.listRevocations(keyID, owner)).map((row)=>normalizedOrNull(row.recipient_did)));
361
+ for (const wrapping of (await credentials.listWrappings(keyID, undefined, owner))){
362
+ const recipients = [
363
+ normalizedOrNull(wrapping.recipient_did),
364
+ ...wrapping.factors.filter((factor)=>factor.kind === 'did').map((factor)=>normalizedOrNull(factor.params.recipient))
365
+ ];
366
+ if (recipients.some((recipient)=>recipient !== null && revoked.has(recipient))) {
367
+ await credentials.deleteWrapping(wrapping.wrapping_id, owner);
368
+ }
369
+ }
370
+ }
371
+ async function wrappingNamesRevokedRecipient(store, owner, keyID, recipient, factors) {
372
+ const credentials = credentialStoreForHooks(store);
373
+ const revoked = new Set((await credentials.listRevocations(keyID, owner)).map((row)=>normalizedOrNull(row.recipient_did)));
374
+ return [
375
+ normalizedOrNull(recipient),
376
+ ...factors.filter((factor)=>factor.kind === 'did').map((factor)=>normalizedOrNull(factor.params.recipient))
377
+ ].some((did)=>did !== null && revoked.has(did));
378
+ }
379
+ function keyBranchType(authority) {
380
+ return {
381
+ type: KEY_BRANCH,
382
+ policy: {
383
+ kind: 'immutable'
384
+ },
385
+ dependsOn: [
386
+ KEY
387
+ ],
388
+ precheck: (op)=>authority.actsFor(op.issuer, op.claim.owner, keyBranchMeta(op.claim)?.delegationTokens),
389
+ async authorize (op, ctx) {
390
+ const meta = keyBranchMeta(op.claim);
391
+ const decoded = decodedAs(ctx.record, op, KEY_BRANCH);
392
+ if (meta === null || decoded === null) {
393
+ return false;
394
+ }
395
+ const { payload } = decoded;
396
+ // The branch retains the key op that introduced it, verbatim: it must be a key op of
397
+ // this owner at this version, and the label must be the one that op mints.
398
+ const keyOp = isCanonicalCompactToken(payload.opJWT) ? await verifyRecordOp(payload.opJWT, authority.methods) : null;
399
+ const keyOpMeta = keyOp === null ? null : keyMeta(keyOp.claim);
400
+ if (keyOp === null || keyOpMeta === null || keyOp.claim.type !== KEY || keyOp.claim.id !== meta.keyID || keyOp.claim.owner !== op.claim.owner || keyOp.claim.deleted || keyOpMeta.version !== meta.keyVersion || keyOp.opHash !== payload.opHash || keyOp.claim.hlc !== payload.hlc || keyOp.issuer !== normalizedOrNull(payload.authorDID) || meta.branchID !== `${payload.hlc}:${payload.opHash}`) {
401
+ return false;
402
+ }
403
+ if (keyOp.claim.payloadDigest !== recordPayloadDigest(encodeCredentialRecordPayload('credential.key', {
404
+ suite: payload.suite,
405
+ state: keyOpMeta.state
406
+ }))) return false;
407
+ // Immutable records settle a conflict on `opHash`, not on the owner: the owner is the
408
+ // key's, never the branch op's own claim.
409
+ if (await storedKeyOwner(ctx.store, op.claim.owner, meta.keyID, authority.methods) !== op.claim.owner) {
410
+ return false;
411
+ }
412
+ return await authority.actsFor(keyOp.issuer, op.claim.owner, keyOpMeta.delegationTokens) && await authority.actsFor(op.issuer, op.claim.owner, meta.delegationTokens);
413
+ },
414
+ visibleTo: visibilityRules([
415
+ 'keyHolder'
416
+ ]),
417
+ dependencies (_record, op) {
418
+ const meta = keyBranchMeta(op.claim);
419
+ return meta === null ? [] : [
420
+ keyAtLeast(op.claim.owner, meta.keyID, meta.keyVersion)
421
+ ];
422
+ }
423
+ };
424
+ }
425
+ function wrappingType(authority, bindReceiver) {
426
+ return {
427
+ type: WRAPPING,
428
+ policy: {
429
+ kind: 'lww'
430
+ },
431
+ dependsOn: [
432
+ KEY,
433
+ KEY_BRANCH
434
+ ],
435
+ precheck: (op)=>authority.actsFor(op.issuer, op.claim.owner, wrappingMeta(op.claim)?.delegationTokens),
436
+ async authorize (op, ctx) {
437
+ const meta = wrappingMeta(op.claim);
438
+ if (meta === null) {
439
+ return false;
440
+ }
441
+ const decoded = op.claim.deleted ? null : decodedAs(ctx.record, op, WRAPPING);
442
+ if (!op.claim.deleted && decoded === null) {
443
+ return false;
444
+ }
445
+ if (decoded !== null && await wrappingNamesRevokedRecipient(ctx.store, op.claim.owner, meta.keyID, meta.recipient, decoded.payload.factors)) {
446
+ return false;
447
+ }
448
+ if (await storedKeyOwner(ctx.store, op.claim.owner, meta.keyID, authority.methods) !== op.claim.owner) {
449
+ return false;
450
+ }
451
+ if (bindReceiver && !await authority.receives(op.claim.owner, meta.recipient)) {
452
+ return false;
453
+ }
454
+ // A delegate carries its administer chain in the op it signs, so the chain cannot be
455
+ // moved onto another wrapping or claimed by another issuer.
456
+ return await authority.actsFor(op.issuer, op.claim.owner, meta.delegationTokens);
457
+ },
458
+ // Never `keyHolder` or `sharedWith`: a co-holder must not learn another recipient's wrapping.
459
+ async visibleTo (record, ctx) {
460
+ const op = await verifySignedRecord(record, authority.methods);
461
+ const meta = op === null ? null : wrappingMeta(op.claim);
462
+ if (op === null || meta === null) return false;
463
+ const decoded = op.claim.deleted ? null : decodedAs(record, op, WRAPPING);
464
+ if (decoded !== null && await wrappingNamesRevokedRecipient(ctx.store, op.claim.owner, meta.keyID, meta.recipient, decoded.payload.factors)) {
465
+ return false;
466
+ }
467
+ return await visibleTo(record, [
468
+ 'recipient',
469
+ 'ownerDevices'
470
+ ], ctx);
471
+ },
472
+ dependencies (_record, op) {
473
+ const meta = wrappingMeta(op.claim);
474
+ if (meta === null) {
475
+ return [];
476
+ }
477
+ if (op.claim.deleted) {
478
+ return [
479
+ {
480
+ type: KEY,
481
+ owner: op.claim.owner,
482
+ id: meta.keyID
483
+ }
484
+ ];
485
+ }
486
+ const { keyID, keyVersion, branchID } = meta;
487
+ return [
488
+ keyAtLeast(op.claim.owner, keyID, keyVersion),
489
+ {
490
+ type: KEY_BRANCH,
491
+ owner: op.claim.owner,
492
+ id: meta.branchRecordID,
493
+ // The exact branch the wrapping signed, at this version. Its owner is the key's, as
494
+ // the authorizer binds the wrapping's: a mismatch is refused there, not parked here.
495
+ satisfiedBy: (branch)=>{
496
+ const branchMeta = keyBranchMeta(branch.claim);
497
+ return branchMeta !== null && branch.claim.owner === op.claim.owner && !branch.claim.deleted && branchMeta.keyID === keyID && branchMeta.keyVersion === keyVersion && branchMeta.branchID === branchID;
498
+ }
499
+ }
500
+ ];
501
+ },
502
+ async afterApply (record, store) {
503
+ const op = await verifySignedRecord(record, authority.methods);
504
+ const meta = op === null ? null : wrappingMeta(op.claim);
505
+ if (op !== null && meta !== null) {
506
+ await dropRevokedWrappings(store, op.claim.owner, meta.keyID);
507
+ }
508
+ }
509
+ };
510
+ }
511
+ function wrappingAuthorityType(authority, deps) {
512
+ return {
513
+ type: WRAPPING_AUTHORITY,
514
+ policy: {
515
+ kind: 'monotonic',
516
+ version: (claim)=>[
517
+ claim.meta.refreshSeq
518
+ ]
519
+ },
520
+ dependsOn: [
521
+ WRAPPING
522
+ ],
523
+ async precheck (op, record) {
524
+ if (op.claim.owner.startsWith('did:kokuin:') && !await authority.actsFor(op.issuer, op.claim.owner, undefined)) return false;
525
+ const meta = wrappingAuthorityMeta(op.claim);
526
+ const decoded = decodedAs(record, op, WRAPPING_AUTHORITY);
527
+ if (meta === null || decoded === null || !isCanonicalCompactToken(decoded.payload.envelope)) {
528
+ return false;
529
+ }
530
+ const claim = await verifyGrantorAuthority(decoded.payload.envelope);
531
+ return claim !== null && claim.grantorDID === op.issuer && normalizedOrNull(claim.ownerDID) === op.claim.owner && claim.wrappingOpHash === meta.wrappingOpHash && claim.refreshSeq === meta.refreshSeq && await deps.verifyGrantorEnvelope(decoded.payload.envelope, meta.wrappingOpHash);
532
+ },
533
+ async authorize (op, ctx) {
534
+ if (op.claim.owner.startsWith('did:kokuin:') && !await authority.actsFor(op.issuer, op.claim.owner, undefined)) return false;
535
+ const meta = wrappingAuthorityMeta(op.claim);
536
+ const decoded = decodedAs(ctx.record, op, WRAPPING_AUTHORITY);
537
+ if (meta === null || decoded === null) {
538
+ return false;
539
+ }
540
+ const envelope = decoded.payload.envelope;
541
+ if (!isCanonicalCompactToken(envelope)) {
542
+ return false;
543
+ }
544
+ // The grantor is whoever signed the wrapping this envelope speaks for.
545
+ const storedWrapping = await ctx.store.get({
546
+ type: WRAPPING,
547
+ owner: op.claim.owner,
548
+ id: meta.wrappingID
549
+ });
550
+ const wrapping = storedWrapping === null || storedWrapping.copies[0] === undefined ? null : await verifySignedRecord(storedWrapping.copies[0], authority.methods);
551
+ const wrappingInfo = wrapping === null ? null : wrappingMeta(wrapping.claim);
552
+ if (wrapping === null || wrappingInfo === null || wrapping.claim.deleted || wrapping.opHash !== meta.wrappingOpHash || wrapping.claim.owner !== op.claim.owner || wrapping.issuer !== op.issuer) {
553
+ return false;
554
+ }
555
+ const claim = await verifyGrantorAuthority(envelope);
556
+ if (claim === null || claim.grantorDID !== op.issuer || normalizedOrNull(claim.ownerDID) !== op.claim.owner || claim.wrappingOpHash !== meta.wrappingOpHash || claim.refreshSeq !== meta.refreshSeq || normalizedOrNull(claim.recipientDID) === null || normalizedOrNull(claim.recipientDID) !== normalizedOrNull(wrappingInfo.recipient)) {
557
+ return false;
558
+ }
559
+ if (!await authority.receives(op.claim.owner, wrappingInfo.recipient)) {
560
+ return false;
561
+ }
562
+ return await deps.verifyGrantorEnvelope(envelope, meta.wrappingOpHash);
563
+ },
564
+ // As its wrapping, once that wrapping is the one the envelope names.
565
+ async visibleTo (record, ctx) {
566
+ const op = await verifySignedRecord(record, authority.methods);
567
+ const meta = op === null ? null : wrappingAuthorityMeta(op.claim);
568
+ if (op === null || meta === null) {
569
+ return false;
570
+ }
571
+ const storedWrapping = await ctx.store.get({
572
+ type: WRAPPING,
573
+ owner: op.claim.owner,
574
+ id: meta.wrappingID
575
+ });
576
+ const wrapping = storedWrapping === null || storedWrapping.copies[0] === undefined ? null : await verifySignedRecord(storedWrapping.copies[0], authority.methods);
577
+ if (storedWrapping === null || wrapping === null || wrapping.claim.deleted || wrapping.opHash !== meta.wrappingOpHash || wrapping.claim.owner !== op.claim.owner) {
578
+ return false;
579
+ }
580
+ return await visibleTo(storedWrapping.copies[0], [
581
+ 'recipient',
582
+ 'ownerDevices'
583
+ ], ctx);
584
+ },
585
+ dependencies (_record, op) {
586
+ const meta = wrappingAuthorityMeta(op.claim);
587
+ if (meta === null) {
588
+ return [];
589
+ }
590
+ return [
591
+ {
592
+ type: WRAPPING,
593
+ owner: op.claim.owner,
594
+ id: meta.wrappingID,
595
+ satisfiedBy: (wrapping)=>!wrapping.claim.deleted && wrapping.opHash === meta.wrappingOpHash && wrapping.claim.owner === op.claim.owner
596
+ }
597
+ ];
598
+ }
599
+ };
600
+ }
601
+ function entryType(authority) {
602
+ return {
603
+ type: ENTRY,
604
+ policy: {
605
+ kind: 'monotonic',
606
+ version: entryOrder
607
+ },
608
+ async afterAuthorize (record, adapter) {
609
+ await credentialStoreForHooks(adapter).retainEntryCandidate(record, authority.methods);
610
+ },
611
+ dependsOn: [
612
+ KEY_BRANCH
613
+ ],
614
+ precheck: async (op)=>validEntryContentStamp(op.claim) && await authority.actsFor(op.issuer, op.claim.owner, entryMeta(op.claim)?.delegationTokens),
615
+ async authorize (op, ctx) {
616
+ const meta = entryMeta(op.claim);
617
+ if (meta === null) {
618
+ return false;
619
+ }
620
+ if (!op.claim.deleted && decodedAs(ctx.record, op, ENTRY) === null) {
621
+ return false;
622
+ }
623
+ if (await storedKeyOwner(ctx.store, op.claim.owner, meta.keyID, authority.methods) !== op.claim.owner) {
624
+ return false;
625
+ }
626
+ // The id binds a grant to its device. Live writes come from that device; owner
627
+ // authority can tombstone the entry during adoption.
628
+ const device = normalizedOrNull(meta.grantDeviceDID);
629
+ const accountOwner = normalizedOrNull(meta.grantOwnerDID);
630
+ if (device === null || meta.grantName === undefined || accountOwner === null) {
631
+ if (op.claim.id.startsWith(GRANT_ENTRY_PREFIX)) {
632
+ return false;
633
+ }
634
+ } else if (op.claim.id !== credentialGrantEntryID(accountOwner, device, meta.grantName) || !op.claim.deleted && op.issuer !== device) {
635
+ return false;
636
+ }
637
+ return await authority.actsFor(op.issuer, op.claim.owner, meta.delegationTokens);
638
+ },
639
+ visibleTo: visibilityRules([
640
+ 'keyHolder'
641
+ ]),
642
+ dependencies (_record, op) {
643
+ const meta = entryMeta(op.claim);
644
+ if (meta === null) {
645
+ return [];
646
+ }
647
+ if (op.claim.deleted) {
648
+ const dependencies = [
649
+ {
650
+ type: KEY,
651
+ owner: op.claim.owner,
652
+ id: meta.keyID,
653
+ satisfiedBy: (key)=>{
654
+ const keyInfo = keyMeta(key.claim);
655
+ return key.claim.owner === op.claim.owner && keyInfo !== null && keyInfo.version >= meta.keyVersion;
656
+ }
657
+ }
658
+ ];
659
+ if (meta.branchID !== undefined && meta.branchRecordID !== undefined) {
660
+ dependencies.push({
661
+ type: KEY_BRANCH,
662
+ owner: op.claim.owner,
663
+ id: meta.branchRecordID,
664
+ satisfiedBy: (branch)=>{
665
+ const branchInfo = keyBranchMeta(branch.claim);
666
+ return branchInfo !== null && branch.claim.owner === op.claim.owner && !branch.claim.deleted && branchInfo.keyID === meta.keyID && branchInfo.keyVersion === meta.keyVersion && branchInfo.branchID === meta.branchID;
667
+ }
668
+ });
669
+ }
670
+ return dependencies;
671
+ }
672
+ const { keyID, keyVersion, branchID } = meta;
673
+ return [
674
+ {
675
+ type: KEY_BRANCH,
676
+ owner: op.claim.owner,
677
+ id: meta.branchRecordID,
678
+ satisfiedBy: (branch)=>{
679
+ const branchInfo = keyBranchMeta(branch.claim);
680
+ return branchInfo !== null && branch.claim.owner === op.claim.owner && !branch.claim.deleted && branchInfo.keyID === keyID && branchInfo.keyVersion === keyVersion && branchInfo.branchID === branchID;
681
+ }
682
+ }
683
+ ];
684
+ }
685
+ };
686
+ }
687
+ function sameManifestKeys(left, right) {
688
+ return left.length === right.length && left.every((key, index)=>key.keyID === right[index]?.keyID && key.version === right[index]?.version);
689
+ }
690
+ function manifestType(authority) {
691
+ return {
692
+ type: MANIFEST,
693
+ policy: {
694
+ kind: 'monotonic',
695
+ version: (claim)=>[
696
+ claim.meta.epoch,
697
+ claim.meta.sequence
698
+ ]
699
+ },
700
+ dependsOn: [],
701
+ async authorize (op, ctx) {
702
+ const meta = manifestMeta(op.claim);
703
+ const decoded = decodedAs(ctx.record, op, MANIFEST);
704
+ if (meta === null || decoded === null) {
705
+ return false;
706
+ }
707
+ const grantor = normalizedOrNull(meta.grantor);
708
+ if (grantor !== op.issuer) {
709
+ return false;
710
+ }
711
+ // The legacy manifest token rides as provenance, and must say what the record says.
712
+ const { payload } = decoded;
713
+ const claim = isCanonicalCompactToken(payload.token) ? await verifyCredentialManifest(payload.token, authority.methods) : null;
714
+ if (claim === null || claim.grantorDID !== op.issuer || normalizedOrNull(claim.ownerDID) !== op.claim.owner || normalizedOrNull(claim.recipientDID) !== normalizedOrNull(meta.recipient) || claim.epoch !== meta.epoch || claim.sequence !== meta.sequence || !sameManifestKeys(claim.keys, payload.keys)) {
715
+ return false;
716
+ }
717
+ if (!await authority.receives(op.claim.owner, meta.recipient)) {
718
+ return false;
719
+ }
720
+ return await authority.actsFor(op.issuer, op.claim.owner, payload.delegationTokens);
721
+ },
722
+ visibleTo: visibilityRules([
723
+ 'recipient'
724
+ ]),
725
+ dependencies: ()=>[]
726
+ };
727
+ }
728
+ function revocationType(authority) {
729
+ return {
730
+ type: REVOCATION,
731
+ policy: {
732
+ kind: 'lww'
733
+ },
734
+ dependsOn: [
735
+ KEY
736
+ ],
737
+ precheck: (op)=>authority.isControllerHead(op.issuer, op.claim.owner),
738
+ async authorize (op, ctx) {
739
+ const meta = revocationMeta(op.claim);
740
+ if (meta === null || decodedAs(ctx.record, op, REVOCATION) === null) return false;
741
+ const stored = await ctx.store.get({
742
+ type: KEY,
743
+ owner: op.claim.owner,
744
+ id: meta.keyID
745
+ });
746
+ const key = stored === null || stored.copies[0] === undefined ? null : await verifySignedRecord(stored.copies[0], authority.methods);
747
+ if (key === null || keyMeta(key.claim) === null || key.claim.owner !== op.claim.owner) return false;
748
+ return await authority.isControllerHead(op.issuer, op.claim.owner);
749
+ },
750
+ visibleTo: visibilityRules([
751
+ 'keyHolder'
752
+ ]),
753
+ dependencies (_record, op) {
754
+ const meta = revocationMeta(op.claim);
755
+ return meta === null ? [] : [
756
+ {
757
+ type: KEY,
758
+ owner: op.claim.owner,
759
+ id: meta.keyID
760
+ }
761
+ ];
762
+ },
763
+ async afterApply (record, store) {
764
+ const op = await verifySignedRecord(record, authority.methods);
765
+ const meta = op === null ? null : revocationMeta(op.claim);
766
+ if (op !== null && meta !== null) await dropRevokedWrappings(store, op.claim.owner, meta.keyID);
767
+ }
768
+ };
769
+ }
770
+ /**
771
+ * The credential record types, in dependency order: how each merges, who may write it, who
772
+ * may receive it, what it needs first, and what applying it does to the indexed tables.
773
+ *
774
+ * Every authorizer decodes a live payload with the store's codec after checking it against the
775
+ * signed digest, binds the record to the owner of the key it belongs to, and accepts only an
776
+ * issuer that acts for that owner. None decrypts or unwraps anything.
777
+ */ export function credentialRecordTypes(deps) {
778
+ const authority = authorityOf(deps);
779
+ const types = [
780
+ keyType(authority),
781
+ keyBranchType(authority),
782
+ wrappingType(authority, true),
783
+ wrappingAuthorityType(authority, deps),
784
+ entryType(authority),
785
+ manifestType(authority),
786
+ revocationType(authority)
787
+ ];
788
+ return new Map(types.map((definition)=>[
789
+ definition.type,
790
+ {
791
+ ...definition,
792
+ async lockKeys (incoming, stored) {
793
+ const keys = new Set();
794
+ for (const record of [
795
+ incoming,
796
+ ...stored?.copies ?? []
797
+ ]){
798
+ if (record === null) continue;
799
+ const op = await verifySignedRecord(record, deps.methods);
800
+ if (op === null) continue;
801
+ const keyID = record.type === KEY ? record.id : op.claim.meta.keyID;
802
+ if (typeof keyID === 'string' && keyID !== '') keys.add(keyID);
803
+ }
804
+ return [
805
+ ...keys
806
+ ].map((key)=>({
807
+ type: definition.type,
808
+ key
809
+ }));
810
+ }
811
+ }
812
+ ]));
813
+ }
814
+ /**
815
+ * The record types that prove a requester holds a key: wrappings only. A revocation also names
816
+ * a key and a recipient, and must never pass for a holding.
817
+ */ export function credentialHolderTypes(types) {
818
+ const wrapping = types.get(WRAPPING);
819
+ return new Map(wrapping === undefined ? [] : [
820
+ [
821
+ WRAPPING,
822
+ wrapping
823
+ ]
824
+ ]);
825
+ }
826
+ /**
827
+ * The responder's current version of a key, for `VisibilityContext.currentKeyVersion`: the
828
+ * stored key record of that id, when it is live and belongs to `owner`.
829
+ */ export function credentialKeyVersionReader(store, methods) {
830
+ return async (owner, keyID)=>{
831
+ const stored = await store.get({
832
+ type: KEY,
833
+ owner,
834
+ id: keyID
835
+ });
836
+ const op = stored === null || stored.copies[0] === undefined ? null : await verifySignedRecord(stored.copies[0], methods);
837
+ const meta = op === null ? null : keyMeta(op.claim);
838
+ return op === null || meta === null || op.claim.deleted || op.claim.owner !== owner ? null : meta.version;
839
+ };
840
+ }
841
+ /**
842
+ * The authorizer a responder runs on a holder proof, for `VisibilityContext.authorizeProof`:
843
+ * the wrapping authorizer apply runs, reading `store`, without the receiver binding — a proof
844
+ * is the requester's wrapping, never addressed to the responder. Anything but a wrapping, or
845
+ * a record whose op does not verify, is refused.
846
+ */ export function credentialProofAuthorizer(deps, store) {
847
+ const wrapping = wrappingType(authorityOf(deps), false);
848
+ return async (record, stored)=>{
849
+ if (record.type !== WRAPPING) {
850
+ return false;
851
+ }
852
+ const op = await verifySignedRecord(record, deps.methods);
853
+ if (op === null) {
854
+ return false;
855
+ }
856
+ const ctx = {
857
+ record,
858
+ stored,
859
+ store,
860
+ resolveController: deps.resolveController,
861
+ verifyCapability: deps.verifyAdministerChain
862
+ };
863
+ return await wrapping.authorize(op, ctx);
864
+ };
865
+ }