@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/state.js
CHANGED
|
@@ -1,34 +1,29 @@
|
|
|
1
|
-
import { foldLog
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* need the same fold-reject-take-last sequence, sync and async, so it lives here once. `context`
|
|
5
|
-
* names the caller so its diagnostics survive the factoring.
|
|
6
|
-
*/ function foldedStates(did, context, result) {
|
|
1
|
+
import { foldLog } from './fold.js';
|
|
2
|
+
import { isControllerLog, MALFORMED_LOG } from './log.js';
|
|
3
|
+
/** The fold's last state, turning a fold failure into a throw. `context` names the caller. */ function lastState(did, context, result) {
|
|
7
4
|
if (!result.ok) {
|
|
8
5
|
throw new Error(`${context}: invalid log for ${did}: ${result.reason} at event ${result.index}`);
|
|
9
6
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Current state via the synchronous {@link foldLog}. A capability-authorised revoke fails closed
|
|
18
|
-
* here — see {@link currentStateAsync}.
|
|
19
|
-
*/ export function currentState(did, events, context) {
|
|
20
|
-
return lastState(did, context, foldLog(did, events));
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Every per-position state, via {@link foldLogAsync}. What the resolver needs and the identity does
|
|
24
|
-
* not: a verifier has to answer about keys the log has since rotated away, since a token outlives the
|
|
25
|
-
* state that gave its `kid` meaning.
|
|
26
|
-
*/ export async function allStatesAsync({ did, events, context, options }) {
|
|
27
|
-
return foldedStates(did, context, await foldLogAsync(did, events, options));
|
|
7
|
+
const last = result.states[result.states.length - 1];
|
|
8
|
+
if (last === undefined) {
|
|
9
|
+
throw new Error(`${context}: no state produced for ${did}`);
|
|
10
|
+
}
|
|
11
|
+
return last;
|
|
28
12
|
}
|
|
29
13
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
14
|
+
* The root key state a log establishes: the chain's last state.
|
|
15
|
+
*
|
|
16
|
+
* The chain alone, deliberately. Epoch events never change a root key (`k`, `ka`, `n` and the
|
|
17
|
+
* derivation indices live on the chain only), so folding them would buy an identity nothing but up
|
|
18
|
+
* to `MAX_EPOCH_EVENTS` signature checks on a path that must stay synchronous and cheap. The returned
|
|
19
|
+
* state's `deny`, `members` and `saturated` are therefore the chain's, not the live ones; nothing
|
|
20
|
+
* that builds an identity reads them.
|
|
21
|
+
*/ export function currentState(did, log, context) {
|
|
22
|
+
if (!isControllerLog(log)) {
|
|
23
|
+
throw new Error(`${context}: invalid log for ${did}: ${MALFORMED_LOG} at event 0`);
|
|
24
|
+
}
|
|
25
|
+
return lastState(did, context, foldLog(did, {
|
|
26
|
+
chain: log.chain,
|
|
27
|
+
events: []
|
|
28
|
+
}));
|
|
34
29
|
}
|
package/lib/supersede.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { type FoldOptions, type FoldResult } from './fold.js';
|
|
1
|
+
import type { SignedEvent } from './events.js';
|
|
3
2
|
export type Duplicity = {
|
|
4
3
|
/** Position of the divergence, from the folded state of the first conflicting event. */
|
|
5
4
|
gen: number;
|
|
@@ -16,82 +15,42 @@ export type Duplicity = {
|
|
|
16
15
|
* - `duplicity` — two events the controller cannot both have authored at one position. The only one
|
|
17
16
|
* that says anything is wrong with the log.
|
|
18
17
|
* - `no-valid-branch` — nothing presented folded at all.
|
|
19
|
-
* - `needs-capability-verification` — **every** branch carries a capability-authorised revoke this
|
|
20
|
-
* call was not equipped to check, so there is nothing left to compare. The log is fine; use
|
|
21
|
-
* {@link resolveBranchesAsync} with a `verifyCapability`. A call where only *some* branches are
|
|
22
|
-
* unverifiable answers with the rest and reports the count as {@link ResolveResult.unverified}.
|
|
23
18
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*/
|
|
27
|
-
export type ResolveFailure = 'duplicity' | 'no-valid-branch' | 'needs-capability-verification';
|
|
28
|
-
/**
|
|
29
|
-
* How many presented branches this call could not check, and therefore did not compare.
|
|
30
|
-
*
|
|
31
|
-
* Non-zero means the answer is **provisional**: an unfoldable branch may have superseded the one
|
|
32
|
-
* reported, so a caller acting on a winner must re-resolve through {@link resolveBranchesAsync} with
|
|
33
|
-
* a `verifyCapability` or treat the result as unconfirmed. Zero for every correctly configured call.
|
|
34
|
-
*
|
|
35
|
-
* Reported rather than fatal because the alternative is an unauthenticated DoS on duplicity
|
|
36
|
-
* detection. A cap-bearing revoke reaches the verifier path *before* any signature check (the
|
|
37
|
-
* capability names the signer), so a keyless peer can copy the public inception, append a `rev` with
|
|
38
|
-
* `sigs: []` and arbitrary `cap` bytes, and present it — and refusing the whole resolution on that
|
|
39
|
-
* would turn a genuine duplicity report between two honest branches into a refusal, for every
|
|
40
|
-
* profile. Dropping such a branch cannot hand an attacker a win (a branch that does not fold is not a
|
|
41
|
-
* contender); the only exposure left is a *stale* answer, which this count marks.
|
|
19
|
+
* `duplicity` still carries the `gen: -1` sentinel for the non-duplicity arm, so a caller tells them
|
|
20
|
+
* apart.
|
|
42
21
|
*/
|
|
22
|
+
export type ResolveFailure = 'duplicity' | 'no-valid-branch';
|
|
43
23
|
export type ResolveResult = {
|
|
44
24
|
ok: true;
|
|
45
25
|
winner: Array<SignedEvent>;
|
|
46
26
|
superseded: number;
|
|
47
|
-
unverified: number;
|
|
48
27
|
} | {
|
|
49
28
|
ok: false;
|
|
50
29
|
failure: ResolveFailure;
|
|
51
30
|
duplicity: Duplicity;
|
|
52
|
-
unverified: number;
|
|
53
31
|
};
|
|
54
32
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* `cap`-bearing revoke is the tier working as designed, not a forgery. Matched by prefix against the
|
|
58
|
-
* fold's exported reasons, the contract those constants exist for.
|
|
59
|
-
*/
|
|
60
|
-
export declare function needsCapabilityVerification(result: FoldResult): boolean;
|
|
61
|
-
/**
|
|
62
|
-
* Pick the authoritative branch. Two rules, in order:
|
|
33
|
+
* Pick the authoritative root chain among `chains` — chains only: epoch events never fork the log, so
|
|
34
|
+
* they are never a branch to choose between (see `mergeLogs` in `log.ts`). Two rules, in order:
|
|
63
35
|
*
|
|
64
36
|
* 1. The highest folded generation wins outright. Only a reset raises the generation, and only the
|
|
65
37
|
* committed recovery key can author a reset — the root's override, unreachable by a thief.
|
|
66
|
-
* 2. Within that generation,
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
38
|
+
* 2. Within that generation, chains are compared **at the point they diverge**, not at their heads. A
|
|
39
|
+
* chain that ends at the divergence point is a prefix of the other and loses to it; two chains
|
|
40
|
+
* that each carry an event there are duplicity, surfaced rather than merged.
|
|
41
|
+
*
|
|
42
|
+
* With root adds and root revokes moved into epochs, the chain holds only `icp` and `rot`, so the
|
|
43
|
+
* second case is always two rotates, or two resets at one generation — both signed by keys only the
|
|
44
|
+
* controller holds, neither outranking the other, and settled only by a reset at a higher generation.
|
|
45
|
+
* The current-key event a cold rotate outranks still exists, but in the epoch that rotate closes,
|
|
46
|
+
* where the fold gives it no effect; branch selection never sees it. That is also why chain length
|
|
47
|
+
* never decides anything here: the one resource a thief holding a current key has in abundance is
|
|
48
|
+
* current-key events, and none of them is on the chain. A longer chain still wins when it is the same
|
|
49
|
+
* history carried further, which the divergence walk sees as a prefix.
|
|
50
|
+
*
|
|
51
|
+
* `superseded` counts the state-advancing events discarded from losing chains (what a cache must
|
|
77
52
|
* invalidate — folded state is not append-only under supersession); skipped events are not counted.
|
|
78
|
-
*
|
|
53
|
+
* Chains that do not fold are filtered out before comparison, so a thief who cannot produce a valid
|
|
79
54
|
* event cannot create a duplicity report.
|
|
80
|
-
*
|
|
81
|
-
* This form folds synchronously and cannot check a capability-authorised revoke: such a branch is
|
|
82
|
-
* counted in {@link ResolveResult.unverified} and left out, and only a call where *every* branch is
|
|
83
|
-
* one answers `failure: 'needs-capability-verification'`. See {@link ResolveResult.unverified} for
|
|
84
|
-
* why the count is reported rather than fatal; the remedy is {@link resolveBranchesAsync} with a
|
|
85
|
-
* verifier.
|
|
86
|
-
*/
|
|
87
|
-
export declare function resolveBranches(did: string, branches: Array<Array<SignedEvent>>): ResolveResult;
|
|
88
|
-
/**
|
|
89
|
-
* Async counterpart of {@link resolveBranches}, and the form the management tier needs — the sync
|
|
90
|
-
* fold fails closed on a capability-authorised revoke, so resolving through it would filter every
|
|
91
|
-
* branch of such a log away as invalid and report "no valid history" for a healthy profile.
|
|
92
|
-
*
|
|
93
|
-
* Branches are folded one at a time: `verifyCapability` is caller-supplied code that may do I/O, and
|
|
94
|
-
* branch counts in a duplicity report are small, so there is nothing to win against calling a
|
|
95
|
-
* caller's verifier concurrently.
|
|
96
55
|
*/
|
|
97
|
-
export declare function
|
|
56
|
+
export declare function resolveBranches(did: string, chains: Array<Array<SignedEvent>>): ResolveResult;
|
package/lib/supersede.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { digestOf } from './canonical.js';
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { foldLog } from './fold.js';
|
|
3
|
+
/** Index into an array whose element is a branch-resolution invariant, throwing rather than reading undefined. */ function required(items, index, what) {
|
|
4
|
+
const item = items[index];
|
|
5
|
+
if (item === undefined) {
|
|
6
|
+
throw new Error(`supersede: ${what}`);
|
|
7
|
+
}
|
|
8
|
+
return item;
|
|
9
|
+
}
|
|
4
10
|
/**
|
|
5
11
|
* The position reported for a failure that is not a fork. Real comparisons never reach `gen < 0` and
|
|
6
12
|
* a real digest is never the empty string — how a caller told these from duplicity before
|
|
@@ -14,26 +20,11 @@ import { CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD, CAPABILITY_REVOKE_NEEDS_VERIFIER, f
|
|
|
14
20
|
]
|
|
15
21
|
};
|
|
16
22
|
function headState({ states }) {
|
|
17
|
-
return states
|
|
23
|
+
return required(states, states.length - 1, 'folded branch has no state');
|
|
18
24
|
}
|
|
19
25
|
/** The digest of the `j`th event on the spine — the whole history up to it, chained. */ function spineDigest(folded, j) {
|
|
20
|
-
|
|
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
|
-
};
|
|
26
|
+
const index = required(folded.spine, j, 'spine index out of range');
|
|
27
|
+
return required(folded.states, index, 'spine points past folded state').digest;
|
|
37
28
|
}
|
|
38
29
|
function branchFrom(branch, result) {
|
|
39
30
|
if (!result.ok) {
|
|
@@ -46,7 +37,7 @@ function branchFrom(branch, result) {
|
|
|
46
37
|
for(let i = 1; i < states.length; i++){
|
|
47
38
|
// A skipped event is the only one that leaves the digest where it was; every other chains its
|
|
48
39
|
// predecessor's digest into its body, so no two can agree.
|
|
49
|
-
if (states
|
|
40
|
+
if (required(states, i, 'state gap').digest !== required(states, i - 1, 'state gap').digest) {
|
|
50
41
|
spine.push(i);
|
|
51
42
|
}
|
|
52
43
|
}
|
|
@@ -63,11 +54,11 @@ function branchFrom(branch, result) {
|
|
|
63
54
|
if (candidate.branch.length !== incumbent.branch.length) {
|
|
64
55
|
return candidate.branch.length < incumbent.branch.length;
|
|
65
56
|
}
|
|
66
|
-
return digestOf(candidate.branch
|
|
57
|
+
return digestOf(required(candidate.branch, candidate.branch.length - 1, 'empty branch').event) < digestOf(required(incumbent.branch, incumbent.branch.length - 1, 'empty branch').event);
|
|
67
58
|
}
|
|
68
59
|
/**
|
|
69
|
-
* How far two branches agree, in spine entries. Zero
|
|
70
|
-
*
|
|
60
|
+
* How far two branches agree, in spine entries. Zero means they disagree on their first entry: on
|
|
61
|
+
* spines trimmed to the top generation, two different resets opening it.
|
|
71
62
|
*/ function commonSpine(a, b) {
|
|
72
63
|
const shortest = Math.min(a.spine.length, b.spine.length);
|
|
73
64
|
let j = 0;
|
|
@@ -77,36 +68,23 @@ function branchFrom(branch, result) {
|
|
|
77
68
|
return j;
|
|
78
69
|
}
|
|
79
70
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
71
|
+
* Walk the contenders down to one, deciding at each divergence point rather than at the heads. A
|
|
72
|
+
* contender ending at the divergence point is a prefix of the others and drops out; once every
|
|
73
|
+
* remaining contender carries an event there, they disagree on it, and nothing on the root chain can
|
|
74
|
+
* settle that within a generation — the chain holds only `icp` and `rot`, and two rotates at one
|
|
75
|
+
* position (or two resets at one generation) are both signed by keys only the controller holds,
|
|
76
|
+
* neither outranking the other. That is duplicity, for a reset at a higher generation to resolve.
|
|
77
|
+
*
|
|
78
|
+
* The whole surviving group is judged at once rather than pairwise, so the report is the same in every
|
|
79
|
+
* presentation order. The group shrinks strictly each iteration, so this terminates.
|
|
103
80
|
*/ function resolveContenders(contenders) {
|
|
104
81
|
let group = contenders;
|
|
105
82
|
while(group.length > 1){
|
|
83
|
+
const pivot = required(group, 0, 'empty contender group');
|
|
106
84
|
// Where they first disagree, against the group as a whole — the point every contender agrees to.
|
|
107
|
-
let divergence =
|
|
85
|
+
let divergence = pivot.spine.length;
|
|
108
86
|
for (const folded of group.slice(1)){
|
|
109
|
-
divergence = Math.min(divergence, commonSpine(
|
|
87
|
+
divergence = Math.min(divergence, commonSpine(pivot, folded));
|
|
110
88
|
}
|
|
111
89
|
// A branch ending at the divergence point is a prefix of the others: extended, not contradicted,
|
|
112
90
|
// so it loses without any event discarded. The ordinary "one peer is behind" case, and the only
|
|
@@ -119,155 +97,76 @@ function branchFrom(branch, result) {
|
|
|
119
97
|
group = extended;
|
|
120
98
|
continue;
|
|
121
99
|
}
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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;
|
|
100
|
+
// Every contender carries an event at the divergence point and at least two differ: two events the
|
|
101
|
+
// controller cannot both have authored. Deterministic regardless of arrival order: sort the
|
|
102
|
+
// conflicting digests, report from the branch sorted first.
|
|
103
|
+
const digests = [
|
|
104
|
+
...new Set(group.map((folded)=>spineDigest(folded, divergence)))
|
|
105
|
+
].sort();
|
|
106
|
+
const firstDigest = required(digests, 0, 'no diverging digests');
|
|
107
|
+
const secondDigest = required(digests, 1, 'duplicity needs two digests');
|
|
108
|
+
const reporter = group.find((folded)=>spineDigest(folded, divergence) === firstDigest);
|
|
109
|
+
const at = required(reporter.states, required(reporter.spine, divergence, 'spine index out of range'), 'divergence points past folded state');
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
failure: 'duplicity',
|
|
113
|
+
duplicity: {
|
|
114
|
+
gen: at.gen,
|
|
115
|
+
seq: at.seq,
|
|
116
|
+
digests: [
|
|
117
|
+
firstDigest,
|
|
118
|
+
secondDigest
|
|
119
|
+
]
|
|
165
120
|
}
|
|
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];
|
|
121
|
+
};
|
|
188
122
|
}
|
|
189
123
|
return {
|
|
190
124
|
ok: true,
|
|
191
|
-
winner: group
|
|
125
|
+
winner: required(group, 0, 'empty contender group')
|
|
192
126
|
};
|
|
193
127
|
}
|
|
194
128
|
/**
|
|
195
|
-
* Pick the authoritative
|
|
129
|
+
* Pick the authoritative root chain among `chains` — chains only: epoch events never fork the log, so
|
|
130
|
+
* they are never a branch to choose between (see `mergeLogs` in `log.ts`). Two rules, in order:
|
|
196
131
|
*
|
|
197
132
|
* 1. The highest folded generation wins outright. Only a reset raises the generation, and only the
|
|
198
133
|
* committed recovery key can author a reset — the root's override, unreachable by a thief.
|
|
199
|
-
* 2. Within that generation,
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* duplicity, surfaced rather than merged.
|
|
134
|
+
* 2. Within that generation, chains are compared **at the point they diverge**, not at their heads. A
|
|
135
|
+
* chain that ends at the divergence point is a prefix of the other and loses to it; two chains
|
|
136
|
+
* that each carry an event there are duplicity, surfaced rather than merged.
|
|
203
137
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
138
|
+
* With root adds and root revokes moved into epochs, the chain holds only `icp` and `rot`, so the
|
|
139
|
+
* second case is always two rotates, or two resets at one generation — both signed by keys only the
|
|
140
|
+
* controller holds, neither outranking the other, and settled only by a reset at a higher generation.
|
|
141
|
+
* The current-key event a cold rotate outranks still exists, but in the epoch that rotate closes,
|
|
142
|
+
* where the fold gives it no effect; branch selection never sees it. That is also why chain length
|
|
143
|
+
* never decides anything here: the one resource a thief holding a current key has in abundance is
|
|
144
|
+
* current-key events, and none of them is on the chain. A longer chain still wins when it is the same
|
|
145
|
+
* history carried further, which the divergence walk sees as a prefix.
|
|
208
146
|
*
|
|
209
|
-
* `superseded` counts the state-advancing events discarded from losing
|
|
147
|
+
* `superseded` counts the state-advancing events discarded from losing chains (what a cache must
|
|
210
148
|
* invalidate — folded state is not append-only under supersession); skipped events are not counted.
|
|
211
|
-
*
|
|
149
|
+
* Chains that do not fold are filtered out before comparison, so a thief who cannot produce a valid
|
|
212
150
|
* 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) {
|
|
151
|
+
*/ export function resolveBranches(did, chains) {
|
|
220
152
|
const valid = [];
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
const folded = branchFrom(branch, result);
|
|
153
|
+
for (const branch of chains){
|
|
154
|
+
const folded = branchFrom(branch, foldLog(did, {
|
|
155
|
+
chain: branch,
|
|
156
|
+
events: []
|
|
157
|
+
}));
|
|
229
158
|
if (folded != null) {
|
|
230
159
|
valid.push(folded);
|
|
231
160
|
}
|
|
232
161
|
}
|
|
233
|
-
return selectWinner(valid
|
|
162
|
+
return selectWinner(valid);
|
|
234
163
|
}
|
|
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) {
|
|
164
|
+
function selectWinner(valid) {
|
|
263
165
|
if (valid.length === 0) {
|
|
264
|
-
|
|
265
|
-
// evidence the log has no history.
|
|
266
|
-
return unverified > 0 ? allUnverified(unverified) : {
|
|
166
|
+
return {
|
|
267
167
|
ok: false,
|
|
268
168
|
failure: 'no-valid-branch',
|
|
269
|
-
duplicity: NO_POSITION
|
|
270
|
-
unverified
|
|
169
|
+
duplicity: NO_POSITION
|
|
271
170
|
};
|
|
272
171
|
}
|
|
273
172
|
// Re-derivation is idempotent: branches folding to the same head are one history presented twice.
|
|
@@ -287,26 +186,41 @@ function selectWinner(valid, unverified) {
|
|
|
287
186
|
...byHead.values()
|
|
288
187
|
];
|
|
289
188
|
const topGen = Math.max(...distinct.map((folded)=>headState(folded).gen));
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
189
|
+
// Compared from the top generation's first event on, not from the inception. A reset chains to the
|
|
190
|
+
// inception rather than to what preceded it, and its state is a function of the event alone, so one
|
|
191
|
+
// reset can sit on different prefixes — appended to a generation-0 chain by one party, straight
|
|
192
|
+
// after the inception in another's pruned copy. Once both contain it, what came before cannot change
|
|
193
|
+
// the winner; compared from the inception, the two would diverge at position 1 and read as a fork.
|
|
194
|
+
// At generation 0 the generation starts at the inception, so nothing is dropped.
|
|
195
|
+
const original = new Map();
|
|
196
|
+
const contenders = distinct.filter((folded)=>headState(folded).gen === topGen).map((folded)=>{
|
|
197
|
+
const start = folded.spine.findIndex((index)=>required(folded.states, index, 'spine points past folded state').gen === topGen);
|
|
198
|
+
const trimmed = {
|
|
199
|
+
...folded,
|
|
200
|
+
spine: folded.spine.slice(start)
|
|
297
201
|
};
|
|
202
|
+
original.set(trimmed, folded);
|
|
203
|
+
return trimmed;
|
|
204
|
+
});
|
|
205
|
+
const resolved = resolveContenders(contenders);
|
|
206
|
+
if (!resolved.ok) {
|
|
207
|
+
return resolved;
|
|
298
208
|
}
|
|
299
|
-
const
|
|
209
|
+
const winner = original.get(resolved.winner) ?? resolved.winner;
|
|
210
|
+
// Discarded: what a loser's spine holds that the winner's does not. Counted by digest rather than
|
|
211
|
+
// from the common prefix, so a reset both carry on different prefixes is not counted as lost.
|
|
212
|
+
const kept = new Set(winner.spine.map((_, j)=>spineDigest(winner, j)));
|
|
300
213
|
let superseded = 0;
|
|
301
214
|
for (const loser of distinct){
|
|
302
215
|
if (loser !== winner) {
|
|
303
|
-
|
|
216
|
+
for(let j = 0; j < loser.spine.length; j++){
|
|
217
|
+
if (!kept.has(spineDigest(loser, j))) superseded++;
|
|
218
|
+
}
|
|
304
219
|
}
|
|
305
220
|
}
|
|
306
221
|
return {
|
|
307
222
|
ok: true,
|
|
308
223
|
winner: winner.branch,
|
|
309
|
-
superseded
|
|
310
|
-
unverified
|
|
224
|
+
superseded
|
|
311
225
|
};
|
|
312
226
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kokuin/controller",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "did:kokuin controller key event log",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"did",
|
|
@@ -25,26 +25,26 @@
|
|
|
25
25
|
"lib/*"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@
|
|
29
|
-
"@noble/
|
|
30
|
-
"@
|
|
31
|
-
"@
|
|
32
|
-
"
|
|
33
|
-
"
|
|
28
|
+
"@kokuin/token": "^0.6.0",
|
|
29
|
+
"@noble/curves": "^2.4.0",
|
|
30
|
+
"@noble/hashes": "^2.4.0",
|
|
31
|
+
"@scure/base": "^2.4.0",
|
|
32
|
+
"@sozai/json": "^0.3.0",
|
|
33
|
+
"micro-key-producer": "^0.10.2"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@kokuin/controller-conformance": "^0.0.0",
|
|
37
|
-
"@kokuin/deterministic": "^0.2.
|
|
38
|
-
"@kokuin/jwe": "^0.1.
|
|
37
|
+
"@kokuin/deterministic": "^0.2.3",
|
|
38
|
+
"@kokuin/jwe": "^0.1.1"
|
|
39
39
|
},
|
|
40
40
|
"publishConfig": {
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
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",
|
|
45
|
+
"build:clean": "del-cli lib",
|
|
46
|
+
"build:js": "del-cli 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
47
|
+
"build:types": "del-cli 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
|
|
48
48
|
"test": "pnpm run test:types && pnpm run test:unit",
|
|
49
49
|
"test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
|
|
50
50
|
"test:unit": "vitest run"
|