@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/supersede.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { digestOf } from './canonical.js';
|
|
2
|
+
import { verifyRotate } from './events.js';
|
|
3
|
+
import { CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD, CAPABILITY_REVOKE_NEEDS_VERIFIER, foldLog, foldLogAsync } from './fold.js';
|
|
4
|
+
/**
|
|
5
|
+
* The position reported for a failure that is not a fork. Real comparisons never reach `gen < 0` and
|
|
6
|
+
* a real digest is never the empty string — how a caller told these from duplicity before
|
|
7
|
+
* {@link ResolveFailure} existed.
|
|
8
|
+
*/ const NO_POSITION = {
|
|
9
|
+
gen: -1,
|
|
10
|
+
seq: -1,
|
|
11
|
+
digests: [
|
|
12
|
+
'',
|
|
13
|
+
''
|
|
14
|
+
]
|
|
15
|
+
};
|
|
16
|
+
function headState({ states }) {
|
|
17
|
+
return states[states.length - 1];
|
|
18
|
+
}
|
|
19
|
+
/** The digest of the `j`th event on the spine — the whole history up to it, chained. */ function spineDigest(folded, j) {
|
|
20
|
+
return folded.states[folded.spine[j]].digest;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether a branch failed to fold only because this call could not check a capability. Filtering such
|
|
24
|
+
* a branch out as invalid is what switched duplicity detection off for the management tier — a
|
|
25
|
+
* `cap`-bearing revoke is the tier working as designed, not a forgery. Matched by prefix against the
|
|
26
|
+
* fold's exported reasons, the contract those constants exist for.
|
|
27
|
+
*/ export function needsCapabilityVerification(result) {
|
|
28
|
+
return !result.ok && (result.reason.startsWith(CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD) || result.reason.startsWith(CAPABILITY_REVOKE_NEEDS_VERIFIER));
|
|
29
|
+
}
|
|
30
|
+
function allUnverified(unverified) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
failure: 'needs-capability-verification',
|
|
34
|
+
duplicity: NO_POSITION,
|
|
35
|
+
unverified
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function branchFrom(branch, result) {
|
|
39
|
+
if (!result.ok) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const { states } = result;
|
|
43
|
+
const spine = [
|
|
44
|
+
0
|
|
45
|
+
];
|
|
46
|
+
for(let i = 1; i < states.length; i++){
|
|
47
|
+
// A skipped event is the only one that leaves the digest where it was; every other chains its
|
|
48
|
+
// predecessor's digest into its body, so no two can agree.
|
|
49
|
+
if (states[i].digest !== states[i - 1].digest) {
|
|
50
|
+
spine.push(i);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
branch,
|
|
55
|
+
states,
|
|
56
|
+
spine
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Which of two branches that fold to the same head to keep. A total order, so the answer never
|
|
61
|
+
* depends on which arrived first.
|
|
62
|
+
*/ function preferred(candidate, incumbent) {
|
|
63
|
+
if (candidate.branch.length !== incumbent.branch.length) {
|
|
64
|
+
return candidate.branch.length < incumbent.branch.length;
|
|
65
|
+
}
|
|
66
|
+
return digestOf(candidate.branch[candidate.branch.length - 1].event) < digestOf(incumbent.branch[incumbent.branch.length - 1].event);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* How far two branches agree, in spine entries. Zero is unreachable for branches of one log (they
|
|
70
|
+
* share an inception), so a common prefix of 1 means they forked immediately after it.
|
|
71
|
+
*/ function commonSpine(a, b) {
|
|
72
|
+
const shortest = Math.min(a.spine.length, b.spine.length);
|
|
73
|
+
let j = 0;
|
|
74
|
+
while(j < shortest && spineDigest(a, j) === spineDigest(b, j)){
|
|
75
|
+
j++;
|
|
76
|
+
}
|
|
77
|
+
return j;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Does `candidate` supersede `incumbent` at the point their branches diverge? Per KERI, a rotate
|
|
81
|
+
* signed by the pre-committed next keys outranks any current-key operation — at the diverging
|
|
82
|
+
* position *and* over everything after it on the losing branch. That is what makes the owner win the
|
|
83
|
+
* race against a thief holding a current key regardless of who published first or how far ahead: a
|
|
84
|
+
* stolen current key cannot rotate but can sign revokes all day, so length must not decide this.
|
|
85
|
+
*/ function supersedes({ candidate, incumbent, priorDigest, priorNext }) {
|
|
86
|
+
if (candidate.event.t !== 'rot') {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (incumbent.event.t === 'rot') {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return verifyRotate(candidate, {
|
|
93
|
+
digest: priorDigest,
|
|
94
|
+
n: priorNext
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Walk the contenders down to one, deciding at each divergence point rather than at the heads. Every
|
|
99
|
+
* step resolves the *whole* surviving group at once: with three or more contenders, a sequential
|
|
100
|
+
* pairwise reduction could compare two losing branches before reaching the one that supersedes both,
|
|
101
|
+
* reporting duplicity between the losers and making the outcome depend on presentation order. The
|
|
102
|
+
* group shrinks strictly each iteration, so this terminates.
|
|
103
|
+
*/ function resolveContenders(contenders) {
|
|
104
|
+
let group = contenders;
|
|
105
|
+
while(group.length > 1){
|
|
106
|
+
// Where they first disagree, against the group as a whole — the point every contender agrees to.
|
|
107
|
+
let divergence = group[0].spine.length;
|
|
108
|
+
for (const folded of group.slice(1)){
|
|
109
|
+
divergence = Math.min(divergence, commonSpine(group[0], folded));
|
|
110
|
+
}
|
|
111
|
+
// A branch ending at the divergence point is a prefix of the others: extended, not contradicted,
|
|
112
|
+
// so it loses without any event discarded. The ordinary "one peer is behind" case, and the only
|
|
113
|
+
// way a longer branch wins.
|
|
114
|
+
const extended = group.filter(({ spine })=>spine.length > divergence);
|
|
115
|
+
if (extended.length === 0) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (extended.length < group.length) {
|
|
119
|
+
group = extended;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// Group the contenders by the event they diverge on. Branches sharing one are decided further
|
|
123
|
+
// down.
|
|
124
|
+
const rivals = new Map();
|
|
125
|
+
for (const folded of group){
|
|
126
|
+
const digest = spineDigest(folded, divergence);
|
|
127
|
+
const bucket = rivals.get(digest);
|
|
128
|
+
if (bucket == null) {
|
|
129
|
+
rivals.set(digest, [
|
|
130
|
+
folded
|
|
131
|
+
]);
|
|
132
|
+
} else {
|
|
133
|
+
bucket.push(folded);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const groups = [
|
|
137
|
+
...rivals.values()
|
|
138
|
+
];
|
|
139
|
+
// The state the diverging events all chain to. Identical across contenders, so any can answer.
|
|
140
|
+
const first = groups[0][0];
|
|
141
|
+
const prior = first.states[first.spine[divergence] - 1];
|
|
142
|
+
let dominant = -1;
|
|
143
|
+
for(let i = 0; i < groups.length; i++){
|
|
144
|
+
const candidate = groups[i][0].branch[groups[i][0].spine[divergence]];
|
|
145
|
+
const dominatesAll = groups.every((other, j)=>{
|
|
146
|
+
if (j === i) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return supersedes({
|
|
150
|
+
candidate,
|
|
151
|
+
incumbent: other[0].branch[other[0].spine[divergence]],
|
|
152
|
+
priorDigest: prior.digest,
|
|
153
|
+
priorNext: prior.next
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
if (dominatesAll) {
|
|
157
|
+
if (dominant !== -1) {
|
|
158
|
+
// More than one branch dominates every other — impossible for a legitimate rotate (two
|
|
159
|
+
// distinct rotates never both supersede one another), but fall through to duplicity
|
|
160
|
+
// defensively rather than pick arbitrarily.
|
|
161
|
+
dominant = -2;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
dominant = i;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (dominant < 0) {
|
|
168
|
+
// Two events the controller cannot both have authored, no rotate to settle it. Deterministic
|
|
169
|
+
// regardless of arrival order: sort the conflicting digests, report from the branch sorted
|
|
170
|
+
// first.
|
|
171
|
+
const digests = groups.map((members)=>spineDigest(members[0], divergence)).sort();
|
|
172
|
+
const reporter = groups.find((members)=>spineDigest(members[0], divergence) === digests[0]);
|
|
173
|
+
const at = reporter[0].states[reporter[0].spine[divergence]];
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
failure: 'duplicity',
|
|
177
|
+
duplicity: {
|
|
178
|
+
gen: at.gen,
|
|
179
|
+
seq: at.seq,
|
|
180
|
+
digests: [
|
|
181
|
+
digests[0],
|
|
182
|
+
digests[1]
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
group = groups[dominant];
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
winner: group[0]
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Pick the authoritative branch. Two rules, in order:
|
|
196
|
+
*
|
|
197
|
+
* 1. The highest folded generation wins outright. Only a reset raises the generation, and only the
|
|
198
|
+
* committed recovery key can author a reset — the root's override, unreachable by a thief.
|
|
199
|
+
* 2. Within that generation, branches are compared **at the point they diverge**, not at their heads:
|
|
200
|
+
* a rotate signed by the pre-committed next keys supersedes the current-key event it forked from
|
|
201
|
+
* and everything that branch accumulated. Two diverging events with no rotate to settle them are
|
|
202
|
+
* duplicity, surfaced rather than merged.
|
|
203
|
+
*
|
|
204
|
+
* Comparing heads instead would make branch *length* decisive below the top generation — the one
|
|
205
|
+
* resource a thief has in abundance (a stolen current key cannot rotate but can sign unlimited
|
|
206
|
+
* revokes). A longer branch still wins when it is the same history carried further, which the
|
|
207
|
+
* divergence walk sees as a prefix.
|
|
208
|
+
*
|
|
209
|
+
* `superseded` counts the state-advancing events discarded from losing branches (what a cache must
|
|
210
|
+
* invalidate — folded state is not append-only under supersession); skipped events are not counted.
|
|
211
|
+
* Branches that do not fold are filtered out before comparison, so a thief who cannot produce a valid
|
|
212
|
+
* event cannot create a duplicity report.
|
|
213
|
+
*
|
|
214
|
+
* This form folds synchronously and cannot check a capability-authorised revoke: such a branch is
|
|
215
|
+
* counted in {@link ResolveResult.unverified} and left out, and only a call where *every* branch is
|
|
216
|
+
* one answers `failure: 'needs-capability-verification'`. See {@link ResolveResult.unverified} for
|
|
217
|
+
* why the count is reported rather than fatal; the remedy is {@link resolveBranchesAsync} with a
|
|
218
|
+
* verifier.
|
|
219
|
+
*/ export function resolveBranches(did, branches) {
|
|
220
|
+
const valid = [];
|
|
221
|
+
let unverified = 0;
|
|
222
|
+
for (const branch of branches){
|
|
223
|
+
const result = foldLog(did, branch);
|
|
224
|
+
if (needsCapabilityVerification(result)) {
|
|
225
|
+
unverified++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const folded = branchFrom(branch, result);
|
|
229
|
+
if (folded != null) {
|
|
230
|
+
valid.push(folded);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return selectWinner(valid, unverified);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Async counterpart of {@link resolveBranches}, and the form the management tier needs — the sync
|
|
237
|
+
* fold fails closed on a capability-authorised revoke, so resolving through it would filter every
|
|
238
|
+
* branch of such a log away as invalid and report "no valid history" for a healthy profile.
|
|
239
|
+
*
|
|
240
|
+
* Branches are folded one at a time: `verifyCapability` is caller-supplied code that may do I/O, and
|
|
241
|
+
* branch counts in a duplicity report are small, so there is nothing to win against calling a
|
|
242
|
+
* caller's verifier concurrently.
|
|
243
|
+
*/ export async function resolveBranchesAsync(did, branches, options = {}) {
|
|
244
|
+
const valid = [];
|
|
245
|
+
let unverified = 0;
|
|
246
|
+
for (const branch of branches){
|
|
247
|
+
const result = await foldLogAsync(did, branch, options);
|
|
248
|
+
// Reachable only with no `verifyCapability` configured — with one, the verifier's answer decides
|
|
249
|
+
// and a rejection is an invalid branch. So a correctly configured call always reports
|
|
250
|
+
// `unverified: 0`.
|
|
251
|
+
if (needsCapabilityVerification(result)) {
|
|
252
|
+
unverified++;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const folded = branchFrom(branch, result);
|
|
256
|
+
if (folded != null) {
|
|
257
|
+
valid.push(folded);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return selectWinner(valid, unverified);
|
|
261
|
+
}
|
|
262
|
+
function selectWinner(valid, unverified) {
|
|
263
|
+
if (valid.length === 0) {
|
|
264
|
+
// Nothing left to compare, and which answer that is depends on *why*: an unchecked branch is not
|
|
265
|
+
// evidence the log has no history.
|
|
266
|
+
return unverified > 0 ? allUnverified(unverified) : {
|
|
267
|
+
ok: false,
|
|
268
|
+
failure: 'no-valid-branch',
|
|
269
|
+
duplicity: NO_POSITION,
|
|
270
|
+
unverified
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// Re-derivation is idempotent: branches folding to the same head are one history presented twice.
|
|
274
|
+
// The shortest representative wins, lexicographically smaller raw head at equal length — picking by
|
|
275
|
+
// arrival order would be the presentation dependence the caller must never observe. Both arms are
|
|
276
|
+
// reachable: a reset chains to the inception, so anyone can re-parent a published one onto a longer
|
|
277
|
+
// prefix; and two branches padded with different skipped events fold to the same head at one length.
|
|
278
|
+
const byHead = new Map();
|
|
279
|
+
for (const folded of valid){
|
|
280
|
+
const digest = headState(folded).digest;
|
|
281
|
+
const kept = byHead.get(digest);
|
|
282
|
+
if (kept == null || preferred(folded, kept)) {
|
|
283
|
+
byHead.set(digest, folded);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const distinct = [
|
|
287
|
+
...byHead.values()
|
|
288
|
+
];
|
|
289
|
+
const topGen = Math.max(...distinct.map((folded)=>headState(folded).gen));
|
|
290
|
+
const resolved = resolveContenders(distinct.filter((f)=>headState(f).gen === topGen));
|
|
291
|
+
if (!resolved.ok) {
|
|
292
|
+
// A duplicity report is provisional the same way a winner is: an unchecked branch might have
|
|
293
|
+
// superseded both conflicting events.
|
|
294
|
+
return {
|
|
295
|
+
...resolved,
|
|
296
|
+
unverified
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const { winner } = resolved;
|
|
300
|
+
let superseded = 0;
|
|
301
|
+
for (const loser of distinct){
|
|
302
|
+
if (loser !== winner) {
|
|
303
|
+
superseded += loser.spine.length - commonSpine(winner, loser);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
ok: true,
|
|
308
|
+
winner: winner.branch,
|
|
309
|
+
superseded,
|
|
310
|
+
unverified
|
|
311
|
+
};
|
|
312
|
+
}
|
package/lib/version.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol version tag. Forms the HKDF `info` string for every derived key, so the DID is a
|
|
3
|
+
* function of it — it can never change once a profile exists. Deliberately independent of the
|
|
4
|
+
* package name.
|
|
5
|
+
*/
|
|
6
|
+
export declare const VERSION_TAG = "did:kokuin/v1";
|
package/lib/version.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kokuin/controller",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "did:kokuin controller key event log",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"did",
|
|
7
|
+
"identity",
|
|
8
|
+
"key-management",
|
|
9
|
+
"rotation"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/TairuFramework/kokuin",
|
|
14
|
+
"directory": "packages/controller"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./lib/index.js"
|
|
21
|
+
},
|
|
22
|
+
"main": "lib/index.js",
|
|
23
|
+
"types": "lib/index.d.ts",
|
|
24
|
+
"files": [
|
|
25
|
+
"lib/*"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@noble/curves": "^2.3.0",
|
|
29
|
+
"@noble/hashes": "^2.3.0",
|
|
30
|
+
"@scure/base": "^2.3.0",
|
|
31
|
+
"@sozai/json": "^0.1.1",
|
|
32
|
+
"micro-key-producer": "^0.10.0",
|
|
33
|
+
"@kokuin/token": "^0.5.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@kokuin/controller-conformance": "^0.0.0",
|
|
37
|
+
"@kokuin/deterministic": "^0.2.2",
|
|
38
|
+
"@kokuin/jwe": "^0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
45
|
+
"build:clean": "del lib",
|
|
46
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
47
|
+
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
48
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
49
|
+
"test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
|
|
50
|
+
"test:unit": "vitest run"
|
|
51
|
+
}
|
|
52
|
+
}
|