@kokuin/controller 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/events.js ADDED
@@ -0,0 +1,465 @@
1
+ import { ed25519 } from '@noble/curves/ed25519.js';
2
+ import { base64urlnopad } from '@scure/base';
3
+ import { canonicalBytes, digestOf, isCanonicalizable } from './canonical.js';
4
+ import { agreementPath, authorityPath, deriveKeyPair, recoveryPath } from './derivation.js';
5
+ import { encodeKey, tryDecodeKey } from './keys.js';
6
+ export const DID_PREFIX = 'did:kokuin:';
7
+ export function signEvent(event, privateKeys) {
8
+ const bytes = canonicalBytes(event);
9
+ return privateKeys.map((key)=>base64urlnopad.encode(ed25519.sign(bytes, key)));
10
+ }
11
+ /**
12
+ * Whether a published threshold agrees with what the fold enforces.
13
+ *
14
+ * `kt`/`nt` are published and digested into the event (and, for an inception, the DID), but the fold
15
+ * enforces n-of-n and nothing else: one valid signature per published key, every committed key
16
+ * revealed and signing. So the only threshold these fields can truthfully carry is the size of the
17
+ * set they govern; anything else is an event whose declaration contradicts the rule judging it. A
18
+ * field nothing reads is a trap for the next reader inside a wire format fixed by the DID; if quorum
19
+ * ever lands, the check moves with the enforcement. `===` against the length, so `"1"` fails.
20
+ */ function isEnforcedThreshold(threshold, keys) {
21
+ return threshold === keys.length;
22
+ }
23
+ /**
24
+ * A published key list: an array of strings. `k`, `n`, `ka` and the deny snapshot are carried into
25
+ * the folded state, so a bad shape must be rejected where it is published, not one event away where
26
+ * it is next read (a non-array `n` would throw in the *next* rotate; a non-array `k` is a `KeyState`
27
+ * whose type lies).
28
+ */ function isKeyList(value) {
29
+ return Array.isArray(value) && value.every((entry)=>typeof entry === 'string');
30
+ }
31
+ /**
32
+ * A key agreement set is valid when non-empty and every entry is a well-formed X25519-tagged key.
33
+ * Shared by every event that carries `ka` (inception, rotate, reset), which must reject the same
34
+ * malformed shapes everywhere.
35
+ */ function verifyAgreementKeys(ka) {
36
+ if (!isKeyList(ka) || ka.length === 0) {
37
+ return false;
38
+ }
39
+ for (const entry of ka){
40
+ const key = tryDecodeKey(entry);
41
+ if (key == null || key.alg !== 'X25519') {
42
+ return false;
43
+ }
44
+ }
45
+ return true;
46
+ }
47
+ /**
48
+ * Total: a malformed signature or key yields false rather than throwing, including a body the
49
+ * canonicalizer refuses. `canonicalBytes` throws on a non-finite number or nesting past
50
+ * `MAX_CANONICAL_DEPTH` (both from `JSON.parse`); the fold's envelope guard rejects such a body one
51
+ * layer out, so this is unreachable through `foldLog` but stays because the export is documented
52
+ * total and a direct caller reaches it with a parsed body. `false` is honest: a body with no
53
+ * canonical encoding has no bytes for a signature to be over.
54
+ */ export function verifySignatures(event, sigs, keys) {
55
+ if (!Array.isArray(sigs) || !Array.isArray(keys)) {
56
+ return false;
57
+ }
58
+ if (sigs.length !== keys.length || sigs.length === 0) {
59
+ return false;
60
+ }
61
+ if (!isCanonicalizable(event)) {
62
+ return false;
63
+ }
64
+ const bytes = canonicalBytes(event);
65
+ for(let i = 0; i < sigs.length; i++){
66
+ try {
67
+ const key = tryDecodeKey(keys[i]);
68
+ if (key == null || key.alg !== 'EdDSA') {
69
+ return false;
70
+ }
71
+ if (!ed25519.verify(base64urlnopad.decode(sigs[i]), bytes, key.publicKey)) {
72
+ return false;
73
+ }
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+ return true;
79
+ }
80
+ /**
81
+ * Whether one of an event's signatures was made by `key` — the counterpart of
82
+ * {@link verifySignatures} for a signer the *event* does not name (resolved from a DID elsewhere). A
83
+ * capability-authorised revoke needs exactly this: its author is the capability's `aud`, which the
84
+ * fold cannot resolve, so it is handed the key and checks the signature itself.
85
+ *
86
+ * Total, EdDSA only — all {@link signEvent} produces. An ES256 audience therefore cannot author a
87
+ * revoke, which fails closed until events grow a second signature algorithm.
88
+ */ export function verifyEventSignedBy(signed, key) {
89
+ // Both arms are unkillable through the fold (`isSignedEventShape` already established `sigs` is a
90
+ // string array; a non-EdDSA key would fail `ed25519.verify`; `[].some()` is `false`) and both stay
91
+ // by the file's guard rule: a direct caller reaches this with a parsed body.
92
+ if (key.alg !== 'EdDSA' || signed.sigs.length === 0) {
93
+ return false;
94
+ }
95
+ if (!isCanonicalizable(signed.event)) {
96
+ return false;
97
+ }
98
+ const bytes = canonicalBytes(signed.event);
99
+ return signed.sigs.some((sig)=>{
100
+ try {
101
+ return ed25519.verify(base64urlnopad.decode(sig), bytes, key.publicKey);
102
+ } catch {
103
+ return false;
104
+ }
105
+ });
106
+ }
107
+ /**
108
+ * Deterministic inception. Only seed-derived, canonical material — no timestamp, nonce, or label —
109
+ * so the DID is a pure function of the seed and profile index. Never add a label: a mistyped one on
110
+ * recovery would reproduce a different DID.
111
+ */ export function createInception(seed, profile) {
112
+ const event = inceptionEvent(seed, profile);
113
+ const current = deriveKeyPair(seed, authorityPath(profile, 0, 0), 'EdDSA');
114
+ return {
115
+ event,
116
+ sigs: signEvent(event, [
117
+ current.privateKey
118
+ ])
119
+ };
120
+ }
121
+ /**
122
+ * The inception body alone, unsigned. Split out because {@link createRotate} needs the DID this seed
123
+ * and profile produce to know whether it is the log's root, and signing a throwaway inception to
124
+ * find out is the one expensive step.
125
+ */ function inceptionEvent(seed, profile) {
126
+ const current = deriveKeyPair(seed, authorityPath(profile, 0, 0), 'EdDSA');
127
+ const next = deriveKeyPair(seed, authorityPath(profile, 0, 1), 'EdDSA');
128
+ const recovery = deriveKeyPair(seed, recoveryPath(profile), 'EdDSA');
129
+ const agreement = deriveKeyPair(seed, agreementPath(profile, 0, 0), 'X25519');
130
+ return {
131
+ v: 1,
132
+ t: 'icp',
133
+ g: 0,
134
+ s: 0,
135
+ crit: true,
136
+ k: [
137
+ encodeKey(current.publicKey, 'EdDSA')
138
+ ],
139
+ ka: [
140
+ encodeKey(agreement.publicKey, 'X25519')
141
+ ],
142
+ n: [
143
+ digestOf(encodeKey(next.publicKey, 'EdDSA'))
144
+ ],
145
+ kt: 1,
146
+ nt: 1,
147
+ r: digestOf(encodeKey(recovery.publicKey, 'EdDSA'))
148
+ };
149
+ }
150
+ export function didFromInception(event) {
151
+ return `${DID_PREFIX}${digestOf(event)}`;
152
+ }
153
+ /** The did:kokuin: identifier a seed and profile produce, without signing an inception. */ export function didFor(seed, profile) {
154
+ return didFromInception(inceptionEvent(seed, profile));
155
+ }
156
+ export function verifyInception(signed, did) {
157
+ if (signed.event.t !== 'icp' || signed.event.i !== undefined) {
158
+ return false;
159
+ }
160
+ // An inception is self-certifying: *any* body an attacker writes is a valid log for the DID it
161
+ // hashes to, which is why the thresholds and key lists it publishes must mean something. `k`/`n`/`r`
162
+ // go straight into the folded state where the next event reads them.
163
+ if (!isKeyList(signed.event.k) || !isKeyList(signed.event.n) || typeof signed.event.r !== 'string') {
164
+ return false;
165
+ }
166
+ if (!isEnforcedThreshold(signed.event.kt, signed.event.k) || !isEnforcedThreshold(signed.event.nt, signed.event.n)) {
167
+ return false;
168
+ }
169
+ if (didFromInception(signed.event) !== did) {
170
+ return false;
171
+ }
172
+ // An icp-only log must not be a valid DID publishing no agreement key.
173
+ if (!verifyAgreementKeys(signed.event.ka)) {
174
+ return false;
175
+ }
176
+ return verifySignatures(signed.event, signed.sigs, signed.event.k);
177
+ }
178
+ /**
179
+ * A rotate reveals the keys the prior event pre-committed and commits the next set, signed by the
180
+ * newly revealed keys (per KERI — what makes a stolen current key unable to rotate). Reproducible
181
+ * from the seed alone unless it carries a seal or deny snapshot.
182
+ *
183
+ * **Throws rather than emitting an event the fold will reject.** The revealed key must be the one
184
+ * `prior` committed in `n`, and `options.keyPosition` decides which key gets derived — so a wrong
185
+ * position used to produce a well-formed, signed event no fold would accept. The two are checked
186
+ * against each other here. When `prior` carries no commitment (a `rev`), `keyPosition` is required:
187
+ * the default would derive a key one past the *revoke*, which nothing committed, and this is the one
188
+ * case unverifiable from `prior` alone.
189
+ */ export function createRotate({ seed, profile, did, prior, options = {} }) {
190
+ const gen = prior.g;
191
+ const seq = prior.s + 1;
192
+ // The commitment the revealed key must match, when `prior` carries one. An `icp`/`rot` does; a
193
+ // `rev` and any hand-built prior of another shape does not.
194
+ const commitment = prior.n;
195
+ const committed = isKeyList(commitment) && commitment.length > 0 ? commitment : undefined;
196
+ // The checks below only have an answer when this seed *is* the log's root — otherwise the
197
+ // commitment was written by somebody else's key material and a mismatch says nothing. Refusing
198
+ // there would stop this generator building a foreign rotate at all (which the conformance suite
199
+ // legitimately does); the fold is the layer that rejects it.
200
+ const root = didFromInception(inceptionEvent(seed, profile)) === did;
201
+ if (root && committed == null && options.keyPosition == null) {
202
+ throw new Error('createRotate: prior event pre-commits no key, so keyPosition is required — pass the fold`s ' + 'KeyState.keyGen / keySeq for the position the last icp/rot established');
203
+ }
204
+ // The key this rotate reveals is the one the last icp/rot committed, one past *its* position; the
205
+ // agreement key lands at the same index so `KeyState.keyGen`/`keySeq` names it for a recipient
206
+ // re-deriving from the seed. See `CreateRotateOptions.keyPosition` for why these diverge from `s`.
207
+ const keyGen = options.keyPosition?.gen ?? gen;
208
+ const keySeq = (options.keyPosition?.seq ?? prior.s) + 1;
209
+ const current = deriveKeyPair(seed, authorityPath(profile, keyGen, keySeq), 'EdDSA');
210
+ const next = deriveKeyPair(seed, authorityPath(profile, keyGen, keySeq + 1), 'EdDSA');
211
+ const agreement = deriveKeyPair(seed, agreementPath(profile, keyGen, keySeq), 'X25519');
212
+ if (root && committed != null) {
213
+ // Exactly what `verifyRotate` checks, checked here where the caller can still act. Arity first:
214
+ // this generator emits a single key, so a prior committing any other number has no rotate here.
215
+ const revealed = encodeKey(current.publicKey, 'EdDSA');
216
+ if (committed.length !== 1 || digestOf(revealed) !== committed[0]) {
217
+ throw new Error(`createRotate: the key at (gen ${keyGen}, seq ${keySeq}) is not the one the prior event ` + 'pre-committed — pass options.keyPosition naming where the last icp/rot established a ' + 'key (the fold`s KeyState.keyGen / keySeq). A revoke advances `s` without establishing ' + 'one, so `s` and the derivation index part company at the first revoke of a generation ' + 'and stay apart.');
218
+ }
219
+ }
220
+ const event = {
221
+ v: 1,
222
+ t: 'rot',
223
+ i: did,
224
+ g: gen,
225
+ s: seq,
226
+ p: digestOf(prior),
227
+ crit: true,
228
+ k: [
229
+ encodeKey(current.publicKey, 'EdDSA')
230
+ ],
231
+ ka: [
232
+ encodeKey(agreement.publicKey, 'X25519')
233
+ ],
234
+ n: [
235
+ digestOf(encodeKey(next.publicKey, 'EdDSA'))
236
+ ],
237
+ kt: 1,
238
+ nt: 1,
239
+ a: options.seal,
240
+ d: options.denySnapshot
241
+ };
242
+ return {
243
+ event,
244
+ sigs: signEvent(event, [
245
+ current.privateKey
246
+ ])
247
+ };
248
+ }
249
+ /**
250
+ * Whether the members a rotate publishes into the folded state have the shape the fold will carry.
251
+ * Shared by {@link verifyRotate} and {@link verifyReset}: both hand `k`/`n`/`d` to the next
252
+ * `KeyState`, and a reset verifies against the recovery key not `k`, so without this it could publish
253
+ * a key set of any shape.
254
+ *
255
+ * `r` is refused outright — the runtime half of removing it from {@link RotateEvent}. A member the
256
+ * fold silently ignored would be the field-that-lies this removal is about: a reader seeing `r` on a
257
+ * rotate would take the recovery key to have moved when nothing moved it.
258
+ */ function isPublishedRotate(event) {
259
+ return isKeyList(event.k) && isKeyList(event.n) && isEnforcedThreshold(event.kt, event.k) && isEnforcedThreshold(event.nt, event.n) && // A seal is opaque but still a digest string on the wire (see `RotateEvent.a`); a non-string one
260
+ // is a member a reader would have to guess at.
261
+ (event.a == null || typeof event.a === 'string') && event.r === undefined && // The snapshot replaces the accumulated set, so the fold builds a `Set` from it: a scalar `d`
262
+ // throws, a string `d` would silently become a set of characters.
263
+ (event.d == null || isKeyList(event.d));
264
+ }
265
+ /**
266
+ * A rotate is valid when it chains to the prior digest, its revealed keys match the prior event's
267
+ * pre-rotation commitment, and its signatures verify against those keys.
268
+ */ export function verifyRotate(signed, prior) {
269
+ const { event, sigs } = signed;
270
+ if (event.t !== 'rot' || event.p !== prior.digest) {
271
+ return false;
272
+ }
273
+ if (!isPublishedRotate(event)) {
274
+ return false;
275
+ }
276
+ if (event.k.length !== prior.n.length) {
277
+ return false;
278
+ }
279
+ for(let i = 0; i < event.k.length; i++){
280
+ if (digestOf(event.k[i]) !== prior.n[i]) {
281
+ return false;
282
+ }
283
+ }
284
+ if (!verifyAgreementKeys(event.ka)) {
285
+ return false;
286
+ }
287
+ return verifySignatures(event, sigs, event.k);
288
+ }
289
+ /**
290
+ * A reset: a rotate signed by the recovery key that increments the generation and discards
291
+ * everything under the prior one, including every capability minted there.
292
+ *
293
+ * Anchored to the inception, not the log head: the recovery key lives on the root-retained
294
+ * derivation branch and its digest is committed in the deterministic inception, so a restored
295
+ * mnemonic can author one with no log knowledge or availability. Carries no options — no seal, `d`
296
+ * always `[]` — so a reset is a pure function of `(seed, profile, gen)`, and two blind resets at one
297
+ * generation produce identical bytes that resolve as idempotent re-derivation, not duplicity.
298
+ *
299
+ * The root need not know the current generation, only eventually exceed it (no attacker can author a
300
+ * competing reset at any generation): a blind root starts at `gen = 1` and retries higher if it
301
+ * learns of one — the cost is a round trip, never loss of control.
302
+ */ export function createReset(seed, profile, gen) {
303
+ if (gen < 1) {
304
+ throw new Error(`Reset: gen must be >= 1, got ${gen}`);
305
+ }
306
+ const inception = createInception(seed, profile);
307
+ const did = didFromInception(inception.event);
308
+ const current = deriveKeyPair(seed, authorityPath(profile, gen, 0), 'EdDSA');
309
+ const next = deriveKeyPair(seed, authorityPath(profile, gen, 1), 'EdDSA');
310
+ const recovery = deriveKeyPair(seed, recoveryPath(profile), 'EdDSA');
311
+ const agreement = deriveKeyPair(seed, agreementPath(profile, gen, 0), 'X25519');
312
+ const event = {
313
+ v: 1,
314
+ t: 'rot',
315
+ i: did,
316
+ g: gen,
317
+ s: 0,
318
+ p: digestOf(inception.event),
319
+ crit: true,
320
+ k: [
321
+ encodeKey(current.publicKey, 'EdDSA')
322
+ ],
323
+ ka: [
324
+ encodeKey(agreement.publicKey, 'X25519')
325
+ ],
326
+ n: [
327
+ digestOf(encodeKey(next.publicKey, 'EdDSA'))
328
+ ],
329
+ kt: 1,
330
+ nt: 1,
331
+ // A reset clears the deny set: every capability under the prior generation is gone anyway.
332
+ d: []
333
+ };
334
+ return {
335
+ event,
336
+ sigs: signEvent(event, [
337
+ recovery.privateKey
338
+ ]),
339
+ recoveryKey: encodeKey(recovery.publicKey, 'EdDSA')
340
+ };
341
+ }
342
+ /**
343
+ * A reset verifies against the committed recovery digest, not the pre-rotation set. Both values it
344
+ * needs — the anchor digest and the recovery commitment — come from the inception, the *only* place
345
+ * either can: that is what lets a root holding nothing but its seed author one with no log
346
+ * availability. If the commitment could move, the root would have to read the log to know which key
347
+ * to sign with, and the one recovery path that survives losing the log would be gone.
348
+ * `recoveryPath(profile)` carries no index for the same reason — one recovery key per profile, for
349
+ * its lifetime. See {@link RotateEvent} for what was removed.
350
+ */ export function verifyReset(signed, inception) {
351
+ const { event, sigs } = signed;
352
+ if (event.t !== 'rot' || event.p !== digestOf(inception) || event.s !== 0 || event.g < 1) {
353
+ return false;
354
+ }
355
+ if (!isPublishedRotate(event)) {
356
+ return false;
357
+ }
358
+ // A reset carries an empty deny set and no seal (see {@link createReset}) — the idempotency the
359
+ // branch selector relies on. Only the recovery-key holder can sign one, so this is not an
360
+ // attacker's event; it is the rule that makes "blind reset" mean the same bytes for every holder of
361
+ // the seed. Enforced here too so a reset carrying a non-empty `d` or a seal cannot fold as valid.
362
+ if (event.a !== undefined || event.d == null || event.d.length !== 0) {
363
+ return false;
364
+ }
365
+ if (!Array.isArray(sigs) || sigs.length !== 1) {
366
+ return false;
367
+ }
368
+ if (!verifyAgreementKeys(event.ka)) {
369
+ return false;
370
+ }
371
+ // The recovery key is committed as a digest and unpublished until used, so a reset must carry the
372
+ // revealed key on the envelope for the commitment to be checkable.
373
+ const revealed = signed.recoveryKey;
374
+ if (revealed == null || digestOf(revealed) !== inception.r) {
375
+ return false;
376
+ }
377
+ return verifySignatures(event, sigs, [
378
+ revealed
379
+ ]);
380
+ }
381
+ /**
382
+ * How a `rev` target names a **key** rather than a DID: `#<the multibase key exactly as it appears in
383
+ * `k`>`, the same spelling a token's `kid` uses. One spelling for one thing — unambiguous against
384
+ * `did:…` on sight, so a bare multibase string is not a second accepted encoding, any more than for
385
+ * `kid`.
386
+ */ export const KEY_TARGET_PREFIX = '#';
387
+ /** The deny-set spelling of `key` — what {@link createRevoke} takes as its target to deny it. */ export function keyTarget(key) {
388
+ return `${KEY_TARGET_PREFIX}${key}`;
389
+ }
390
+ /**
391
+ * The key a `rev` target names, or `null` when it names a DID instead. Total on any string: a target
392
+ * of neither form lands in the deny set as the opaque identifier it is. The body is not validated
393
+ * against multibase — a `#` naming nothing denies nothing, exactly as a `did:` naming nobody does,
394
+ * and rejecting it would only add a way for a well-formed log to stop folding.
395
+ */ export function keyFromTarget(target) {
396
+ return target.startsWith(KEY_TARGET_PREFIX) ? target.slice(KEY_TARGET_PREFIX.length) : null;
397
+ }
398
+ /**
399
+ * Revoke a DID or a key — see {@link RevokeEvent.x} for the two spellings.
400
+ *
401
+ * Naming the device DID rather than a `jti` makes this one entry per device for its life — covering
402
+ * capabilities the verifier has never seen and future re-mints, where per-`jti` revocation would grow
403
+ * with every renewal. A key target retires a leaked key for material it *already* signed: `rotate`
404
+ * only retires it for new issuance (`resolve` is head-only), but already-issued material verifies
405
+ * through `resolveHistoric`, which survives a rotate by design, so a thief holding a rotated-away key
406
+ * could still mint one that verified. Retirement is therefore explicit, keeping the promise that a
407
+ * routine rotate does not invalidate already-issued material intact.
408
+ *
409
+ * **A key the profile currently publishes cannot be denied** — the fold rejects it (see the `rev`
410
+ * branch of `stepEvent`). Rotate first, then deny the key the rotate retired.
411
+ *
412
+ * `prior` answers "next sequence, what to chain to"; `keyPosition` separately answers "where the
413
+ * active authority key lives". They coincide only for an `icp`/`rot` prior — a `rev` establishes no
414
+ * key, so a revoke on a revoke still points at the last `icp`/`rot` position.
415
+ */ export function createRevoke({ seed, profile, did, prior, target, keyPosition, cap }) {
416
+ const current = deriveKeyPair(seed, authorityPath(profile, keyPosition.gen, keyPosition.seq), 'EdDSA');
417
+ return createRevokeWithKey({
418
+ privateKey: current.privateKey,
419
+ did,
420
+ prior,
421
+ target,
422
+ cap
423
+ });
424
+ }
425
+ /**
426
+ * Revoke a DID, signing with an Ed25519 key the caller already holds rather than deriving one from
427
+ * the profile seed.
428
+ *
429
+ * The builder for the actor a capability-authorised revoke exists for: a device holding a management
430
+ * capability, which by design never receives the profile sub-seed. `createRevoke` can only sign as
431
+ * the profile itself, so without this the feature had no API but re-implementing the signing
432
+ * convention per consumer — the duplication `createControllerCapabilityVerifier` prevents on the
433
+ * verifying side.
434
+ *
435
+ * The key is the audience key the capability pins in `cnf`, and the fold checks the event's signature
436
+ * against exactly that, so the two must match. Ed25519 only. Takes the private key rather than an
437
+ * identity because no identity type here signs raw bytes — a `KeyStore` entry hands back exactly this
438
+ * shape. Byte-identical to {@link createRevoke} given the same key; no `keyPosition`, which exists
439
+ * only to derive a key from a seed.
440
+ */ export function createRevokeWithKey({ privateKey, did, prior, target, cap }) {
441
+ const event = {
442
+ v: 1,
443
+ t: 'rev',
444
+ i: did,
445
+ g: prior.g,
446
+ s: prior.s + 1,
447
+ p: digestOf(prior),
448
+ crit: true,
449
+ x: target,
450
+ cap
451
+ };
452
+ return {
453
+ event,
454
+ sigs: signEvent(event, [
455
+ privateKey
456
+ ])
457
+ };
458
+ }
459
+ /** Authority-signed revoke. Capability-authorised revokes are checked in the fold. */ export function verifyRevoke(signed, prior) {
460
+ const { event, sigs } = signed;
461
+ if (event.t !== 'rev' || event.p !== prior.digest) {
462
+ return false;
463
+ }
464
+ return verifySignatures(event, sigs, prior.keys);
465
+ }
package/lib/fold.d.ts ADDED
@@ -0,0 +1,162 @@
1
+ import type { DIDMethodResolver, ResolvedSigningKey } from '@kokuin/token';
2
+ import { type SignedEvent } from './events.js';
3
+ export type KeyState = {
4
+ did: string;
5
+ gen: number;
6
+ seq: number;
7
+ /** Generation in which the current `keys` were established. */
8
+ keyGen: number;
9
+ /**
10
+ * Derivation index of the current `keys`: key-establishing events this generation, counting the
11
+ * `icp`/`rot` that opened it as 0. Equal to `seq` until a revoke intervenes, and deliberately not
12
+ * the same thing — a revoke advances `seq` while establishing no key, so the pre-rotation chain
13
+ * (which commits the *next* key's digest, one index on) stops tracking `seq` there. Deriving at
14
+ * `seq` after a revoke produces a key the log never committed.
15
+ */
16
+ keySeq: number;
17
+ keys: Array<string>;
18
+ /** Key agreement keys — an OR set. Established by icp/rot, carried forward across rev. */
19
+ agreement: Array<string>;
20
+ next: Array<string>;
21
+ /**
22
+ * The inception's recovery commitment, carried unchanged through every event. Fixed for the life
23
+ * of the DID, and this field says so because {@link verifyReset} checks the revealed recovery key
24
+ * against `inception.r` and nothing else. A state that disagreed with the verifier would be worse
25
+ * than no state — see {@link RotateEvent} for the `r` that was removed.
26
+ */
27
+ recovery: string;
28
+ /**
29
+ * What this position denies, in the two spellings a `rev` target may take — see `RevokeEvent.x`. A
30
+ * `did:…` entry denies a *holder* (`@kokuin/capability` refuses a capability whose `aud` it names);
31
+ * a `#<multibase key>` entry denies a *signer* (`signingKeyFrom` refuses that key, `agreementKeysFrom`
32
+ * drops it).
33
+ *
34
+ * One heterogeneous set rather than two fields: the spellings cannot collide, and every mechanism
35
+ * the set already has (position-dependence, the `d` snapshot, a reset's clear, `resolveDenySet`,
36
+ * the `@kokuin/capability` wrapper that forwards it) then covers keys with nothing new to wire or
37
+ * forget. A second optional resolver member is a second thing a wrapper can drop, and dropping it
38
+ * fails open.
39
+ *
40
+ * **Invariant, enforced by both branches of `stepEvent`: no key in this state's `keys` or
41
+ * `agreement` is denied here.** So every reader may take a folded head's key set at face value.
42
+ */
43
+ deny: ReadonlySet<string>;
44
+ /** Digest of the event that produced this state — the `p` any successor must carry. */
45
+ digest: string;
46
+ };
47
+ export type FoldResult = {
48
+ ok: true;
49
+ states: Array<KeyState>;
50
+ } | {
51
+ ok: false;
52
+ reason: string;
53
+ index: number;
54
+ };
55
+ /**
56
+ * What a capability verifier answers a cap-bearing revoke with.
57
+ *
58
+ * `audienceKey` is the key the capability pins as its audience at mint time, never resolved at
59
+ * verification time; the fold checks the event's own signature against it, binding the grant to the
60
+ * party it was issued to.
61
+ *
62
+ * A key rather than a boolean because the fold, not the verifier, must tie the answer to the event
63
+ * in hand. The log is public, so the serialized capability inside a revoke is readable by everyone
64
+ * who can resolve the profile; with a boolean, any such reader could lift it out and chain a revoke
65
+ * of their own with any bytes in `sigs`, covering whatever the capability's `res` covers — a
66
+ * wildcard, for a management capability, so every device. Among divisions keeping a *single*
67
+ * callback, this is the only one where neither side can leave the binding out.
68
+ */
69
+ export type CapabilityAuthorisation = {
70
+ authorised: true;
71
+ audienceKey: ResolvedSigningKey;
72
+ }
73
+ /** `reason` becomes the fold's failure reason verbatim, so it must describe this rejection. */
74
+ | {
75
+ authorised: false;
76
+ reason: string;
77
+ };
78
+ export type FoldOptions = {
79
+ /**
80
+ * Verify a capability authorising a non-authority signer to revoke. Injected rather than imported
81
+ * so the sync path stays free of a capability dependency.
82
+ *
83
+ * Receives the serialized capability, the controller DID (which must be the capability `sub`), the
84
+ * DID being denied, and a resolver for the controller **at the position being verified**. That
85
+ * fourth argument makes the position contract keepable: the capability is issued by the very
86
+ * profile whose log carries the revoke, and the state to resolve it against is the prefix before
87
+ * this event — not the head (which would let a device revoked at event 1 keep authoring at event 2,
88
+ * the deny set being position-dependent only inside the fold) and not the whole log (which would
89
+ * recurse into the asking fold). Nothing outside the fold knows which position is being verified,
90
+ * so the fold answers rather than asks: this resolver holds exactly `states[0..i-1]`, is complete
91
+ * for the profile, and answers `Unknown DID` for anything else so a verifier can merge it into a
92
+ * wider registry for a chain's delegates. See {@link CapabilityAuthorisation}.
93
+ */
94
+ verifyCapability?: (params: VerifyCapabilityParams) => Promise<CapabilityAuthorisation>;
95
+ };
96
+ /** What the fold hands {@link FoldOptions.verifyCapability} — see that member for each field. */
97
+ export type VerifyCapabilityParams = {
98
+ cap: string;
99
+ subject: string;
100
+ target: string;
101
+ subjectAtPosition: DIDMethodResolver;
102
+ };
103
+ /** A verifier threw. The thrown message is appended after `: `, so match with `startsWith`. */
104
+ export declare const CAPABILITY_VERIFIER_FAILED = "capability verifier failed";
105
+ /** A verifier answered with something that is not a {@link CapabilityAuthorisation}. */
106
+ export declare const CAPABILITY_VERIFIER_MALFORMED_ANSWER = "capability verifier returned a malformed answer";
107
+ /**
108
+ * The capability authorised the revoke and pinned an audience key, and somebody else signed the
109
+ * event. Distinct from a rejected grant: the delegation is sound and the signature is not.
110
+ */
111
+ export declare const REVOKE_NOT_SIGNED_BY_AUDIENCE = "revoke is not signed by the capability audience";
112
+ /**
113
+ * The sync fold met a capability-authorised revoke, which it cannot verify by construction — the
114
+ * management tier working as designed, answered by `foldLogAsync`. The capability follows after `: `,
115
+ * so match with `startsWith`. Exported so speculative folders (`resolveBranches`) can tell it from a
116
+ * genuinely broken log.
117
+ */
118
+ export declare const CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD = "capability-authorised revoke needs an async fold";
119
+ /**
120
+ * The async fold met a capability-authorised revoke with no `verifyCapability` configured. Same
121
+ * shape: the log is fine and the call was not equipped for it.
122
+ */
123
+ export declare const CAPABILITY_REVOKE_NEEDS_VERIFIER = "capability-authorised revoke needs a verifier";
124
+ /**
125
+ * How many events a log may carry that this version cannot understand, beyond the number it can.
126
+ *
127
+ * A skipped event is accepted without a signature and advances neither `seq` nor the digest, so
128
+ * nothing about it is authenticated: a relaying peer can insert any number anywhere and the log still
129
+ * folds to the same head. The resolver re-folds on every resolution and a group replays the whole log
130
+ * at every welcome, so an unbounded skip path makes log size — and the verifier's CPU/memory — an
131
+ * attacker's choice. Bounded against the log's own real length so the ceiling stays proportional to
132
+ * work the verifier was doing anyway; the slack keeps a v1 verifier able to fold a log with a few
133
+ * later-version non-critical types near the front, which is the whole point of the skip path.
134
+ */
135
+ export declare const MAX_SKIPPED_SLACK = 8;
136
+ /** What a log padded past {@link MAX_SKIPPED_SLACK} fails with. */
137
+ export declare const TOO_MANY_UNKNOWN_EVENTS = "too many unknown events";
138
+ /**
139
+ * Fold a controller's log into per-position key state. `states[i]` is the state *after* `events[i]`,
140
+ * which a verifier evaluating at position `i` must use — that is what makes the deny set
141
+ * position-dependent: clearing a DID later never retroactively validates its earlier actions.
142
+ *
143
+ * Synchronous, so it stays usable on an offline verifier's apply path. A capability-authorised revoke
144
+ * needs async verification, so it fails closed here rather than trusting an unchecked capability — use
145
+ * `foldLogAsync` when one may be present.
146
+ */
147
+ export declare function foldLog(did: string, events: Array<SignedEvent>): FoldResult;
148
+ /**
149
+ * Async counterpart of {@link foldLog}. Shares {@link stepEvent}, so the only difference is a
150
+ * capability-authorised revoke: this entry point awaits `options.verifyCapability` instead of failing
151
+ * closed.
152
+ */
153
+ export declare function foldLogAsync(did: string, events: Array<SignedEvent>, options?: FoldOptions): Promise<FoldResult>;
154
+ export declare function keyStateAt(result: FoldResult, position: number): KeyState | undefined;
155
+ /**
156
+ * The deny set of `state` with `drop` removed — what to pass as a rotate's `denySnapshot` to prune a
157
+ * few entries rather than replace the set. A rotate's `d` replaces the accumulated set outright, so
158
+ * pruning by hand means writing out everything that stays; miss one and the profile silently
159
+ * un-revokes a device or un-retires a leaked key. This builds it from the fold's own answer instead.
160
+ * Entries in `drop` the state does not carry are ignored.
161
+ */
162
+ export declare function pruneDenySet(state: KeyState, drop: Iterable<string>): Array<string>;