@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.
- package/dist/accountability.d.ts +43 -0
- package/dist/accountability.d.ts.map +1 -0
- package/dist/accountability.js +64 -0
- package/dist/accountability.js.map +1 -0
- package/dist/adapters/monitor-http.d.ts +31 -0
- package/dist/adapters/monitor-http.d.ts.map +1 -0
- package/dist/adapters/monitor-http.js +59 -0
- package/dist/adapters/monitor-http.js.map +1 -0
- package/dist/adapters/witness-http.d.ts +62 -0
- package/dist/adapters/witness-http.d.ts.map +1 -0
- package/dist/adapters/witness-http.js +212 -0
- package/dist/adapters/witness-http.js.map +1 -0
- package/dist/durable-witness.d.ts +101 -0
- package/dist/durable-witness.d.ts.map +1 -0
- package/dist/durable-witness.js +179 -0
- package/dist/durable-witness.js.map +1 -0
- package/dist/fan-out.d.ts +20 -0
- package/dist/fan-out.d.ts.map +1 -0
- package/dist/fan-out.js +30 -0
- package/dist/fan-out.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/monitor.d.ts +83 -0
- package/dist/monitor.d.ts.map +1 -0
- package/dist/monitor.js +133 -0
- package/dist/monitor.js.map +1 -0
- package/dist/pollination.d.ts +109 -0
- package/dist/pollination.d.ts.map +1 -0
- package/dist/pollination.js +170 -0
- package/dist/pollination.js.map +1 -0
- package/dist/ssrf.d.ts +20 -0
- package/dist/ssrf.d.ts.map +1 -0
- package/dist/ssrf.js +205 -0
- package/dist/ssrf.js.map +1 -0
- package/package.json +33 -0
- package/src/accountability.ts +72 -0
- package/src/adapters/monitor-http.ts +69 -0
- package/src/adapters/witness-http.ts +235 -0
- package/src/durable-witness.ts +190 -0
- package/src/fan-out.ts +34 -0
- package/src/index.ts +28 -0
- package/src/monitor.ts +159 -0
- package/src/pollination.ts +229 -0
- 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
|
+
}
|