@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/resolver.js
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { DID_PREFIX } from './events.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { authoritativeLogWithin } from './history.js';
|
|
3
|
+
import { isControllerLog } from './log.js';
|
|
4
|
+
import { resolverOver } from './state-resolver.js';
|
|
5
5
|
const CONTEXT = 'Controller resolver';
|
|
6
6
|
/**
|
|
7
7
|
* A `did:kokuin:` resolver for `@kokuin/token`. Injected rather than imported by token: this package
|
|
8
8
|
* depends on token for signing, so the reverse import would cycle. Token resolves `iss` through the
|
|
9
9
|
* interface without knowing the fold exists.
|
|
10
|
+
*
|
|
11
|
+
* Every member answers from the head (the chain's last state merged with the current epoch) except
|
|
12
|
+
* `resolveHistoric`, which scans the chain's states for a rotated-away key. While the epoch is
|
|
13
|
+
* saturated, every member refuses (see `resolverOver`).
|
|
10
14
|
*/ export function createControllerResolver(options) {
|
|
15
|
+
return createControllerResolverWithin(options);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* {@link createControllerResolver} under lowered epoch limits. Test-only: saturation is then
|
|
19
|
+
* reachable without thousands of signatures. Not exported from the package.
|
|
20
|
+
*/ export function createControllerResolverWithin(options, limits) {
|
|
11
21
|
// The fold currently under way for each DID, so overlapping resolutions of one DID share it (two
|
|
12
22
|
// tokens from one issuer verified in parallel is ordinary — token caches only `did:peer:4`).
|
|
13
23
|
//
|
|
@@ -18,43 +28,45 @@ const CONTEXT = 'Controller resolver';
|
|
|
18
28
|
//
|
|
19
29
|
// Not a cache: removed as soon as the fold settles, so a later resolution re-folds.
|
|
20
30
|
const inFlight = new Map();
|
|
21
|
-
// Shared by every member: load, fold, throw `Unknown DID` when absent. The
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
async function computeStates(did) {
|
|
27
|
-
const events = await options.loadLog(did);
|
|
28
|
-
if (events == null || events.length === 0) {
|
|
31
|
+
// Shared by every member: load, fold, throw `Unknown DID` when absent. The chain's states as well
|
|
32
|
+
// as the head, because `resolveHistoric` answers about rotated-away keys.
|
|
33
|
+
async function computeView(did) {
|
|
34
|
+
const log = await options.loadLog(did);
|
|
35
|
+
if (log == null || isControllerLog(log) && log.chain.length === 0) {
|
|
29
36
|
throw new Error(`Unknown DID: ${did}`);
|
|
30
37
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
};
|
|
38
|
+
// Without a store, the same fold against no memory: the request limit, the chain, then the
|
|
39
|
+
// epoch — one path, so the two configurations cannot disagree about what a log means.
|
|
34
40
|
if (options.history == null) {
|
|
35
|
-
return
|
|
41
|
+
return authoritativeLogWithin({
|
|
36
42
|
did,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
});
|
|
43
|
+
loaded: log,
|
|
44
|
+
seen: undefined,
|
|
45
|
+
context: CONTEXT
|
|
46
|
+
}, limits).result;
|
|
41
47
|
}
|
|
42
48
|
// Compared against what this party last accepted, so a truncated log — folding cleanly, missing
|
|
43
49
|
// the revoke that matters — is refused. See `LogStore`.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
// Inside the store's merge, so two resolutions of one DID never lose each other's events. A log
|
|
51
|
+
// that lost throws out of the update, so nothing is written and the store never moves backwards.
|
|
52
|
+
// The answer is the merged fold — what was seen plus what was loaded — not the presentation.
|
|
53
|
+
let view;
|
|
54
|
+
await options.history.merge(did, async (seen)=>{
|
|
55
|
+
const settled = authoritativeLogWithin({
|
|
56
|
+
did,
|
|
57
|
+
loaded: log,
|
|
58
|
+
seen,
|
|
59
|
+
context: CONTEXT
|
|
60
|
+
}, limits);
|
|
61
|
+
view = settled.result;
|
|
62
|
+
return settled.log;
|
|
51
63
|
});
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
64
|
+
if (view === undefined) {
|
|
65
|
+
throw new Error(`${CONTEXT}: the log store did not run the merge for ${did}`);
|
|
66
|
+
}
|
|
67
|
+
return view;
|
|
56
68
|
}
|
|
57
|
-
async function
|
|
69
|
+
async function loadView(did) {
|
|
58
70
|
if (!did.startsWith(DID_PREFIX)) {
|
|
59
71
|
throw new Error(`Unknown DID: ${did}`);
|
|
60
72
|
}
|
|
@@ -67,7 +79,7 @@ const CONTEXT = 'Controller resolver';
|
|
|
67
79
|
// Deferred by a microtask so the entry is registered *before* `loadLog` runs — otherwise a
|
|
68
80
|
// `loadLog` that re-enters `resolve` before its own first `await` recurses to a stack overflow
|
|
69
81
|
// while the map is still empty.
|
|
70
|
-
const fold = Promise.resolve().then(()=>
|
|
82
|
+
const fold = Promise.resolve().then(()=>computeView(did));
|
|
71
83
|
inFlight.set(did, fold);
|
|
72
84
|
try {
|
|
73
85
|
return await fold;
|
|
@@ -79,43 +91,27 @@ const CONTEXT = 'Controller resolver';
|
|
|
79
91
|
}
|
|
80
92
|
}
|
|
81
93
|
return {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
...resolverOver(loadView),
|
|
95
|
+
frozen () {
|
|
96
|
+
// The promise, not the settled view: concurrent first calls for one DID join one load and
|
|
97
|
+
// fold, rather than each starting its own and answering from whichever settled.
|
|
98
|
+
const views = new Map();
|
|
99
|
+
return resolverOver((did)=>{
|
|
100
|
+
const known = views.get(did);
|
|
101
|
+
if (known !== undefined) {
|
|
102
|
+
return known;
|
|
103
|
+
}
|
|
104
|
+
const view = loadView(did);
|
|
105
|
+
views.set(did, view);
|
|
106
|
+
// A failed load is no answer to freeze: dropped, so a later call loads afresh. Every answer
|
|
107
|
+
// this view gave for the DID came from that failure, so the next fold is still its first.
|
|
108
|
+
view.catch(()=>{
|
|
109
|
+
if (views.get(did) === view) {
|
|
110
|
+
views.delete(did);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return view;
|
|
101
114
|
});
|
|
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
115
|
}
|
|
120
116
|
};
|
|
121
117
|
}
|
package/lib/state-resolver.d.ts
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
|
-
import { type DIDMethodResolver, type ResolvedAgreementKey, type ResolvedSigningKey, type ResolveIssuerHeader } from '@kokuin/token';
|
|
1
|
+
import { type DIDMethodResolver, type MemberQuery, type ResolvedAgreementKey, type ResolvedMember, type ResolvedSigningKey, type ResolveIssuerHeader } from '@kokuin/token';
|
|
2
2
|
import type { KeyState } from './fold.js';
|
|
3
3
|
/** The method segment of {@link DID_PREFIX}, so the two cannot drift. */
|
|
4
4
|
export declare const DID_METHOD: string;
|
|
5
|
+
/**
|
|
6
|
+
* A folded log as every resolver member reads it: `states`, one per chain event, for the historic
|
|
7
|
+
* key scan; `head`, the last of them merged with the current epoch, for every live answer (keys,
|
|
8
|
+
* denials, members, saturation).
|
|
9
|
+
*/
|
|
10
|
+
export type FoldedView = {
|
|
11
|
+
states: Array<KeyState>;
|
|
12
|
+
head: KeyState;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* What every resolver member throws while the controller's current epoch is saturated, followed by
|
|
16
|
+
* `: ` and the DID. A plain error, not `IssuerKeyNotFoundError`: nothing about the key failed, the
|
|
17
|
+
* controller as a whole cannot be answered for, and `@kokuin/capability` denies on exactly that.
|
|
18
|
+
*/
|
|
19
|
+
export declare const EPOCH_SATURATED = "controller epoch is saturated";
|
|
20
|
+
/** What `resolveMember` rejects a member with that is not live in the head's generation. */
|
|
21
|
+
export declare const MEMBER_NOT_EFFECTIVE = "not an effective management member";
|
|
5
22
|
/**
|
|
6
23
|
* What a token signed by a revoked key is rejected with. The `#`-prefixed key follows after `: `, so
|
|
7
24
|
* match with `startsWith`.
|
|
8
25
|
*/
|
|
9
26
|
export declare const KEY_REVOKED = "kid names a key the controller has revoked";
|
|
10
27
|
/**
|
|
11
|
-
* The signing key `header` names, out of an already-folded
|
|
28
|
+
* The signing key `header` names, out of an already-folded view. `historic` picks which question above
|
|
12
29
|
* is asked, and defaults to the safe one.
|
|
13
30
|
*
|
|
14
|
-
* **A revoked key answers neither question.** A
|
|
15
|
-
* has *already* signed, and this is where that bites — every `did:kokuin:` signature check reaches a
|
|
31
|
+
* **A revoked key answers neither question.** A deny entry naming `#<key>` retires a key for material
|
|
32
|
+
* it has *already* signed, and this is where that bites — every `did:kokuin:` signature check reaches a
|
|
16
33
|
* resolved key through here, so the denial covers `verifyToken` on both `historic` settings, every
|
|
17
|
-
* capability and revocation record `@kokuin/capability` verifies
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `resolveHistoric` would accept a leaked key forever.
|
|
34
|
+
* capability and revocation record `@kokuin/capability` verifies. The historic arm is the live one:
|
|
35
|
+
* a revoked key is never in the head's `k` (the `KeyState.deny` invariant), so only the backwards
|
|
36
|
+
* scan can select one, and without this `resolveHistoric` would accept a leaked key forever.
|
|
21
37
|
*
|
|
22
|
-
* The head arm cannot be reached from a log this package folded, but is not decoration: `
|
|
23
|
-
* arrives from the caller, and the invariant is the fold's property, not the
|
|
38
|
+
* The head arm cannot be reached from a log this package folded, but is not decoration: `view`
|
|
39
|
+
* arrives from the caller, and the invariant is the fold's property, not the view's — a
|
|
24
40
|
* caller-built state whose head publishes a key it also denies resolves to nothing here.
|
|
25
41
|
*
|
|
26
42
|
* **Evaluated against the head's deny set, never the position the key was selected at.** A denial is
|
|
@@ -28,33 +44,53 @@ export declare const KEY_REVOKED = "kid names a key the controller has revoked";
|
|
|
28
44
|
* thief presents a token resolving at a position before the revoke. Deliberately *not* how a DID
|
|
29
45
|
* denial behaves inside the fold, where `states[i]` is consulted at position `i` because "was this
|
|
30
46
|
* author allowed to act *here*" is the whole question; the two rules answer different questions.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
47
|
+
* The head here is the chain's last state merged with the current epoch, so a key retired by an
|
|
48
|
+
* epoch revoke is refused as surely as one a settlement checkpointed.
|
|
33
49
|
*/
|
|
34
|
-
export declare function signingKeyFrom({ did,
|
|
50
|
+
export declare function signingKeyFrom({ did, view, header, historic, }: {
|
|
35
51
|
did: string;
|
|
36
|
-
|
|
52
|
+
view: FoldedView;
|
|
37
53
|
header: ResolveIssuerHeader;
|
|
38
54
|
historic?: boolean;
|
|
39
55
|
}): ResolvedSigningKey;
|
|
40
56
|
/**
|
|
41
|
-
* The head's key agreement set, minus anything it has revoked. A
|
|
42
|
-
* readily as a signing one (the target spelling is the key, not its purpose), and these are the keys
|
|
57
|
+
* The head's key agreement set, minus anything it has revoked. A deny entry may name an agreement key
|
|
58
|
+
* as readily as a signing one (the target spelling is the key, not its purpose), and these are the keys
|
|
43
59
|
* a *sender* encrypts to — encrypting to a revoked key is the one outcome a denial must prevent.
|
|
44
60
|
*
|
|
45
61
|
* Like the signing side, unreachable from a log this package folded (the `KeyState.deny` invariant)
|
|
46
|
-
* but `
|
|
62
|
+
* but `head` arrives from the caller. Filtering to empty is the honest answer for a state that
|
|
47
63
|
* denies everything it publishes: `@kokuin/jwe` then encrypts nothing, the fail-closed direction.
|
|
48
64
|
*/
|
|
49
|
-
export declare function agreementKeysFrom(did: string,
|
|
65
|
+
export declare function agreementKeysFrom(did: string, head: KeyState): Array<ResolvedAgreementKey>;
|
|
50
66
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* one
|
|
67
|
+
* The key and lease the head pins for one management member, or `IssuerKeyNotFoundError` carrying
|
|
68
|
+
* {@link MEMBER_NOT_EFFECTIVE}.
|
|
69
|
+
*
|
|
70
|
+
* Every condition is asked of the head alone, the "answer for now" the interface demands: the member
|
|
71
|
+
* must be present in it under the queried `add` digest, not removed and not targeted by a denial
|
|
72
|
+
* (`effective`, which the fold computes with the one denial predicate it uses everywhere), and the
|
|
73
|
+
* query's DID must be the member's under canonical DID equivalence — the long and short spellings of
|
|
74
|
+
* one `did:peer:4` are one member; any other DID is another, even one carrying the same key.
|
|
75
|
+
*
|
|
76
|
+
* **The generation is part of the identity.** A reset carrying a snapshot keeps each member's `add`
|
|
77
|
+
* reference, so incarnation alone cannot tell a grant minted before the reset from one minted after;
|
|
78
|
+
* only `gen` can. A query naming any generation but the head's is refused, which is what makes a
|
|
79
|
+
* reset kill every grant of the generation it replaces, carried members' included. The lease is
|
|
80
|
+
* reported, never judged: the log has no clock, and bounding a grant by it is the verifier's step.
|
|
81
|
+
*
|
|
82
|
+
* `IssuerKeyNotFoundError` throughout, for the reason `keyFromKid` gives: the controller resolved and
|
|
83
|
+
* what failed is a claim a token made about it, which must not read as the controller being
|
|
84
|
+
* unresolvable.
|
|
85
|
+
*/
|
|
86
|
+
export declare function memberFrom(did: string, head: KeyState, query: MemberQuery): ResolvedMember;
|
|
87
|
+
/**
|
|
88
|
+
* A `DIDMethodResolver` answering every member out of the view `load` yields for the asked DID —
|
|
89
|
+
* the one shape the live resolver and its frozen views share, so the
|
|
90
|
+
* saturation refusal and the choice of which half of the view answers what are written once.
|
|
91
|
+
*
|
|
92
|
+
* **A saturated head answers nothing**, `resolveHistoric` included: the epoch's denials can no
|
|
93
|
+
* longer be read, so any answer could be one a denial withdrew. The chain still folds, so root can
|
|
94
|
+
* settle into a fresh epoch, and the refusal ends there.
|
|
59
95
|
*/
|
|
60
|
-
export declare function
|
|
96
|
+
export declare function resolverOver(load: (did: string) => Promise<FoldedView>): DIDMethodResolver;
|
package/lib/state-resolver.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import { IssuerKeyNotFoundError } from '@kokuin/token';
|
|
1
|
+
import { IssuerKeyNotFoundError, normalizeDID } from '@kokuin/token';
|
|
2
2
|
import { DID_PREFIX, keyTarget } from './events.js';
|
|
3
3
|
import { decodeKey } from './keys.js';
|
|
4
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
|
+
* What every resolver member throws while the controller's current epoch is saturated, followed by
|
|
7
|
+
* `: ` and the DID. A plain error, not `IssuerKeyNotFoundError`: nothing about the key failed, the
|
|
8
|
+
* controller as a whole cannot be answered for, and `@kokuin/capability` denies on exactly that.
|
|
9
|
+
*/ export const EPOCH_SATURATED = 'controller epoch is saturated';
|
|
10
|
+
/** What `resolveMember` rejects a member with that is not live in the head's generation. */ export const MEMBER_NOT_EFFECTIVE = 'not an effective management member';
|
|
5
11
|
/**
|
|
6
12
|
* The `kid` fragment's body, or `null` when the header names no key. Format is `#<the multibase key
|
|
7
13
|
* exactly as it appears in `k`>`, matched against the folded key sets by membership — an index-based
|
|
@@ -32,11 +38,14 @@ import { decodeKey } from './keys.js';
|
|
|
32
38
|
* a fall back to `keys[0]`**, which would check the signature against a key the token never claimed.
|
|
33
39
|
* An absent `kid` resolves to the head's first key: volunteering one is not the same as accepting one
|
|
34
40
|
* the token names. Past-issued material is {@link historicSigningKey}, via explicit `resolveHistoric`.
|
|
35
|
-
*/ function headSigningKey(did,
|
|
36
|
-
const head = states[states.length - 1];
|
|
41
|
+
*/ function headSigningKey(did, head, kid) {
|
|
37
42
|
const key = keyFromKid(did, kid);
|
|
38
43
|
if (key == null) {
|
|
39
|
-
|
|
44
|
+
const first = head.keys[0];
|
|
45
|
+
if (first === undefined) {
|
|
46
|
+
throw new IssuerKeyNotFoundError(`Controller ${did} has no signing key`);
|
|
47
|
+
}
|
|
48
|
+
return first;
|
|
40
49
|
}
|
|
41
50
|
if (!head.keys.includes(key)) {
|
|
42
51
|
throw new IssuerKeyNotFoundError(`Controller ${did} kid names a key that is not current: ${kid}`);
|
|
@@ -55,14 +64,21 @@ import { decodeKey } from './keys.js';
|
|
|
55
64
|
* decreases, so the backwards scan stops dead at the first earlier-generation state. Runs backwards
|
|
56
65
|
* because the head's own key set is the common answer. A `kid` naming a never-published key, or one
|
|
57
66
|
* 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];
|
|
67
|
+
*/ function historicSigningKey(did, { states, head }, kid) {
|
|
60
68
|
const key = keyFromKid(did, kid);
|
|
61
69
|
if (key == null) {
|
|
62
|
-
|
|
70
|
+
const first = head.keys[0];
|
|
71
|
+
if (first === undefined) {
|
|
72
|
+
throw new IssuerKeyNotFoundError(`Controller ${did} has no signing key`);
|
|
73
|
+
}
|
|
74
|
+
return first;
|
|
63
75
|
}
|
|
64
|
-
for(let i = states.length - 1; i >= 0
|
|
65
|
-
|
|
76
|
+
for(let i = states.length - 1; i >= 0; i--){
|
|
77
|
+
const state = states[i];
|
|
78
|
+
if (state === undefined || state.gen !== head.gen) {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
if (state.keys.includes(key)) {
|
|
66
82
|
return key;
|
|
67
83
|
}
|
|
68
84
|
}
|
|
@@ -73,19 +89,18 @@ import { decodeKey } from './keys.js';
|
|
|
73
89
|
* match with `startsWith`.
|
|
74
90
|
*/ export const KEY_REVOKED = 'kid names a key the controller has revoked';
|
|
75
91
|
/**
|
|
76
|
-
* The signing key `header` names, out of an already-folded
|
|
92
|
+
* The signing key `header` names, out of an already-folded view. `historic` picks which question above
|
|
77
93
|
* is asked, and defaults to the safe one.
|
|
78
94
|
*
|
|
79
|
-
* **A revoked key answers neither question.** A
|
|
80
|
-
* has *already* signed, and this is where that bites — every `did:kokuin:` signature check reaches a
|
|
95
|
+
* **A revoked key answers neither question.** A deny entry naming `#<key>` retires a key for material
|
|
96
|
+
* it has *already* signed, and this is where that bites — every `did:kokuin:` signature check reaches a
|
|
81
97
|
* resolved key through here, so the denial covers `verifyToken` on both `historic` settings, every
|
|
82
|
-
* capability and revocation record `@kokuin/capability` verifies
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* `resolveHistoric` would accept a leaked key forever.
|
|
98
|
+
* capability and revocation record `@kokuin/capability` verifies. The historic arm is the live one:
|
|
99
|
+
* a revoked key is never in the head's `k` (the `KeyState.deny` invariant), so only the backwards
|
|
100
|
+
* scan can select one, and without this `resolveHistoric` would accept a leaked key forever.
|
|
86
101
|
*
|
|
87
|
-
* The head arm cannot be reached from a log this package folded, but is not decoration: `
|
|
88
|
-
* arrives from the caller, and the invariant is the fold's property, not the
|
|
102
|
+
* The head arm cannot be reached from a log this package folded, but is not decoration: `view`
|
|
103
|
+
* arrives from the caller, and the invariant is the fold's property, not the view's — a
|
|
89
104
|
* caller-built state whose head publishes a key it also denies resolves to nothing here.
|
|
90
105
|
*
|
|
91
106
|
* **Evaluated against the head's deny set, never the position the key was selected at.** A denial is
|
|
@@ -93,14 +108,14 @@ import { decodeKey } from './keys.js';
|
|
|
93
108
|
* thief presents a token resolving at a position before the revoke. Deliberately *not* how a DID
|
|
94
109
|
* denial behaves inside the fold, where `states[i]` is consulted at position `i` because "was this
|
|
95
110
|
* author allowed to act *here*" is the whole question; the two rules answer different questions.
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*/ export function signingKeyFrom({ did,
|
|
99
|
-
const head =
|
|
111
|
+
* The head here is the chain's last state merged with the current epoch, so a key retired by an
|
|
112
|
+
* epoch revoke is refused as surely as one a settlement checkpointed.
|
|
113
|
+
*/ export function signingKeyFrom({ did, view, header, historic = false }) {
|
|
114
|
+
const { head } = view;
|
|
100
115
|
if (head.keys.length === 0) {
|
|
101
116
|
throw new Error(`Controller ${did} has no signing key`);
|
|
102
117
|
}
|
|
103
|
-
const selected = historic ? historicSigningKey(did,
|
|
118
|
+
const selected = historic ? historicSigningKey(did, view, header.kid) : headSigningKey(did, head, header.kid);
|
|
104
119
|
// `IssuerKeyNotFoundError` like every rejection here: the DID resolved and the log folded, and what
|
|
105
120
|
// failed is the key. Retyping it unresolvable would let a `kid` (unauthenticated) make this
|
|
106
121
|
// controller read as unresolvable to every fail-closed caller. See {@link keyFromKid}.
|
|
@@ -117,15 +132,14 @@ import { decodeKey } from './keys.js';
|
|
|
117
132
|
};
|
|
118
133
|
}
|
|
119
134
|
/**
|
|
120
|
-
* The head's key agreement set, minus anything it has revoked. A
|
|
121
|
-
* readily as a signing one (the target spelling is the key, not its purpose), and these are the keys
|
|
135
|
+
* The head's key agreement set, minus anything it has revoked. A deny entry may name an agreement key
|
|
136
|
+
* as readily as a signing one (the target spelling is the key, not its purpose), and these are the keys
|
|
122
137
|
* a *sender* encrypts to — encrypting to a revoked key is the one outcome a denial must prevent.
|
|
123
138
|
*
|
|
124
139
|
* Like the signing side, unreachable from a log this package folded (the `KeyState.deny` invariant)
|
|
125
|
-
* but `
|
|
140
|
+
* but `head` arrives from the caller. Filtering to empty is the honest answer for a state that
|
|
126
141
|
* denies everything it publishes: `@kokuin/jwe` then encrypts nothing, the fail-closed direction.
|
|
127
|
-
*/ export function agreementKeysFrom(did,
|
|
128
|
-
const head = states[states.length - 1];
|
|
142
|
+
*/ export function agreementKeysFrom(did, head) {
|
|
129
143
|
return head.agreement.flatMap((value)=>{
|
|
130
144
|
if (head.deny.has(keyTarget(value))) {
|
|
131
145
|
return [];
|
|
@@ -141,46 +155,109 @@ import { decodeKey } from './keys.js';
|
|
|
141
155
|
});
|
|
142
156
|
}
|
|
143
157
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
158
|
+
* The key and lease the head pins for one management member, or `IssuerKeyNotFoundError` carrying
|
|
159
|
+
* {@link MEMBER_NOT_EFFECTIVE}.
|
|
160
|
+
*
|
|
161
|
+
* Every condition is asked of the head alone, the "answer for now" the interface demands: the member
|
|
162
|
+
* must be present in it under the queried `add` digest, not removed and not targeted by a denial
|
|
163
|
+
* (`effective`, which the fold computes with the one denial predicate it uses everywhere), and the
|
|
164
|
+
* query's DID must be the member's under canonical DID equivalence — the long and short spellings of
|
|
165
|
+
* one `did:peer:4` are one member; any other DID is another, even one carrying the same key.
|
|
166
|
+
*
|
|
167
|
+
* **The generation is part of the identity.** A reset carrying a snapshot keeps each member's `add`
|
|
168
|
+
* reference, so incarnation alone cannot tell a grant minted before the reset from one minted after;
|
|
169
|
+
* only `gen` can. A query naming any generation but the head's is refused, which is what makes a
|
|
170
|
+
* reset kill every grant of the generation it replaces, carried members' included. The lease is
|
|
171
|
+
* reported, never judged: the log has no clock, and bounding a grant by it is the verifier's step.
|
|
172
|
+
*
|
|
173
|
+
* `IssuerKeyNotFoundError` throughout, for the reason `keyFromKid` gives: the controller resolved and
|
|
174
|
+
* what failed is a claim a token made about it, which must not read as the controller being
|
|
175
|
+
* unresolvable.
|
|
176
|
+
*/ export function memberFrom(did, head, query) {
|
|
177
|
+
const refuse = (why)=>new IssuerKeyNotFoundError(`Controller ${did} ${query.did} is ${MEMBER_NOT_EFFECTIVE}: ${why}`);
|
|
178
|
+
if (query.gen !== head.gen) {
|
|
179
|
+
throw refuse(`generation ${String(query.gen)} is not the current ${head.gen}`);
|
|
180
|
+
}
|
|
181
|
+
const member = head.members.get(query.add);
|
|
182
|
+
if (member === undefined) {
|
|
183
|
+
throw refuse(`no member was added by ${query.add}`);
|
|
184
|
+
}
|
|
185
|
+
if (!member.effective) {
|
|
186
|
+
throw refuse(member.removed ? 'removed' : 'denied');
|
|
187
|
+
}
|
|
188
|
+
if (normalizeDID(member.did) !== normalizeDID(query.did)) {
|
|
189
|
+
throw refuse(`incarnation ${query.add} is another DID`);
|
|
190
|
+
}
|
|
191
|
+
const key = decodeKey(member.key);
|
|
192
|
+
// Unreachable from a log this package folded (a member pin is Ed25519, adds and snapshots alike),
|
|
193
|
+
// but `head` may be caller-built, and an agreement key must never come back as a signing one.
|
|
194
|
+
if (key.alg !== 'EdDSA') {
|
|
195
|
+
throw refuse(`pinned key is not Ed25519: ${key.alg}`);
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
key: {
|
|
199
|
+
alg: key.alg,
|
|
200
|
+
publicKey: key.publicKey
|
|
201
|
+
},
|
|
202
|
+
lease: member.lease
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* A `DIDMethodResolver` answering every member out of the view `load` yields for the asked DID —
|
|
207
|
+
* the one shape the live resolver and its frozen views share, so the
|
|
208
|
+
* saturation refusal and the choice of which half of the view answers what are written once.
|
|
148
209
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*/ export function
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
|
|
210
|
+
* **A saturated head answers nothing**, `resolveHistoric` included: the epoch's denials can no
|
|
211
|
+
* longer be read, so any answer could be one a denial withdrew. The chain still folds, so root can
|
|
212
|
+
* settle into a fresh epoch, and the refusal ends there.
|
|
213
|
+
*/ export function resolverOver(load) {
|
|
214
|
+
async function live(did) {
|
|
215
|
+
const view = await load(did);
|
|
216
|
+
if (view.head.saturated) {
|
|
217
|
+
throw new Error(`${EPOCH_SATURATED}: ${did}`);
|
|
156
218
|
}
|
|
157
|
-
return
|
|
219
|
+
return view;
|
|
158
220
|
}
|
|
159
221
|
return {
|
|
160
222
|
method: DID_METHOD,
|
|
161
|
-
|
|
223
|
+
// The head's key set only: a profile that rotated away from a key answers "no" for it here, which
|
|
224
|
+
// is what makes `rotate` retire a leaked authority key rather than add one beside it.
|
|
225
|
+
async resolve (did, header = {}) {
|
|
162
226
|
return signingKeyFrom({
|
|
163
|
-
did
|
|
164
|
-
|
|
227
|
+
did,
|
|
228
|
+
view: await live(did),
|
|
165
229
|
header
|
|
166
230
|
});
|
|
167
231
|
},
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
232
|
+
// Every key set within the current generation, for a caller verifying past-issued material. A
|
|
233
|
+
// `rotate` must not invalidate those; a `reset` still does, and the scan stops at the generation
|
|
234
|
+
// boundary. See {@link signingKeyFrom}.
|
|
235
|
+
async resolveHistoric (did, header = {}) {
|
|
171
236
|
return signingKeyFrom({
|
|
172
|
-
did
|
|
173
|
-
|
|
237
|
+
did,
|
|
238
|
+
view: await live(did),
|
|
174
239
|
header,
|
|
175
240
|
historic: true
|
|
176
241
|
});
|
|
177
242
|
},
|
|
178
|
-
async resolveDenySet (
|
|
179
|
-
|
|
180
|
-
|
|
243
|
+
async resolveDenySet (did) {
|
|
244
|
+
// The head's set, deliberately, not the state at any position a capability names: `iat` is
|
|
245
|
+
// author-supplied and backdatable, so anchoring to it would let a revoked holder pick a
|
|
246
|
+
// position at which it was not yet denied. The head merges the current epoch, so a denial
|
|
247
|
+
// made there without root counts as soon as this party has seen it. Both spellings ride in it
|
|
248
|
+
// (see `KeyState.deny`); the key half is enforced by `signingKeyFrom`, and the two forms
|
|
249
|
+
// cannot collide. `load` throws `Unknown DID` rather than answering an empty (= "nobody
|
|
250
|
+
// revoked") set.
|
|
251
|
+
return (await live(did)).head.deny;
|
|
252
|
+
},
|
|
253
|
+
async resolveAgreementKey (did) {
|
|
254
|
+
// The head's set only, unlike `resolve`. A retired agreement key is a downgrade, not a grant
|
|
255
|
+
// the profile already made: the recipient decrypts old ciphertexts by re-deriving from the
|
|
256
|
+
// seed, never by resolution.
|
|
257
|
+
return agreementKeysFrom(did, (await live(did)).head);
|
|
181
258
|
},
|
|
182
|
-
async
|
|
183
|
-
return
|
|
259
|
+
async resolveMember (subject, member) {
|
|
260
|
+
return memberFrom(subject, (await live(subject)).head, member);
|
|
184
261
|
}
|
|
185
262
|
};
|
|
186
263
|
}
|
package/lib/state.d.ts
CHANGED
|
@@ -1,28 +1,12 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { type
|
|
1
|
+
import { type KeyState } from './fold.js';
|
|
2
|
+
import { type ControllerLog } from './log.js';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The root key state a log establishes: the chain's last state.
|
|
5
|
+
*
|
|
6
|
+
* The chain alone, deliberately. Epoch events never change a root key (`k`, `ka`, `n` and the
|
|
7
|
+
* derivation indices live on the chain only), so folding them would buy an identity nothing but up
|
|
8
|
+
* to `MAX_EPOCH_EVENTS` signature checks on a path that must stay synchronous and cheap. The returned
|
|
9
|
+
* state's `deny`, `members` and `saturated` are therefore the chain's, not the live ones; nothing
|
|
10
|
+
* that builds an identity reads them.
|
|
6
11
|
*/
|
|
7
|
-
export declare function currentState(did: string,
|
|
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>;
|
|
12
|
+
export declare function currentState(did: string, log: ControllerLog, context: string): KeyState;
|