@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/epoch.js ADDED
@@ -0,0 +1,505 @@
1
+ import { digestOf } from './canonical.js';
2
+ import { isEpochEventShape, verifyEpochSignature } from './epoch-events.js';
3
+ import { keyFromTarget } from './events.js';
4
+ import { MAX_EPOCH_EVENTS, MAX_FAILED_SIGNATURES } from './limits.js';
5
+ import { createDenialMatcher, isRootKey, memberCarriesKey } from './members.js';
6
+ // Merging an epoch. The epoch's events are a set, not a sequence: nothing here may depend on the order
7
+ // they were presented in, how often, or which copy of a duplicate arrived first. The three places that
8
+ // could leak order are closed deliberately -- duplicates collapse by digest, ready events are taken
9
+ // by ascending digest, and an event's causal state is a function of its parents alone.
10
+ // Why a member-lifecycle event is refused. Each lands in `rejected` like any other refusal and never
11
+ // fails the fold; named so a test can tell which rule refused an event.
12
+ /** A member-signed add. Only root admits members, so sock-puppets cannot renew each other forever. */ export const ADD_NOT_ROOT = 'only root may add a member';
13
+ /** An add pinning a key on, or committed by, the generation's root chain, or the recovery key. */ export const ADD_KEY_IS_ROOT = 'member key is a root key';
14
+ /** An add whose DID is not a `did:key` or long-form `did:peer:4` carrying exactly its pin. */ export const ADD_DID_MISMATCH = 'member DID does not carry its key';
15
+ /** An add of a DID or key that a denial in its causal past targets. */ export const ADD_KEY_DENIED = 'member is denied in the causal state';
16
+ /** An add of a key an effective member of its causal past already pins. */ export const ADD_KEY_ALREADY_MEMBER = 'member key is already an effective member';
17
+ /** A member renewing an incarnation pinning its own key. */ export const SELF_RENEW = 'a member may not renew itself';
18
+ /** A member removing an incarnation pinning its own key. */ export const SELF_REMOVE = 'a member may not remove itself';
19
+ /** A renew or remove naming an add its causal past does not hold. */ export const UNKNOWN_MEMBER = 'target is not a member of the causal state';
20
+ /** A member revoke targeting an effective member of its causal past: members leave through remove. */ export const MEMBER_REVOKES_MEMBER = 'a member may not revoke a member';
21
+ /** A revoke denying a key the chain head publishes in `k` or `ka`. */ export const REVOKE_NAMES_ROOT_KEY = 'revoke targets a key the chain publishes';
22
+ /**
23
+ * What a presentation fails with, as a whole, when the signatures on its copies that do not verify
24
+ * exceed {@link MAX_FAILED_SIGNATURES}. A request limit, so it shares `LOG_TOO_LARGE`'s prefix.
25
+ */ export const TOO_MANY_FAILED_SIGNATURES = 'controller log exceeds request limits: too many signatures fail to verify';
26
+ /** What {@link foldEpoch} uses when a caller lowers nothing: pinned so a test can see it. */ export const DEFAULT_EPOCH_LIMITS = {
27
+ MAX_EPOCH_EVENTS
28
+ };
29
+ function emptyState() {
30
+ return {
31
+ added: new Map(),
32
+ removed: new Set(),
33
+ leases: new Map(),
34
+ deny: new Set()
35
+ };
36
+ }
37
+ function copyState(state) {
38
+ return {
39
+ added: new Map(state.added),
40
+ removed: new Set(state.removed),
41
+ leases: new Map(state.leases),
42
+ deny: new Set(state.deny)
43
+ };
44
+ }
45
+ /** `into` becomes the join of itself and `other`. */ function joinInto(into, other) {
46
+ for (const [add, entry] of other.added)into.added.set(add, entry);
47
+ for (const add of other.removed)into.removed.add(add);
48
+ for (const [add, lease] of other.leases){
49
+ const current = into.leases.get(add);
50
+ if (current === undefined || lease > current) into.leases.set(add, lease);
51
+ }
52
+ for (const entry of other.deny)into.deny.add(entry);
53
+ }
54
+ function anchorState(anchor) {
55
+ const state = emptyState();
56
+ for (const member of anchor.members.values()){
57
+ state.added.set(member.add, {
58
+ did: member.did,
59
+ key: member.key,
60
+ add: member.add,
61
+ lease: member.lease
62
+ });
63
+ if (member.removed) state.removed.add(member.add);
64
+ }
65
+ for (const entry of anchor.deny)state.deny.add(entry);
66
+ return state;
67
+ }
68
+ /**
69
+ * Whether any denial in `deny` targets `member`: the one predicate, `denialTargetsMember`, through the
70
+ * fold's memoised `matches`.
71
+ */ function isDenied(deny, member, matches) {
72
+ for (const entry of deny){
73
+ if (matches(entry, member)) return true;
74
+ }
75
+ return false;
76
+ }
77
+ /**
78
+ * Added, not removed, not denied: a member whose events the state authorises. The same test the head
79
+ * applies to `MemberState.effective`, here asked of a causal state.
80
+ */ function isEffective(state, member, matches) {
81
+ return !state.removed.has(member.add) && !isDenied(state.deny, member, matches);
82
+ }
83
+ function exhausted(budget) {
84
+ return budget.failed > MAX_FAILED_SIGNATURES;
85
+ }
86
+ /** Why an event is refused outright, or `null` when it is a candidate to evaluate. */ function intrinsicRefusal(event, did, anchor) {
87
+ if (event.i !== did) return 'event names a different controller';
88
+ if (event.g !== anchor.gen) return 'event names a different generation';
89
+ return null;
90
+ }
91
+ /**
92
+ * Among the copies that verify, the one whose signature list sorts first, so two parties that saw the
93
+ * same event under different valid signatures remember the same bytes, whatever order each saw them
94
+ * in. Each distinct signature list is checked once, in ascending order, stopping at the first that
95
+ * verifies: repeating a copy costs no extra verification, and neither does any list sorting after a
96
+ * genuine one. Every list that fails is charged to `budget`; the walk stops once it is spent.
97
+ */ function canonicalCopy(copies, verifies, budget) {
98
+ const distinct = new Map();
99
+ for (const copy of copies){
100
+ // JSON, not a join: signatures are untrusted strings and may hold any separator, and a lossy key
101
+ // would let a forgery spelling the genuine list as one string shadow it, checked first.
102
+ const key = JSON.stringify(copy.sigs);
103
+ if (!distinct.has(key)) distinct.set(key, copy);
104
+ }
105
+ for (const key of [
106
+ ...distinct.keys()
107
+ ].sort()){
108
+ const copy = distinct.get(key);
109
+ if (verifies(copy)) return copy;
110
+ budget.failed += copy.sigs.length;
111
+ if (exhausted(budget)) return undefined;
112
+ }
113
+ return undefined;
114
+ }
115
+ /**
116
+ * Judge `event` against the state its parents established, and say what it adds. `null` reason means
117
+ * it applies; the returned `effect` is then the event's own contribution.
118
+ */ function evaluate(candidate, causal, anchor, roots, matches, budget) {
119
+ const { event, digest } = candidate;
120
+ // A copy of the same body under a different signature is not a different event, so the event is
121
+ // authentic if any presented copy is. Judging only the first would let a forged copy shadow the
122
+ // genuine one depending on arrival order. A trusted copy was judged so when it was kept.
123
+ const authenticate = (verifies)=>candidate.trusted ?? canonicalCopy(candidate.copies, verifies, budget);
124
+ let signer = null;
125
+ if (event.by === undefined) {
126
+ candidate.authentic = authenticate((c)=>verifyEpochSignature(c, {
127
+ rootKeys: anchor.keys
128
+ }));
129
+ if (candidate.authentic === undefined) {
130
+ return {
131
+ reason: 'invalid root signature'
132
+ };
133
+ }
134
+ } else {
135
+ const member = causal.added.get(event.by);
136
+ if (member === undefined) return {
137
+ reason: 'signer is not a member of the causal state'
138
+ };
139
+ if (causal.removed.has(event.by)) return {
140
+ reason: 'signer was removed in the causal state'
141
+ };
142
+ if (isDenied(causal.deny, member, matches)) return {
143
+ reason: 'signer is denied in the causal state'
144
+ };
145
+ candidate.authentic = authenticate((c)=>verifyEpochSignature(c, {
146
+ memberKey: member.key
147
+ }));
148
+ if (candidate.authentic === undefined) {
149
+ return {
150
+ reason: 'invalid member signature'
151
+ };
152
+ }
153
+ signer = member;
154
+ }
155
+ const effect = emptyState();
156
+ switch(event.t){
157
+ case 'rev':
158
+ {
159
+ // A member removes members; it does not deny them. Denial dominates membership and a removal
160
+ // does not, so a revoke would be a removal that skips the self-remove rule and that root's own
161
+ // add could never undo short of a settlement: a denied key or DID refuses every later add of it.
162
+ // Every member of the causal past is shielded, removed or denied ones included -- otherwise one
163
+ // member removes another and a second denies it on top, and root can no longer re-admit that
164
+ // key under any DID. Root alone may deny a member it can see. The shield is the causal past
165
+ // only: a member root adds concurrently is not in it, so a member on a branch that has not seen
166
+ // the add can deny its key or DID, and root's re-add is refused (`ADD_KEY_DENIED`) until a
167
+ // settlement drops the denial. An accepted stolen-member risk; see the security reference.
168
+ if (signer !== null) {
169
+ for (const member of causal.added.values()){
170
+ if (matches(event.x, member)) {
171
+ return {
172
+ reason: MEMBER_REVOKES_MEMBER
173
+ };
174
+ }
175
+ }
176
+ }
177
+ // Any signer, root included: a denied published key would leave a head whose `k` or `ka`
178
+ // resolves to nothing (the `KeyState.deny` invariant). Retired keys may be denied.
179
+ const key = keyFromTarget(event.x);
180
+ if (key != null && (anchor.keys.includes(key) || anchor.agreement.includes(key))) {
181
+ return {
182
+ reason: REVOKE_NAMES_ROOT_KEY
183
+ };
184
+ }
185
+ effect.deny.add(event.x);
186
+ return {
187
+ effect
188
+ };
189
+ }
190
+ case 'add':
191
+ {
192
+ if (signer !== null) return {
193
+ reason: ADD_NOT_ROOT
194
+ };
195
+ const entry = {
196
+ did: event.did,
197
+ key: event.key,
198
+ add: digest,
199
+ lease: event.lease
200
+ };
201
+ // Ed25519 only, and the DID carries exactly the pin: a grant verifier that reads the DID and
202
+ // one that reads the pin must agree on the key. A non-Ed25519 pin fails here too.
203
+ if (!memberCarriesKey(entry.did, entry.key)) return {
204
+ reason: ADD_DID_MISMATCH
205
+ };
206
+ // The same disjointness a snapshot obeys, by the same predicate: no management device holds a
207
+ // root key, so member authority and root authority are never mistaken for each other.
208
+ if (isRootKey(entry.key, roots)) return {
209
+ reason: ADD_KEY_IS_ROOT
210
+ };
211
+ // Judged against the causal past only. A denial concurrent with the add does not refuse it --
212
+ // neither may be dropped for arriving second -- it makes the merged member non-effective.
213
+ if (isDenied(causal.deny, entry, matches)) return {
214
+ reason: ADD_KEY_DENIED
215
+ };
216
+ // One key, one live incarnation along any causal line: a second effective add of the same key
217
+ // would be an alias whose renewals the self-renew rule could only catch by key anyway, and
218
+ // root has no reason to issue one. Concurrent adds (a retry) cannot see each other, so both
219
+ // stand; pinned-key equality is what keeps them from renewing each other.
220
+ for (const member of causal.added.values()){
221
+ if (member.key === entry.key && isEffective(causal, member, matches)) {
222
+ return {
223
+ reason: ADD_KEY_ALREADY_MEMBER
224
+ };
225
+ }
226
+ }
227
+ effect.added.set(digest, entry);
228
+ return {
229
+ effect
230
+ };
231
+ }
232
+ case 'ren':
233
+ case 'rmv':
234
+ {
235
+ // An incarnation is its add digest: a renew or remove of an add the causal past never held
236
+ // has no target, and a delayed one of an old incarnation never reaches a re-add of its key.
237
+ const target = causal.added.get(event.add);
238
+ if (target === undefined) return {
239
+ reason: UNKNOWN_MEMBER
240
+ };
241
+ // "Another member" is another pinned key, whatever the DID or incarnation: one key added
242
+ // twice (as a `did:key` and as a peer DID, or by a retried add) is still one device.
243
+ if (signer !== null && signer.key === target.key) {
244
+ return {
245
+ reason: event.t === 'ren' ? SELF_RENEW : SELF_REMOVE
246
+ };
247
+ }
248
+ // Neither looks at whether the target is removed: removal dominates renewal at the join, so a
249
+ // renewal of a removed member is harmless and refusing it would make the outcome depend on
250
+ // which branch the renewer happened to see.
251
+ if (event.t === 'ren') effect.leases.set(event.add, event.lease);
252
+ else effect.removed.add(event.add);
253
+ return {
254
+ effect
255
+ };
256
+ }
257
+ default:
258
+ return {
259
+ reason: 'unknown epoch event type'
260
+ };
261
+ }
262
+ }
263
+ /** Insert `digest` into `sorted` keeping ascending order. */ function insertSorted(sorted, digest) {
264
+ let lo = 0;
265
+ let hi = sorted.length;
266
+ while(lo < hi){
267
+ const mid = lo + hi >>> 1;
268
+ if (sorted[mid] < digest) lo = mid + 1;
269
+ else hi = mid;
270
+ }
271
+ sorted.splice(lo, 0, digest);
272
+ }
273
+ /**
274
+ * Merge the current epoch into the chain's last state. Total: whatever `events` holds, the result is
275
+ * a head or a refusal, never a throw -- an entry that is not a valid epoch event is counted and
276
+ * dropped. The one refusal is the request limit on failed signatures ({@link TOO_MANY_FAILED_SIGNATURES}),
277
+ * which `trusted` copies never count towards.
278
+ */ export function foldEpochDetailed(params) {
279
+ const { did, chainStates, events } = params;
280
+ const maxEvents = params.limits?.MAX_EPOCH_EVENTS ?? DEFAULT_EPOCH_LIMITS.MAX_EPOCH_EVENTS;
281
+ const anchor = chainStates[chainStates.length - 1];
282
+ const reasons = new Map();
283
+ let rejected = 0;
284
+ // Digests of chain events: a parent naming one is a cross-epoch parent, not a missing one.
285
+ const chainDigests = new Set(chainStates.map((state)=>state.digest));
286
+ // The root keys an add's pin must avoid: every `k`/`ka`/`n` on the anchor's generation, read off
287
+ // the chain alone as the rotate's snapshot check reads them, so an add and a snapshot refuse the
288
+ // same pins. A reset opens a generation, so earlier generations' keys are not counted -- they
289
+ // cannot sign for this one. Skipped states repeat their prior, which the sets absorb.
290
+ const rootKeys = new Set();
291
+ const commitments = new Set();
292
+ for (const state of chainStates){
293
+ if (state.gen !== anchor.gen) continue;
294
+ for (const key of [
295
+ ...state.keys,
296
+ ...state.agreement
297
+ ])rootKeys.add(key);
298
+ for (const commitment of state.next)commitments.add(commitment);
299
+ }
300
+ const roots = {
301
+ keys: rootKeys,
302
+ commitments,
303
+ recovery: anchor.recovery
304
+ };
305
+ // One canonicalisation per distinct DID for the whole fold: see `createDenialMatcher`.
306
+ const matches = createDenialMatcher();
307
+ // 1. Triage. Dedupe by digest; an event of another epoch is ignored silently (its effect is in the
308
+ // newer snapshot or was dropped by it), but it is remembered by digest so that naming it as a
309
+ // parent reads as a cross-epoch parent rather than a missing one.
310
+ const candidates = new Map();
311
+ const otherEpochs = new Set();
312
+ const counted = new Set();
313
+ // Memory first, so every presented copy of a held body finds its trusted copy already there. Held
314
+ // copies are this verifier's own, so one that is not of this epoch, or not an epoch event at all
315
+ // (a corrupted store), is simply not used: it is no refusal of anything presented.
316
+ for (const entry of params.trusted ?? []){
317
+ if (!isEpochEventShape(entry) || entry.event.e !== anchor.epoch) continue;
318
+ if (intrinsicRefusal(entry.event, did, anchor) !== null) continue;
319
+ const digest = digestOf(entry.event);
320
+ if (!candidates.has(digest)) {
321
+ candidates.set(digest, {
322
+ digest,
323
+ event: entry.event,
324
+ copies: [
325
+ entry
326
+ ],
327
+ trusted: entry
328
+ });
329
+ }
330
+ }
331
+ for (const entry of events){
332
+ if (!isEpochEventShape(entry)) {
333
+ rejected++;
334
+ continue;
335
+ }
336
+ const digest = digestOf(entry.event);
337
+ if (entry.event.e !== anchor.epoch) {
338
+ otherEpochs.add(digest);
339
+ continue;
340
+ }
341
+ const refusal = intrinsicRefusal(entry.event, did, anchor);
342
+ if (refusal !== null) {
343
+ reasons.set(digest, refusal);
344
+ if (!counted.has(digest)) {
345
+ counted.add(digest);
346
+ rejected++;
347
+ }
348
+ continue;
349
+ }
350
+ const existing = candidates.get(digest);
351
+ if (existing === undefined) {
352
+ candidates.set(digest, {
353
+ digest,
354
+ event: entry.event,
355
+ copies: [
356
+ entry
357
+ ]
358
+ });
359
+ } else if (existing.trusted === undefined) {
360
+ existing.copies.push(entry);
361
+ }
362
+ }
363
+ // Parents. A parent that is neither the anchor nor a candidate is judged by what it is: another
364
+ // epoch's event or a chain event refuses the child; a refused same-epoch event leaves it pending;
365
+ // anything else is simply not here yet, which is also pending.
366
+ //
367
+ // Two passes: every candidate's parents are classified before any edge is registered, so an event
368
+ // refused for a cross-epoch parent never leaves an edge behind for a surviving parent to follow.
369
+ const crossEpoch = [];
370
+ for (const candidate of candidates.values()){
371
+ if (candidate.event.p.some((parent)=>parent !== anchor.epoch && !candidates.has(parent) && (otherEpochs.has(parent) || chainDigests.has(parent)))) {
372
+ crossEpoch.push(candidate.digest);
373
+ }
374
+ }
375
+ for (const digest of crossEpoch){
376
+ candidates.delete(digest);
377
+ reasons.set(digest, 'event lists a parent outside its epoch');
378
+ rejected++;
379
+ }
380
+ const children = new Map();
381
+ const waiting = new Map();
382
+ const ready = [];
383
+ for (const candidate of candidates.values()){
384
+ let waits = 0;
385
+ for (const parent of candidate.event.p){
386
+ if (parent === anchor.epoch) continue;
387
+ waits++;
388
+ if (candidates.has(parent)) {
389
+ const list = children.get(parent);
390
+ if (list === undefined) children.set(parent, [
391
+ candidate.digest
392
+ ]);
393
+ else list.push(candidate.digest);
394
+ }
395
+ // Anything else -- a refused event, an absent one -- never applies, so this edge never clears.
396
+ }
397
+ waiting.set(candidate.digest, waits);
398
+ if (waits === 0) insertSorted(ready, candidate.digest);
399
+ }
400
+ // 2-4. Kahn's algorithm, smallest ready digest first. `after` is memoised per digest and dropped
401
+ // once its last child has read it. That frees a plain chain, but not every shape: a chain e1..en
402
+ // with a side event per ei that also names en keeps about n/2 states alive until en applies. Work
403
+ // and memory are bounded by the request limit (MAX_LOG_EVENTS events of at most MAX_EVENT_PARENTS
404
+ // parents each), roughly events x parents x state size, not by this release.
405
+ const anchorAfter = anchorState(anchor);
406
+ const after = new Map();
407
+ const unread = new Map();
408
+ const applied = [];
409
+ const authentic = new Map();
410
+ const union = copyState(anchorAfter);
411
+ const budget = {
412
+ failed: 0
413
+ };
414
+ let evaluated = 0;
415
+ while(ready.length > 0){
416
+ const digest = ready.shift();
417
+ // `ready` only ever holds candidates, and none is deleted once edges are registered.
418
+ const candidate = candidates.get(digest);
419
+ evaluated++;
420
+ const causal = emptyState();
421
+ let seenAnchor = false;
422
+ for (const parent of candidate.event.p){
423
+ if (parent === anchor.epoch) {
424
+ if (!seenAnchor) joinInto(causal, anchorAfter);
425
+ seenAnchor = true;
426
+ continue;
427
+ }
428
+ const parentAfter = after.get(parent);
429
+ if (parentAfter !== undefined) joinInto(causal, parentAfter);
430
+ const left = (unread.get(parent) ?? 1) - 1;
431
+ if (left <= 0) {
432
+ unread.delete(parent);
433
+ after.delete(parent);
434
+ } else {
435
+ unread.set(parent, left);
436
+ }
437
+ }
438
+ const outcome = evaluate(candidate, causal, anchor, roots, matches, budget);
439
+ if (exhausted(budget)) {
440
+ return {
441
+ ok: false,
442
+ reason: TOO_MANY_FAILED_SIGNATURES
443
+ };
444
+ }
445
+ if ('reason' in outcome) {
446
+ rejected++;
447
+ reasons.set(digest, outcome.reason);
448
+ continue;
449
+ }
450
+ applied.push(digest);
451
+ if (candidate.authentic !== undefined) authentic.set(digest, candidate.authentic);
452
+ joinInto(union, outcome.effect);
453
+ const kids = children.get(digest);
454
+ if (kids !== undefined && kids.length > 0) {
455
+ joinInto(causal, outcome.effect);
456
+ after.set(digest, causal);
457
+ unread.set(digest, kids.length);
458
+ for (const kid of kids){
459
+ // Edges are registered only between candidates, each with its own count.
460
+ const left = waiting.get(kid);
461
+ waiting.set(kid, left - 1);
462
+ if (left === 1) insertSorted(ready, kid);
463
+ }
464
+ }
465
+ }
466
+ const pending = candidates.size - evaluated;
467
+ const appliedSet = new Set(applied);
468
+ for (const digest of candidates.keys()){
469
+ if (!appliedSet.has(digest) && !reasons.has(digest)) {
470
+ reasons.set(digest, 'a parent is missing or was not applied');
471
+ }
472
+ }
473
+ const members = new Map();
474
+ for (const [add, entry] of union.added){
475
+ const lease = Math.max(entry.lease, union.leases.get(add) ?? 0);
476
+ const removed = union.removed.has(add);
477
+ const effective = !removed && !isDenied(union.deny, entry, matches);
478
+ members.set(add, {
479
+ ...entry,
480
+ lease,
481
+ removed,
482
+ effective
483
+ });
484
+ }
485
+ return {
486
+ ok: true,
487
+ head: {
488
+ ...anchor,
489
+ deny: union.deny,
490
+ members,
491
+ saturated: applied.length > maxEvents
492
+ },
493
+ applied,
494
+ pending,
495
+ rejected,
496
+ reasons,
497
+ authentic
498
+ };
499
+ }
500
+ /** {@link foldEpochDetailed} without the per-event reasons and copies. */ export function foldEpoch(params) {
501
+ const detail = foldEpochDetailed(params);
502
+ if (!detail.ok) return detail;
503
+ const { reasons: _reasons, authentic: _authentic, ...result } = detail;
504
+ return result;
505
+ }