@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/resolver.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { DID_PREFIX } from './events.js';
|
|
2
|
+
import { authoritativeStates } from './history.js';
|
|
3
|
+
import { allStatesAsync } from './state.js';
|
|
4
|
+
import { agreementKeysFrom, DID_METHOD, signingKeyFrom } from './state-resolver.js';
|
|
5
|
+
const CONTEXT = 'Controller resolver';
|
|
6
|
+
/**
|
|
7
|
+
* A `did:kokuin:` resolver for `@kokuin/token`. Injected rather than imported by token: this package
|
|
8
|
+
* depends on token for signing, so the reverse import would cycle. Token resolves `iss` through the
|
|
9
|
+
* interface without knowing the fold exists.
|
|
10
|
+
*/ export function createControllerResolver(options) {
|
|
11
|
+
// The fold currently under way for each DID, so overlapping resolutions of one DID share it (two
|
|
12
|
+
// tokens from one issuer verified in parallel is ordinary — token caches only `did:peer:4`).
|
|
13
|
+
//
|
|
14
|
+
// Also the last line against a `loadLog` that re-enters resolution for the DID it is loading. The
|
|
15
|
+
// fold no longer does that itself, but a caller's `loadLog` can still reach `resolve` for the same
|
|
16
|
+
// DID: sharing the pending fold makes that a quiet deadlock the caller's request timeout catches,
|
|
17
|
+
// rather than an await-chained Ed25519 loop that starves the timer queue.
|
|
18
|
+
//
|
|
19
|
+
// Not a cache: removed as soon as the fold settles, so a later resolution re-folds.
|
|
20
|
+
const inFlight = new Map();
|
|
21
|
+
// Shared by every member: load, fold, throw `Unknown DID` when absent. The whole state array
|
|
22
|
+
// rather than the head, because `resolveHistoric` answers about rotated-away keys; head-only
|
|
23
|
+
// members take `states[states.length - 1]`. Always the async fold: it differs from `foldLog` only
|
|
24
|
+
// in awaiting a capability-authorised revoke's verifier, so a mode-selecting option would choose
|
|
25
|
+
// nothing — the verifier is the only thing a caller supplies.
|
|
26
|
+
async function computeStates(did) {
|
|
27
|
+
const events = await options.loadLog(did);
|
|
28
|
+
if (events == null || events.length === 0) {
|
|
29
|
+
throw new Error(`Unknown DID: ${did}`);
|
|
30
|
+
}
|
|
31
|
+
const foldOptions = {
|
|
32
|
+
verifyCapability: options.verifyCapability
|
|
33
|
+
};
|
|
34
|
+
if (options.history == null) {
|
|
35
|
+
return await allStatesAsync({
|
|
36
|
+
did,
|
|
37
|
+
events,
|
|
38
|
+
context: CONTEXT,
|
|
39
|
+
options: foldOptions
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// Compared against what this party last accepted, so a truncated log — folding cleanly, missing
|
|
43
|
+
// the revoke that matters — is refused. See `LogStore`.
|
|
44
|
+
const seen = await options.history.get(did);
|
|
45
|
+
const { log, states } = await authoritativeStates({
|
|
46
|
+
did,
|
|
47
|
+
loaded: events,
|
|
48
|
+
seen,
|
|
49
|
+
context: CONTEXT,
|
|
50
|
+
options: foldOptions
|
|
51
|
+
});
|
|
52
|
+
// Only after it has folded, and only in the memory-keeping direction: a log that lost never
|
|
53
|
+
// reaches here, so the store never moves backwards.
|
|
54
|
+
await options.history.set(did, log);
|
|
55
|
+
return states;
|
|
56
|
+
}
|
|
57
|
+
async function loadStates(did) {
|
|
58
|
+
if (!did.startsWith(DID_PREFIX)) {
|
|
59
|
+
throw new Error(`Unknown DID: ${did}`);
|
|
60
|
+
}
|
|
61
|
+
const pending = inFlight.get(did);
|
|
62
|
+
if (pending != null) {
|
|
63
|
+
// Awaiting an existing fold, never clearing it — the call that created it owns removal, so a
|
|
64
|
+
// joiner cannot evict an entry a third caller is about to join.
|
|
65
|
+
return await pending;
|
|
66
|
+
}
|
|
67
|
+
// Deferred by a microtask so the entry is registered *before* `loadLog` runs — otherwise a
|
|
68
|
+
// `loadLog` that re-enters `resolve` before its own first `await` recurses to a stack overflow
|
|
69
|
+
// while the map is still empty.
|
|
70
|
+
const fold = Promise.resolve().then(()=>computeStates(did));
|
|
71
|
+
inFlight.set(did, fold);
|
|
72
|
+
try {
|
|
73
|
+
return await fold;
|
|
74
|
+
} finally{
|
|
75
|
+
// Removal is what keeps this from being a cache: the next resolution re-reads and re-folds, so
|
|
76
|
+
// a revoke landing between two resolutions takes effect. In `finally`, so a rejected fold
|
|
77
|
+
// clears too.
|
|
78
|
+
inFlight.delete(did);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
method: DID_METHOD,
|
|
83
|
+
// The head's key set only: a profile that rotated away from a key answers "no" for it here, which
|
|
84
|
+
// is what makes `rotate` retire a leaked authority key rather than add one beside it.
|
|
85
|
+
async resolve (did, header = {}) {
|
|
86
|
+
return signingKeyFrom({
|
|
87
|
+
did,
|
|
88
|
+
states: await loadStates(did),
|
|
89
|
+
header
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
// Every key set within the current generation, for a caller verifying past-issued material. A
|
|
93
|
+
// `rotate` must not invalidate those; a `reset` still does, and the scan stops at the generation
|
|
94
|
+
// boundary. See `signingKeyFrom` in `state-resolver.ts`.
|
|
95
|
+
async resolveHistoric (did, header = {}) {
|
|
96
|
+
return signingKeyFrom({
|
|
97
|
+
did,
|
|
98
|
+
states: await loadStates(did),
|
|
99
|
+
header,
|
|
100
|
+
historic: true
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
async resolveDenySet (did) {
|
|
104
|
+
// The head's set, deliberately, not the state at any position a capability names: `iat` is
|
|
105
|
+
// author-supplied and backdatable, so anchoring to it would let a revoked holder pick a
|
|
106
|
+
// position at which it was not yet denied. The set is position-dependent *within the fold*; the
|
|
107
|
+
// question a verifier asks is "is this grant valid now". Both spellings ride in it (see
|
|
108
|
+
// `KeyState.deny`); the key half is enforced by `signingKeyFrom`, and the two forms cannot
|
|
109
|
+
// collide. `loadStates` throws `Unknown DID` rather than answering an empty (= "nobody
|
|
110
|
+
// revoked") set.
|
|
111
|
+
const states = await loadStates(did);
|
|
112
|
+
return states[states.length - 1].deny;
|
|
113
|
+
},
|
|
114
|
+
async resolveAgreementKey (did) {
|
|
115
|
+
// The head's set only, unlike `resolve`. A retired agreement key is a downgrade, not a grant
|
|
116
|
+
// the profile already made: the recipient decrypts old ciphertexts by re-deriving from the
|
|
117
|
+
// seed, never by resolution.
|
|
118
|
+
return agreementKeysFrom(did, await loadStates(did));
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type DIDMethodResolver, type ResolvedAgreementKey, type ResolvedSigningKey, type ResolveIssuerHeader } from '@kokuin/token';
|
|
2
|
+
import type { KeyState } from './fold.js';
|
|
3
|
+
/** The method segment of {@link DID_PREFIX}, so the two cannot drift. */
|
|
4
|
+
export declare const DID_METHOD: string;
|
|
5
|
+
/**
|
|
6
|
+
* What a token signed by a revoked key is rejected with. The `#`-prefixed key follows after `: `, so
|
|
7
|
+
* match with `startsWith`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const KEY_REVOKED = "kid names a key the controller has revoked";
|
|
10
|
+
/**
|
|
11
|
+
* The signing key `header` names, out of an already-folded log. `historic` picks which question above
|
|
12
|
+
* is asked, and defaults to the safe one.
|
|
13
|
+
*
|
|
14
|
+
* **A revoked key answers neither question.** A `rev` naming `#<key>` retires a key for material it
|
|
15
|
+
* has *already* signed, and this is where that bites — every `did:kokuin:` signature check reaches a
|
|
16
|
+
* resolved key through here, so the denial covers `verifyToken` on both `historic` settings, every
|
|
17
|
+
* capability and revocation record `@kokuin/capability` verifies, and the fold's own cap-authorised
|
|
18
|
+
* revoke. The historic arm is the live one: a revoked key is never in the head's `k` (the
|
|
19
|
+
* `KeyState.deny` invariant), so only the backwards scan can select one, and without this
|
|
20
|
+
* `resolveHistoric` would accept a leaked key forever.
|
|
21
|
+
*
|
|
22
|
+
* The head arm cannot be reached from a log this package folded, but is not decoration: `states`
|
|
23
|
+
* arrives from the caller, and the invariant is the fold's property, not the array's — a
|
|
24
|
+
* caller-built state whose head publishes a key it also denies resolves to nothing here.
|
|
25
|
+
*
|
|
26
|
+
* **Evaluated against the head's deny set, never the position the key was selected at.** A denial is
|
|
27
|
+
* about the key, not one event, and the position an artefact carries is author-supplied — else a
|
|
28
|
+
* thief presents a token resolving at a position before the revoke. Deliberately *not* how a DID
|
|
29
|
+
* denial behaves inside the fold, where `states[i]` is consulted at position `i` because "was this
|
|
30
|
+
* author allowed to act *here*" is the whole question; the two rules answer different questions.
|
|
31
|
+
* Inside the fold they coincide anyway: `createStateResolver` gets the prefix `states[0..i-1]`, so
|
|
32
|
+
* its head *is* the position being verified.
|
|
33
|
+
*/
|
|
34
|
+
export declare function signingKeyFrom({ did, states, header, historic, }: {
|
|
35
|
+
did: string;
|
|
36
|
+
states: Array<KeyState>;
|
|
37
|
+
header: ResolveIssuerHeader;
|
|
38
|
+
historic?: boolean;
|
|
39
|
+
}): ResolvedSigningKey;
|
|
40
|
+
/**
|
|
41
|
+
* The head's key agreement set, minus anything it has revoked. A `rev` may name an agreement key as
|
|
42
|
+
* readily as a signing one (the target spelling is the key, not its purpose), and these are the keys
|
|
43
|
+
* a *sender* encrypts to — encrypting to a revoked key is the one outcome a denial must prevent.
|
|
44
|
+
*
|
|
45
|
+
* Like the signing side, unreachable from a log this package folded (the `KeyState.deny` invariant)
|
|
46
|
+
* but `states` arrives from the caller. Filtering to empty is the honest answer for a state that
|
|
47
|
+
* denies everything it publishes: `@kokuin/jwe` then encrypts nothing, the fail-closed direction.
|
|
48
|
+
*/
|
|
49
|
+
export declare function agreementKeysFrom(did: string, states: Array<KeyState>): Array<ResolvedAgreementKey>;
|
|
50
|
+
/**
|
|
51
|
+
* A `DIDMethodResolver` answering for exactly one profile, out of already-folded state. The fold
|
|
52
|
+
* hands one to a capability-authorised revoke's verifier, built from the states *before* the event
|
|
53
|
+
* being verified — the prefix contract made unforgettable: the verifier no longer needs a
|
|
54
|
+
* position-aware registry, because the fold supplies the answer. See `FoldOptions.verifyCapability`.
|
|
55
|
+
*
|
|
56
|
+
* Answers only for `did`; any other identifier is `Unknown DID`, so a caller merging this into a
|
|
57
|
+
* wider registry falls back to its own resolver for another `did:kokuin:` delegate and never serves
|
|
58
|
+
* one profile's state for another's. Not a cache: the states are the caller's, fixed at construction.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createStateResolver(did: string, states: Array<KeyState>): DIDMethodResolver;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { IssuerKeyNotFoundError } from '@kokuin/token';
|
|
2
|
+
import { DID_PREFIX, keyTarget } from './events.js';
|
|
3
|
+
import { decodeKey } from './keys.js';
|
|
4
|
+
/** The method segment of {@link DID_PREFIX}, so the two cannot drift. */ export const DID_METHOD = DID_PREFIX.slice('did:'.length, -1);
|
|
5
|
+
/**
|
|
6
|
+
* The `kid` fragment's body, or `null` when the header names no key. Format is `#<the multibase key
|
|
7
|
+
* exactly as it appears in `k`>`, matched against the folded key sets by membership — an index-based
|
|
8
|
+
* fragment would mean whatever the key set said at the time, and a token outlives that. The bare key
|
|
9
|
+
* without `#` is rejected, not accepted as a second spelling.
|
|
10
|
+
*
|
|
11
|
+
* Every rejection on this path is an `IssuerKeyNotFoundError`, not the plain error the rest of this
|
|
12
|
+
* file throws: the DID resolved and its log folded — what failed is the key. Load-bearing, because
|
|
13
|
+
* `resolveIssuerWithDoc` retypes everything else as `UnresolvableIssuerError`, on which
|
|
14
|
+
* `@kokuin/capability` *denies* a capability; since `kid` is unauthenticated, a fabricated record
|
|
15
|
+
* naming this DID and any invented key would otherwise deny every capability this controller issued.
|
|
16
|
+
*/ function keyFromKid(did, kid) {
|
|
17
|
+
if (kid == null) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (!kid.startsWith('#')) {
|
|
21
|
+
throw new IssuerKeyNotFoundError(`Controller ${did} kid is not a key fragment: ${kid}`);
|
|
22
|
+
}
|
|
23
|
+
return kid.slice(1);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The key a token's `kid` names, out of the **head's** key set alone — what `resolve` answers, and
|
|
27
|
+
* the only question authenticating a live signer may ask ("can this profile sign with this key
|
|
28
|
+
* *now*"). A key the profile rotated away is not an answer, which is what makes a `rotate` retire a
|
|
29
|
+
* leaked authority key for new issuance — the middle rung of the `revoke → rotate → reset` ladder.
|
|
30
|
+
*
|
|
31
|
+
* A `kid` naming a key not in the head's `k` (including one published earlier) is an error, **never
|
|
32
|
+
* a fall back to `keys[0]`**, which would check the signature against a key the token never claimed.
|
|
33
|
+
* An absent `kid` resolves to the head's first key: volunteering one is not the same as accepting one
|
|
34
|
+
* the token names. Past-issued material is {@link historicSigningKey}, via explicit `resolveHistoric`.
|
|
35
|
+
*/ function headSigningKey(did, states, kid) {
|
|
36
|
+
const head = states[states.length - 1];
|
|
37
|
+
const key = keyFromKid(did, kid);
|
|
38
|
+
if (key == null) {
|
|
39
|
+
return head.keys[0];
|
|
40
|
+
}
|
|
41
|
+
if (!head.keys.includes(key)) {
|
|
42
|
+
throw new IssuerKeyNotFoundError(`Controller ${did} kid names a key that is not current: ${kid}`);
|
|
43
|
+
}
|
|
44
|
+
return key;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The key a token's `kid` names, out of **every position in the current generation** — what
|
|
48
|
+
* `resolveHistoric` answers. It accepts a key the profile has since rotated away, so it establishes
|
|
49
|
+
* "this profile did once hold this key", never "holds". The right answer for material minted in the
|
|
50
|
+
* past (an already-issued capability, a revocation record), because a routine `rotate` must not
|
|
51
|
+
* invalidate what the profile issued to third parties who cannot know it rotated; the wrong answer
|
|
52
|
+
* for a live signer.
|
|
53
|
+
*
|
|
54
|
+
* A `reset` is what invalidates (it discards everything under the prior generation), and `gen` never
|
|
55
|
+
* decreases, so the backwards scan stops dead at the first earlier-generation state. Runs backwards
|
|
56
|
+
* because the head's own key set is the common answer. A `kid` naming a never-published key, or one
|
|
57
|
+
* from a superseded generation, is an error — never a fall back to `keys[0]`.
|
|
58
|
+
*/ function historicSigningKey(did, states, kid) {
|
|
59
|
+
const head = states[states.length - 1];
|
|
60
|
+
const key = keyFromKid(did, kid);
|
|
61
|
+
if (key == null) {
|
|
62
|
+
return head.keys[0];
|
|
63
|
+
}
|
|
64
|
+
for(let i = states.length - 1; i >= 0 && states[i].gen === head.gen; i--){
|
|
65
|
+
if (states[i].keys.includes(key)) {
|
|
66
|
+
return key;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
throw new IssuerKeyNotFoundError(`Controller ${did} kid names a key outside the current generation: ${kid}`);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* What a token signed by a revoked key is rejected with. The `#`-prefixed key follows after `: `, so
|
|
73
|
+
* match with `startsWith`.
|
|
74
|
+
*/ export const KEY_REVOKED = 'kid names a key the controller has revoked';
|
|
75
|
+
/**
|
|
76
|
+
* The signing key `header` names, out of an already-folded log. `historic` picks which question above
|
|
77
|
+
* is asked, and defaults to the safe one.
|
|
78
|
+
*
|
|
79
|
+
* **A revoked key answers neither question.** A `rev` naming `#<key>` retires a key for material it
|
|
80
|
+
* has *already* signed, and this is where that bites — every `did:kokuin:` signature check reaches a
|
|
81
|
+
* resolved key through here, so the denial covers `verifyToken` on both `historic` settings, every
|
|
82
|
+
* capability and revocation record `@kokuin/capability` verifies, and the fold's own cap-authorised
|
|
83
|
+
* revoke. The historic arm is the live one: a revoked key is never in the head's `k` (the
|
|
84
|
+
* `KeyState.deny` invariant), so only the backwards scan can select one, and without this
|
|
85
|
+
* `resolveHistoric` would accept a leaked key forever.
|
|
86
|
+
*
|
|
87
|
+
* The head arm cannot be reached from a log this package folded, but is not decoration: `states`
|
|
88
|
+
* arrives from the caller, and the invariant is the fold's property, not the array's — a
|
|
89
|
+
* caller-built state whose head publishes a key it also denies resolves to nothing here.
|
|
90
|
+
*
|
|
91
|
+
* **Evaluated against the head's deny set, never the position the key was selected at.** A denial is
|
|
92
|
+
* about the key, not one event, and the position an artefact carries is author-supplied — else a
|
|
93
|
+
* thief presents a token resolving at a position before the revoke. Deliberately *not* how a DID
|
|
94
|
+
* denial behaves inside the fold, where `states[i]` is consulted at position `i` because "was this
|
|
95
|
+
* author allowed to act *here*" is the whole question; the two rules answer different questions.
|
|
96
|
+
* Inside the fold they coincide anyway: `createStateResolver` gets the prefix `states[0..i-1]`, so
|
|
97
|
+
* its head *is* the position being verified.
|
|
98
|
+
*/ export function signingKeyFrom({ did, states, header, historic = false }) {
|
|
99
|
+
const head = states[states.length - 1];
|
|
100
|
+
if (head.keys.length === 0) {
|
|
101
|
+
throw new Error(`Controller ${did} has no signing key`);
|
|
102
|
+
}
|
|
103
|
+
const selected = historic ? historicSigningKey(did, states, header.kid) : headSigningKey(did, states, header.kid);
|
|
104
|
+
// `IssuerKeyNotFoundError` like every rejection here: the DID resolved and the log folded, and what
|
|
105
|
+
// failed is the key. Retyping it unresolvable would let a `kid` (unauthenticated) make this
|
|
106
|
+
// controller read as unresolvable to every fail-closed caller. See {@link keyFromKid}.
|
|
107
|
+
if (head.deny.has(keyTarget(selected))) {
|
|
108
|
+
throw new IssuerKeyNotFoundError(`Controller ${did} ${KEY_REVOKED}: ${keyTarget(selected)}`);
|
|
109
|
+
}
|
|
110
|
+
const key = decodeKey(selected);
|
|
111
|
+
if (key.alg === 'X25519') {
|
|
112
|
+
throw new Error(`Controller ${did} signing key is not a signature algorithm: ${key.alg}`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
alg: key.alg,
|
|
116
|
+
publicKey: key.publicKey
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The head's key agreement set, minus anything it has revoked. A `rev` may name an agreement key as
|
|
121
|
+
* readily as a signing one (the target spelling is the key, not its purpose), and these are the keys
|
|
122
|
+
* a *sender* encrypts to — encrypting to a revoked key is the one outcome a denial must prevent.
|
|
123
|
+
*
|
|
124
|
+
* Like the signing side, unreachable from a log this package folded (the `KeyState.deny` invariant)
|
|
125
|
+
* but `states` arrives from the caller. Filtering to empty is the honest answer for a state that
|
|
126
|
+
* denies everything it publishes: `@kokuin/jwe` then encrypts nothing, the fail-closed direction.
|
|
127
|
+
*/ export function agreementKeysFrom(did, states) {
|
|
128
|
+
const head = states[states.length - 1];
|
|
129
|
+
return head.agreement.flatMap((value)=>{
|
|
130
|
+
if (head.deny.has(keyTarget(value))) {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const key = decodeKey(value);
|
|
134
|
+
if (key.alg !== 'X25519') {
|
|
135
|
+
throw new Error(`Controller ${did} publishes an unsupported agreement key: ${key.alg}`);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
alg: key.alg,
|
|
139
|
+
publicKey: key.publicKey
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A `DIDMethodResolver` answering for exactly one profile, out of already-folded state. The fold
|
|
145
|
+
* hands one to a capability-authorised revoke's verifier, built from the states *before* the event
|
|
146
|
+
* being verified — the prefix contract made unforgettable: the verifier no longer needs a
|
|
147
|
+
* position-aware registry, because the fold supplies the answer. See `FoldOptions.verifyCapability`.
|
|
148
|
+
*
|
|
149
|
+
* Answers only for `did`; any other identifier is `Unknown DID`, so a caller merging this into a
|
|
150
|
+
* wider registry falls back to its own resolver for another `did:kokuin:` delegate and never serves
|
|
151
|
+
* one profile's state for another's. Not a cache: the states are the caller's, fixed at construction.
|
|
152
|
+
*/ export function createStateResolver(did, states) {
|
|
153
|
+
function statesFor(asked) {
|
|
154
|
+
if (asked !== did) {
|
|
155
|
+
throw new Error(`Unknown DID: ${asked}`);
|
|
156
|
+
}
|
|
157
|
+
return states;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
method: DID_METHOD,
|
|
161
|
+
async resolve (asked, header = {}) {
|
|
162
|
+
return signingKeyFrom({
|
|
163
|
+
did: asked,
|
|
164
|
+
states: statesFor(asked),
|
|
165
|
+
header
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
// The head of *this prefix*, not the log: these states end at the position the fold is verifying.
|
|
169
|
+
// A capability issued before that position is the archived material `resolveHistoric` exists for.
|
|
170
|
+
async resolveHistoric (asked, header = {}) {
|
|
171
|
+
return signingKeyFrom({
|
|
172
|
+
did: asked,
|
|
173
|
+
states: statesFor(asked),
|
|
174
|
+
header,
|
|
175
|
+
historic: true
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
async resolveDenySet (asked) {
|
|
179
|
+
const known = statesFor(asked);
|
|
180
|
+
return known[known.length - 1].deny;
|
|
181
|
+
},
|
|
182
|
+
async resolveAgreementKey (asked) {
|
|
183
|
+
return agreementKeysFrom(asked, statesFor(asked));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
package/lib/state.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SignedEvent } from './events.js';
|
|
2
|
+
import { type FoldOptions, type KeyState } from './fold.js';
|
|
3
|
+
/**
|
|
4
|
+
* Current state via the synchronous {@link foldLog}. A capability-authorised revoke fails closed
|
|
5
|
+
* here — see {@link currentStateAsync}.
|
|
6
|
+
*/
|
|
7
|
+
export declare function currentState(did: string, events: Array<SignedEvent>, context: string): KeyState;
|
|
8
|
+
/**
|
|
9
|
+
* Every per-position state, via {@link foldLogAsync}. What the resolver needs and the identity does
|
|
10
|
+
* not: a verifier has to answer about keys the log has since rotated away, since a token outlives the
|
|
11
|
+
* state that gave its `kid` meaning.
|
|
12
|
+
*/
|
|
13
|
+
export declare function allStatesAsync({ did, events, context, options, }: {
|
|
14
|
+
did: string;
|
|
15
|
+
events: Array<SignedEvent>;
|
|
16
|
+
context: string;
|
|
17
|
+
options?: FoldOptions;
|
|
18
|
+
}): Promise<Array<KeyState>>;
|
|
19
|
+
/**
|
|
20
|
+
* Current state via {@link foldLogAsync}, which can await `options.verifyCapability` for a
|
|
21
|
+
* capability-authorised revoke instead of failing closed on it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function currentStateAsync({ did, events, context, options, }: {
|
|
24
|
+
did: string;
|
|
25
|
+
events: Array<SignedEvent>;
|
|
26
|
+
context: string;
|
|
27
|
+
options?: FoldOptions;
|
|
28
|
+
}): Promise<KeyState>;
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { foldLog, foldLogAsync } from './fold.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fold results to states, turning a fold failure into a throw. The identity and the resolver both
|
|
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) {
|
|
7
|
+
if (!result.ok) {
|
|
8
|
+
throw new Error(`${context}: invalid log for ${did}: ${result.reason} at event ${result.index}`);
|
|
9
|
+
}
|
|
10
|
+
return result.states;
|
|
11
|
+
}
|
|
12
|
+
function lastState(did, context, result) {
|
|
13
|
+
const states = foldedStates(did, context, result);
|
|
14
|
+
return states[states.length - 1];
|
|
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));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Current state via {@link foldLogAsync}, which can await `options.verifyCapability` for a
|
|
31
|
+
* capability-authorised revoke instead of failing closed on it.
|
|
32
|
+
*/ export async function currentStateAsync({ did, events, context, options }) {
|
|
33
|
+
return lastState(did, context, await foldLogAsync(did, events, options));
|
|
34
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type SignedEvent } from './events.js';
|
|
2
|
+
import { type FoldOptions, type FoldResult } from './fold.js';
|
|
3
|
+
export type Duplicity = {
|
|
4
|
+
/** Position of the divergence, from the folded state of the first conflicting event. */
|
|
5
|
+
gen: number;
|
|
6
|
+
seq: number;
|
|
7
|
+
/**
|
|
8
|
+
* Digests of the two conflicting events *at the divergence point*, sorted — not of the branch
|
|
9
|
+
* heads, which for branches of unequal length say nothing about where they disagree.
|
|
10
|
+
*/
|
|
11
|
+
digests: [string, string];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Why branch selection could not name a winner.
|
|
15
|
+
*
|
|
16
|
+
* - `duplicity` — two events the controller cannot both have authored at one position. The only one
|
|
17
|
+
* that says anything is wrong with the log.
|
|
18
|
+
* - `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
|
+
*
|
|
24
|
+
* Added rather than swapped in: `duplicity` still carries the `gen: -1` sentinel for the two
|
|
25
|
+
* non-duplicity arms, so a caller written against the previous shape still tells them apart.
|
|
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.
|
|
42
|
+
*/
|
|
43
|
+
export type ResolveResult = {
|
|
44
|
+
ok: true;
|
|
45
|
+
winner: Array<SignedEvent>;
|
|
46
|
+
superseded: number;
|
|
47
|
+
unverified: number;
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
failure: ResolveFailure;
|
|
51
|
+
duplicity: Duplicity;
|
|
52
|
+
unverified: number;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Whether a branch failed to fold only because this call could not check a capability. Filtering such
|
|
56
|
+
* a branch out as invalid is what switched duplicity detection off for the management tier — a
|
|
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:
|
|
63
|
+
*
|
|
64
|
+
* 1. The highest folded generation wins outright. Only a reset raises the generation, and only the
|
|
65
|
+
* committed recovery key can author a reset — the root's override, unreachable by a thief.
|
|
66
|
+
* 2. Within that generation, branches are compared **at the point they diverge**, not at their heads:
|
|
67
|
+
* a rotate signed by the pre-committed next keys supersedes the current-key event it forked from
|
|
68
|
+
* and everything that branch accumulated. Two diverging events with no rotate to settle them are
|
|
69
|
+
* duplicity, surfaced rather than merged.
|
|
70
|
+
*
|
|
71
|
+
* Comparing heads instead would make branch *length* decisive below the top generation — the one
|
|
72
|
+
* resource a thief has in abundance (a stolen current key cannot rotate but can sign unlimited
|
|
73
|
+
* revokes). A longer branch still wins when it is the same history carried further, which the
|
|
74
|
+
* divergence walk sees as a prefix.
|
|
75
|
+
*
|
|
76
|
+
* `superseded` counts the state-advancing events discarded from losing branches (what a cache must
|
|
77
|
+
* invalidate — folded state is not append-only under supersession); skipped events are not counted.
|
|
78
|
+
* Branches that do not fold are filtered out before comparison, so a thief who cannot produce a valid
|
|
79
|
+
* 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
|
+
*/
|
|
97
|
+
export declare function resolveBranchesAsync(did: string, branches: Array<Array<SignedEvent>>, options?: FoldOptions): Promise<ResolveResult>;
|