@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.
@@ -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
+ }
@@ -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
@@ -0,0 +1,5 @@
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
+ */ export const VERSION_TAG = 'did:kokuin/v1';
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
+ }