@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.
@@ -0,0 +1,139 @@
1
+ import { type SignedEvent } from './events.js';
2
+ import type { KeyState } from './fold.js';
3
+ export type EpochEventType = 'add' | 'ren' | 'rmv' | 'rev';
4
+ export type EpochEventCommon = {
5
+ /** Format version. Always written, never inferred. */
6
+ v: 1;
7
+ t: EpochEventType;
8
+ /** Profile DID. */
9
+ i: string;
10
+ /** Generation of the root chain this event's epoch belongs to. */
11
+ g: number;
12
+ /**
13
+ * Digest of the root-chain event that opened the epoch. An event naming any other epoch has no
14
+ * effect: its effect is either in the newer snapshot or was dropped by it.
15
+ */
16
+ e: string;
17
+ /**
18
+ * Parents: `e` itself or digests of events of the same epoch -- every head the author knows, a
19
+ * recommendation the fold cannot enforce. 1 to {@link MAX_EVENT_PARENTS} entries, no duplicates. A
20
+ * list, not a single predecessor, because concurrent members branch and a later event joins them.
21
+ */
22
+ p: Array<string>;
23
+ /**
24
+ * Criticality, in the envelope for the same reason as on the root chain. Always `true` here, so a
25
+ * verifier predating the management tier fails closed rather than skipping a revoke.
26
+ */
27
+ crit: boolean;
28
+ /**
29
+ * The signing member's add digest -- its incarnation, not its DID, so a removed and re-added member
30
+ * signs under a different reference. Absent means root: n-of-n over the chain head's `k`.
31
+ */
32
+ by?: string;
33
+ };
34
+ /** Root-only: a member is admitted only by a root signature. */
35
+ export type AddEvent = EpochEventCommon & {
36
+ t: 'add';
37
+ /** The member's DID: an Ed25519 `did:key`, or a long-form `did:peer:4` carrying `key`. */
38
+ did: string;
39
+ /** The pinned Ed25519 key, encoded as a key in `k` is. Everything the member signs verifies under it. */
40
+ key: string;
41
+ /** Lease end, unix seconds. Bounds the member's grants; the fold never reads it. */
42
+ lease: number;
43
+ };
44
+ export type RenewEvent = EpochEventCommon & {
45
+ t: 'ren';
46
+ /** The incarnation renewed, by add digest, so a delayed renew never carries over to a re-add. */
47
+ add: string;
48
+ /** New lease end, unix seconds. Merges as the maximum per incarnation. */
49
+ lease: number;
50
+ };
51
+ export type RemoveEvent = EpochEventCommon & {
52
+ t: 'rmv';
53
+ /** The incarnation removed, by add digest. Dominates any renewal of it. */
54
+ add: string;
55
+ };
56
+ export type RevokeEvent = EpochEventCommon & {
57
+ t: 'rev';
58
+ /** A DID or a key target, the two spellings the root chain's deny set takes -- see `keyTarget`. */
59
+ x: string;
60
+ };
61
+ export type EpochEvent = AddEvent | RenewEvent | RemoveEvent | RevokeEvent;
62
+ export type EpochSigner = {
63
+ root: {
64
+ seed: Uint8Array;
65
+ profile: number;
66
+ };
67
+ } | {
68
+ member: {
69
+ privateKey: Uint8Array;
70
+ add: string;
71
+ };
72
+ };
73
+ type BuildBase = {
74
+ did: string;
75
+ head: KeyState;
76
+ parents: Array<string>;
77
+ };
78
+ export type CreateAddParams = BuildBase & {
79
+ seed: Uint8Array;
80
+ profile: number;
81
+ member: {
82
+ did: string;
83
+ key: string;
84
+ lease: number;
85
+ };
86
+ };
87
+ export type CreateRenewParams = BuildBase & {
88
+ signer: EpochSigner;
89
+ add: string;
90
+ lease: number;
91
+ };
92
+ export type CreateRemoveParams = BuildBase & {
93
+ signer: EpochSigner;
94
+ add: string;
95
+ };
96
+ export type CreateRevokeParams = BuildBase & {
97
+ signer: EpochSigner;
98
+ target: string;
99
+ };
100
+ /** Admit a member. Root-only by type: there is no member signer for an add. */
101
+ export declare function createAdd({ seed, profile, member, ...base }: CreateAddParams): SignedEvent<AddEvent>;
102
+ /** Extend an incarnation's lease. A member may renew another member, never itself. */
103
+ export declare function createRenew({ signer, add, lease, ...base }: CreateRenewParams): SignedEvent<RenewEvent>;
104
+ /** End an incarnation's log authority. A member may remove another member, never itself. */
105
+ export declare function createRemove({ signer, add, ...base }: CreateRemoveParams): SignedEvent<RemoveEvent>;
106
+ /**
107
+ * Deny an edge holder or a retired key. Root or any single member; a member may not target a member
108
+ * of its causal past, which removal covers instead.
109
+ */
110
+ export declare function createRevoke({ signer, target, ...base }: CreateRevokeParams): SignedEvent<RevokeEvent>;
111
+ /**
112
+ * Whether `value` has the shape of a signed epoch event: the envelope, the fields its type carries,
113
+ * the parent rules, and a canonical body within {@link MAX_EVENT_BYTES}. Total on anything
114
+ * `JSON.parse` yields, since an epoch event arrives from a peer or an untrusted store. A request-limit
115
+ * check: failing it rejects input and changes no state.
116
+ *
117
+ * Structure only. It says nothing about who signed, whether the signer may, or whether the parents
118
+ * exist -- those belong to {@link verifyEpochSignature} and the fold. `crit` must be exactly `true`:
119
+ * every type here is critical, and a known type declaring otherwise is a body whose envelope
120
+ * contradicts it. A `recoveryKey` on the envelope is refused rather than ignored -- only a reset
121
+ * reveals one, and a member nothing reads is a field that lies.
122
+ */
123
+ export declare function isEpochEventShape(value: unknown): value is SignedEvent<EpochEvent>;
124
+ /**
125
+ * Whether `signed` verifies under the signer the caller names. The fold names it from `by`: absent,
126
+ * the chain head's keys; present, the key the referenced add pinned.
127
+ *
128
+ * The signer kind must agree with `by`, so a root-signed body cannot pass as a member's or the
129
+ * reverse: a single-key root and a member are otherwise the same check, and an event whose `by` the
130
+ * caller did not consult would be authorised as whoever happened to be asked about. Root is n-of-n
131
+ * over `rootKeys` (via `verifySignatures`); a member signs alone, so exactly one signature, under the
132
+ * pinned key. Total: a malformed body or signature is `false`.
133
+ */
134
+ export declare function verifyEpochSignature(signed: SignedEvent<EpochEvent>, signer: {
135
+ rootKeys: Array<string>;
136
+ } | {
137
+ memberKey: string;
138
+ }): boolean;
139
+ export {};
@@ -0,0 +1,177 @@
1
+ import { canonicalBytes, isCanonicalizable } from './canonical.js';
2
+ import { authorityPath, deriveKeyPair } from './derivation.js';
3
+ import { didFor, signEvent, verifySignatures } from './events.js';
4
+ import { encodeKey } from './keys.js';
5
+ import { MAX_EVENT_BYTES, MAX_EVENT_PARENTS, MAX_EVENT_SIGNATURES } from './limits.js';
6
+ const EPOCH_EVENT_TYPES = new Set([
7
+ 'add',
8
+ 'ren',
9
+ 'rmv',
10
+ 'rev'
11
+ ]);
12
+ /**
13
+ * Sign `fields` as an event of `head`'s epoch.
14
+ *
15
+ * **Throws rather than emitting an event the fold will reject**, the rule the root-chain builders
16
+ * follow: an empty or oversized parent list, a fractional lease, or a body past the byte limit is
17
+ * refused here, where the caller can still act. So is a root signer whose derived key `head` does
18
+ * not publish, when the seed is this DID's root -- a stale `keyGen`/`keySeq` would otherwise produce
19
+ * a well-formed, signed event no fold accepts. A foreign seed is left alone, as `createRotate` leaves
20
+ * it: a mismatch there says nothing, and the fold is the layer that rejects it.
21
+ */ function buildEvent(t, signer, { did, head, parents }, fields) {
22
+ if (parents.length === 0) {
23
+ throw new Error('Epoch event: at least one parent is required -- the epoch digest at minimum');
24
+ }
25
+ // Code-unit order, so the written list is a function of the set alone.
26
+ const p = [
27
+ ...new Set(parents)
28
+ ].sort();
29
+ let privateKey;
30
+ if ('root' in signer) {
31
+ const { seed, profile } = signer.root;
32
+ const pair = deriveKeyPair(seed, authorityPath(profile, head.keyGen, head.keySeq), 'EdDSA');
33
+ const derived = encodeKey(pair.publicKey, 'EdDSA');
34
+ // Exactly the one key: the verifier checks n-of-n, and this generator signs with one.
35
+ if (didFor(seed, profile) === did && !(head.keys.length === 1 && head.keys[0] === derived)) {
36
+ throw new Error(`Epoch event: the key at (gen ${head.keyGen}, seq ${head.keySeq}) is not the head's only ` + 'published key -- pass the folded head, whose keyGen / keySeq name the active authority key');
37
+ }
38
+ privateKey = pair.privateKey;
39
+ } else {
40
+ privateKey = signer.member.privateKey;
41
+ }
42
+ const common = {
43
+ v: 1,
44
+ t,
45
+ i: did,
46
+ g: head.gen,
47
+ e: head.epoch,
48
+ p,
49
+ crit: true
50
+ };
51
+ // Conditionally spread: a root event has no `by` key at all, not one set to undefined.
52
+ const event = {
53
+ ...common,
54
+ ...'member' in signer ? {
55
+ by: signer.member.add
56
+ } : {},
57
+ ...fields
58
+ };
59
+ const signed = {
60
+ event,
61
+ sigs: signEvent(event, [
62
+ privateKey
63
+ ])
64
+ };
65
+ if (!isEpochEventShape(signed)) {
66
+ throw new Error(`Epoch event: the ${t} built is not a well-formed epoch event -- check the parent count ` + `(at most ${MAX_EVENT_PARENTS} distinct), integer leases and the ${MAX_EVENT_BYTES}-byte body limit`);
67
+ }
68
+ return signed;
69
+ }
70
+ /** Admit a member. Root-only by type: there is no member signer for an add. */ export function createAdd({ seed, profile, member, ...base }) {
71
+ return buildEvent('add', {
72
+ root: {
73
+ seed,
74
+ profile
75
+ }
76
+ }, base, {
77
+ did: member.did,
78
+ key: member.key,
79
+ lease: member.lease
80
+ });
81
+ }
82
+ /** Extend an incarnation's lease. A member may renew another member, never itself. */ export function createRenew({ signer, add, lease, ...base }) {
83
+ return buildEvent('ren', signer, base, {
84
+ add,
85
+ lease
86
+ });
87
+ }
88
+ /** End an incarnation's log authority. A member may remove another member, never itself. */ export function createRemove({ signer, add, ...base }) {
89
+ return buildEvent('rmv', signer, base, {
90
+ add
91
+ });
92
+ }
93
+ /**
94
+ * Deny an edge holder or a retired key. Root or any single member; a member may not target a member
95
+ * of its causal past, which removal covers instead.
96
+ */ export function createRevoke({ signer, target, ...base }) {
97
+ return buildEvent('rev', signer, base, {
98
+ x: target
99
+ });
100
+ }
101
+ function isRecord(value) {
102
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
103
+ }
104
+ /** Length is bounded before the walk, so an oversized list costs one comparison. */ function isStringList(value, max) {
105
+ return Array.isArray(value) && value.length <= max && value.every((e)=>typeof e === 'string');
106
+ }
107
+ /** Unix seconds: a non-negative safe integer, so `"1800000000"` and `1.5` both fail. */ function isSeconds(value) {
108
+ return Number.isSafeInteger(value) && value >= 0;
109
+ }
110
+ function hasTypeFields(event) {
111
+ switch(event.t){
112
+ case 'add':
113
+ return typeof event.did === 'string' && typeof event.key === 'string' && isSeconds(event.lease);
114
+ case 'ren':
115
+ return typeof event.add === 'string' && isSeconds(event.lease);
116
+ case 'rmv':
117
+ return typeof event.add === 'string';
118
+ case 'rev':
119
+ return typeof event.x === 'string';
120
+ default:
121
+ return false;
122
+ }
123
+ }
124
+ /**
125
+ * Whether `value` has the shape of a signed epoch event: the envelope, the fields its type carries,
126
+ * the parent rules, and a canonical body within {@link MAX_EVENT_BYTES}. Total on anything
127
+ * `JSON.parse` yields, since an epoch event arrives from a peer or an untrusted store. A request-limit
128
+ * check: failing it rejects input and changes no state.
129
+ *
130
+ * Structure only. It says nothing about who signed, whether the signer may, or whether the parents
131
+ * exist -- those belong to {@link verifyEpochSignature} and the fold. `crit` must be exactly `true`:
132
+ * every type here is critical, and a known type declaring otherwise is a body whose envelope
133
+ * contradicts it. A `recoveryKey` on the envelope is refused rather than ignored -- only a reset
134
+ * reveals one, and a member nothing reads is a field that lies.
135
+ */ export function isEpochEventShape(value) {
136
+ if (!isRecord(value) || !isRecord(value.event)) {
137
+ return false;
138
+ }
139
+ const { event, sigs } = value;
140
+ if (!isStringList(sigs, MAX_EVENT_SIGNATURES) || sigs.length === 0 || value.recoveryKey !== undefined) {
141
+ return false;
142
+ }
143
+ if (event.v !== 1 || !EPOCH_EVENT_TYPES.has(event.t) || typeof event.i !== 'string' || !isSeconds(event.g) || typeof event.e !== 'string' || event.crit !== true || event.by !== undefined && typeof event.by !== 'string') {
144
+ return false;
145
+ }
146
+ const p = event.p;
147
+ if (!isStringList(p, MAX_EVENT_PARENTS) || p.length === 0 || new Set(p).size !== p.length) {
148
+ return false;
149
+ }
150
+ if (!hasTypeFields(event)) {
151
+ return false;
152
+ }
153
+ // Last: the only check whose cost grows with the body, and the one that needs the rest settled.
154
+ return isCanonicalizable(event) && canonicalBytes(event).length <= MAX_EVENT_BYTES;
155
+ }
156
+ /**
157
+ * Whether `signed` verifies under the signer the caller names. The fold names it from `by`: absent,
158
+ * the chain head's keys; present, the key the referenced add pinned.
159
+ *
160
+ * The signer kind must agree with `by`, so a root-signed body cannot pass as a member's or the
161
+ * reverse: a single-key root and a member are otherwise the same check, and an event whose `by` the
162
+ * caller did not consult would be authorised as whoever happened to be asked about. Root is n-of-n
163
+ * over `rootKeys` (via `verifySignatures`); a member signs alone, so exactly one signature, under the
164
+ * pinned key. Total: a malformed body or signature is `false`.
165
+ */ export function verifyEpochSignature(signed, signer) {
166
+ const { event, sigs } = signed;
167
+ if (!isRecord(event) || !Array.isArray(sigs) || sigs.length > MAX_EVENT_SIGNATURES) {
168
+ return false;
169
+ }
170
+ if ('rootKeys' in signer) {
171
+ return event.by === undefined && verifySignatures(event, sigs, signer.rootKeys);
172
+ }
173
+ // One key, and `verifySignatures` matches signatures to keys one for one: exactly one signature.
174
+ return sigs.length === 1 && typeof event.by === 'string' && verifySignatures(event, sigs, [
175
+ signer.memberKey
176
+ ]);
177
+ }
package/lib/epoch.d.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { type EpochEvent } from './epoch-events.js';
2
+ import { type SignedEvent } from './events.js';
3
+ import type { KeyState } from './fold.js';
4
+ /** A member-signed add. Only root admits members, so sock-puppets cannot renew each other forever. */
5
+ export declare const ADD_NOT_ROOT = "only root may add a member";
6
+ /** An add pinning a key on, or committed by, the generation's root chain, or the recovery key. */
7
+ export declare const ADD_KEY_IS_ROOT = "member key is a root key";
8
+ /** An add whose DID is not a `did:key` or long-form `did:peer:4` carrying exactly its pin. */
9
+ export declare const ADD_DID_MISMATCH = "member DID does not carry its key";
10
+ /** An add of a DID or key that a denial in its causal past targets. */
11
+ export declare const ADD_KEY_DENIED = "member is denied in the causal state";
12
+ /** An add of a key an effective member of its causal past already pins. */
13
+ export declare const ADD_KEY_ALREADY_MEMBER = "member key is already an effective member";
14
+ /** A member renewing an incarnation pinning its own key. */
15
+ export declare const SELF_RENEW = "a member may not renew itself";
16
+ /** A member removing an incarnation pinning its own key. */
17
+ export declare const SELF_REMOVE = "a member may not remove itself";
18
+ /** A renew or remove naming an add its causal past does not hold. */
19
+ export declare const UNKNOWN_MEMBER = "target is not a member of the causal state";
20
+ /** A member revoke targeting an effective member of its causal past: members leave through remove. */
21
+ export declare const MEMBER_REVOKES_MEMBER = "a member may not revoke a member";
22
+ /** A revoke denying a key the chain head publishes in `k` or `ka`. */
23
+ export declare const REVOKE_NAMES_ROOT_KEY = "revoke targets a key the chain publishes";
24
+ /**
25
+ * What a presentation fails with, as a whole, when the signatures on its copies that do not verify
26
+ * exceed {@link MAX_FAILED_SIGNATURES}. A request limit, so it shares `LOG_TOO_LARGE`'s prefix.
27
+ */
28
+ export declare const TOO_MANY_FAILED_SIGNATURES = "controller log exceeds request limits: too many signatures fail to verify";
29
+ export type EpochLimits = {
30
+ MAX_EPOCH_EVENTS: number;
31
+ };
32
+ /** What {@link foldEpoch} uses when a caller lowers nothing: pinned so a test can see it. */
33
+ export declare const DEFAULT_EPOCH_LIMITS: EpochLimits;
34
+ export type FoldEpochParams = {
35
+ did: string;
36
+ /** The chain's states, in order; the last is the anchor the epoch grew from. */
37
+ chainStates: Array<KeyState>;
38
+ /** Untrusted: whatever was presented as epoch events. */
39
+ events: Array<unknown>;
40
+ /**
41
+ * Copies this verifier authenticated itself and kept: its own memory, never a presentation. A
42
+ * trusted copy of an event of this epoch is taken as authentic without checking its signatures
43
+ * again, and stands in for every presented copy of the same body, which are then not checked
44
+ * either. Its signer is fixed by the epoch: a root event's keys are the chain event the epoch names,
45
+ * a member event's key the add or snapshot entry its `by` digest names, so a copy that verified
46
+ * once under this epoch verifies under it every time. Authorisation is still judged afresh.
47
+ */
48
+ trusted?: Array<unknown>;
49
+ /** Test-only: lowers a limit so saturation is reachable without thousands of signatures. */
50
+ limits?: Partial<EpochLimits>;
51
+ };
52
+ export type FoldEpochResult = {
53
+ ok: true;
54
+ head: KeyState;
55
+ /** Digests of the events that applied, parents first, ties by ascending digest. */
56
+ applied: Array<string>;
57
+ /** Well-formed events of this epoch whose parents never all applied. */
58
+ pending: number;
59
+ /**
60
+ * Refusals, counted by distinct event: an event presented any number of times counts once. An entry
61
+ * that is not an epoch event at all has no digest to dedupe by and counts once per entry.
62
+ */
63
+ rejected: number;
64
+ };
65
+ export type FoldEpochDetail = FoldEpochResult & {
66
+ /** Why each refused event was refused, by digest. Entries that are not events at all have no digest. */
67
+ reasons: Map<string, string>;
68
+ /**
69
+ * The copy that verified, for each applied digest: what may be remembered of the event. A copy of a
70
+ * body under other signatures shares its digest, so the digest alone does not say which to keep.
71
+ */
72
+ authentic: Map<string, SignedEvent<EpochEvent>>;
73
+ };
74
+ /**
75
+ * A presentation refused as a whole by a request limit on the work it asked for. Never a judgement on
76
+ * any one event, so nothing about the epoch is learned from it.
77
+ */
78
+ export type FoldEpochRefusal = {
79
+ ok: false;
80
+ reason: string;
81
+ };
82
+ /**
83
+ * Merge the current epoch into the chain's last state. Total: whatever `events` holds, the result is
84
+ * a head or a refusal, never a throw -- an entry that is not a valid epoch event is counted and
85
+ * dropped. The one refusal is the request limit on failed signatures ({@link TOO_MANY_FAILED_SIGNATURES}),
86
+ * which `trusted` copies never count towards.
87
+ */
88
+ export declare function foldEpochDetailed(params: FoldEpochParams): FoldEpochDetail | FoldEpochRefusal;
89
+ /** {@link foldEpochDetailed} without the per-event reasons and copies. */
90
+ export declare function foldEpoch(params: FoldEpochParams): FoldEpochResult | FoldEpochRefusal;