@kyaki/witness 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.
Files changed (46) hide show
  1. package/dist/accountability.d.ts +43 -0
  2. package/dist/accountability.d.ts.map +1 -0
  3. package/dist/accountability.js +64 -0
  4. package/dist/accountability.js.map +1 -0
  5. package/dist/adapters/monitor-http.d.ts +31 -0
  6. package/dist/adapters/monitor-http.d.ts.map +1 -0
  7. package/dist/adapters/monitor-http.js +59 -0
  8. package/dist/adapters/monitor-http.js.map +1 -0
  9. package/dist/adapters/witness-http.d.ts +62 -0
  10. package/dist/adapters/witness-http.d.ts.map +1 -0
  11. package/dist/adapters/witness-http.js +212 -0
  12. package/dist/adapters/witness-http.js.map +1 -0
  13. package/dist/durable-witness.d.ts +101 -0
  14. package/dist/durable-witness.d.ts.map +1 -0
  15. package/dist/durable-witness.js +179 -0
  16. package/dist/durable-witness.js.map +1 -0
  17. package/dist/fan-out.d.ts +20 -0
  18. package/dist/fan-out.d.ts.map +1 -0
  19. package/dist/fan-out.js +30 -0
  20. package/dist/fan-out.js.map +1 -0
  21. package/dist/index.d.ts +17 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +17 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/monitor.d.ts +83 -0
  26. package/dist/monitor.d.ts.map +1 -0
  27. package/dist/monitor.js +133 -0
  28. package/dist/monitor.js.map +1 -0
  29. package/dist/pollination.d.ts +109 -0
  30. package/dist/pollination.d.ts.map +1 -0
  31. package/dist/pollination.js +170 -0
  32. package/dist/pollination.js.map +1 -0
  33. package/dist/ssrf.d.ts +20 -0
  34. package/dist/ssrf.d.ts.map +1 -0
  35. package/dist/ssrf.js +205 -0
  36. package/dist/ssrf.js.map +1 -0
  37. package/package.json +33 -0
  38. package/src/accountability.ts +72 -0
  39. package/src/adapters/monitor-http.ts +69 -0
  40. package/src/adapters/witness-http.ts +235 -0
  41. package/src/durable-witness.ts +190 -0
  42. package/src/fan-out.ts +34 -0
  43. package/src/index.ts +28 -0
  44. package/src/monitor.ts +159 -0
  45. package/src/pollination.ts +229 -0
  46. package/src/ssrf.ts +190 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * adapters/witness-http.ts — the witness's HTTP edge (a transport, not authority).
3
+ *
4
+ * A pure `(req) => Promise<res>` handler plus a node:http binding, mirroring
5
+ * @kyaki/api's adapter — but HARDENED per the design review, because a witness is a
6
+ * public-facing party an operator does not control:
7
+ * - a request-body BYTE CAP in the binding (413) so an unbounded POST can't
8
+ * exhaust heap before parsing;
9
+ * - a consistency-proof LENGTH CAP in the handler (400) — a real proof is
10
+ * O(log n) (≤ 64 nodes covers 2^64 entries), so a longer one is junk/abuse;
11
+ * - a try/catch around dispatch so a thrown WITNESS_* error becomes a WRITTEN
12
+ * response with the right status, never a hung socket / TCP reset;
13
+ * - an error TAXONOMY that keeps the smoking gun loud: an operator-attributable
14
+ * CONFLICT (equivocation / non-monotonic / non-consistent fork) is 409, an
15
+ * input fault (bad signature / missing or mis-shaped proof / wrong log) is 400,
16
+ * and only an unexpected internal fault is 500.
17
+ *
18
+ * NOTE (documented out-of-scope, per review): /cosign is unauthenticated. Any
19
+ * co-signature a replayer obtains is honest (the witness only ever endorses the
20
+ * operator's own signed heads), but advancing the anchor and the write path should
21
+ * be gated to the operator in production (private network / mTLS / a signed request
22
+ * envelope). The byte cap blunts the worst abuse; rate-limiting is a deployment concern.
23
+ */
24
+ import { createServer as nodeCreateServer, type Server } from 'node:http';
25
+ import type { ConsistencyProof, SignedTreeHead, WitnessCosignature, WitnessHead } from '@kyaki/core';
26
+
27
+ /** The witness surface the adapter needs (satisfied by DurableWitness). */
28
+ export interface WitnessLike {
29
+ cosign(sth: SignedTreeHead, consistencyProof?: ConsistencyProof): Promise<WitnessCosignature>;
30
+ head(): Promise<WitnessHead | undefined>;
31
+ /** The operator-signed STH endorsed at `treeSize` (latest if omitted) — the gossip
32
+ * source a WitnessMonitor pulls via `GET /sth`. */
33
+ signedHead(treeSize?: number): Promise<SignedTreeHead | undefined>;
34
+ /** This witness's endorsement (its endorsed STH + its own co-signature) at `treeSize`
35
+ * (latest if omitted) — what a relying party pulls via `GET /cosig` to POLLINATE the
36
+ * head it was served (confirm a quorum of trusted witnesses co-signed the exact root). */
37
+ endorsement(treeSize?: number): Promise<{ sth: SignedTreeHead; cosignature: WitnessCosignature } | undefined>;
38
+ }
39
+
40
+ export interface HttpRequest {
41
+ method: string;
42
+ path: string;
43
+ query?: Record<string, string | undefined>;
44
+ body?: unknown;
45
+ }
46
+
47
+ export interface HttpResponse {
48
+ status: number;
49
+ body: unknown;
50
+ }
51
+
52
+ export type HttpHandler = (req: HttpRequest) => Promise<HttpResponse>;
53
+
54
+ /** A real Merkle consistency proof is O(log n); anything longer is junk or abuse. */
55
+ const MAX_PROOF_NODES = 64;
56
+ /** Honest STH + proof is well under 1 KiB; cap the raw body far below heap-risk. */
57
+ const MAX_BODY_BYTES = 64 * 1024;
58
+
59
+ /** Operator-attributable conflicts → 409 (the alarm); input faults → 400. */
60
+ const OPERATOR_CONFLICTS = new Set([
61
+ 'WITNESS_NON_MONOTONIC', 'WITNESS_EQUIVOCATION', 'WITNESS_CONSISTENCY_INVALID',
62
+ 'WITNESS_STORE_NON_MONOTONIC', 'WITNESS_STORE_EQUIVOCATION',
63
+ ]);
64
+ const INPUT_FAULTS = new Set([
65
+ 'WITNESS_STH_SIGNATURE_INVALID', 'WITNESS_CONSISTENCY_REQUIRED',
66
+ 'WITNESS_CONSISTENCY_SIZE_MISMATCH', 'WITNESS_WRONG_LOG',
67
+ ]);
68
+
69
+ /** The machine code is the message up to the first ':' or space. */
70
+ function codeOf(err: unknown): string {
71
+ const msg = err instanceof Error ? err.message : String(err);
72
+ return msg.split(/[:\s]/)[0] ?? 'INTERNAL';
73
+ }
74
+
75
+ function statusForCode(code: string): number {
76
+ if (INPUT_FAULTS.has(code)) return 400;
77
+ if (OPERATOR_CONFLICTS.has(code)) return 409;
78
+ // Any OTHER integrity-prefixed code (e.g. a store backstop like WITNESS_STORE_LOG_MISMATCH,
79
+ // or a future WITNESS_*/COSIG_* fault) is operator-attributable — surface it LOUD as a 409,
80
+ // never let it fall through to 500 where the client would misread it as transport/liveness.
81
+ if (code.startsWith('WITNESS_') || code.startsWith('COSIG_')) return 409;
82
+ return 500;
83
+ }
84
+
85
+ /**
86
+ * Routes:
87
+ * POST /cosign { sth, consistencyProof? } → 200 WitnessCosignature
88
+ * · 400 input fault · 409 operator conflict
89
+ * · 400 PROOF_TOO_LARGE
90
+ * GET /head → 200 { head: WitnessHead | null }
91
+ */
92
+ export function createWitnessHttpHandler(witness: WitnessLike): HttpHandler {
93
+ return async (req) => {
94
+ const { method, path } = req;
95
+
96
+ if (method === 'POST' && path === '/cosign') {
97
+ const body = (req.body ?? {}) as { sth?: SignedTreeHead; consistencyProof?: ConsistencyProof };
98
+ if (!body.sth) {
99
+ return { status: 400, body: { error: 'MISSING_STH' } };
100
+ }
101
+ if (body.consistencyProof && Array.isArray(body.consistencyProof.proof)
102
+ && body.consistencyProof.proof.length > MAX_PROOF_NODES) {
103
+ return { status: 400, body: { error: 'PROOF_TOO_LARGE', max: MAX_PROOF_NODES } };
104
+ }
105
+ try {
106
+ const cosignature = await witness.cosign(body.sth, body.consistencyProof);
107
+ return { status: 200, body: cosignature };
108
+ } catch (err) {
109
+ const code = codeOf(err);
110
+ const status = statusForCode(code);
111
+ // A 500 is an unexpected internal fault — surface a generic body, never a hang.
112
+ return { status, body: { error: status === 500 ? 'INTERNAL' : code } };
113
+ }
114
+ }
115
+
116
+ if (method === 'GET' && path === '/head') {
117
+ try {
118
+ const head = await witness.head();
119
+ return { status: 200, body: { head: head ?? null } };
120
+ } catch {
121
+ // Keep the "no thrown error escapes the handler" invariant uniform across routes —
122
+ // a store/DB fault in head() becomes a written 500, not an unhandled rejection for
123
+ // an in-process caller composing the pure handler without the node:http binding.
124
+ return { status: 500, body: { error: 'INTERNAL' } };
125
+ }
126
+ }
127
+
128
+ if (method === 'GET' && path === '/sth') {
129
+ // The gossip read a WitnessMonitor pulls. `?size=` selects a specific endorsed
130
+ // size (for cross-witness same-size comparison); omitted ⇒ the latest endorsed STH.
131
+ const raw = req.query?.['size'];
132
+ let treeSize: number | undefined;
133
+ if (raw !== undefined && raw !== '') {
134
+ treeSize = Number(raw);
135
+ if (!Number.isInteger(treeSize) || treeSize < 0) {
136
+ return { status: 400, body: { error: 'BAD_SIZE' } };
137
+ }
138
+ }
139
+ try {
140
+ const sth = await witness.signedHead(treeSize);
141
+ return { status: 200, body: { sth: sth ?? null } };
142
+ } catch {
143
+ return { status: 500, body: { error: 'INTERNAL' } };
144
+ }
145
+ }
146
+
147
+ if (method === 'GET' && path === '/cosig') {
148
+ // The relying-party POLLINATION read: this witness's endorsement (STH + its own
149
+ // co-signature) at `?size=`. A victim pulls this DIRECTLY from each trusted witness
150
+ // to confirm the head it was served — so the operator cannot withhold/cherry-pick.
151
+ const raw = req.query?.['size'];
152
+ let treeSize: number | undefined;
153
+ if (raw !== undefined && raw !== '') {
154
+ treeSize = Number(raw);
155
+ if (!Number.isInteger(treeSize) || treeSize < 0) {
156
+ return { status: 400, body: { error: 'BAD_SIZE' } };
157
+ }
158
+ }
159
+ try {
160
+ const e = await witness.endorsement(treeSize);
161
+ return { status: 200, body: { sth: e?.sth ?? null, cosignature: e?.cosignature ?? null } };
162
+ } catch {
163
+ return { status: 500, body: { error: 'INTERNAL' } };
164
+ }
165
+ }
166
+
167
+ return { status: 404, body: { error: 'NOT_FOUND' } };
168
+ };
169
+ }
170
+
171
+ /** Bind the pure handler to node:http, enforcing the request-body byte cap. */
172
+ export function createWitnessServer(witness: WitnessLike): Server {
173
+ const handle = createWitnessHttpHandler(witness);
174
+ return nodeCreateServer((req, res) => {
175
+ // A public-facing endpoint an operator does not control: a client that resets the
176
+ // connection mid-body makes the request stream emit 'error', which Node re-throws as
177
+ // an uncaught exception (crashing the witness) if unhandled. Swallow it on both streams.
178
+ req.on('error', () => {});
179
+ res.on('error', () => {});
180
+ const chunks: Buffer[] = [];
181
+ let total = 0;
182
+ let aborted = false;
183
+ const fail = (status: number, error: string): void => {
184
+ // Send the response and close the connection; the `aborted` flag stops further
185
+ // buffering. We do NOT req.destroy() here — destroying the socket before the body
186
+ // flushes can RST the connection and lose the response (e.g. the 413).
187
+ aborted = true;
188
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Connection': 'close' });
189
+ res.end(JSON.stringify({ error }));
190
+ };
191
+ req.on('data', (c: Buffer) => {
192
+ if (aborted) return;
193
+ total += c.length;
194
+ if (total > MAX_BODY_BYTES) {
195
+ fail(413, 'PAYLOAD_TOO_LARGE');
196
+ return;
197
+ }
198
+ chunks.push(c);
199
+ });
200
+ req.on('end', () => {
201
+ if (aborted) return;
202
+ const fail500 = (): void => {
203
+ try { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'INTERNAL' })); }
204
+ catch { /* already responded */ }
205
+ };
206
+ void (async () => {
207
+ const raw = Buffer.concat(chunks).toString('utf8');
208
+ let body: unknown;
209
+ try {
210
+ body = raw ? JSON.parse(raw) : undefined;
211
+ } catch {
212
+ res.writeHead(400, { 'Content-Type': 'application/json' });
213
+ res.end(JSON.stringify({ error: 'INVALID_JSON' }));
214
+ return;
215
+ }
216
+ // Parse path + query WITHOUT new URL(): a raw target like `//` makes new URL() THROW
217
+ // ('Invalid URL'), which — before the dispatch try — would escape this voided async
218
+ // IIFE as an unhandled rejection, hanging the socket (and crashing under the default
219
+ // --unhandled-rejections=throw). split + URLSearchParams never throw on a bad target.
220
+ const [rawPath, qs] = (req.url ?? '/').split('?');
221
+ const query: Record<string, string> = {};
222
+ for (const [k, v] of new URLSearchParams(qs ?? '')) query[k] = v;
223
+ try {
224
+ const result = await handle({ method: req.method ?? 'GET', path: rawPath || '/', query, body });
225
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
226
+ res.end(JSON.stringify(result.body));
227
+ } catch {
228
+ // The pure handler maps every expected rejection itself; anything escaping
229
+ // here is a genuine internal fault — still write a body, never hang.
230
+ fail500();
231
+ }
232
+ })().catch(fail500); // belt-and-suspenders: no throw can leave the socket hung
233
+ });
234
+ });
235
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * durable-witness.ts — the DURABLE, independent witness party.
3
+ *
4
+ * `@kyaki/core`'s `Witness` is the verification ENGINE, but its remembered head is
5
+ * in-process: it forgets on restart, so it provides no cross-restart rollback
6
+ * defense and cannot be horizontally scaled. `DurableWitness` closes that — it is
7
+ * the leg of "anchored AND externally witnessed" the moat rests on.
8
+ *
9
+ * The cross-party guarantee lives in TWO places working together:
10
+ * 1. PERSIST-BEFORE-EMIT — a co-signature is returned only AFTER the head it
11
+ * endorses is durably committed. Releasing a co-sig before recording it would
12
+ * let the witness forget, after a crash, that it had endorsed head N and later
13
+ * co-sign a forked N′ — exactly the equivocation it exists to prevent.
14
+ * 2. VERIFY-UNDER-LOCK against the DURABLE head — the append-only consistency
15
+ * check (which the store cannot perform; it has no Merkle state) runs inside
16
+ * the store's per-log lock against the head read FROM STORAGE, never an
17
+ * in-memory cache. So two instances sharing one store can never co-sign a fork
18
+ * from stale local state. In-process requests are additionally serialized per
19
+ * log by a KeyedMutex so two concurrent /cosign calls cannot interleave across
20
+ * the store await.
21
+ *
22
+ * What this does NOT defend (honest boundary, enforced at the relying party by
23
+ * `verifyWitnessedTreeHead`): a single witness does not defeat a general cross-party
24
+ * split-view, nor a compromised/colluding witness key — those need quorum ≥ 2 under
25
+ * disjoint control and, ultimately, witness-to-witness gossip (a documented follow-up).
26
+ */
27
+ import {
28
+ KeyedMutex, buildWitnessCosignature, checkWitnessAdvance,
29
+ type ConsistencyProof, type EndorsedHead, type Identity, type SignedTreeHead,
30
+ type WitnessCosignature, type WitnessHead, type WitnessStateStore,
31
+ } from '@kyaki/core';
32
+
33
+ export class DurableWitness {
34
+ private readonly mutex = new KeyedMutex();
35
+
36
+ private constructor(
37
+ private readonly keys: Identity,
38
+ private readonly logId: string,
39
+ private readonly store: WitnessStateStore,
40
+ ) {}
41
+
42
+ /**
43
+ * Build a witness pinned to one operator log. The durable store is the SOLE
44
+ * authority for the witness's last head — it is read under lock on every cosign,
45
+ * so there is no in-memory baseline to go stale. `create()` is the only
46
+ * constructor so a caller cannot bypass the store with a hand-seeded head.
47
+ */
48
+ static async create(keys: Identity, logId: string, store: WitnessStateStore): Promise<DurableWitness> {
49
+ return new DurableWitness(keys, logId, store);
50
+ }
51
+
52
+ get id(): string {
53
+ return this.keys.did;
54
+ }
55
+
56
+ /** The witness's durable last-co-signed head. The operator builds its next
57
+ * consistency proof from THIS (reading storage, never a cache) so an honest
58
+ * extension is never spuriously rejected for a size mismatch. */
59
+ async head(): Promise<WitnessHead | undefined> {
60
+ return this.store.load();
61
+ }
62
+
63
+ /**
64
+ * Co-sign `sth` iff it is an append-only extension of the witness's durable head.
65
+ * Throws a named WITNESS_* error on rejection (the caller maps it to a status):
66
+ * - WITNESS_WRONG_LOG — sth is for a log this witness does not watch;
67
+ * - WITNESS_STH_SIGNATURE_INVALID / _CONSISTENCY_REQUIRED / _SIZE_MISMATCH
68
+ * — input faults (the proof is missing/wrong shape);
69
+ * - WITNESS_NON_MONOTONIC / _EQUIVOCATION / _CONSISTENCY_INVALID
70
+ * — operator-attributable conflicts (a fork attempt).
71
+ */
72
+ async cosign(sth: SignedTreeHead, consistencyProof?: ConsistencyProof, now?: Date): Promise<WitnessCosignature> {
73
+ if (sth.logId !== this.logId) {
74
+ throw new Error(`WITNESS_WRONG_LOG: this witness is pinned to ${this.logId}, not ${sth.logId}`);
75
+ }
76
+ const at = (now ?? new Date()).toISOString();
77
+ return this.mutex.runExclusive(this.logId, () =>
78
+ this.store.cosign((prior) => {
79
+ // The append-only check runs HERE — inside the store's per-log lock, against
80
+ // the DURABLE prior head. This is the load-bearing placement: a stale cache
81
+ // is never the baseline, so a scaled-out witness cannot co-sign a fork.
82
+ checkWitnessAdvance(prior, sth, consistencyProof);
83
+ const head: WitnessHead = { logId: sth.logId, treeSize: sth.treeSize, rootHash: sth.rootHash, at };
84
+ const cosignature = buildWitnessCosignature(this.keys, sth);
85
+ // Retain the OPERATOR-signed STH (verbatim) + the consistency proof we just
86
+ // verified, so this witness can later serve the exact head it endorsed at THIS
87
+ // size for gossip — even after advancing far beyond it. checkWitnessAdvance
88
+ // already validated sth's operator signature before we reach here.
89
+ const endorsed: EndorsedHead = { head, cosignature, endorsedSth: sth };
90
+ if (consistencyProof) endorsed.prefixProof = consistencyProof;
91
+ return endorsed;
92
+ }),
93
+ );
94
+ }
95
+
96
+ /** The operator-signed STH this witness endorsed at `treeSize` (its latest endorsed
97
+ * STH if omitted), read straight from the durable store — the gossip source a
98
+ * WitnessMonitor pulls to detect cross-witness equivocation. */
99
+ async signedHead(treeSize?: number): Promise<SignedTreeHead | undefined> {
100
+ return this.store.signedHead(treeSize);
101
+ }
102
+
103
+ /**
104
+ * This witness's OWN endorsement at `treeSize` (its latest if omitted): the
105
+ * operator-signed STH it endorsed AND its co-signature over it. The co-signature is
106
+ * re-derived deterministically from the retained STH — its body is a pure function of
107
+ * (witnessId, logId, treeSize, rootHash), so this needs no extra stored state and is
108
+ * byte-identical to the one emitted at cosign time.
109
+ *
110
+ * This is what relying-party POLLINATION pulls DIRECTLY from each trusted witness: the
111
+ * victim asks the witnesses themselves "did you co-sign this exact root at this size?",
112
+ * so the operator cannot withhold or cherry-pick which witnesses endorsed which root,
113
+ * and the witness-signed co-signature cannot be forged by a man-in-the-middle.
114
+ * undefined if this witness retained no endorsement at that size.
115
+ */
116
+ async endorsement(treeSize?: number): Promise<{ sth: SignedTreeHead; cosignature: WitnessCosignature } | undefined> {
117
+ const sth = await this.store.signedHead(treeSize);
118
+ if (!sth) return undefined;
119
+ return { sth, cosignature: buildWitnessCosignature(this.keys, sth) };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * An in-memory `WitnessStateStore` for single-process tests and demos. It serializes
125
+ * `cosign` via a promise chain (mirroring the durable per-log lock) and applies the
126
+ * same monotonic + equivocation backstop the durable store does. It is NOT durable —
127
+ * it forgets on restart, so the cross-restart guarantee requires a durable store
128
+ * (`@kyaki/postgres` `PgWitnessStore`). Use it to exercise the witness LOGIC.
129
+ */
130
+ export class MemoryWitnessStore implements WitnessStateStore {
131
+ private current: WitnessHead | undefined;
132
+ private cosignature: WitnessCosignature | undefined;
133
+ /** Append-only operator-signed STHs keyed by tree size (FIRST-write-wins, mirroring
134
+ * the durable kya_endorsed_sth ON CONFLICT DO NOTHING). Retained so a past size
135
+ * stays serveable for gossip after the witness advances beyond it. */
136
+ private readonly endorsed = new Map<number, SignedTreeHead>();
137
+ private chain: Promise<unknown> = Promise.resolve();
138
+
139
+ async load(): Promise<WitnessHead | undefined> {
140
+ return this.current ? { ...this.current } : undefined;
141
+ }
142
+
143
+ /** The co-signature co-committed with the current head (for parity with the
144
+ * durable store, which persists both in one transaction). */
145
+ async latestCosignature(): Promise<WitnessCosignature | undefined> {
146
+ return this.cosignature ? { ...this.cosignature } : undefined;
147
+ }
148
+
149
+ /** The endorsed operator STH at `treeSize` (latest if omitted). undefined if none. */
150
+ async signedHead(treeSize?: number): Promise<SignedTreeHead | undefined> {
151
+ const size = treeSize ?? (this.endorsed.size > 0 ? Math.max(...this.endorsed.keys()) : undefined);
152
+ if (size === undefined) return undefined;
153
+ const sth = this.endorsed.get(size);
154
+ return sth ? { ...sth } : undefined;
155
+ }
156
+
157
+ cosign(decide: (prior: WitnessHead | undefined) => EndorsedHead): Promise<WitnessCosignature> {
158
+ const run = this.chain.then(() => {
159
+ const prior = this.current ? { ...this.current } : undefined;
160
+ const { head, cosignature, endorsedSth } = decide(prior); // throws ⇒ rejection, state untouched
161
+ // Parity with PgWitnessStore: the head and the co-signature it endorses must name the
162
+ // same log (a mis-targeted decide() from a non-tree composer fails closed here too).
163
+ if (head.logId !== cosignature.logId) {
164
+ throw new Error('WITNESS_STORE_LOG_MISMATCH: head/cosig logId disagree');
165
+ }
166
+ // The retained STH must be the very head being endorsed (same size+root), or a
167
+ // monitor would serve an STH that does not match the co-signature.
168
+ if (endorsedSth.treeSize !== head.treeSize || endorsedSth.rootHash !== head.rootHash) {
169
+ throw new Error('WITNESS_STORE_STH_MISMATCH: endorsed STH does not match the head');
170
+ }
171
+ // Defense-in-depth backstop (the store cannot check Merkle consistency, but it
172
+ // can refuse a non-monotonic or same-size-equivocating head outright).
173
+ if (prior) {
174
+ if (head.treeSize < prior.treeSize) {
175
+ throw new Error('WITNESS_STORE_NON_MONOTONIC: refusing a head smaller than the durable one');
176
+ }
177
+ if (head.treeSize === prior.treeSize && head.rootHash !== prior.rootHash) {
178
+ throw new Error('WITNESS_STORE_EQUIVOCATION: a different root at an already-recorded size');
179
+ }
180
+ }
181
+ this.current = { ...head }; // co-commit: head + co-signature + endorsed STH together
182
+ this.cosignature = { ...cosignature };
183
+ if (!this.endorsed.has(endorsedSth.treeSize)) this.endorsed.set(endorsedSth.treeSize, { ...endorsedSth });
184
+ return cosignature;
185
+ });
186
+ // Keep the chain alive after a rejection so one bad request can't wedge the lock.
187
+ this.chain = run.then(() => undefined, () => undefined);
188
+ return run;
189
+ }
190
+ }
package/src/fan-out.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * fan-out.ts — bounded-concurrency fan-out over a list (RULES §5.12 / §5.16).
3
+ *
4
+ * Pulls every item concurrently but never more than `concurrency` in flight, so a large
5
+ * pinned source set cannot open unbounded sockets or buffer unbounded heap in a single tick
6
+ * (a relying-party / monitor self-DoS). Per-item settle (`Promise.allSettled`): one
7
+ * rejecting or slow item never rejects the whole call, and worst-case wall-clock is one
8
+ * timeout PER BATCH, not the sum. Shared by the WitnessMonitor gossip poll and relying-party
9
+ * pollination so the two stay symmetric (the same bound the monitor was hardened to has to
10
+ * hold for the victim's pull too).
11
+ */
12
+
13
+ /** Default in-flight cap. A handful of concurrent pulls is plenty for a witness/clearinghouse
14
+ * set; the bound exists to stop a large set from exhausting sockets/FDs, not to maximize throughput. */
15
+ export const POLL_CONCURRENCY = 8;
16
+
17
+ /**
18
+ * Run `fn` over every item, at most `concurrency` in flight at a time. Never rejects — each
19
+ * item's outcome is returned as a settled result in input order, so callers classify per item.
20
+ */
21
+ export async function boundedFanOut<S, T>(
22
+ items: readonly S[],
23
+ fn: (item: S, index: number) => Promise<T>,
24
+ concurrency: number = POLL_CONCURRENCY,
25
+ ): Promise<PromiseSettledResult<T>[]> {
26
+ const width = Number.isInteger(concurrency) && concurrency > 0 ? concurrency : POLL_CONCURRENCY;
27
+ const out: PromiseSettledResult<T>[] = new Array(items.length);
28
+ for (let i = 0; i < items.length; i += width) {
29
+ const batch = items.slice(i, i + width);
30
+ const settled = await Promise.allSettled(batch.map((s, j) => fn(s, i + j)));
31
+ for (let j = 0; j < settled.length; j++) out[i + j] = settled[j]!;
32
+ }
33
+ return out;
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @kyaki/witness — the independent, durable witness party.
3
+ *
4
+ * A separate party from the operator: it depends ONLY on @kyaki/core (it can never
5
+ * reach the operator's audit log), holds its own key, and remembers the last head
6
+ * it endorsed OUT OF BAND in its own WitnessStateStore — so it refuses to
7
+ * contradict that head even across its own restart or horizontal scaling. This is
8
+ * the cross-party rollback defense the Mandate Transparency Log rests on.
9
+ */
10
+ export { DurableWitness, MemoryWitnessStore } from './durable-witness.js';
11
+ export {
12
+ createWitnessHttpHandler, createWitnessServer,
13
+ type WitnessLike, type HttpHandler, type HttpRequest, type HttpResponse,
14
+ } from './adapters/witness-http.js';
15
+ export {
16
+ WitnessMonitor, HttpGossipSource,
17
+ type WitnessGossipSource, type WitnessMonitorConfig, type PollResult,
18
+ } from './monitor.js';
19
+ export { assertSafeUrl, safeFetchJson, isPrivateIp, type SsrfPolicy } from './ssrf.js';
20
+ export {
21
+ pollinate, HttpPollinationSource,
22
+ type PollinationSource, type PollinationResult, type PollinateOptions,
23
+ } from './pollination.js';
24
+ export { WitnessAccountabilityMonitor, type WitnessAccountabilityConfig } from './accountability.js';
25
+ export {
26
+ createMonitorHttpHandler, createMonitorServer, CONFLICT_WIRE_VERSION,
27
+ type MonitorHttpHandler, type MonitorHttpRequest, type MonitorHttpResponse,
28
+ } from './adapters/monitor-http.js';
package/src/monitor.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * monitor.ts — the WitnessMonitor: an INDEPENDENT aggregator that gossips operator-
3
+ * signed STHs from a config-pinned witness set and surfaces a portable EquivocationProof
4
+ * when two witnesses disagree at one tree size.
5
+ *
6
+ * TRUST MODEL (read this — the monitor needs NO trust): it only ever relays
7
+ * self-verifying artifacts. Every STH it ingests is re-checked with verifySignedTreeHead
8
+ * against the operator key before it is retained, and every proof it emits is a pair of
9
+ * operator-signed STHs any third party re-verifies with verifyEquivocation against the
10
+ * pinned operator DID. A malicious monitor therefore cannot FABRICATE a false alarm
11
+ * (it cannot forge the operator's signature) — it can only WITHHOLD one. That makes the
12
+ * monitor a LIVENESS party, not a safety party.
13
+ *
14
+ * WHAT IT DOES AND DOES NOT CLOSE (§9 honesty — do not overstate):
15
+ * - It produces an irrefutable proof WHEN two conflicting operator-signed heads at one
16
+ * size both reach its corpus. The actual SAFETY control against a split-view remains
17
+ * verifyWitnessedTreeHead at quorum ≥ 2 (fail-closed at the relying party).
18
+ * - Detection is best-effort and operator-suppressible: an operator that never lets the
19
+ * monitor observe both forks (targeted split-view), or DDoSes/partitions it, yields no
20
+ * proof. ABSENCE OF A PROOF PROVES NOTHING.
21
+ * - SPENDS NEVER DEPEND ON THE MONITOR. It is read-only gossip + detection; the spend
22
+ * path (submit/approve) never calls it.
23
+ */
24
+ import {
25
+ detectEquivocation, verifyEquivocation, verifySignedTreeHead,
26
+ type ConflictStore, type EquivocationProof, type ObservedSthStore, type SignedTreeHead,
27
+ } from '@kyaki/core';
28
+ import { boundedFanOut } from './fan-out.js';
29
+ import { safeFetchJson, type SsrfPolicy } from './ssrf.js';
30
+
31
+ /** A gossip source the monitor pulls STHs from. Satisfied in-process by `DurableWitness`
32
+ * (it has `id` + `signedHead`) and over the wire by `HttpGossipSource`. */
33
+ export interface WitnessGossipSource {
34
+ /** The witness DID — used to attribute and key the observed-STH corpus. */
35
+ readonly id?: string;
36
+ /** The operator-signed STH the witness endorsed at `treeSize` (its latest if omitted). */
37
+ signedHead(treeSize?: number): Promise<SignedTreeHead | undefined>;
38
+ }
39
+
40
+ /** Outcome of one gossip round. `proof` is set the first round a conflict is detected
41
+ * (it is then durably recorded and re-served by ConflictStore). `unreachable` lists
42
+ * sources that errored — a LIVENESS signal, never blocking. */
43
+ export interface PollResult {
44
+ observed: number;
45
+ proof?: EquivocationProof;
46
+ unreachable: string[];
47
+ }
48
+
49
+ /** Cap on distinct sizes back-filled per round, so a hostile source advertising a huge
50
+ * size cannot blow up the per-round fan-out. */
51
+ const MAX_BACKFILL_SIZES = 64;
52
+
53
+ export interface WitnessMonitorConfig {
54
+ /** The operator log this monitor watches (the pinned operator DID). REQUIRED. */
55
+ logId: string;
56
+ /** The operator-INDEPENDENT, config-pinned witness set to gossip. */
57
+ sources: WitnessGossipSource[];
58
+ /** Durable observed-STH corpus (the detection window). */
59
+ observed: ObservedSthStore;
60
+ /** Durable klaxon store for detected proofs. */
61
+ conflicts: ConflictStore;
62
+ }
63
+
64
+ export class WitnessMonitor {
65
+ constructor(private readonly cfg: WitnessMonitorConfig) {}
66
+
67
+ /** One gossip round: pull each source's latest head, back-fill each source at the
68
+ * sizes the others reached (so a same-size fork across witnesses is observable),
69
+ * retain only authentic operator STHs, then run same-size detection over the corpus
70
+ * and durably record any proof. Returns the proof the first round it forms.
71
+ *
72
+ * Idempotent and append-only: re-running never loses or fabricates evidence. Source
73
+ * errors are tolerated (collected in `unreachable`) — detection is liveness, not safety. */
74
+ async poll(): Promise<PollResult> {
75
+ const { logId, sources, observed, conflicts } = this.cfg;
76
+ const unreachable = new Set<string>();
77
+ const pulled = new Set<string>(); // `${i}:${size}` already fetched this round (skip re-pulls)
78
+ let observedCount = 0; // counts DISTINCT newly-recorded heads, not re-observations
79
+
80
+ // Pass 1: latest head from every source.
81
+ const latests = await this.fanOut(sources, (s) => s.signedHead());
82
+ const sizes = new Set<number>();
83
+ for (let i = 0; i < sources.length; i++) {
84
+ const r = latests[i]!;
85
+ const label = sources[i]!.id ?? `source#${i}`;
86
+ if (r.status === 'rejected') { unreachable.add(label); continue; }
87
+ if (!r.value) continue;
88
+ pulled.add(`${i}:${r.value.treeSize}`);
89
+ sizes.add(r.value.treeSize);
90
+ if (await this.retain(label, r.value)) observedCount++;
91
+ }
92
+
93
+ // Pass 2: back-fill each source at the sizes OTHER sources reached (skipping a
94
+ // (source, size) already pulled in pass 1) — this is what makes a same-size fork
95
+ // (W1@n on fork A, W2@n on fork B) land as two corpus rows for detection.
96
+ const backfillSizes = [...sizes].sort((a, b) => a - b).slice(0, MAX_BACKFILL_SIZES);
97
+ for (const size of backfillSizes) {
98
+ const targets = sources
99
+ .map((s, i) => ({ s, i }))
100
+ .filter(({ i }) => !pulled.has(`${i}:${size}`));
101
+ if (targets.length === 0) continue;
102
+ const got = await this.fanOut(targets.map((t) => t.s), (s) => s.signedHead(size));
103
+ for (let j = 0; j < targets.length; j++) {
104
+ const { i } = targets[j]!;
105
+ const r = got[j]!;
106
+ const label = sources[i]!.id ?? `source#${i}`;
107
+ if (r.status === 'rejected') { unreachable.add(label); continue; }
108
+ if (!r.value) continue;
109
+ pulled.add(`${i}:${size}`);
110
+ if (await this.retain(label, r.value)) observedCount++;
111
+ }
112
+ }
113
+
114
+ // Detect over the deduped corpus; a proof is SELF-verifying, but re-check before
115
+ // persisting (belt and suspenders — the store must never hold a non-proof).
116
+ const proof = detectEquivocation(await observed.distinct(), logId);
117
+ if (proof && verifyEquivocation(proof, logId)) {
118
+ await conflicts.record(proof);
119
+ return { observed: observedCount, proof, unreachable: [...unreachable] };
120
+ }
121
+ return { observed: observedCount, unreachable: [...unreachable] };
122
+ }
123
+
124
+ /** Verify-before-retain: only an authentic STH for the watched log enters the corpus,
125
+ * so junk/forged input can never form a pair. Returns whether it was NEWLY recorded
126
+ * (a duplicate re-observation returns false), so the caller counts distinct heads. */
127
+ private async retain(witnessId: string, sth: SignedTreeHead): Promise<boolean> {
128
+ if (sth.logId !== this.cfg.logId || !verifySignedTreeHead(sth)) return false;
129
+ return this.cfg.observed.record(witnessId, sth);
130
+ }
131
+
132
+ /** Bounded-concurrency fan-out over sources; never throws (per-source settle).
133
+ * Delegates to the shared `boundedFanOut` so the monitor and relying-party pollination
134
+ * enforce the SAME in-flight cap (POLL_CONCURRENCY). */
135
+ private fanOut<T>(
136
+ sources: WitnessGossipSource[],
137
+ fn: (s: WitnessGossipSource) => Promise<T>,
138
+ ): Promise<PromiseSettledResult<T>[]> {
139
+ return boundedFanOut(sources, (s) => fn(s));
140
+ }
141
+ }
142
+
143
+ /**
144
+ * HttpGossipSource — pulls a witness's endorsed STHs over the wire via `GET /sth?size`,
145
+ * through the SSRF-safe fetch. `id` is the witness DID (for corpus attribution).
146
+ */
147
+ export class HttpGossipSource implements WitnessGossipSource {
148
+ constructor(
149
+ private readonly baseUrl: string,
150
+ readonly id: string,
151
+ private readonly ssrf: SsrfPolicy,
152
+ ) {}
153
+
154
+ async signedHead(treeSize?: number): Promise<SignedTreeHead | undefined> {
155
+ const url = treeSize === undefined ? `${this.baseUrl}/sth` : `${this.baseUrl}/sth?size=${treeSize}`;
156
+ const body = (await safeFetchJson(url, { method: 'GET' }, this.ssrf)) as { sth?: SignedTreeHead | null } | undefined;
157
+ return body?.sth ?? undefined;
158
+ }
159
+ }