@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/fold.js ADDED
@@ -0,0 +1,499 @@
1
+ import { digestOf, isCanonicalizable } from './canonical.js';
2
+ import { keyFromTarget, keyTarget, verifyEventSignedBy, verifyInception, verifyReset, verifyRevoke, verifyRotate } from './events.js';
3
+ import { createStateResolver } from './state-resolver.js';
4
+ /**
5
+ * Whether a verifier's answer is one this fold can act on. Total, and strict about both arms: an
6
+ * `authorised: true` with no usable key would reach `verifyEventSignedBy` and throw, an
7
+ * `authorised: false` with no reason would produce a non-string `FoldResult.reason`. Neither is
8
+ * reachable from typed code, both from a stale build.
9
+ *
10
+ * The discriminant is compared against the literals, so a `'true'` string is malformed rather than
11
+ * authorising. The key bytes go through `ArrayBuffer.isView`, not `instanceof Uint8Array`, which is
12
+ * per-realm and would reject a correct answer built in a worker, a `vm` context, or across an
13
+ * Electron bridge — the one way this guard could turn away a good answer.
14
+ */ function isCapabilityAuthorisation(value) {
15
+ if (value == null || typeof value !== 'object') {
16
+ return false;
17
+ }
18
+ const answer = value;
19
+ if (answer.authorised === true) {
20
+ const key = answer.audienceKey;
21
+ return key != null && typeof key.alg === 'string' && ArrayBuffer.isView(key.publicKey);
22
+ }
23
+ return answer.authorised === false && typeof answer.reason === 'string';
24
+ }
25
+ function fail(reason, index) {
26
+ return {
27
+ ok: false,
28
+ reason,
29
+ index
30
+ };
31
+ }
32
+ /** What a log entry that is not a {@link SignedEvent} at all fails with. */ const MALFORMED_EVENT = 'malformed event';
33
+ // The fold's capability-revoke failure reasons. Exported because they already are a contract:
34
+ // `@kokuin/capability` asserts on them by string literal across a package boundary. `FoldResult.reason`
35
+ // stays a plain string, so these name values rather than widening the type.
36
+ /** A verifier threw. The thrown message is appended after `: `, so match with `startsWith`. */ export const CAPABILITY_VERIFIER_FAILED = 'capability verifier failed';
37
+ /** A verifier answered with something that is not a {@link CapabilityAuthorisation}. */ export const CAPABILITY_VERIFIER_MALFORMED_ANSWER = 'capability verifier returned a malformed answer';
38
+ /**
39
+ * The capability authorised the revoke and pinned an audience key, and somebody else signed the
40
+ * event. Distinct from a rejected grant: the delegation is sound and the signature is not.
41
+ */ export const REVOKE_NOT_SIGNED_BY_AUDIENCE = 'revoke is not signed by the capability audience';
42
+ /**
43
+ * The sync fold met a capability-authorised revoke, which it cannot verify by construction — the
44
+ * management tier working as designed, answered by `foldLogAsync`. The capability follows after `: `,
45
+ * so match with `startsWith`. Exported so speculative folders (`resolveBranches`) can tell it from a
46
+ * genuinely broken log.
47
+ */ export const CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD = 'capability-authorised revoke needs an async fold';
48
+ /**
49
+ * The async fold met a capability-authorised revoke with no `verifyCapability` configured. Same
50
+ * shape: the log is fine and the call was not equipped for it.
51
+ */ export const CAPABILITY_REVOKE_NEEDS_VERIFIER = 'capability-authorised revoke needs a verifier';
52
+ /**
53
+ * Whether a log entry has the envelope shape everything downstream reads without checking. The
54
+ * fold's input is untrusted (`JSON.parse` of whatever was on the wire), so `signed.event`/`signed.sigs`
55
+ * are assumptions until checked; unchecked they are reachable `TypeError`s, and the worst case is
56
+ * not the throw but that `resolveBranches` filters branches by folding them — so one malformed entry
57
+ * could crash duplicity resolution for every honest branch beside it.
58
+ *
59
+ * Only the envelope is checked here; what the body must contain depends on `t` and stays with each
60
+ * verifier. Canonicalizability is checked here too: every path out canonicalizes the whole body
61
+ * (signature hash, `digestOf`), and both ways it can refuse (`Infinity` from `1e400`, nesting past
62
+ * `MAX_CANONICAL_DEPTH`) arrive as ordinary wire input. Both are the same failure as a bad shape — a
63
+ * `FoldResult`, not an exception from a fold documented total.
64
+ */ function isSignedEventShape(value) {
65
+ if (value == null || typeof value !== 'object') {
66
+ return false;
67
+ }
68
+ const signed = value;
69
+ if (signed.event == null || typeof signed.event !== 'object') {
70
+ return false;
71
+ }
72
+ if (!Array.isArray(signed.sigs) || signed.sigs.some((sig)=>typeof sig !== 'string')) {
73
+ return false;
74
+ }
75
+ if (signed.recoveryKey !== undefined && typeof signed.recoveryKey !== 'string') {
76
+ return false;
77
+ }
78
+ // The body is canonicalized as the top-level value, so this is exactly what the canonicalizer sees.
79
+ return isCanonicalizable(signed.event);
80
+ }
81
+ /**
82
+ * Validate one event against the state so far and produce the next state. Pure and total — every
83
+ * rejection is a returned reason, never a throw. Criticality is decided here so both entry points
84
+ * inherit it: an unknown critical event fails closed, an unknown `crit: false` event at the next
85
+ * position is skipped (prior state carried forward unchanged), and anything else fails closed.
86
+ */ function stepEvent(did, inception, signed, prior) {
87
+ if (!isSignedEventShape(signed)) {
88
+ return {
89
+ status: 'fail',
90
+ reason: MALFORMED_EVENT
91
+ };
92
+ }
93
+ const event = signed.event;
94
+ if (event.i !== did) {
95
+ return {
96
+ status: 'fail',
97
+ reason: 'event names a different controller'
98
+ };
99
+ }
100
+ if (event.t === 'rot') {
101
+ const rot = signed;
102
+ const isReset = rot.event.g > prior.gen;
103
+ if (isReset) {
104
+ // A reset chains to the inception, not the head, so `p` is recomputable without the log. This
105
+ // reason is unobservable (`verifyReset` below rejects the same event with `invalid reset`) but
106
+ // stays because `s` is wire data and this states what a reset's sequence must be — do not spend
107
+ // a round trying to reach it.
108
+ if (rot.event.s !== 0) {
109
+ return {
110
+ status: 'fail',
111
+ reason: 'reset must restart the sequence'
112
+ };
113
+ }
114
+ if (!verifyReset(rot, inception)) {
115
+ return {
116
+ status: 'fail',
117
+ reason: 'invalid reset'
118
+ };
119
+ }
120
+ } else {
121
+ if (rot.event.p !== prior.digest) {
122
+ return {
123
+ status: 'fail',
124
+ reason: 'event does not chain to the previous digest'
125
+ };
126
+ }
127
+ if (rot.event.g !== prior.gen || rot.event.s !== prior.seq + 1) {
128
+ return {
129
+ status: 'fail',
130
+ reason: 'sequence gap'
131
+ };
132
+ }
133
+ if (!verifyRotate(rot, {
134
+ digest: prior.digest,
135
+ n: prior.next
136
+ })) {
137
+ return {
138
+ status: 'fail',
139
+ reason: 'invalid rotate'
140
+ };
141
+ }
142
+ }
143
+ // The deny set this rotate leaves behind: its own snapshot when it carries one (validated by
144
+ // `isPublishedRotate`), else the accumulated set carried forward.
145
+ const denyAfter = rot.event.d == null ? prior.deny : new Set(rot.event.d);
146
+ // A key this event *establishes* must not be one it denies. `d` and the carried-forward set are
147
+ // both attacker-influenced, so without this a single rotate could publish a key and deny it in
148
+ // one breath, and the `KeyState.deny` invariant would hold only by good manners. Failing the fold
149
+ // is the only answer: applying it leaves a head whose `k` resolves to nothing.
150
+ const establishedButDenied = [
151
+ ...rot.event.k,
152
+ ...rot.event.ka
153
+ ].find((key)=>denyAfter.has(keyTarget(key)));
154
+ if (establishedButDenied != null) {
155
+ return {
156
+ status: 'fail',
157
+ reason: `rotate establishes a denied key: ${establishedButDenied}`
158
+ };
159
+ }
160
+ return {
161
+ status: 'ok',
162
+ state: {
163
+ did,
164
+ gen: rot.event.g,
165
+ seq: rot.event.s,
166
+ // New keys are one derivation index on from the last that established one — not at `s`, which
167
+ // any intervening revoke advanced. A reset opens a fresh generation, so its index restarts at
168
+ // 0 (also its `s`).
169
+ keyGen: rot.event.g,
170
+ keySeq: isReset ? 0 : prior.keySeq + 1,
171
+ keys: rot.event.k,
172
+ agreement: rot.event.ka,
173
+ next: rot.event.n,
174
+ // Carried, never replaced — including across a reset. See `KeyState.recovery`.
175
+ recovery: prior.recovery,
176
+ deny: denyAfter,
177
+ digest: digestOf(rot.event)
178
+ }
179
+ };
180
+ }
181
+ if (event.p !== prior.digest) {
182
+ return {
183
+ status: 'fail',
184
+ reason: 'event does not chain to the previous digest'
185
+ };
186
+ }
187
+ if (event.t === 'rev') {
188
+ const rev = signed;
189
+ if (rev.event.g !== prior.gen || rev.event.s !== prior.seq + 1) {
190
+ return {
191
+ status: 'fail',
192
+ reason: 'sequence gap'
193
+ };
194
+ }
195
+ // `x` goes into the deny set and, for a cap-authorised revoke, into the verifier as the resource
196
+ // asked for — where a wildcard grant would happily authorise denying `undefined`.
197
+ if (typeof rev.event.x !== 'string') {
198
+ return {
199
+ status: 'fail',
200
+ reason: 'revoke names no target'
201
+ };
202
+ }
203
+ // `cap` crosses a callback boundary into caller-supplied code, the one place a wire value is
204
+ // handed to something that cannot have checked it. `x` above was checked and this was not.
205
+ if (rev.event.cap !== undefined && typeof rev.event.cap !== 'string') {
206
+ return {
207
+ status: 'fail',
208
+ reason: 'revoke capability is not a serialized token'
209
+ };
210
+ }
211
+ // A key target may not name a key this position currently publishes. `resolve` is head-only, so
212
+ // a key still in `k` is one the profile asserts it signs with *now*; the event that stops that is
213
+ // `rotate` (whose pre-rotation commitment the leaked-key holder cannot forge), and denial is for
214
+ // what the rotate leaves behind — the historic path, which survives a rotate. So: rotate-then-deny.
215
+ // Allowing it would let one cap-authorised revoke (wildcard `res`) from a device that never held
216
+ // the sub-seed disable the root tier, and leave a head whose own `k` the resolver must refuse.
217
+ const deniedKey = keyFromTarget(rev.event.x);
218
+ if (deniedKey != null && (prior.keys.includes(deniedKey) || prior.agreement.includes(deniedKey))) {
219
+ return {
220
+ status: 'fail',
221
+ reason: `revoke names a key the profile publishes: ${rev.event.x}`
222
+ };
223
+ }
224
+ const deny = new Set(prior.deny);
225
+ deny.add(rev.event.x);
226
+ // `keyGen`/`keySeq` ride along in the spread: a revoke establishes no key, so the active position
227
+ // is still wherever the last icp/rot put it.
228
+ const state = {
229
+ ...prior,
230
+ seq: rev.event.s,
231
+ deny,
232
+ digest: digestOf(rev.event)
233
+ };
234
+ if (rev.event.cap != null) {
235
+ return {
236
+ status: 'capability',
237
+ cap: rev.event.cap,
238
+ target: rev.event.x,
239
+ state,
240
+ signed: rev
241
+ };
242
+ }
243
+ if (!verifyRevoke(rev, {
244
+ digest: prior.digest,
245
+ keys: prior.keys
246
+ })) {
247
+ return {
248
+ status: 'fail',
249
+ reason: 'invalid revoke'
250
+ };
251
+ }
252
+ return {
253
+ status: 'ok',
254
+ state
255
+ };
256
+ }
257
+ // Unknown type. Criticality lives in the envelope so this decision needs no understanding of `t`;
258
+ // failing closed on a critical event stops a verifier that does not understand `rev` accepting a
259
+ // revoked device. Only an explicit `crit: false` skips: `crit` is wire data, an absent member is
260
+ // falsy, so a truthiness test would let an attacker claim the skip path by *omitting* the flag. The
261
+ // two reasons separate "declared critical" from "declared nothing".
262
+ if (event.crit !== false) {
263
+ return {
264
+ status: 'fail',
265
+ reason: event.crit ? `unknown critical event type: ${String(event.t)}` : `unknown event type with no criticality flag: ${String(event.t)}`
266
+ };
267
+ }
268
+ // A skipped event is the only one accepted without a signature check, so it must not claim a
269
+ // position it did not earn. `p` is checked above; without this, `g`/`s` were free wire data on an
270
+ // unsigned event, and anything ordering branches by the raw head could be handed
271
+ // `Number.MAX_SAFE_INTEGER` by someone holding no key material.
272
+ if (event.g !== prior.gen || event.s !== prior.seq + 1) {
273
+ return {
274
+ status: 'fail',
275
+ reason: 'sequence gap'
276
+ };
277
+ }
278
+ // Non-critical: skip, carrying state forward so positions stay aligned with the input array. `seq`
279
+ // deliberately does not advance — the skipped event established nothing — so a run of skipped
280
+ // events all claim the same `s`.
281
+ return {
282
+ status: 'ok',
283
+ state: {
284
+ ...prior,
285
+ digest: prior.digest
286
+ },
287
+ skipped: true
288
+ };
289
+ }
290
+ /**
291
+ * How many events a log may carry that this version cannot understand, beyond the number it can.
292
+ *
293
+ * A skipped event is accepted without a signature and advances neither `seq` nor the digest, so
294
+ * nothing about it is authenticated: a relaying peer can insert any number anywhere and the log still
295
+ * folds to the same head. The resolver re-folds on every resolution and a group replays the whole log
296
+ * at every welcome, so an unbounded skip path makes log size — and the verifier's CPU/memory — an
297
+ * attacker's choice. Bounded against the log's own real length so the ceiling stays proportional to
298
+ * work the verifier was doing anyway; the slack keeps a v1 verifier able to fold a log with a few
299
+ * later-version non-critical types near the front, which is the whole point of the skip path.
300
+ */ export const MAX_SKIPPED_SLACK = 8;
301
+ /** What a log padded past {@link MAX_SKIPPED_SLACK} fails with. */ export const TOO_MANY_UNKNOWN_EVENTS = 'too many unknown events';
302
+ /**
303
+ * The running budget for skipped events, shared by both fold entry points so the two cannot drift.
304
+ * `understood` counts validated events (the inception is the first), `skipped` the ones carried past.
305
+ * Checked as each skipped event arrives, so a padded log stops folding at the point it exceeds the
306
+ * budget rather than after.
307
+ */ function skipBudget() {
308
+ let understood = 1;
309
+ let skipped = 0;
310
+ return {
311
+ understood () {
312
+ understood++;
313
+ },
314
+ skip () {
315
+ skipped++;
316
+ return skipped <= understood + MAX_SKIPPED_SLACK;
317
+ }
318
+ };
319
+ }
320
+ /**
321
+ * Validate the inception and seed the state array both fold entry points start from. Shared so the
322
+ * sync/async split begins only where the two loops differ.
323
+ */ function initFold(did, events) {
324
+ if (!Array.isArray(events)) {
325
+ return {
326
+ ok: false,
327
+ result: fail('malformed log', 0)
328
+ };
329
+ }
330
+ if (events.length === 0) {
331
+ return {
332
+ ok: false,
333
+ result: fail('empty log', 0)
334
+ };
335
+ }
336
+ if (!isSignedEventShape(events[0])) {
337
+ return {
338
+ ok: false,
339
+ result: fail(MALFORMED_EVENT, 0)
340
+ };
341
+ }
342
+ const first = events[0];
343
+ // Unobservable, like `reset must restart the sequence`: `verifyInception` checks `t` too and
344
+ // rejects the same log with `invalid inception`. Kept because `t` is wire data and this states what
345
+ // a log must open with; recorded as unreachable so the next reader does not hunt for the test.
346
+ if (first.event.t !== 'icp') {
347
+ return {
348
+ ok: false,
349
+ result: fail('first event must be an inception', 0)
350
+ };
351
+ }
352
+ if (!verifyInception(first, did)) {
353
+ return {
354
+ ok: false,
355
+ result: fail('invalid inception', 0)
356
+ };
357
+ }
358
+ return {
359
+ ok: true,
360
+ inception: first.event,
361
+ states: [
362
+ {
363
+ did,
364
+ gen: first.event.g,
365
+ seq: first.event.s,
366
+ keyGen: first.event.g,
367
+ // The derivation index, not the event position — the same number for every inception this
368
+ // package builds (`s` is 0). Writing `s` here would hand a self-certifying log carrying any
369
+ // `s` a `KeyState` naming a key it never committed.
370
+ keySeq: 0,
371
+ keys: first.event.k,
372
+ agreement: first.event.ka,
373
+ next: first.event.n,
374
+ recovery: first.event.r,
375
+ deny: new Set(),
376
+ digest: digestOf(first.event)
377
+ }
378
+ ]
379
+ };
380
+ }
381
+ /**
382
+ * Fold a controller's log into per-position key state. `states[i]` is the state *after* `events[i]`,
383
+ * which a verifier evaluating at position `i` must use — that is what makes the deny set
384
+ * position-dependent: clearing a DID later never retroactively validates its earlier actions.
385
+ *
386
+ * Synchronous, so it stays usable on an offline verifier's apply path. A capability-authorised revoke
387
+ * needs async verification, so it fails closed here rather than trusting an unchecked capability — use
388
+ * `foldLogAsync` when one may be present.
389
+ */ export function foldLog(did, events) {
390
+ const init = initFold(did, events);
391
+ if (!init.ok) {
392
+ return init.result;
393
+ }
394
+ const { inception, states } = init;
395
+ const budget = skipBudget();
396
+ for(let i = 1; i < events.length; i++){
397
+ const outcome = stepEvent(did, inception, events[i], states[i - 1]);
398
+ if (outcome.status === 'fail') {
399
+ return fail(outcome.reason, i);
400
+ }
401
+ if (outcome.status === 'capability') {
402
+ return fail(`${CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD}: ${outcome.cap}`, i);
403
+ }
404
+ if (outcome.skipped) {
405
+ if (!budget.skip()) {
406
+ return fail(TOO_MANY_UNKNOWN_EVENTS, i);
407
+ }
408
+ } else {
409
+ budget.understood();
410
+ }
411
+ states.push(outcome.state);
412
+ }
413
+ return {
414
+ ok: true,
415
+ states
416
+ };
417
+ }
418
+ /**
419
+ * Async counterpart of {@link foldLog}. Shares {@link stepEvent}, so the only difference is a
420
+ * capability-authorised revoke: this entry point awaits `options.verifyCapability` instead of failing
421
+ * closed.
422
+ */ export async function foldLogAsync(did, events, options = {}) {
423
+ const init = initFold(did, events);
424
+ if (!init.ok) {
425
+ return init.result;
426
+ }
427
+ const { inception, states } = init;
428
+ const budget = skipBudget();
429
+ for(let i = 1; i < events.length; i++){
430
+ const outcome = stepEvent(did, inception, events[i], states[i - 1]);
431
+ if (outcome.status === 'fail') {
432
+ return fail(outcome.reason, i);
433
+ }
434
+ if (outcome.status === 'capability') {
435
+ if (options.verifyCapability == null) {
436
+ return fail(`${CAPABILITY_REVOKE_NEEDS_VERIFIER}: ${outcome.cap}`, i);
437
+ }
438
+ // The fold is total by contract; a verifier is caller-supplied code TypeScript cannot police
439
+ // across a package boundary or a stale build. Both ways it can break the contract — throwing,
440
+ // and answering with the wrong shape — come back as a `FoldResult` with a real reason, never an
441
+ // escaping exception or an `undefined` reason. Our adapter never throws; a third party's need
442
+ // not, and a throw is not evidence the capability authorises anything.
443
+ let authorisation;
444
+ try {
445
+ authorisation = await options.verifyCapability({
446
+ cap: outcome.cap,
447
+ subject: did,
448
+ target: outcome.target,
449
+ // A copy: `states` keeps growing, and this resolver must keep answering for the position it
450
+ // was built at even if the verifier holds on to it.
451
+ subjectAtPosition: createStateResolver(did, [
452
+ ...states
453
+ ])
454
+ });
455
+ } catch (cause) {
456
+ return fail(`${CAPABILITY_VERIFIER_FAILED}: ${cause instanceof Error ? cause.message : String(cause)}`, i);
457
+ }
458
+ if (!isCapabilityAuthorisation(authorisation)) {
459
+ return fail(CAPABILITY_VERIFIER_MALFORMED_ANSWER, i);
460
+ }
461
+ if (!authorisation.authorised) {
462
+ return fail(authorisation.reason, i);
463
+ }
464
+ // The capability authorises its audience, not its bearer. Checked here rather than left to the
465
+ // verifier so a verifier that simply forgot cannot make the fold accept a revoke from anyone
466
+ // who read the log — see `CapabilityAuthorisation`.
467
+ if (!verifyEventSignedBy(outcome.signed, authorisation.audienceKey)) {
468
+ return fail(REVOKE_NOT_SIGNED_BY_AUDIENCE, i);
469
+ }
470
+ }
471
+ if (outcome.status === 'ok' && outcome.skipped) {
472
+ if (!budget.skip()) {
473
+ return fail(TOO_MANY_UNKNOWN_EVENTS, i);
474
+ }
475
+ } else {
476
+ budget.understood();
477
+ }
478
+ states.push(outcome.state);
479
+ }
480
+ return {
481
+ ok: true,
482
+ states
483
+ };
484
+ }
485
+ export function keyStateAt(result, position) {
486
+ return result.ok ? result.states[position] : undefined;
487
+ }
488
+ /**
489
+ * The deny set of `state` with `drop` removed — what to pass as a rotate's `denySnapshot` to prune a
490
+ * few entries rather than replace the set. A rotate's `d` replaces the accumulated set outright, so
491
+ * pruning by hand means writing out everything that stays; miss one and the profile silently
492
+ * un-revokes a device or un-retires a leaked key. This builds it from the fold's own answer instead.
493
+ * Entries in `drop` the state does not carry are ignored.
494
+ */ export function pruneDenySet(state, drop) {
495
+ const dropped = new Set(drop);
496
+ return [
497
+ ...state.deny
498
+ ].filter((entry)=>!dropped.has(entry));
499
+ }
@@ -0,0 +1,63 @@
1
+ import type { SignedEvent } from './events.js';
2
+ import { type FoldOptions, type KeyState } from './fold.js';
3
+ /**
4
+ * Where a consumer keeps the last log it accepted for a DID, so the next one can be compared against
5
+ * it.
6
+ *
7
+ * A folded log carries no proof it is the *whole* log: a peer serving a prefix — stopping just before
8
+ * the `rev` denying their device — produces one that folds cleanly and yields a deny set missing
9
+ * exactly the entry that matters. Nothing inside a single log distinguishes truncation from a log
10
+ * that is simply short, so the fork-detection limitation does not reach it: there is no second branch,
11
+ * only an honest-looking prefix. Comparing against what this party has already seen supplies the
12
+ * missing second branch — a local guarantee, not a global one (it does nothing on a first encounter),
13
+ * but the difference between a suppression attack that works once and one that works every time.
14
+ *
15
+ * Deliberately not a high-water mark over `(gen, seq)`. Supersession legitimately *lowers* the
16
+ * sequence: a thief's long current-key branch is beaten by the owner's recovering rotate at a much
17
+ * lower `seq`, which a "never go backwards" mark would reject, bricking the profile as it is rescued.
18
+ * A divergence is settled by {@link resolveBranchesAsync} instead.
19
+ */
20
+ export type LogStore = {
21
+ /** The last accepted log for this DID, or undefined when none has been seen. */
22
+ get(did: string): Promise<Array<SignedEvent> | undefined>;
23
+ /** Record a log as the last accepted one. Called only after it has folded. */
24
+ set(did: string, log: Array<SignedEvent>): Promise<void>;
25
+ };
26
+ /** What a resolver answers with when the log it loaded loses to the one already seen. */
27
+ export declare const LOG_NOT_AUTHORITATIVE = "the loaded log does not supersede the one already seen";
28
+ /** What a resolver answers with when the loaded log and the one already seen are a genuine fork. */
29
+ export declare const LOG_FORKED = "the loaded log and the one already seen are a fork";
30
+ /**
31
+ * An in-memory {@link LogStore}. Enough for a process that resolves the same DIDs repeatedly, and for
32
+ * tests; a consumer that survives restarts wants a persistent one, since the guarantee is only as
33
+ * durable as the memory of what was seen.
34
+ */
35
+ export declare function createMemoryLogStore(): LogStore;
36
+ /**
37
+ * Fold the loaded log, having established that it is not behind one this party has already seen.
38
+ *
39
+ * The two rejections are throws because a resolver has no other way to say "I will not answer for this
40
+ * DID", and answering from the loser is the failure this prevents: a prefix loses to the log it is a
41
+ * prefix of (truncation refused), and a fork is refused outright (resolving to either side is a
42
+ * guess). The comparison is on the **folded head digest**, never array identity — the same history
43
+ * re-loaded is a different array, and identity would reject the commonest call there is.
44
+ *
45
+ * A stored log that no longer folds is treated as no memory, but only when it *genuinely* no longer
46
+ * folds: local corruption or a tightened fold rule is one round of the guarantee to give up, since
47
+ * only a folded log was ever stored and refusing forever would brick every cached profile. A stored
48
+ * log that fails only because this call has no `verifyCapability` for a cap-authorised revoke it
49
+ * carries is the opposite — nothing changed, the call is simply not equipped — so it fails closed;
50
+ * treating it as no memory would disable the truncation guard for management-tier profiles.
51
+ *
52
+ * The states are returned so the caller does not fold twice; the log travels with them for the store.
53
+ */
54
+ export declare function authoritativeStates({ did, loaded, seen, context, options, }: {
55
+ did: string;
56
+ loaded: Array<SignedEvent>;
57
+ seen: Array<SignedEvent> | undefined;
58
+ context: string;
59
+ options: FoldOptions;
60
+ }): Promise<{
61
+ log: Array<SignedEvent>;
62
+ states: Array<KeyState>;
63
+ }>;
package/lib/history.js ADDED
@@ -0,0 +1,97 @@
1
+ import { foldLogAsync } from './fold.js';
2
+ import { allStatesAsync } from './state.js';
3
+ import { needsCapabilityVerification, resolveBranchesAsync } from './supersede.js';
4
+ /** What a resolver answers with when the log it loaded loses to the one already seen. */ export const LOG_NOT_AUTHORITATIVE = 'the loaded log does not supersede the one already seen';
5
+ /** What a resolver answers with when the loaded log and the one already seen are a genuine fork. */ export const LOG_FORKED = 'the loaded log and the one already seen are a fork';
6
+ /**
7
+ * An in-memory {@link LogStore}. Enough for a process that resolves the same DIDs repeatedly, and for
8
+ * tests; a consumer that survives restarts wants a persistent one, since the guarantee is only as
9
+ * durable as the memory of what was seen.
10
+ */ export function createMemoryLogStore() {
11
+ const logs = new Map();
12
+ return {
13
+ async get (did) {
14
+ return logs.get(did);
15
+ },
16
+ async set (did, log) {
17
+ logs.set(did, log);
18
+ }
19
+ };
20
+ }
21
+ /**
22
+ * Fold the loaded log, having established that it is not behind one this party has already seen.
23
+ *
24
+ * The two rejections are throws because a resolver has no other way to say "I will not answer for this
25
+ * DID", and answering from the loser is the failure this prevents: a prefix loses to the log it is a
26
+ * prefix of (truncation refused), and a fork is refused outright (resolving to either side is a
27
+ * guess). The comparison is on the **folded head digest**, never array identity — the same history
28
+ * re-loaded is a different array, and identity would reject the commonest call there is.
29
+ *
30
+ * A stored log that no longer folds is treated as no memory, but only when it *genuinely* no longer
31
+ * folds: local corruption or a tightened fold rule is one round of the guarantee to give up, since
32
+ * only a folded log was ever stored and refusing forever would brick every cached profile. A stored
33
+ * log that fails only because this call has no `verifyCapability` for a cap-authorised revoke it
34
+ * carries is the opposite — nothing changed, the call is simply not equipped — so it fails closed;
35
+ * treating it as no memory would disable the truncation guard for management-tier profiles.
36
+ *
37
+ * The states are returned so the caller does not fold twice; the log travels with them for the store.
38
+ */ export async function authoritativeStates({ did, loaded, seen, context, options }) {
39
+ const states = await allStatesAsync({
40
+ did,
41
+ events: loaded,
42
+ context,
43
+ options: options
44
+ });
45
+ if (seen == null || seen.length === 0) {
46
+ return {
47
+ log: loaded,
48
+ states
49
+ };
50
+ }
51
+ const previous = await foldLogAsync(did, seen, options);
52
+ if (!previous.ok) {
53
+ // "Fold rule tightened" is safe to treat as no memory (only a folded log was stored, so refusing
54
+ // forever would brick every cached profile). "I remember a management-tier log and got no verifier
55
+ // for it" is not: accepting the loaded log there disables the truncation guard for exactly those
56
+ // profiles, letting an attacker's prefix that stops before the cap-authorised `rev` be accepted
57
+ // because the fuller stored log is the one that could not be folded. Fail closed — what could not
58
+ // be checked is not evidence its target is absent.
59
+ if (needsCapabilityVerification(previous)) {
60
+ throw new Error(`${LOG_NOT_AUTHORITATIVE}: ${did}`);
61
+ }
62
+ return {
63
+ log: loaded,
64
+ states
65
+ };
66
+ }
67
+ const head = states[states.length - 1].digest;
68
+ // The loaded log extends, or is, the history seen: its fold passed through that head. Ordinary path.
69
+ if (states.some((state)=>state.digest === previous.states[previous.states.length - 1].digest)) {
70
+ return {
71
+ log: loaded,
72
+ states
73
+ };
74
+ }
75
+ // They diverge. Which is authoritative is what branch resolution answers, and the only thing that
76
+ // knows a superseding rotate outranks a longer branch of current-key events.
77
+ const resolved = await resolveBranchesAsync(did, [
78
+ seen,
79
+ loaded
80
+ ], options);
81
+ if (!resolved.ok) {
82
+ throw new Error(`${resolved.failure === 'duplicity' ? LOG_FORKED : LOG_NOT_AUTHORITATIVE}: ${did}`);
83
+ }
84
+ // A comparison that did not happen is a refusal, not a pass: `unverified` means one side could not
85
+ // be folded for want of a verifier. Always zero with one configured.
86
+ if (resolved.unverified > 0) {
87
+ throw new Error(`${LOG_NOT_AUTHORITATIVE}: ${did}`);
88
+ }
89
+ const winner = await foldLogAsync(did, resolved.winner, options);
90
+ if (!winner.ok || winner.states[winner.states.length - 1].digest !== head) {
91
+ throw new Error(`${LOG_NOT_AUTHORITATIVE}: ${did}`);
92
+ }
93
+ return {
94
+ log: loaded,
95
+ states
96
+ };
97
+ }