@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-events.d.ts +139 -0
- package/lib/epoch-events.js +177 -0
- package/lib/epoch.d.ts +90 -0
- package/lib/epoch.js +505 -0
- package/lib/events.d.ts +97 -143
- package/lib/events.js +94 -143
- package/lib/fold.d.ts +78 -100
- package/lib/fold.js +200 -225
- package/lib/history.d.ts +102 -51
- package/lib/history.js +160 -71
- package/lib/identity.d.ts +13 -36
- package/lib/identity.js +27 -63
- package/lib/index.d.ts +11 -6
- package/lib/index.js +10 -5
- package/lib/keystore-identity.d.ts +1 -4
- package/lib/keystore-identity.js +11 -8
- package/lib/limits.d.ts +29 -0
- package/lib/limits.js +27 -0
- package/lib/log.d.ts +124 -0
- package/lib/log.js +268 -0
- package/lib/members.d.ts +103 -0
- package/lib/members.js +152 -0
- package/lib/profiles.js +7 -1
- package/lib/resolver.d.ts +34 -29
- package/lib/resolver.js +64 -68
- package/lib/state-resolver.d.ts +63 -27
- package/lib/state-resolver.js +130 -53
- package/lib/state.d.ts +10 -26
- package/lib/state.js +23 -28
- package/lib/supersede.d.ts +22 -63
- package/lib/supersede.js +100 -186
- package/package.json +12 -12
package/lib/fold.js
CHANGED
|
@@ -1,27 +1,10 @@
|
|
|
1
1
|
import { digestOf, isCanonicalizable } from './canonical.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
}
|
|
2
|
+
import { foldEpoch } from './epoch.js';
|
|
3
|
+
import { keyFromTarget, keyTarget, verifyInception, verifyReset, verifyRotate } from './events.js';
|
|
4
|
+
import { MAX_LOG_EVENTS } from './limits.js';
|
|
5
|
+
import { isControllerLog, LOG_TOO_LARGE, MALFORMED_LOG } from './log.js';
|
|
6
|
+
import { denialTargetsMember, ROTATE_NEEDS_SNAPSHOT, validateSnapshot } from './members.js';
|
|
7
|
+
export { ROTATE_NEEDS_SNAPSHOT, SNAPSHOT_DROPS_RETIRED_KEY, SNAPSHOT_MALFORMED, SNAPSHOT_MEMBER_DID_MISMATCH, SNAPSHOT_MEMBER_KEY_IS_ROOT } from './members.js';
|
|
25
8
|
function fail(reason, index) {
|
|
26
9
|
return {
|
|
27
10
|
ok: false,
|
|
@@ -30,25 +13,6 @@ function fail(reason, index) {
|
|
|
30
13
|
};
|
|
31
14
|
}
|
|
32
15
|
/** 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
16
|
/**
|
|
53
17
|
* Whether a log entry has the envelope shape everything downstream reads without checking. The
|
|
54
18
|
* fold's input is untrusted (`JSON.parse` of whatever was on the wire), so `signed.event`/`signed.sigs`
|
|
@@ -80,10 +44,10 @@ function fail(reason, index) {
|
|
|
80
44
|
}
|
|
81
45
|
/**
|
|
82
46
|
* 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
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*/ function stepEvent(did, inception, signed, prior) {
|
|
47
|
+
* rejection is a returned reason, never a throw. Criticality is decided here: an unknown critical
|
|
48
|
+
* event fails closed, an unknown `crit: false` event at the next position is skipped (prior state
|
|
49
|
+
* carried forward unchanged), and anything else fails closed.
|
|
50
|
+
*/ function stepEvent(did, inception, signed, prior, chain) {
|
|
87
51
|
if (!isSignedEventShape(signed)) {
|
|
88
52
|
return {
|
|
89
53
|
status: 'fail',
|
|
@@ -140,13 +104,72 @@ function fail(reason, index) {
|
|
|
140
104
|
};
|
|
141
105
|
}
|
|
142
106
|
}
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
107
|
+
// Every rotate is a settlement: the state after it is its snapshot. A non-reset rotate must carry
|
|
108
|
+
// both halves — there is no "carry the prior state forward", since the prior state may hold epoch
|
|
109
|
+
// effects a chain-only verifier cannot reproduce, and half a snapshot would read as "no members"
|
|
110
|
+
// or "no denials" when its author meant neither. A reset without `m` is blind — `verifyReset`
|
|
111
|
+
// admits one only with `d` exactly `[]` — and a blind reset's state is empty.
|
|
112
|
+
if (!isReset && (rot.event.m == null || rot.event.d == null)) {
|
|
113
|
+
return {
|
|
114
|
+
status: 'fail',
|
|
115
|
+
reason: ROTATE_NEEDS_SNAPSHOT
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const snapshot = rot.event.m == null ? {
|
|
119
|
+
members: [],
|
|
120
|
+
deny: []
|
|
121
|
+
} : {
|
|
122
|
+
members: rot.event.m,
|
|
123
|
+
deny: rot.event.d ?? []
|
|
124
|
+
};
|
|
125
|
+
// The generation's root keys and commitments, this event's included: a reset opens a new
|
|
126
|
+
// generation and is judged against its own keys alone; a rotate against every chain state of its
|
|
127
|
+
// generation. Skipped states repeat their prior, which the sets absorb.
|
|
128
|
+
const generation = isReset ? [] : chain.filter((state)=>state.gen === prior.gen);
|
|
129
|
+
const generationKeys = new Set([
|
|
130
|
+
...rot.event.k,
|
|
131
|
+
...rot.event.ka
|
|
132
|
+
]);
|
|
133
|
+
const generationCommitments = new Set(rot.event.n);
|
|
134
|
+
for (const state of generation){
|
|
135
|
+
for (const key of [
|
|
136
|
+
...state.keys,
|
|
137
|
+
...state.agreement
|
|
138
|
+
])generationKeys.add(key);
|
|
139
|
+
for (const commitment of state.next)generationCommitments.add(commitment);
|
|
140
|
+
}
|
|
141
|
+
// Tombstones: the retirements the prior state checkpointed — its key-target denials naming a key
|
|
142
|
+
// this generation's chain established. They must survive every later snapshot in the generation;
|
|
143
|
+
// read off the chain alone, so a pruned and a full store agree. Only a reset clears them.
|
|
144
|
+
const tombstones = new Set();
|
|
145
|
+
if (!isReset) {
|
|
146
|
+
for (const entry of prior.deny){
|
|
147
|
+
const key = keyFromTarget(entry);
|
|
148
|
+
if (key != null && generationKeys.has(key)) tombstones.add(entry);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const invalid = validateSnapshot({
|
|
152
|
+
snapshot,
|
|
153
|
+
generationKeys: [
|
|
154
|
+
...generationKeys
|
|
155
|
+
],
|
|
156
|
+
generationCommitments: [
|
|
157
|
+
...generationCommitments
|
|
158
|
+
],
|
|
159
|
+
recovery: prior.recovery,
|
|
160
|
+
tombstones
|
|
161
|
+
});
|
|
162
|
+
if (invalid != null) {
|
|
163
|
+
return {
|
|
164
|
+
status: 'fail',
|
|
165
|
+
reason: invalid
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const denyAfter = new Set(snapshot.deny);
|
|
169
|
+
// A key this event *establishes* must not be one it denies. `d` is attacker-influenced wire data,
|
|
170
|
+
// so without this a single rotate could publish a key and deny it in one breath, and the
|
|
171
|
+
// `KeyState.deny` invariant would hold only by good manners. Failing the fold is the only answer:
|
|
172
|
+
// applying it leaves a head whose `k` resolves to nothing.
|
|
150
173
|
const establishedButDenied = [
|
|
151
174
|
...rot.event.k,
|
|
152
175
|
...rot.event.ka
|
|
@@ -157,15 +180,32 @@ function fail(reason, index) {
|
|
|
157
180
|
reason: `rotate establishes a denied key: ${establishedButDenied}`
|
|
158
181
|
};
|
|
159
182
|
}
|
|
183
|
+
// Only the fields a member carries: a snapshot entry is wire data and may hold anything else,
|
|
184
|
+
// which has no meaning to the fold and must not ride into the state as if it had.
|
|
185
|
+
const members = new Map();
|
|
186
|
+
for (const { did: memberDID, key, add, lease } of snapshot.members){
|
|
187
|
+
const entry = {
|
|
188
|
+
did: memberDID,
|
|
189
|
+
key,
|
|
190
|
+
add,
|
|
191
|
+
lease
|
|
192
|
+
};
|
|
193
|
+
const denied = snapshot.deny.some((denial)=>denialTargetsMember(denial, entry));
|
|
194
|
+
members.set(add, {
|
|
195
|
+
...entry,
|
|
196
|
+
removed: false,
|
|
197
|
+
effective: !denied
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const digest = digestOf(rot.event);
|
|
160
201
|
return {
|
|
161
202
|
status: 'ok',
|
|
162
203
|
state: {
|
|
163
204
|
did,
|
|
164
205
|
gen: rot.event.g,
|
|
165
206
|
seq: rot.event.s,
|
|
166
|
-
// New keys are one derivation index on from the last that established one
|
|
167
|
-
//
|
|
168
|
-
// 0 (also its `s`).
|
|
207
|
+
// New keys are one derivation index on from the last that established one. A reset opens a
|
|
208
|
+
// fresh generation, so its index restarts at 0 (also its `s`).
|
|
169
209
|
keyGen: rot.event.g,
|
|
170
210
|
keySeq: isReset ? 0 : prior.keySeq + 1,
|
|
171
211
|
keys: rot.event.k,
|
|
@@ -174,7 +214,10 @@ function fail(reason, index) {
|
|
|
174
214
|
// Carried, never replaced — including across a reset. See `KeyState.recovery`.
|
|
175
215
|
recovery: prior.recovery,
|
|
176
216
|
deny: denyAfter,
|
|
177
|
-
|
|
217
|
+
members,
|
|
218
|
+
epoch: digest,
|
|
219
|
+
saturated: false,
|
|
220
|
+
digest
|
|
178
221
|
}
|
|
179
222
|
};
|
|
180
223
|
}
|
|
@@ -184,81 +227,12 @@ function fail(reason, index) {
|
|
|
184
227
|
reason: 'event does not chain to the previous digest'
|
|
185
228
|
};
|
|
186
229
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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".
|
|
230
|
+
// Unknown type — a `rev`, which the chain no longer carries, included. Criticality lives in the
|
|
231
|
+
// envelope so this decision needs no understanding of `t`; failing closed on a critical event stops
|
|
232
|
+
// a verifier that does not understand a type accepting what that type would have refused. Only an
|
|
233
|
+
// explicit `crit: false` skips: `crit` is wire data, an absent member is falsy, so a truthiness test
|
|
234
|
+
// would let an attacker claim the skip path by *omitting* the flag. The two reasons separate
|
|
235
|
+
// "declared critical" from "declared nothing".
|
|
262
236
|
if (event.crit !== false) {
|
|
263
237
|
return {
|
|
264
238
|
status: 'fail',
|
|
@@ -299,11 +273,11 @@ function fail(reason, index) {
|
|
|
299
273
|
* later-version non-critical types near the front, which is the whole point of the skip path.
|
|
300
274
|
*/ export const MAX_SKIPPED_SLACK = 8;
|
|
301
275
|
/** What a log padded past {@link MAX_SKIPPED_SLACK} fails with. */ export const TOO_MANY_UNKNOWN_EVENTS = 'too many unknown events';
|
|
276
|
+
/** A fold-loop index landed off the event or state array — a broken invariant, not a bad log. */ export const INTERNAL_FOLD_INVARIANT = 'internal fold invariant: missing event or prior state';
|
|
302
277
|
/**
|
|
303
|
-
* The running budget for skipped events
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* budget rather than after.
|
|
278
|
+
* The running budget for skipped events. `understood` counts validated events (the inception is the
|
|
279
|
+
* first), `skipped` the ones carried past. Checked as each skipped event arrives, so a padded log
|
|
280
|
+
* stops folding at the point it exceeds the budget rather than after.
|
|
307
281
|
*/ function skipBudget() {
|
|
308
282
|
let understood = 1;
|
|
309
283
|
let skipped = 0;
|
|
@@ -318,13 +292,12 @@ function fail(reason, index) {
|
|
|
318
292
|
};
|
|
319
293
|
}
|
|
320
294
|
/**
|
|
321
|
-
* Validate the inception and seed the state array
|
|
322
|
-
* sync/async split begins only where the two loops differ.
|
|
295
|
+
* Validate the inception and seed the state array the fold starts from.
|
|
323
296
|
*/ function initFold(did, events) {
|
|
324
297
|
if (!Array.isArray(events)) {
|
|
325
298
|
return {
|
|
326
299
|
ok: false,
|
|
327
|
-
result: fail(
|
|
300
|
+
result: fail(MALFORMED_LOG, 0)
|
|
328
301
|
};
|
|
329
302
|
}
|
|
330
303
|
if (events.length === 0) {
|
|
@@ -373,37 +346,44 @@ function fail(reason, index) {
|
|
|
373
346
|
next: first.event.n,
|
|
374
347
|
recovery: first.event.r,
|
|
375
348
|
deny: new Set(),
|
|
349
|
+
// The inception opens the first epoch with an empty management set.
|
|
350
|
+
members: new Map(),
|
|
351
|
+
epoch: digestOf(first.event),
|
|
352
|
+
saturated: false,
|
|
376
353
|
digest: digestOf(first.event)
|
|
377
354
|
}
|
|
378
355
|
]
|
|
379
356
|
};
|
|
380
357
|
}
|
|
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) {
|
|
358
|
+
/** The chain alone: one state per event, or the first event that does not fold. */ function foldChain(did, events) {
|
|
390
359
|
const init = initFold(did, events);
|
|
391
360
|
if (!init.ok) {
|
|
392
|
-
return init
|
|
361
|
+
return init;
|
|
393
362
|
}
|
|
394
363
|
const { inception, states } = init;
|
|
395
364
|
const budget = skipBudget();
|
|
396
365
|
for(let i = 1; i < events.length; i++){
|
|
397
|
-
const
|
|
398
|
-
if (
|
|
399
|
-
return
|
|
366
|
+
const prior = states[i - 1];
|
|
367
|
+
if (prior === undefined) {
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
result: fail(INTERNAL_FOLD_INVARIANT, i)
|
|
371
|
+
};
|
|
400
372
|
}
|
|
401
|
-
|
|
402
|
-
|
|
373
|
+
// `events[i]` may be a hole or `undefined`; `stepEvent` reports that as `malformed event`.
|
|
374
|
+
const outcome = stepEvent(did, inception, events[i], prior, states);
|
|
375
|
+
if (outcome.status === 'fail') {
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
result: fail(outcome.reason, i)
|
|
379
|
+
};
|
|
403
380
|
}
|
|
404
381
|
if (outcome.skipped) {
|
|
405
382
|
if (!budget.skip()) {
|
|
406
|
-
return
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
result: fail(TOO_MANY_UNKNOWN_EVENTS, i)
|
|
386
|
+
};
|
|
407
387
|
}
|
|
408
388
|
} else {
|
|
409
389
|
budget.understood();
|
|
@@ -416,84 +396,79 @@ function fail(reason, index) {
|
|
|
416
396
|
};
|
|
417
397
|
}
|
|
418
398
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
399
|
+
* Fold a controller's log into per-position key state. `states[i]` is the state *after* `chain[i]`,
|
|
400
|
+
* which a verifier evaluating at position `i` must use — that is what makes the deny set
|
|
401
|
+
* position-dependent: clearing a DID later never retroactively validates its earlier actions.
|
|
402
|
+
*
|
|
403
|
+
* The epoch events then merge into the chain's last state to give `head` (see `foldEpoch`). Only request
|
|
404
|
+
* limits can fail that half: more than {@link MAX_LOG_EVENTS} entries is `LOG_TOO_LARGE`, at
|
|
405
|
+
* index -1 because it is no event's fault, and so is more than `MAX_FAILED_SIGNATURES` signatures
|
|
406
|
+
* failing to verify (`TOO_MANY_FAILED_SIGNATURES`). Short of those, nothing in `log.events` can fail
|
|
407
|
+
* the fold -- a bad entry is counted and dropped -- so a relay cannot make a log unfoldable by padding
|
|
408
|
+
* it, only refused by forging past the limit, which no honest relay does.
|
|
409
|
+
*
|
|
410
|
+
* Synchronous, so it stays usable on an offline verifier's apply path.
|
|
411
|
+
*/ export function foldLog(did, log) {
|
|
412
|
+
if (!isControllerLog(log)) {
|
|
413
|
+
return fail(MALFORMED_LOG, 0);
|
|
426
414
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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);
|
|
415
|
+
if (log.events.length > MAX_LOG_EVENTS) {
|
|
416
|
+
return fail(LOG_TOO_LARGE, -1);
|
|
417
|
+
}
|
|
418
|
+
const chain = foldChain(did, log.chain);
|
|
419
|
+
if (!chain.ok) {
|
|
420
|
+
return chain.result;
|
|
421
|
+
}
|
|
422
|
+
const { states } = chain;
|
|
423
|
+
const merged = foldEpoch({
|
|
424
|
+
did,
|
|
425
|
+
chainStates: states,
|
|
426
|
+
events: log.events
|
|
427
|
+
});
|
|
428
|
+
if (!merged.ok) {
|
|
429
|
+
return fail(merged.reason, -1);
|
|
479
430
|
}
|
|
480
431
|
return {
|
|
481
432
|
ok: true,
|
|
482
|
-
states
|
|
433
|
+
states,
|
|
434
|
+
head: merged.head,
|
|
435
|
+
applied: merged.applied,
|
|
436
|
+
pending: merged.pending,
|
|
437
|
+
rejected: merged.rejected
|
|
483
438
|
};
|
|
484
439
|
}
|
|
485
440
|
export function keyStateAt(result, position) {
|
|
486
441
|
return result.ok ? result.states[position] : undefined;
|
|
487
442
|
}
|
|
488
443
|
/**
|
|
489
|
-
* The
|
|
490
|
-
* few entries rather than
|
|
491
|
-
*
|
|
492
|
-
* un-revokes a device
|
|
493
|
-
*
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
444
|
+
* The state snapshot `state` holds, minus `drop` — what to pass as a rotate's `snapshot` to carry the
|
|
445
|
+
* state forward, pruning a few entries rather than replacing it. A rotate's snapshot replaces the
|
|
446
|
+
* state outright, so writing one by hand means writing out everything that stays; miss one and the
|
|
447
|
+
* profile silently un-revokes a device, un-retires a leaked key or dismisses a management member.
|
|
448
|
+
* This builds it from the fold's own answer instead: every member not removed (`drop.members` names
|
|
449
|
+
* members by `add` digest) and every deny entry (`drop.deny`). Entries in `drop` the state does not
|
|
450
|
+
* carry are ignored.
|
|
451
|
+
*
|
|
452
|
+
* Root's tooling owes its user the warnings the fold cannot give: dropping a holder DID denial
|
|
453
|
+
* re-authorises it, safe only once every authority that could have minted it a grant has ended; and
|
|
454
|
+
* a retired root key the chain shows but this deny set lacks is one to add, not to leave out.
|
|
455
|
+
*/ export function snapshotOf(state, drop = {}) {
|
|
456
|
+
const droppedDeny = new Set(drop.deny ?? []);
|
|
457
|
+
const droppedMembers = new Set(drop.members ?? []);
|
|
458
|
+
const members = [];
|
|
459
|
+
for (const member of state.members.values()){
|
|
460
|
+
if (member.removed || droppedMembers.has(member.add)) continue;
|
|
461
|
+
members.push({
|
|
462
|
+
did: member.did,
|
|
463
|
+
key: member.key,
|
|
464
|
+
add: member.add,
|
|
465
|
+
lease: member.lease
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
members,
|
|
470
|
+
deny: [
|
|
471
|
+
...state.deny
|
|
472
|
+
].filter((entry)=>!droppedDeny.has(entry))
|
|
473
|
+
};
|
|
499
474
|
}
|