@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/README.md +7 -0
- package/lib/canonical.d.ts +61 -0
- package/lib/canonical.js +114 -0
- package/lib/derivation.d.ts +23 -0
- package/lib/derivation.js +75 -0
- package/lib/events.d.ts +286 -0
- package/lib/events.js +465 -0
- package/lib/fold.d.ts +162 -0
- package/lib/fold.js +499 -0
- package/lib/history.d.ts +63 -0
- package/lib/history.js +97 -0
- package/lib/identity.d.ts +74 -0
- package/lib/identity.js +190 -0
- package/lib/index.d.ts +23 -0
- package/lib/index.js +22 -0
- package/lib/keys.d.ts +9 -0
- package/lib/keys.js +61 -0
- package/lib/keystore-identity.d.ts +22 -0
- package/lib/keystore-identity.js +26 -0
- package/lib/profiles.d.ts +21 -0
- package/lib/profiles.js +42 -0
- package/lib/resolver.d.ts +56 -0
- package/lib/resolver.js +121 -0
- package/lib/state-resolver.d.ts +60 -0
- package/lib/state-resolver.js +186 -0
- package/lib/state.d.ts +28 -0
- package/lib/state.js +34 -0
- package/lib/supersede.d.ts +97 -0
- package/lib/supersede.js +312 -0
- package/lib/version.d.ts +6 -0
- package/lib/version.js +5 -0
- package/package.json +52 -0
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
|
+
}
|
package/lib/history.d.ts
ADDED
|
@@ -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
|
+
}
|