@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/log.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { digestOf } from './canonical.js';
|
|
2
|
+
import { DEFAULT_EPOCH_LIMITS, foldEpochDetailed } from './epoch.js';
|
|
3
|
+
import { isEpochEventShape } from './epoch-events.js';
|
|
4
|
+
import { foldLog } from './fold.js';
|
|
5
|
+
import { MAX_EPOCH_EVENTS, MAX_EVENT_PARENTS, MAX_LOG_EVENTS } from './limits.js';
|
|
6
|
+
import { resolveBranches } from './supersede.js';
|
|
7
|
+
/** What a request carrying more epoch events than {@link MAX_LOG_EVENTS} fails with. */ export const LOG_TOO_LARGE = 'controller log exceeds request limits';
|
|
8
|
+
/**
|
|
9
|
+
* Request limit for a log this party holds or is about to merge into what it holds: one saturated
|
|
10
|
+
* epoch kept in memory (`MAX_EPOCH_EVENTS + 1` events) plus one presentation (`MAX_LOG_EVENTS`).
|
|
11
|
+
* What {@link epochHeads}, {@link pruneLog} and {@link mergeLogs} accept; anything larger is no log
|
|
12
|
+
* a verifier could have built and fails `LOG_TOO_LARGE`.
|
|
13
|
+
*/ const MAX_HELD_EVENTS = MAX_EPOCH_EVENTS + 1 + MAX_LOG_EVENTS;
|
|
14
|
+
/** What an input that is not a {@link ControllerLog} at all fails with. */ export const MALFORMED_LOG = 'malformed log';
|
|
15
|
+
/**
|
|
16
|
+
* Whether `value` has the two lists a log is made of. Only the outer shape: every entry is untrusted
|
|
17
|
+
* and is checked by the fold that reads it, which reports a bad one rather than throwing.
|
|
18
|
+
*/ export function isControllerLog(value) {
|
|
19
|
+
if (value == null || typeof value !== 'object') {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const log = value;
|
|
23
|
+
return Array.isArray(log.chain) && Array.isArray(log.events);
|
|
24
|
+
}
|
|
25
|
+
/** What a resolver answers with when the loaded log loses to the one already seen. */ export const LOG_NOT_AUTHORITATIVE = 'the loaded log does not supersede the one already seen';
|
|
26
|
+
/** 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';
|
|
27
|
+
/** What {@link mergeLogs} and {@link pruneLog} fail with when no chain they were given folds. */ export const LOG_UNFOLDABLE = 'no controller log chain folds';
|
|
28
|
+
/**
|
|
29
|
+
* Fold a log this party already holds: the chain through the full fold, the epoch under the held
|
|
30
|
+
* request limit rather than `MAX_LOG_EVENTS`. The request limit bounds what one *input* may make a
|
|
31
|
+
* verifier do; a held log is the union of inputs that each passed it and is already bounded by what
|
|
32
|
+
* memory keeps (one epoch, at most `MAX_EPOCH_EVENTS + 1` events once saturated), but the union with a
|
|
33
|
+
* fresh presentation can exceed it. Folding through {@link foldLog} would turn that into
|
|
34
|
+
* `LOG_TOO_LARGE` — "no memory" — which is exactly the forgetting the history exists to prevent.
|
|
35
|
+
*
|
|
36
|
+
* Nothing in `log` is trusted: every signature is checked, under the failed-signature limit.
|
|
37
|
+
*/ export function foldHeld(did, log, limits) {
|
|
38
|
+
if (!isControllerLog(log)) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: MALFORMED_LOG,
|
|
42
|
+
index: 0
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (log.events.length > MAX_HELD_EVENTS) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: LOG_TOO_LARGE,
|
|
49
|
+
index: -1
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const chain = foldLog(did, {
|
|
53
|
+
chain: log.chain,
|
|
54
|
+
events: []
|
|
55
|
+
});
|
|
56
|
+
if (!chain.ok) {
|
|
57
|
+
return chain;
|
|
58
|
+
}
|
|
59
|
+
return withEpoch(did, chain.states, log.events, limits);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The chain's states merged with `events` as the current epoch, as {@link foldLog} reports it.
|
|
63
|
+
* `trusted` is this party's own memory (see `FoldEpochParams.trusted`), never a presentation. Refused,
|
|
64
|
+
* at index -1, when `events` passes the failed-signature limit.
|
|
65
|
+
*/ export function withEpoch(did, states, events, limits, trusted) {
|
|
66
|
+
const merged = foldEpochDetailed({
|
|
67
|
+
did,
|
|
68
|
+
chainStates: states,
|
|
69
|
+
events,
|
|
70
|
+
limits,
|
|
71
|
+
trusted
|
|
72
|
+
});
|
|
73
|
+
if (!merged.ok) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
reason: merged.reason,
|
|
77
|
+
index: -1
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
result: {
|
|
82
|
+
ok: true,
|
|
83
|
+
states,
|
|
84
|
+
head: merged.head,
|
|
85
|
+
applied: merged.applied,
|
|
86
|
+
pending: merged.pending,
|
|
87
|
+
rejected: merged.rejected
|
|
88
|
+
},
|
|
89
|
+
authentic: merged.authentic
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The parents an epoch event built now should name: the current epoch's applied events that no
|
|
94
|
+
* applied event names as a parent, sorted ascending, or `[head.epoch]` when none applied. An event
|
|
95
|
+
* naming every tip is causally after everything its author has seen, and authorisation is judged
|
|
96
|
+
* against exactly that causal past -- naming fewer leaves the author's own view out of it.
|
|
97
|
+
*
|
|
98
|
+
* Pending and rejected events are never heads: they are not part of the folded epoch. Throws, with
|
|
99
|
+
* the fold's reason, when the log does not fold; a builder has nothing to extend then.
|
|
100
|
+
*
|
|
101
|
+
* At most `MAX_EVENT_PARENTS` are returned, so the answer is always a valid `parents`: when the epoch
|
|
102
|
+
* has more tips (concurrent events are free to make), the smallest digests. The event built on them is
|
|
103
|
+
* causally after those alone; the rest stay tips, and the next event built from `epochHeads` names
|
|
104
|
+
* them, so repeated building folds every tip in. Throws `LOG_TOO_LARGE` on a log larger than any a
|
|
105
|
+
* verifier holds.
|
|
106
|
+
*/ export function epochHeads(did, log) {
|
|
107
|
+
const held = foldHeld(did, log);
|
|
108
|
+
if ('ok' in held) {
|
|
109
|
+
throw new Error(`${held.reason}: ${did}`);
|
|
110
|
+
}
|
|
111
|
+
const { result, authentic } = held;
|
|
112
|
+
const named = new Set();
|
|
113
|
+
for (const digest of result.applied){
|
|
114
|
+
for (const parent of authentic.get(digest)?.event.p ?? []){
|
|
115
|
+
named.add(parent);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const heads = result.applied.filter((digest)=>!named.has(digest)).sort();
|
|
119
|
+
return heads.length === 0 ? [
|
|
120
|
+
result.head.epoch
|
|
121
|
+
] : heads.slice(0, MAX_EVENT_PARENTS);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One copy of each event the fold applied, in the fold's own order (parents first): all that may be
|
|
125
|
+
* remembered of an epoch. An entry the fold did not apply was never authenticated, so it never
|
|
126
|
+
* reaches memory however it was presented.
|
|
127
|
+
*
|
|
128
|
+
* The copy is the one the fold itself verified, never re-derived here. Copies of one body share a
|
|
129
|
+
* digest and differ only in signatures, and the fold applies the event if *any* copy verifies — so
|
|
130
|
+
* "applied" alone does not say which copy to keep. Keeping the first one presented would let a relay
|
|
131
|
+
* put a forged copy ahead of the genuine one and have memory keep the forgery, losing the event on
|
|
132
|
+
* the next fold; re-checking signatures against a signer looked up afresh would answer a subtly
|
|
133
|
+
* different question from the one the fold answered, and drop the event whenever they disagree.
|
|
134
|
+
*
|
|
135
|
+
* Saturation: an epoch holding more than `MAX_EPOCH_EVENTS` applied events is kept as only the
|
|
136
|
+
* first `MAX_EPOCH_EVENTS + 1` of them. A prefix of the fold's topological order is closed under
|
|
137
|
+
* ancestors, and an event's authorisation is a function of its ancestors alone, so the prefix refolds
|
|
138
|
+
* to the same events applied — one more than the limit, saturated on every refold, never more held.
|
|
139
|
+
*/ export function appliedCopies({ result, authentic }, limits) {
|
|
140
|
+
const maxEvents = limits?.MAX_EPOCH_EVENTS ?? DEFAULT_EPOCH_LIMITS.MAX_EPOCH_EVENTS;
|
|
141
|
+
const keep = result.head.saturated ? result.applied.slice(0, maxEvents + 1) : result.applied;
|
|
142
|
+
const kept = [];
|
|
143
|
+
for (const digest of keep){
|
|
144
|
+
const copy = authentic.get(digest);
|
|
145
|
+
if (copy !== undefined) {
|
|
146
|
+
kept.push({
|
|
147
|
+
event: copy.event,
|
|
148
|
+
sigs: [
|
|
149
|
+
...copy.sigs
|
|
150
|
+
]
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return kept;
|
|
155
|
+
}
|
|
156
|
+
function sameSignatures(a, b) {
|
|
157
|
+
return a.length === b.length && a.every((sig, i)=>sig === b[i]);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The chain two logs agree on: `b`'s when it extends (or is) `a`'s, otherwise whatever
|
|
161
|
+
* {@link resolveBranches} names. Throws when that is nothing — a fork ({@link LOG_FORKED}) or no
|
|
162
|
+
* chain that folds ({@link LOG_UNFOLDABLE}).
|
|
163
|
+
*/ function settledChain(did, a, b) {
|
|
164
|
+
const left = foldLog(did, {
|
|
165
|
+
chain: a,
|
|
166
|
+
events: []
|
|
167
|
+
});
|
|
168
|
+
const right = foldLog(did, {
|
|
169
|
+
chain: b,
|
|
170
|
+
events: []
|
|
171
|
+
});
|
|
172
|
+
if (left.ok && right.ok) {
|
|
173
|
+
const through = left.states[left.states.length - 1]?.digest;
|
|
174
|
+
if (right.states.some((state)=>state.digest === through)) {
|
|
175
|
+
return b;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const resolved = resolveBranches(did, [
|
|
179
|
+
a,
|
|
180
|
+
b
|
|
181
|
+
]);
|
|
182
|
+
if (!resolved.ok) {
|
|
183
|
+
throw new Error(`${resolved.failure === 'duplicity' ? LOG_FORKED : LOG_UNFOLDABLE}: ${did}`);
|
|
184
|
+
}
|
|
185
|
+
return resolved.winner;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Two views of one controller's log, combined. A pure merge, for a party joining what it holds with
|
|
189
|
+
* what it was handed (a grant's proof, a peer's copy) before folding.
|
|
190
|
+
*
|
|
191
|
+
* The chain is settled by the branch rules: `b`'s when it extends `a`'s, else the winner of
|
|
192
|
+
* {@link resolveBranches}, and a fork throws rather than merging two histories root never wrote as
|
|
193
|
+
* one. The events are a union, deduplicated by digest *and signatures*: a copy carrying different
|
|
194
|
+
* signatures is kept beside the first, since the fold applies an event when any copy verifies and
|
|
195
|
+
* keeping only the first would let a forged copy shadow the genuine one. Entries that are not epoch
|
|
196
|
+
* events at all are dropped; the fold would only have refused them.
|
|
197
|
+
*
|
|
198
|
+
* Nothing here authenticates an event. The result is input to a fold, not memory: persist what
|
|
199
|
+
* `authoritativeLog` returns instead. A union of two inputs can exceed the request limit a single
|
|
200
|
+
* input is held to, which {@link foldLog} then reports. Each input may be at most what a verifier
|
|
201
|
+
* holds plus one presentation; a larger one throws `LOG_TOO_LARGE` before any work.
|
|
202
|
+
*/ export function mergeLogs(did, a, b) {
|
|
203
|
+
if (a.events.length > MAX_HELD_EVENTS || b.events.length > MAX_HELD_EVENTS) {
|
|
204
|
+
throw new Error(`${LOG_TOO_LARGE}: ${did}`);
|
|
205
|
+
}
|
|
206
|
+
const chain = settledChain(did, a.chain, b.chain);
|
|
207
|
+
const events = [];
|
|
208
|
+
const kept = new Map();
|
|
209
|
+
for (const entry of [
|
|
210
|
+
...a.events,
|
|
211
|
+
...b.events
|
|
212
|
+
]){
|
|
213
|
+
if (!isEpochEventShape(entry)) continue;
|
|
214
|
+
const digest = digestOf(entry.event);
|
|
215
|
+
const signatures = kept.get(digest);
|
|
216
|
+
if (signatures?.some((sigs)=>sameSignatures(sigs, entry.sigs))) continue;
|
|
217
|
+
if (signatures === undefined) {
|
|
218
|
+
kept.set(digest, [
|
|
219
|
+
entry.sigs
|
|
220
|
+
]);
|
|
221
|
+
} else {
|
|
222
|
+
signatures.push(entry.sigs);
|
|
223
|
+
}
|
|
224
|
+
events.push({
|
|
225
|
+
event: entry.event,
|
|
226
|
+
sigs: [
|
|
227
|
+
...entry.sigs
|
|
228
|
+
]
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
chain,
|
|
233
|
+
events
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The least of a log that still folds to the same head: the inception, the current generation's
|
|
238
|
+
* chain, and the current epoch's applied events.
|
|
239
|
+
*
|
|
240
|
+
* Earlier generations go because a reset chains to the inception, not to what preceded it, and the
|
|
241
|
+
* generation's keys and tombstones are read off the generation alone. Unknown events the chain fold
|
|
242
|
+
* skipped go too: they moved no state, so nothing chains to them. Earlier epochs' events go because
|
|
243
|
+
* an event naming another epoch has no effect; unapplied ones because they were never authenticated.
|
|
244
|
+
* A saturated epoch keeps exactly enough to stay saturated (see {@link appliedCopies}).
|
|
245
|
+
*
|
|
246
|
+
* Meant for a log this party already holds: the epoch is folded under the held request limit, not
|
|
247
|
+
* `MAX_LOG_EVENTS` (see {@link foldHeld}). Throws {@link LOG_UNFOLDABLE} when the chain does not fold,
|
|
248
|
+
* and the request limit's reason when the log passes one.
|
|
249
|
+
*/ export function pruneLog(did, log) {
|
|
250
|
+
const held = foldHeld(did, log);
|
|
251
|
+
if (!('result' in held)) {
|
|
252
|
+
// Index -1 is a request limit: the chain may fold fine, the log is refused as too much work.
|
|
253
|
+
throw new Error(held.index === -1 ? `${held.reason}: ${did}` : `${LOG_UNFOLDABLE}: ${did}: ${held.reason} at event ${held.index}`);
|
|
254
|
+
}
|
|
255
|
+
const { states, head } = held.result;
|
|
256
|
+
const chain = [];
|
|
257
|
+
for (const [i, signed] of log.chain.entries()){
|
|
258
|
+
const state = states[i];
|
|
259
|
+
const prior = states[i - 1];
|
|
260
|
+
if (i === 0 || state?.gen === head.gen && state.digest !== prior?.digest) {
|
|
261
|
+
chain.push(signed);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
chain,
|
|
266
|
+
events: appliedCopies(held)
|
|
267
|
+
};
|
|
268
|
+
}
|
package/lib/members.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type MemberEntry, type StateSnapshot } from './events.js';
|
|
2
|
+
/** What a non-reset rotate missing either half of its state snapshot fails with. */
|
|
3
|
+
export declare const ROTATE_NEEDS_SNAPSHOT = "rotate carries no state snapshot";
|
|
4
|
+
/**
|
|
5
|
+
* What a snapshot fails with when its shape is wrong: `m` not a list of {@link MemberEntry}, a pin
|
|
6
|
+
* that is not an Ed25519 key, two entries for one `add` or one pinned key, or `d` not a key list.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SNAPSHOT_MALFORMED = "malformed state snapshot";
|
|
9
|
+
/** A snapshot member pinning a key on, or committed by, the generation's root chain. */
|
|
10
|
+
export declare const SNAPSHOT_MEMBER_KEY_IS_ROOT = "snapshot member key is a root key";
|
|
11
|
+
/** A snapshot member whose DID is not a `did:key` or long-form `did:peer:4` carrying its pin. */
|
|
12
|
+
export declare const SNAPSHOT_MEMBER_DID_MISMATCH = "snapshot member DID does not carry its key";
|
|
13
|
+
/** A rotate whose deny set leaves out a retired-key denial an earlier rotate checkpointed. */
|
|
14
|
+
export declare const SNAPSHOT_DROPS_RETIRED_KEY = "snapshot drops a retired key denial";
|
|
15
|
+
/**
|
|
16
|
+
* Whether `did` carries exactly `key` as its signing key: a `did:key` of that key, or a long-form
|
|
17
|
+
* `did:peer:4` whose document carries that one Ed25519 signing key and no other. Everything else is
|
|
18
|
+
* false — a short-form `did:peer:4` or a network-backed DID carries no key to check, and a document
|
|
19
|
+
* carrying a second signing key would let a grant verifier that reads the DID rather than the pin
|
|
20
|
+
* accept a key the log never admitted.
|
|
21
|
+
*
|
|
22
|
+
* Total: an identifier that claims to carry a key and does not (`signingKeysOf` throws on it) is
|
|
23
|
+
* simply not a match.
|
|
24
|
+
*/
|
|
25
|
+
export declare function memberCarriesKey(did: string, key: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* **The** predicate for "this denial targets this membership", and the only one: the member-revoke
|
|
28
|
+
* guard and denial dominance use exactly this. True iff `entry` is `#` plus the member's pinned key,
|
|
29
|
+
* or the member's DID under canonical DID equivalence (`normalizeDID`: the long and short forms of a
|
|
30
|
+
* `did:peer:4` are one DID).
|
|
31
|
+
*
|
|
32
|
+
* Nothing broader. "Any DID containing that public key" is not an alias: anyone can manufacture a
|
|
33
|
+
* document containing someone else's public key, so a `did:key` of a peer member's pin is a
|
|
34
|
+
* different DID, denied through the key target or not at all.
|
|
35
|
+
*/
|
|
36
|
+
export declare function denialTargetsMember(entry: string, member: MemberEntry): boolean;
|
|
37
|
+
/** {@link denialTargetsMember}, as one fold asks it many times over. */
|
|
38
|
+
export type DenialMatcher = (entry: string, member: MemberEntry) => boolean;
|
|
39
|
+
/**
|
|
40
|
+
* {@link denialTargetsMember} with each DID canonicalised once. The predicate is the same; only the
|
|
41
|
+
* work is shared. A fold asks it for every deny entry against every member it judges, and a hostile
|
|
42
|
+
* entry can be a long-form `did:peer:4` close to the event byte limit, which `normalizeDID` decodes
|
|
43
|
+
* in full on every call. Scoped to one fold by its caller, so the cache holds only strings that
|
|
44
|
+
* fold was handed and dies with it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createDenialMatcher(): DenialMatcher;
|
|
47
|
+
/**
|
|
48
|
+
* Every root key a member pin must stay clear of, for one generation: each `k`/`ka` on its chain,
|
|
49
|
+
* each commitment in an `n`, and the inception's recovery commitment (one recovery key per profile
|
|
50
|
+
* for its life, so a root key in every generation).
|
|
51
|
+
*/
|
|
52
|
+
export type RootKeySet = {
|
|
53
|
+
keys: ReadonlySet<string>;
|
|
54
|
+
/** Digests, as `n` carries them. */
|
|
55
|
+
commitments: ReadonlySet<string>;
|
|
56
|
+
/** A digest, as `r` carries it. */
|
|
57
|
+
recovery: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Whether `key` is a root key of the generation `root` describes. **The** disjointness test: a
|
|
61
|
+
* snapshot's members and an epoch's adds are judged by exactly this, so the two paths that admit a
|
|
62
|
+
* member cannot drift apart on which keys a management device may never hold.
|
|
63
|
+
*/
|
|
64
|
+
export declare function isRootKey(key: string, root: RootKeySet): boolean;
|
|
65
|
+
export type ValidateSnapshotParams = {
|
|
66
|
+
snapshot: StateSnapshot;
|
|
67
|
+
/** Every `k`/`ka` on the current generation's chain, including the event carrying the snapshot. */
|
|
68
|
+
generationKeys: Array<string>;
|
|
69
|
+
/** Every `n` on the current generation's chain, including the event carrying the snapshot. */
|
|
70
|
+
generationCommitments: Array<string>;
|
|
71
|
+
/**
|
|
72
|
+
* The inception's recovery commitment (`r`, `KeyState.recovery`). Kept apart from
|
|
73
|
+
* `generationCommitments` because it is not a generation's: one recovery key per profile for its
|
|
74
|
+
* life, so it is a root key across every generation, the reset that reveals it included.
|
|
75
|
+
*/
|
|
76
|
+
recovery: string;
|
|
77
|
+
/**
|
|
78
|
+
* The retired-key denials the snapshot must keep: the prior chain state's key-target denials that
|
|
79
|
+
* name a key in `generationKeys`. Empty on a reset, which opens a new generation.
|
|
80
|
+
*/
|
|
81
|
+
tombstones: ReadonlySet<string>;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Judge a state snapshot against the generation it lands on: `null` when it may become the state,
|
|
85
|
+
* else the fold reason. Total on wire data — `snapshot` arrives from `JSON.parse` like everything
|
|
86
|
+
* else on the log, so no part of it is trusted to have the type it claims.
|
|
87
|
+
*
|
|
88
|
+
* Rules, in order:
|
|
89
|
+
*
|
|
90
|
+
* - **Shape** ({@link SNAPSHOT_MALFORMED}): `members` is a list of {@link MemberEntry} whose pins
|
|
91
|
+
* decode as Ed25519, no two sharing an `add` (one incarnation, one entry) or a pinned key (one key
|
|
92
|
+
* twice would let it renew itself through the alias); `deny` is a key list.
|
|
93
|
+
* - **Disjoint from root** ({@link SNAPSHOT_MEMBER_KEY_IS_ROOT}): no pin is a `k`/`ka` on the
|
|
94
|
+
* generation's chain, committed in one of its `n`, or the recovery key the inception commits. A
|
|
95
|
+
* management device never holds a root key, so member authority and root authority cannot be
|
|
96
|
+
* confused for each other.
|
|
97
|
+
* - **The DID carries the pin** ({@link SNAPSHOT_MEMBER_DID_MISMATCH}): see {@link memberCarriesKey}.
|
|
98
|
+
* - **Checkpointed retirements stay** ({@link SNAPSHOT_DROPS_RETIRED_KEY}): every tombstone is in
|
|
99
|
+
* `deny`. Historic resolution would otherwise accept fresh tokens signed with a retired key again,
|
|
100
|
+
* and the chain alone enforces it, so a pruned and a full store agree. Holder DID denials carry no
|
|
101
|
+
* such rule: dropping one is a deliberate re-authorisation.
|
|
102
|
+
*/
|
|
103
|
+
export declare function validateSnapshot(params: ValidateSnapshotParams): string | null;
|
package/lib/members.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { normalizeDID, signingKeysOf } from '@kokuin/token';
|
|
2
|
+
import { digestOf } from './canonical.js';
|
|
3
|
+
import { isKeyList, isMemberEntry, keyTarget } from './events.js';
|
|
4
|
+
import { encodeKey, tryDecodeKey } from './keys.js';
|
|
5
|
+
/** What a non-reset rotate missing either half of its state snapshot fails with. */ export const ROTATE_NEEDS_SNAPSHOT = 'rotate carries no state snapshot';
|
|
6
|
+
/**
|
|
7
|
+
* What a snapshot fails with when its shape is wrong: `m` not a list of {@link MemberEntry}, a pin
|
|
8
|
+
* that is not an Ed25519 key, two entries for one `add` or one pinned key, or `d` not a key list.
|
|
9
|
+
*/ export const SNAPSHOT_MALFORMED = 'malformed state snapshot';
|
|
10
|
+
/** A snapshot member pinning a key on, or committed by, the generation's root chain. */ export const SNAPSHOT_MEMBER_KEY_IS_ROOT = 'snapshot member key is a root key';
|
|
11
|
+
/** A snapshot member whose DID is not a `did:key` or long-form `did:peer:4` carrying its pin. */ export const SNAPSHOT_MEMBER_DID_MISMATCH = 'snapshot member DID does not carry its key';
|
|
12
|
+
/** A rotate whose deny set leaves out a retired-key denial an earlier rotate checkpointed. */ export const SNAPSHOT_DROPS_RETIRED_KEY = 'snapshot drops a retired key denial';
|
|
13
|
+
/**
|
|
14
|
+
* Whether `did` carries exactly `key` as its signing key: a `did:key` of that key, or a long-form
|
|
15
|
+
* `did:peer:4` whose document carries that one Ed25519 signing key and no other. Everything else is
|
|
16
|
+
* false — a short-form `did:peer:4` or a network-backed DID carries no key to check, and a document
|
|
17
|
+
* carrying a second signing key would let a grant verifier that reads the DID rather than the pin
|
|
18
|
+
* accept a key the log never admitted.
|
|
19
|
+
*
|
|
20
|
+
* Total: an identifier that claims to carry a key and does not (`signingKeysOf` throws on it) is
|
|
21
|
+
* simply not a match.
|
|
22
|
+
*/ export function memberCarriesKey(did, key) {
|
|
23
|
+
let keys;
|
|
24
|
+
try {
|
|
25
|
+
keys = signingKeysOf(did);
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (keys == null || keys.length !== 1) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const [carried] = keys;
|
|
33
|
+
return carried != null && carried.alg === 'EdDSA' && encodeKey(carried.publicKey, 'EdDSA') === key;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* `normalizeDID`, total: it reads a `did:peer:4` long form apart, and a deny entry is arbitrary wire
|
|
37
|
+
* data. An entry it cannot read names nobody under canonical equivalence, so it compares as itself.
|
|
38
|
+
*/ function canonicalDID(did) {
|
|
39
|
+
try {
|
|
40
|
+
return normalizeDID(did);
|
|
41
|
+
} catch {
|
|
42
|
+
return did;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* **The** predicate for "this denial targets this membership", and the only one: the member-revoke
|
|
47
|
+
* guard and denial dominance use exactly this. True iff `entry` is `#` plus the member's pinned key,
|
|
48
|
+
* or the member's DID under canonical DID equivalence (`normalizeDID`: the long and short forms of a
|
|
49
|
+
* `did:peer:4` are one DID).
|
|
50
|
+
*
|
|
51
|
+
* Nothing broader. "Any DID containing that public key" is not an alias: anyone can manufacture a
|
|
52
|
+
* document containing someone else's public key, so a `did:key` of a peer member's pin is a
|
|
53
|
+
* different DID, denied through the key target or not at all.
|
|
54
|
+
*/ export function denialTargetsMember(entry, member) {
|
|
55
|
+
return targets(entry, member, canonicalDID);
|
|
56
|
+
}
|
|
57
|
+
function targets(entry, member, canonical) {
|
|
58
|
+
if (entry === keyTarget(member.key)) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return entry.startsWith('did:') && canonical(entry) === canonical(member.did);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* {@link denialTargetsMember} with each DID canonicalised once. The predicate is the same; only the
|
|
65
|
+
* work is shared. A fold asks it for every deny entry against every member it judges, and a hostile
|
|
66
|
+
* entry can be a long-form `did:peer:4` close to the event byte limit, which `normalizeDID` decodes
|
|
67
|
+
* in full on every call. Scoped to one fold by its caller, so the cache holds only strings that
|
|
68
|
+
* fold was handed and dies with it.
|
|
69
|
+
*/ export function createDenialMatcher() {
|
|
70
|
+
const canonical = new Map();
|
|
71
|
+
const canonicalOnce = (did)=>{
|
|
72
|
+
let value = canonical.get(did);
|
|
73
|
+
if (value === undefined) {
|
|
74
|
+
value = canonicalDID(did);
|
|
75
|
+
canonical.set(did, value);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
};
|
|
79
|
+
return (entry, member)=>targets(entry, member, canonicalOnce);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Whether `key` is a root key of the generation `root` describes. **The** disjointness test: a
|
|
83
|
+
* snapshot's members and an epoch's adds are judged by exactly this, so the two paths that admit a
|
|
84
|
+
* member cannot drift apart on which keys a management device may never hold.
|
|
85
|
+
*/ export function isRootKey(key, root) {
|
|
86
|
+
if (root.keys.has(key)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
const digest = digestOf(key);
|
|
90
|
+
return root.commitments.has(digest) || digest === root.recovery;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Judge a state snapshot against the generation it lands on: `null` when it may become the state,
|
|
94
|
+
* else the fold reason. Total on wire data — `snapshot` arrives from `JSON.parse` like everything
|
|
95
|
+
* else on the log, so no part of it is trusted to have the type it claims.
|
|
96
|
+
*
|
|
97
|
+
* Rules, in order:
|
|
98
|
+
*
|
|
99
|
+
* - **Shape** ({@link SNAPSHOT_MALFORMED}): `members` is a list of {@link MemberEntry} whose pins
|
|
100
|
+
* decode as Ed25519, no two sharing an `add` (one incarnation, one entry) or a pinned key (one key
|
|
101
|
+
* twice would let it renew itself through the alias); `deny` is a key list.
|
|
102
|
+
* - **Disjoint from root** ({@link SNAPSHOT_MEMBER_KEY_IS_ROOT}): no pin is a `k`/`ka` on the
|
|
103
|
+
* generation's chain, committed in one of its `n`, or the recovery key the inception commits. A
|
|
104
|
+
* management device never holds a root key, so member authority and root authority cannot be
|
|
105
|
+
* confused for each other.
|
|
106
|
+
* - **The DID carries the pin** ({@link SNAPSHOT_MEMBER_DID_MISMATCH}): see {@link memberCarriesKey}.
|
|
107
|
+
* - **Checkpointed retirements stay** ({@link SNAPSHOT_DROPS_RETIRED_KEY}): every tombstone is in
|
|
108
|
+
* `deny`. Historic resolution would otherwise accept fresh tokens signed with a retired key again,
|
|
109
|
+
* and the chain alone enforces it, so a pruned and a full store agree. Holder DID denials carry no
|
|
110
|
+
* such rule: dropping one is a deliberate re-authorisation.
|
|
111
|
+
*/ export function validateSnapshot(params) {
|
|
112
|
+
const { snapshot, generationKeys, generationCommitments, recovery, tombstones } = params;
|
|
113
|
+
const members = snapshot.members;
|
|
114
|
+
const deny = snapshot.deny;
|
|
115
|
+
if (!Array.isArray(members) || !isKeyList(deny)) {
|
|
116
|
+
return SNAPSHOT_MALFORMED;
|
|
117
|
+
}
|
|
118
|
+
const adds = new Set();
|
|
119
|
+
const pins = new Set();
|
|
120
|
+
for (const entry of members){
|
|
121
|
+
if (!isMemberEntry(entry) || tryDecodeKey(entry.key)?.alg !== 'EdDSA') {
|
|
122
|
+
return SNAPSHOT_MALFORMED;
|
|
123
|
+
}
|
|
124
|
+
if (adds.has(entry.add) || pins.has(entry.key)) {
|
|
125
|
+
return SNAPSHOT_MALFORMED;
|
|
126
|
+
}
|
|
127
|
+
adds.add(entry.add);
|
|
128
|
+
pins.add(entry.key);
|
|
129
|
+
}
|
|
130
|
+
const root = {
|
|
131
|
+
keys: new Set(generationKeys),
|
|
132
|
+
commitments: new Set(generationCommitments),
|
|
133
|
+
recovery
|
|
134
|
+
};
|
|
135
|
+
for (const entry of members){
|
|
136
|
+
if (isRootKey(entry.key, root)) {
|
|
137
|
+
return SNAPSHOT_MEMBER_KEY_IS_ROOT;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const entry of members){
|
|
141
|
+
if (!memberCarriesKey(entry.did, entry.key)) {
|
|
142
|
+
return SNAPSHOT_MEMBER_DID_MISMATCH;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const denied = new Set(deny);
|
|
146
|
+
for (const tombstone of tombstones){
|
|
147
|
+
if (!denied.has(tombstone)) {
|
|
148
|
+
return SNAPSHOT_DROPS_RETIRED_KEY;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
package/lib/profiles.js
CHANGED
|
@@ -13,9 +13,15 @@ const VOWELS = 'aeou';
|
|
|
13
13
|
* feed the DID).
|
|
14
14
|
*/ export function handleForDID(did) {
|
|
15
15
|
const digest = sha256(encoder.encode(did));
|
|
16
|
+
const pick = (alphabet, byte)=>{
|
|
17
|
+
if (byte === undefined) {
|
|
18
|
+
throw new Error('handleForDID: digest too short');
|
|
19
|
+
}
|
|
20
|
+
return alphabet[byte % alphabet.length] ?? '';
|
|
21
|
+
};
|
|
16
22
|
const syllables = [];
|
|
17
23
|
for(let i = 0; i < 3; i++){
|
|
18
|
-
syllables.push(CONSONANTS
|
|
24
|
+
syllables.push(pick(CONSONANTS, digest[i * 3]) + pick(VOWELS, digest[i * 3 + 1]) + pick(CONSONANTS, digest[i * 3 + 2]));
|
|
19
25
|
}
|
|
20
26
|
return syllables.join('-');
|
|
21
27
|
}
|
package/lib/resolver.d.ts
CHANGED
|
@@ -1,45 +1,27 @@
|
|
|
1
1
|
import type { DIDMethodResolver } from '@kokuin/token';
|
|
2
|
-
import {
|
|
3
|
-
import type { FoldOptions } from './fold.js';
|
|
2
|
+
import type { EpochLimits } from './epoch.js';
|
|
4
3
|
import { type LogStore } from './history.js';
|
|
4
|
+
import { type ControllerLog } from './log.js';
|
|
5
5
|
export type ControllerResolverOptions = {
|
|
6
6
|
/**
|
|
7
7
|
* Load a controller's event log. Returns undefined when the DID is unknown.
|
|
8
8
|
*
|
|
9
|
-
* **Answer with the whole log
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* and neither question can be answered from a DID alone, all this callback receives. So neither is
|
|
13
|
-
* asked of it: the fold answers both by handing the verifier a resolver built from the prefix
|
|
14
|
-
* states. See `FoldOptions.verifyCapability`.
|
|
15
|
-
*
|
|
16
|
-
* ```ts
|
|
17
|
-
* const resolver = createControllerResolver({
|
|
18
|
-
* loadLog: async (did) => await store.log(did),
|
|
19
|
-
* verifyCapability: createControllerCapabilityVerifier(),
|
|
20
|
-
* })
|
|
21
|
-
* ```
|
|
9
|
+
* **Answer with the whole log**: the fold needs every chain event and every current-epoch event,
|
|
10
|
+
* and a prefix resolves to a head that is missing whatever came after it — a withheld epoch revoke
|
|
11
|
+
* is a denial this resolution does not see.
|
|
22
12
|
*
|
|
23
13
|
* Reuse the instance rather than building one per resolution: concurrent resolutions of one DID
|
|
24
14
|
* share a single in-flight fold. Not a cache — the entry drops as soon as the fold settles.
|
|
25
15
|
*
|
|
26
|
-
* One re-entry hazard remains, not this callback's: a `verifyToken` hook
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*/
|
|
30
|
-
loadLog(did: string): Promise<Array<SignedEvent> | undefined>;
|
|
31
|
-
/**
|
|
32
|
-
* Verify a capability authorising a non-authority signer to revoke, forwarded to the fold. Optional
|
|
33
|
-
* because most logs carry none: without it a log containing one fails to fold rather than being
|
|
34
|
-
* trusted. `createControllerCapabilityVerifier()` from `@kokuin/capability` is the implementation;
|
|
35
|
-
* it needs a registry only for a chain delegate whose DID method is not resolvable from the
|
|
36
|
-
* identifier alone.
|
|
16
|
+
* One re-entry hazard remains, not this callback's: a `verifyToken` hook that resolves *this* DID
|
|
17
|
+
* through *this* resolver joins the calling fold and awaits a promise that cannot settle. Give such
|
|
18
|
+
* a hook a resolver of its own.
|
|
37
19
|
*/
|
|
38
|
-
|
|
20
|
+
loadLog(did: string): Promise<ControllerLog | undefined>;
|
|
39
21
|
/**
|
|
40
22
|
* Where to remember the last log accepted for each DID, so a later one that is *behind* it is
|
|
41
23
|
* refused rather than answered from. Without one, truncation is a silent revocation bypass: a peer
|
|
42
|
-
* serving a prefix that stops just before the
|
|
24
|
+
* serving a prefix that stops just before the event denying their device produces a log that folds
|
|
43
25
|
* cleanly and yields a deny set missing exactly that entry.
|
|
44
26
|
*
|
|
45
27
|
* Optional, because the guarantee needs storage this package cannot provide and a first encounter
|
|
@@ -48,9 +30,32 @@ export type ControllerResolverOptions = {
|
|
|
48
30
|
*/
|
|
49
31
|
history?: LogStore;
|
|
50
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* A `did:kokuin:` resolver that can also hand out frozen views of itself.
|
|
35
|
+
*
|
|
36
|
+
* `frozen()` returns a resolver that loads and folds each DID at most once for its lifetime and
|
|
37
|
+
* answers every member from that one fold. A verification asking several questions of one controller
|
|
38
|
+
* — is the issuer a member, which key does it pin, until when, is any audience on the delegation
|
|
39
|
+
* chain denied — must get them all from one state of the log: answered live, a revoke landing
|
|
40
|
+
* between two of them gives a verdict no single state of the log supports (a member checked before
|
|
41
|
+
* its removal, a denial checked after a settlement dropped it). Use one view per verification and
|
|
42
|
+
* drop it after, since it never sees anything newer than its first fold.
|
|
43
|
+
*/
|
|
44
|
+
export type ControllerResolver = DIDMethodResolver & {
|
|
45
|
+
frozen(): DIDMethodResolver;
|
|
46
|
+
};
|
|
51
47
|
/**
|
|
52
48
|
* A `did:kokuin:` resolver for `@kokuin/token`. Injected rather than imported by token: this package
|
|
53
49
|
* depends on token for signing, so the reverse import would cycle. Token resolves `iss` through the
|
|
54
50
|
* interface without knowing the fold exists.
|
|
51
|
+
*
|
|
52
|
+
* Every member answers from the head (the chain's last state merged with the current epoch) except
|
|
53
|
+
* `resolveHistoric`, which scans the chain's states for a rotated-away key. While the epoch is
|
|
54
|
+
* saturated, every member refuses (see `resolverOver`).
|
|
55
|
+
*/
|
|
56
|
+
export declare function createControllerResolver(options: ControllerResolverOptions): ControllerResolver;
|
|
57
|
+
/**
|
|
58
|
+
* {@link createControllerResolver} under lowered epoch limits. Test-only: saturation is then
|
|
59
|
+
* reachable without thousands of signatures. Not exported from the package.
|
|
55
60
|
*/
|
|
56
|
-
export declare function
|
|
61
|
+
export declare function createControllerResolverWithin(options: ControllerResolverOptions, limits?: Partial<EpochLimits>): ControllerResolver;
|