@kokuin/controller 0.1.0 → 0.2.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 CHANGED
@@ -25,7 +25,7 @@ export function signEvent(event, privateKeys) {
25
25
  * the folded state, so a bad shape must be rejected where it is published, not one event away where
26
26
  * it is next read (a non-array `n` would throw in the *next* rotate; a non-array `k` is a `KeyState`
27
27
  * whose type lies).
28
- */ function isKeyList(value) {
28
+ */ export function isKeyList(value) {
29
29
  return Array.isArray(value) && value.every((entry)=>typeof entry === 'string');
30
30
  }
31
31
  /**
@@ -62,13 +62,17 @@ export function signEvent(event, privateKeys) {
62
62
  return false;
63
63
  }
64
64
  const bytes = canonicalBytes(event);
65
- for(let i = 0; i < sigs.length; i++){
65
+ for (const [i, sig] of sigs.entries()){
66
66
  try {
67
- const key = tryDecodeKey(keys[i]);
67
+ const encodedKey = keys[i];
68
+ if (encodedKey == null) {
69
+ return false;
70
+ }
71
+ const key = tryDecodeKey(encodedKey);
68
72
  if (key == null || key.alg !== 'EdDSA') {
69
73
  return false;
70
74
  }
71
- if (!ed25519.verify(base64urlnopad.decode(sigs[i]), bytes, key.publicKey)) {
75
+ if (!ed25519.verify(base64urlnopad.decode(sig), bytes, key.publicKey)) {
72
76
  return false;
73
77
  }
74
78
  } catch {
@@ -77,33 +81,6 @@ export function signEvent(event, privateKeys) {
77
81
  }
78
82
  return true;
79
83
  }
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
84
  /**
108
85
  * Deterministic inception. Only seed-derived, canonical material — no timestamp, nonce, or label —
109
86
  * so the DID is a pure function of the seed and profile index. Never add a label: a mistyped one on
@@ -175,22 +152,33 @@ export function verifyInception(signed, did) {
175
152
  }
176
153
  return verifySignatures(signed.event, signed.sigs, signed.event.k);
177
154
  }
155
+ /**
156
+ * The shape of one snapshot member, nothing more: whether the DID carries the key, whether the key
157
+ * is Ed25519, and whether it collides with a root key are rules over the whole snapshot and its chain,
158
+ * checked by the fold. `lease` must be a non-negative safe integer — unix seconds — because grants
159
+ * compare `exp` against it, and a fraction, a negative or an unsafe integer has no single reading.
160
+ */ export function isMemberEntry(value) {
161
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
162
+ return false;
163
+ }
164
+ const entry = value;
165
+ return typeof entry.did === 'string' && typeof entry.key === 'string' && typeof entry.add === 'string' && typeof entry.lease === 'number' && Number.isSafeInteger(entry.lease) && entry.lease >= 0;
166
+ }
178
167
  /**
179
168
  * A rotate reveals the keys the prior event pre-committed and commits the next set, signed by the
180
169
  * 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.
170
+ * from the seed and the snapshot unless it carries a seal.
171
+ *
172
+ * The root chain holds only key-establishing events, so a rotate's sequence and its derivation index
173
+ * are the same number: the key it reveals sits one past `prior`'s position.
182
174
  *
183
175
  * **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 = {} }) {
176
+ * `prior` committed in `n`, which a prior not built from this chain's own events need not be.
177
+ */ export function createRotate({ seed, profile, did, prior, snapshot, seal }) {
190
178
  const gen = prior.g;
191
179
  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.
180
+ // The commitment the revealed key must match. Every `icp`/`rot` carries one; a hand-built prior of
181
+ // another shape does not.
194
182
  const commitment = prior.n;
195
183
  const committed = isKeyList(commitment) && commitment.length > 0 ? commitment : undefined;
196
184
  // The checks below only have an answer when this seed *is* the log's root — otherwise the
@@ -198,23 +186,20 @@ export function verifyInception(signed, did) {
198
186
  // there would stop this generator building a foreign rotate at all (which the conformance suite
199
187
  // legitimately does); the fold is the layer that rejects it.
200
188
  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');
189
+ if (root && committed == null) {
190
+ throw new Error('createRotate: prior event pre-commits no key, so there is nothing to reveal');
203
191
  }
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');
192
+ // The agreement key lands at the same index as the authority key, so `KeyState.keyGen`/`keySeq`
193
+ // names both for a recipient re-deriving from the seed.
194
+ const current = deriveKeyPair(seed, authorityPath(profile, gen, seq), 'EdDSA');
195
+ const next = deriveKeyPair(seed, authorityPath(profile, gen, seq + 1), 'EdDSA');
196
+ const agreement = deriveKeyPair(seed, agreementPath(profile, gen, seq), 'X25519');
212
197
  if (root && committed != null) {
213
198
  // Exactly what `verifyRotate` checks, checked here where the caller can still act. Arity first:
214
199
  // this generator emits a single key, so a prior committing any other number has no rotate here.
215
200
  const revealed = encodeKey(current.publicKey, 'EdDSA');
216
201
  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.');
202
+ throw new Error(`createRotate: the key at (gen ${gen}, seq ${seq}) is not the one the prior event ` + 'pre-committed — the prior is not an event of this log');
218
203
  }
219
204
  }
220
205
  const event = {
@@ -236,8 +221,9 @@ export function verifyInception(signed, did) {
236
221
  ],
237
222
  kt: 1,
238
223
  nt: 1,
239
- a: options.seal,
240
- d: options.denySnapshot
224
+ a: seal,
225
+ m: snapshot.members,
226
+ d: snapshot.deny
241
227
  };
242
228
  return {
243
229
  event,
@@ -248,9 +234,14 @@ export function verifyInception(signed, did) {
248
234
  }
249
235
  /**
250
236
  * 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.
237
+ * Shared by {@link verifyRotate} and {@link verifyReset}: both hand `k`/`n` to the next `KeyState`,
238
+ * and a reset verifies against the recovery key not `k`, so without this it could publish a key set
239
+ * of any shape.
240
+ *
241
+ * The snapshot (`m`/`d`) is not judged here: whether it is well formed, disjoint from the root keys
242
+ * and keeps the generation's retired keys depends on the chain it lands on, so the fold judges it
243
+ * with a reason of its own (see `validateSnapshot`). These verifiers answer only whether the event
244
+ * is signed by, and chains to, what it claims.
254
245
  *
255
246
  * `r` is refused outright — the runtime half of removing it from {@link RotateEvent}. A member the
256
247
  * fold silently ignored would be the field-that-lies this removal is about: a reader seeing `r` on a
@@ -258,9 +249,7 @@ export function verifyInception(signed, did) {
258
249
  */ function isPublishedRotate(event) {
259
250
  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
251
  // 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));
252
+ (event.a == null || typeof event.a === 'string') && event.r === undefined;
264
253
  }
265
254
  /**
266
255
  * A rotate is valid when it chains to the prior digest, its revealed keys match the prior event's
@@ -292,14 +281,24 @@ export function verifyInception(signed, did) {
292
281
  *
293
282
  * Anchored to the inception, not the log head: the recovery key lives on the root-retained
294
283
  * 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.
284
+ * mnemonic can author one with no log knowledge or availability.
285
+ *
286
+ * **Blind** (no `snapshot`): no seal, `d: []` and no `m`, so a blind reset is a pure function of
287
+ * `(seed, profile, gen)` and two blind resets at one generation produce identical bytes that resolve
288
+ * as idempotent re-derivation, not duplicity. The new epoch starts empty and root re-adds members.
289
+ * This is the path for a root that does not know the log's state.
290
+ *
291
+ * **With a snapshot**: the same reset carries the member and deny sets, so one recovery-key
292
+ * signature is the whole recovery. Two resets at one generation with different bytes are then
293
+ * duplicity — which only the recovery-key holder can cause, and a reset at a higher generation
294
+ * resolves. Members keep their `add` references, but every grant names its generation, so every
295
+ * grant minted under the prior one still dies. The snapshot is checked against the **new**
296
+ * generation's keys, and no retired-key denial of the old one need carry over.
298
297
  *
299
298
  * The root need not know the current generation, only eventually exceed it (no attacker can author a
300
299
  * competing reset at any generation): a blind root starts at `gen = 1` and retries higher if it
301
300
  * learns of one — the cost is a round trip, never loss of control.
302
- */ export function createReset(seed, profile, gen) {
301
+ */ export function createReset(seed, profile, gen, snapshot) {
303
302
  if (gen < 1) {
304
303
  throw new Error(`Reset: gen must be >= 1, got ${gen}`);
305
304
  }
@@ -327,10 +326,18 @@ export function verifyInception(signed, did) {
327
326
  digestOf(encodeKey(next.publicKey, 'EdDSA'))
328
327
  ],
329
328
  kt: 1,
330
- nt: 1,
331
- // A reset clears the deny set: every capability under the prior generation is gone anyway.
332
- d: []
329
+ nt: 1
333
330
  };
331
+ if (snapshot == null || snapshot.members.length === 0 && snapshot.deny.length === 0) {
332
+ // A blind reset clears the deny set: every capability under the prior generation is gone anyway.
333
+ // Written as `d: []` rather than omitted so a blind reset keeps the bytes it always had. An empty
334
+ // snapshot is the same state and is written the same way, so a retry with one is byte-identical
335
+ // to a blind reset rather than a second encoding of it.
336
+ event.d = [];
337
+ } else {
338
+ event.m = snapshot.members;
339
+ event.d = snapshot.deny;
340
+ }
334
341
  return {
335
342
  event,
336
343
  sigs: signEvent(event, [
@@ -355,11 +362,23 @@ export function verifyInception(signed, did) {
355
362
  if (!isPublishedRotate(event)) {
356
363
  return false;
357
364
  }
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) {
365
+ // A reset carries no seal, and either a whole snapshot (`m` and `d` together) or none. Without `m`
366
+ // it is blind, and a blind reset's `d` is exactly the `[]` {@link createReset} writes: one encoding,
367
+ // so "blind reset" means the same bytes for every holder of the seed — the idempotency the branch
368
+ // selector relies on. For the same reason an empty snapshot (`m: []` with `d: []`) is refused: it is
369
+ // the blind state spelled a second way. Only the recovery-key holder can sign one, so this is not an
370
+ // attacker's event; it is the rule that keeps one state from having two resets at a generation,
371
+ // and half a snapshot (`d` with no `m`, or `m` with no `d`) from folding as a reset that silently
372
+ // dismissed every member or cleared every denial. The snapshot's contents are the fold's to judge,
373
+ // against the new generation's keys.
374
+ if (event.a !== undefined) {
375
+ return false;
376
+ }
377
+ const emptyDeny = isKeyList(event.d) && event.d.length === 0;
378
+ if (event.m == null ? !emptyDeny : event.d == null) {
379
+ return false;
380
+ }
381
+ if (Array.isArray(event.m) && event.m.length === 0 && emptyDeny) {
363
382
  return false;
364
383
  }
365
384
  if (!Array.isArray(sigs) || sigs.length !== 1) {
@@ -379,87 +398,19 @@ export function verifyInception(signed, did) {
379
398
  ]);
380
399
  }
381
400
  /**
382
- * How a `rev` target names a **key** rather than a DID: `#<the multibase key exactly as it appears in
401
+ * How a deny entry names a **key** rather than a DID: `#<the multibase key exactly as it appears in
383
402
  * `k`>`, the same spelling a token's `kid` uses. One spelling for one thing — unambiguous against
384
403
  * `did:…` on sight, so a bare multibase string is not a second accepted encoding, any more than for
385
404
  * `kid`.
386
405
  */ 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) {
406
+ /** The deny-set spelling of `key` — what a snapshot's `d` carries to deny it. */ export function keyTarget(key) {
388
407
  return `${KEY_TARGET_PREFIX}${key}`;
389
408
  }
390
409
  /**
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
410
+ * The key a deny entry names, or `null` when it names a DID instead. Total on any string: an entry of
411
+ * neither form lands in the deny set as the opaque identifier it is. The body is not validated
393
412
  * against multibase — a `#` naming nothing denies nothing, exactly as a `did:` naming nobody does,
394
413
  * and rejecting it would only add a way for a well-formed log to stop folding.
395
414
  */ export function keyFromTarget(target) {
396
415
  return target.startsWith(KEY_TARGET_PREFIX) ? target.slice(KEY_TARGET_PREFIX.length) : null;
397
416
  }
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 CHANGED
@@ -1,5 +1,16 @@
1
- import type { DIDMethodResolver, ResolvedSigningKey } from '@kokuin/token';
2
- import { type SignedEvent } from './events.js';
1
+ import { type MemberEntry, type StateSnapshot } from './events.js';
2
+ import { type ControllerLog } from './log.js';
3
+ export { ROTATE_NEEDS_SNAPSHOT, SNAPSHOT_DROPS_RETIRED_KEY, SNAPSHOT_MALFORMED, SNAPSHOT_MEMBER_DID_MISMATCH, SNAPSHOT_MEMBER_KEY_IS_ROOT, } from './members.js';
4
+ /**
5
+ * One management member as a state holds it: the snapshot entry, plus whether a removal has ended
6
+ * it (`removed`) and whether it may act at all (`effective`: present, not removed, and not targeted
7
+ * by any entry of the same state's deny set — see `denialTargetsMember`). A chain state's members are
8
+ * its snapshot's, so none is removed; removal is an epoch effect.
9
+ */
10
+ export type MemberState = MemberEntry & {
11
+ removed: boolean;
12
+ effective: boolean;
13
+ };
3
14
  export type KeyState = {
4
15
  did: string;
5
16
  gen: number;
@@ -8,14 +19,13 @@ export type KeyState = {
8
19
  keyGen: number;
9
20
  /**
10
21
  * 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.
22
+ * `icp`/`rot` that opened it as 0. The root chain holds only key-establishing events, so this is
23
+ * `seq`; it is named apart because it answers a different question — where to derive the key, not
24
+ * where the event sits — and an inception's `s` is wire data this package does not derive from.
15
25
  */
16
26
  keySeq: number;
17
27
  keys: Array<string>;
18
- /** Key agreement keys — an OR set. Established by icp/rot, carried forward across rev. */
28
+ /** Key agreement keys — an OR set, established by the same icp/rot as `keys`. */
19
29
  agreement: Array<string>;
20
30
  next: Array<string>;
21
31
  /**
@@ -26,101 +36,59 @@ export type KeyState = {
26
36
  */
27
37
  recovery: string;
28
38
  /**
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).
39
+ * What this position denies, in two spellings. A `did:…` entry denies a *holder*
40
+ * (`@kokuin/capability` refuses a capability whose `aud` it names); a `#<multibase key>` entry
41
+ * ({@link keyTarget}) denies a *signer* (`signingKeyFrom` refuses that key, `agreementKeysFrom` drops
42
+ * it). A chain state's set is exactly its event's snapshot `d` (empty after an inception or a blind
43
+ * reset).
33
44
  *
34
45
  * 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
46
+ * the set already has (position-dependence, the snapshot, a reset's clear, `resolveDenySet`, the
47
+ * `@kokuin/capability` wrapper that forwards it) then covers keys with nothing new to wire or
37
48
  * forget. A second optional resolver member is a second thing a wrapper can drop, and dropping it
38
49
  * fails open.
39
50
  *
40
- * **Invariant, enforced by both branches of `stepEvent`: no key in this state's `keys` or
51
+ * **Invariant, enforced by the rotate branch of `stepEvent`: no key in this state's `keys` or
41
52
  * `agreement` is denied here.** So every reader may take a folded head's key set at face value.
42
53
  */
43
54
  deny: ReadonlySet<string>;
55
+ /**
56
+ * The management set, keyed by each member's `add` digest (its incarnation). A chain state's is its
57
+ * event's snapshot `m`, in snapshot order.
58
+ */
59
+ members: ReadonlyMap<string, MemberState>;
60
+ /**
61
+ * Digest of the chain event that opened the epoch this state belongs to. Every chain event opens
62
+ * one, so for a chain state this is its own `digest`; epoch events name it as their `e`.
63
+ */
64
+ epoch: string;
65
+ /**
66
+ * Whether the epoch holds more authenticated events than a verifier will evaluate, failing it
67
+ * closed until root settles. A chain state has seen no epoch events, so it is never saturated.
68
+ */
69
+ saturated: boolean;
44
70
  /** Digest of the event that produced this state — the `p` any successor must carry. */
45
71
  digest: string;
46
72
  };
73
+ /**
74
+ * `states` is one state per chain event, which is what historic resolution scans. `head` is the last
75
+ * chain state merged with the current epoch: its `deny`, `members` and `saturated` are the live
76
+ * answers. `applied`, `pending` and `rejected` account for the epoch events presented -- the digests
77
+ * that took effect, the well-formed ones still waiting on a parent, and the ones refused (see
78
+ * `FoldEpochResult` for how refusals are counted).
79
+ */
47
80
  export type FoldResult = {
48
81
  ok: true;
49
82
  states: Array<KeyState>;
83
+ head: KeyState;
84
+ applied: Array<string>;
85
+ pending: number;
86
+ rejected: number;
50
87
  } | {
51
88
  ok: false;
52
89
  reason: string;
53
90
  index: number;
54
91
  };
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
92
  /**
125
93
  * How many events a log may carry that this version cannot understand, beyond the number it can.
126
94
  *
@@ -135,28 +103,38 @@ export declare const CAPABILITY_REVOKE_NEEDS_VERIFIER = "capability-authorised r
135
103
  export declare const MAX_SKIPPED_SLACK = 8;
136
104
  /** What a log padded past {@link MAX_SKIPPED_SLACK} fails with. */
137
105
  export declare const TOO_MANY_UNKNOWN_EVENTS = "too many unknown events";
106
+ /** A fold-loop index landed off the event or state array — a broken invariant, not a bad log. */
107
+ export declare const INTERNAL_FOLD_INVARIANT = "internal fold invariant: missing event or prior state";
138
108
  /**
139
- * Fold a controller's log into per-position key state. `states[i]` is the state *after* `events[i]`,
109
+ * Fold a controller's log into per-position key state. `states[i]` is the state *after* `chain[i]`,
140
110
  * which a verifier evaluating at position `i` must use — that is what makes the deny set
141
111
  * position-dependent: clearing a DID later never retroactively validates its earlier actions.
142
112
  *
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.
113
+ * The epoch events then merge into the chain's last state to give `head` (see `foldEpoch`). Only request
114
+ * limits can fail that half: more than {@link MAX_LOG_EVENTS} entries is `LOG_TOO_LARGE`, at
115
+ * index -1 because it is no event's fault, and so is more than `MAX_FAILED_SIGNATURES` signatures
116
+ * failing to verify (`TOO_MANY_FAILED_SIGNATURES`). Short of those, nothing in `log.events` can fail
117
+ * the fold -- a bad entry is counted and dropped -- so a relay cannot make a log unfoldable by padding
118
+ * it, only refused by forging past the limit, which no honest relay does.
119
+ *
120
+ * Synchronous, so it stays usable on an offline verifier's apply path.
152
121
  */
153
- export declare function foldLogAsync(did: string, events: Array<SignedEvent>, options?: FoldOptions): Promise<FoldResult>;
122
+ export declare function foldLog(did: string, log: ControllerLog): FoldResult;
154
123
  export declare function keyStateAt(result: FoldResult, position: number): KeyState | undefined;
155
124
  /**
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.
125
+ * The state snapshot `state` holds, minus `drop` — what to pass as a rotate's `snapshot` to carry the
126
+ * state forward, pruning a few entries rather than replacing it. A rotate's snapshot replaces the
127
+ * state outright, so writing one by hand means writing out everything that stays; miss one and the
128
+ * profile silently un-revokes a device, un-retires a leaked key or dismisses a management member.
129
+ * This builds it from the fold's own answer instead: every member not removed (`drop.members` names
130
+ * members by `add` digest) and every deny entry (`drop.deny`). Entries in `drop` the state does not
131
+ * carry are ignored.
132
+ *
133
+ * Root's tooling owes its user the warnings the fold cannot give: dropping a holder DID denial
134
+ * re-authorises it, safe only once every authority that could have minted it a grant has ended; and
135
+ * a retired root key the chain shows but this deny set lacks is one to add, not to leave out.
161
136
  */
162
- export declare function pruneDenySet(state: KeyState, drop: Iterable<string>): Array<string>;
137
+ export declare function snapshotOf(state: KeyState, drop?: {
138
+ deny?: Iterable<string>;
139
+ members?: Iterable<string>;
140
+ }): StateSnapshot;