@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 @@
1
+ {"version":3,"file":"pollination.d.ts","sourceRoot":"","sources":["../src/pollination.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,EAEL,KAAK,cAAc,EAAE,KAAK,kBAAkB,EAA0B,KAAK,aAAa,EACzF,MAAM,aAAa,CAAC;AAErB,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AAE3D;;;4FAG4F;AAC5F,MAAM,WAAW,iBAAiB;IAChC;yEACqE;IACrE,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB;;iGAE6F;IAC7F,WAAW,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,cAAc,CAAC;QAAC,WAAW,EAAE,kBAAkB,CAAA;KAAE,GAAG,SAAS,CAAC,CAAC;CAC/G;AAED,MAAM,WAAW,iBAAiB;IAChC;;;;;;;;+DAQ2D;IAC3D,SAAS,EAAE,OAAO,CAAC;IACnB;;;4GAGwG;IACxG,OAAO,EAAE,MAAM,CAAC;IAChB;2GACuG;IACvG,KAAK,EAAE,MAAM,CAAC;IACd;uFACmF;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB;;;sGAGkG;IAClG,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,sCAAsC;AACtC,MAAM,WAAW,gBAAgB;IAC/B;;;;yBAIqB;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAkBD;;;;;;;;GAQG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,EACzB,OAAO,EAAE,SAAS,iBAAiB,EAAE,EACrC,MAAM,EAAE,aAAa,EACrB,IAAI,CAAC,EAAE,gBAAgB,GACtB,OAAO,CAAC,iBAAiB,CAAC,CAuF5B;AAQD;;6EAE6E;AAC7E,qBAAa,qBAAsB,YAAW,iBAAiB;IACjD,OAAO,CAAC,QAAQ,CAAC,OAAO;IAAU,QAAQ,CAAC,EAAE,EAAE,MAAM;IAAE,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAA3D,OAAO,EAAE,MAAM,EAAW,EAAE,EAAE,MAAM,EAAmB,IAAI,EAAE,UAAU;IAE9F,WAAW,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,cAAc,CAAC;QAAC,WAAW,EAAE,kBAAkB,CAAA;KAAE,GAAG,SAAS,CAAC;CAOpH"}
@@ -0,0 +1,170 @@
1
+ /**
2
+ * pollination.ts — relying-party STH pollination (Phase 6).
3
+ *
4
+ * The one sound defense against a TARGETED split-view — an operator that shows a victim a
5
+ * root B@n it never shows any honest witness. The monitor cannot see B (it never enters the
6
+ * witnessed world), and quorum verification over the operator's OWN co-signature bundle can
7
+ * be cherry-picked by the operator. Pollination closes it by having the VICTIM pull each
8
+ * trusted witness's co-signature DIRECTLY (operator-independent, SSRF-safe) and run the
9
+ * audited `verifyWitnessedTreeHead`, FAIL-CLOSED: unless >= quorum distinct trusted witnesses
10
+ * co-signed the EXACT served root, the victim refuses.
11
+ *
12
+ * Why it is sound: a witness co-signature is WITNESS-signed over (logId, treeSize, rootHash),
13
+ * so neither a MITM nor the operator can forge "W endorsed B". The victim asks the witnesses
14
+ * themselves, so the operator cannot turn suppression into a false ACCEPT — if it withholds the
15
+ * witness responses the victim simply FAILS CLOSED (detection of the contradiction requires the
16
+ * victim to actually reach >= quorum honest witnesses directly). It does NOT defeat COLLUSION
17
+ * (>= quorum witnesses lying together — quorum >= 2 under disjoint control remains the only
18
+ * in-band lever). And SPENDS NEVER DEPEND ON WITNESS LIVENESS: pollination is an ADDITIONAL
19
+ * relying-party fail-closed check layered on quorum, not the kernel's safety gate; an
20
+ * unreachable witness is reported and simply does not count toward the quorum.
21
+ *
22
+ * DISPLAY ↔ VERDICT (load-bearing, do not weaken): `confirmed` is taken SOLELY from the audited
23
+ * `verifyWitnessedTreeHead` — never a hand-ported quorum rule. The display fields
24
+ * (matched/revoked/unreachable) are a MIRROR of that verdict's own accounting, gated on the
25
+ * SAME pre-count refusals the verifier applies (the policy.logId pin, the quorum-validity bound,
26
+ * and STH authenticity). So `matched` can never be reported under a basis the verdict already
27
+ * rejected — `matched >= quorum` if and only if `confirmed` (see the gate below). The two were
28
+ * once allowed to diverge on a malformed policy (a §4 over-claim caught by the Phase-6 review);
29
+ * that is now closed by computing the display under the verifier's exact gates.
30
+ */
31
+ import { verifyWitnessedTreeHead, verifyWitnessCosignature, verifySignedTreeHead, } from '@kyaki/core';
32
+ import { boundedFanOut } from './fan-out.js';
33
+ import { safeFetchJson } from './ssrf.js';
34
+ const DEFAULT_SOURCE_TIMEOUT_MS = 10_000;
35
+ /** Resolve `p`, or reject with POLLINATION_SOURCE_TIMEOUT after `ms`. The underlying work may
36
+ * continue (an in-process op is not cancellable) but its result is ignored — what is bounded is
37
+ * how long the caller WAITS, which is the liveness property that matters. ms<=0 disables the bound. */
38
+ function withTimeout(p, ms) {
39
+ if (!Number.isFinite(ms) || ms <= 0)
40
+ return p;
41
+ return new Promise((resolve, reject) => {
42
+ const timer = setTimeout(() => reject(new Error('POLLINATION_SOURCE_TIMEOUT')), ms);
43
+ p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
44
+ });
45
+ }
46
+ /**
47
+ * Pollinate: pull each trusted witness's co-signature at the served head's size and confirm,
48
+ * fail-closed, that >= quorum distinct trusted witnesses co-signed the EXACT (logId, treeSize,
49
+ * rootHash) the relying party was served. The verdict is the audited `verifyWitnessedTreeHead`
50
+ * (never a hand-ported quorum rule). An unreachable witness is reported and never makes the
51
+ * check pass. Liveness-independent: the fan-out is concurrency-bounded and per-source
52
+ * timeout-bounded, so neither a large pinned witness set nor a hung source can DoS the victim,
53
+ * and a pollination failure never blocks a spend (it is an additional relying-party check).
54
+ */
55
+ export async function pollinate(servedSth, sources, policy, opts) {
56
+ const trusted = new Set(policy.trustedWitnesses ?? []);
57
+ const revoked = new Set(policy.revokedWitnesses ?? []); // Phase 7 v1b — proven equivocators
58
+ const timeoutMs = opts?.sourceTimeoutMs ?? DEFAULT_SOURCE_TIMEOUT_MS;
59
+ // Guard the served size at the boundary: a non-integer/negative treeSize can only come from a
60
+ // malformed STH (which `verifyWitnessedTreeHead` rejects anyway) and would build a junk
61
+ // `?size=NaN` query. Skip the fetch entirely in that case — but STILL derive `confirmed` from
62
+ // the audited verifier below (never hand-decide it).
63
+ const validSize = Number.isInteger(servedSth.treeSize) && servedSth.treeSize >= 0;
64
+ const cosignatures = [];
65
+ const downLabels = [];
66
+ if (validSize) {
67
+ // Pull every source with a BOUNDED in-flight cap (a large pinned set can't exhaust sockets/heap)
68
+ // and a per-source backstop timeout. Each branch catches its OWN failure — including a null/
69
+ // malformed source entry (the label is computed defensively) — so a dead source lands in
70
+ // `unreachable` and never rejects the whole call. Worst-case wall-clock is one timeout per batch.
71
+ const settled = await boundedFanOut(sources, async (src, i) => {
72
+ const label = src?.id ?? `witness#${i}`;
73
+ try {
74
+ const e = await withTimeout(src.endorsement(servedSth.treeSize), timeoutMs);
75
+ const cosig = e?.cosignature;
76
+ if (!cosig)
77
+ return { down: label }; // no endorsement ⇒ cannot corroborate
78
+ // Bind the source's claimed identity to the co-signature it returns: a source may only
79
+ // ever contribute its OWN endorsement, so a single (possibly operator-controlled) endpoint
80
+ // cannot stand in for the quorum by replaying several witnesses' public co-signatures — the
81
+ // operator-INDEPENDENCE the mechanism rests on. A mismatch ⇒ this source did not return its
82
+ // own endorsement ⇒ treat it as no corroboration (it never counts toward quorum).
83
+ if (src?.id !== undefined && cosig.witnessId !== src.id)
84
+ return { down: label };
85
+ return { cosig };
86
+ }
87
+ catch {
88
+ return { down: label }; // transport failure / timeout ⇒ liveness
89
+ }
90
+ });
91
+ for (const r of settled) {
92
+ if (r.status !== 'fulfilled')
93
+ continue; // boundedFanOut settles per item and never throws; defensive
94
+ if ('cosig' in r.value)
95
+ cosignatures.push(r.value.cosig);
96
+ else
97
+ downLabels.push(r.value.down);
98
+ }
99
+ }
100
+ const wth = { sth: servedSth, cosignatures };
101
+ const confirmed = verifyWitnessedTreeHead(wth, policy); // the AUTHORITATIVE fail-closed verdict
102
+ // DISPLAY-NEVER-CONTRADICTS-VERDICT. The display fields must mirror EXACTLY what the verdict
103
+ // counts. `verifyWitnessedTreeHead` refuses BEFORE counting any endorser when the served head is
104
+ // not the pinned operator's authentic STH (its logId != policy.logId, or the operator signature is
105
+ // invalid), the trusted set is empty, or the quorum is structurally invalid. On ANY of those the
106
+ // honest display is "0 corroborated" — never a parallel count under a basis the verdict already
107
+ // rejected. Mirror those exact pre-count gates (transparency.ts verifyWitnessedTreeHead) here.
108
+ const quorum = policy.quorum ?? trusted.size;
109
+ const quorumValid = Number.isInteger(quorum) && quorum >= 1 && quorum <= trusted.size;
110
+ const structurallyValid = validSize
111
+ && trusted.size > 0
112
+ && quorumValid
113
+ && servedSth.logId === policy.logId // the policy.logId PIN — NOT the served head's own logId
114
+ && verifySignedTreeHead(servedSth); // authentic operator STH (catches a forged sig over a real root)
115
+ if (!structurallyValid) {
116
+ return { confirmed, matched: 0, total: trusted.size, revoked: 0, unreachable: trustedDistinct(downLabels, trusted) };
117
+ }
118
+ // On the structurally-valid path these per-cosig filters are byte-identical to
119
+ // `verifyWitnessedTreeHead`'s (self-witness exclusion, trusted membership, exact tuple, authentic
120
+ // cosig, revocation skip, distinct-by-witnessId), so `endorsers.size === the verifier's count` and
121
+ // `matched >= quorum <=> confirmed`. This is a DISPLAY mirror of the verdict, never a substitute.
122
+ const endorsers = new Set();
123
+ const revokedSeen = new Set(); // DISTINCT revoked witnesses that bound the head (never counted)
124
+ for (const c of cosignatures) {
125
+ if (!c || c.witnessId === policy.logId)
126
+ continue; // no operator self-witness (policy.logId == servedSth.logId here)
127
+ if (!trusted.has(c.witnessId))
128
+ continue;
129
+ if (c.logId !== policy.logId || c.treeSize !== servedSth.treeSize || c.rootHash !== servedSth.rootHash)
130
+ continue;
131
+ if (!verifyWitnessCosignature(c))
132
+ continue;
133
+ if (revoked.has(c.witnessId)) {
134
+ revokedSeen.add(c.witnessId);
135
+ continue;
136
+ } // bound the head, but NOT counted (v1b)
137
+ endorsers.add(c.witnessId);
138
+ }
139
+ // `unreachable`: distinct TRUSTED witnesses whose source(s) failed, EXCLUDING any that ended up
140
+ // matched or revoked (a witness reachable on one endpoint and down on another is NOT "unreachable").
141
+ // Keyed to the trusted set so matched + revoked + unreachable <= total — a coherent partition.
142
+ const unreachable = [...new Set(downLabels)].filter((id) => trusted.has(id) && !endorsers.has(id) && !revokedSeen.has(id));
143
+ return { confirmed, matched: endorsers.size, total: trusted.size, revoked: revokedSeen.size, unreachable };
144
+ }
145
+ /** Distinct TRUSTED labels from a down-source list — the `unreachable` field on a structurally
146
+ * refused head (no witness can have counted, so there is nothing to exclude). */
147
+ function trustedDistinct(labels, trusted) {
148
+ return [...new Set(labels)].filter((id) => trusted.has(id));
149
+ }
150
+ /** Pulls a witness's endorsement over the wire via `GET /cosig?size`, SSRF-guarded.
151
+ * The load-bearing object is the returned `cosignature` (witness-signed over the exact tuple);
152
+ * the returned `sth` is informational and not re-trusted by `pollinate`. */
153
+ export class HttpPollinationSource {
154
+ baseUrl;
155
+ id;
156
+ ssrf;
157
+ constructor(baseUrl, id, ssrf) {
158
+ this.baseUrl = baseUrl;
159
+ this.id = id;
160
+ this.ssrf = ssrf;
161
+ }
162
+ async endorsement(treeSize) {
163
+ const url = treeSize === undefined ? `${this.baseUrl}/cosig` : `${this.baseUrl}/cosig?size=${treeSize}`;
164
+ const body = (await safeFetchJson(url, { method: 'GET' }, this.ssrf));
165
+ if (!body?.sth || !body.cosignature)
166
+ return undefined;
167
+ return { sth: body.sth, cosignature: body.cosignature };
168
+ }
169
+ }
170
+ //# sourceMappingURL=pollination.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pollination.js","sourceRoot":"","sources":["../src/pollination.ts"],"names":[],"mappings":"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"}
package/dist/ssrf.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ export interface SsrfPolicy {
2
+ /** Exact hostnames (lowercased) the monitor may connect to — the pinned witness set.
3
+ * REQUIRED and non-empty: an empty allowlist refuses everything (fail-closed). */
4
+ allowHosts: string[];
5
+ /** Permit plain http and loopback/private targets — loopback dev & tests ONLY.
6
+ * Default false ⇒ https required and every internal range is refused. */
7
+ allowHttp?: boolean;
8
+ /** Max response body bytes. Default 64 KiB (an STH/proof is well under 1 KiB). */
9
+ maxBytes?: number;
10
+ /** Per-request timeout in ms. Default 5000. */
11
+ timeoutMs?: number;
12
+ }
13
+ /** Is `ip` (a valid v4/v6 literal) in a loopback/private/link-local/ULA/CGNAT/NAT64 range? */
14
+ export declare function isPrivateIp(ip: string): boolean;
15
+ /** Validate a URL against the policy; throws a named `SSRF_*` error on any violation. */
16
+ export declare function assertSafeUrl(rawUrl: string, policy: SsrfPolicy): Promise<void>;
17
+ /** A fetch that enforces the SSRF policy, blocks redirects, and caps body + time.
18
+ * Returns the parsed JSON body (or throws on a non-2xx / oversize / timeout). */
19
+ export declare function safeFetchJson(rawUrl: string, init: RequestInit, policy: SsrfPolicy): Promise<unknown>;
20
+ //# sourceMappingURL=ssrf.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssrf.d.ts","sourceRoot":"","sources":["../src/ssrf.ts"],"names":[],"mappings":"AA4BA,MAAM,WAAW,UAAU;IACzB;uFACmF;IACnF,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB;8EAC0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAWD,8FAA8F;AAC9F,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAqC/C;AAED,yFAAyF;AACzF,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAkCrF;AAoCD;kFACkF;AAClF,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CA0B3G"}
package/dist/ssrf.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * ssrf.ts — an SSRF-safe outbound fetch for the WitnessMonitor (RULES §5.3).
3
+ *
4
+ * The monitor PULLS operator-signed STHs from witness URLs. Left unguarded, a witness
5
+ * base URL of `http://169.254.169.254/` (cloud metadata) or `http://localhost:5432`
6
+ * (an internal service) turns the monitor into a server-side request forgery primitive.
7
+ * §0 ranks security above correctness, so this is a build-blocking control, not a
8
+ * deployment afterthought. The layered defense:
9
+ * 1. HOST ALLOWLIST — the monitor may only connect to the hostnames of its
10
+ * CONFIG-PINNED witness set; a request to any other host is refused. (The witness
11
+ * set is operator-INDEPENDENT and not attacker-registerable — that is the whole
12
+ * basis of the cross-party guarantee, so pinning it here is free.)
13
+ * 2. SCHEME — https only, unless `allowHttp` is set for loopback dev/tests.
14
+ * 3. NO INTERNAL TARGETS — reject IP-literal hosts in loopback/RFC1918/link-local/ULA
15
+ * ranges, AND resolve hostnames and reject any answer in those ranges
16
+ * (resolve-then-check defeats a DNS-rebinding allowlisted name pointing inward).
17
+ * 4. NO REDIRECTS — `redirect: 'error'` so a 3xx to an internal target cannot smuggle
18
+ * past the checks; BODY CAP + TIMEOUT so a hostile/slow endpoint cannot exhaust
19
+ * heap or wedge a polling round.
20
+ *
21
+ * Residual (documented): a TOCTOU between resolve-check and connect remains (the OS may
22
+ * re-resolve to a different IP). The host allowlist is the primary control; full IP
23
+ * pinning of the connection is the further hardening if the monitor is ever pointed at
24
+ * untrusted-DNS hosts. For a pinned, operator-independent witness set this is sufficient.
25
+ */
26
+ import { lookup } from 'node:dns/promises';
27
+ import { isIP } from 'node:net';
28
+ const DEFAULT_MAX_BYTES = 64 * 1024;
29
+ const DEFAULT_TIMEOUT_MS = 5000;
30
+ /** Map a pair of 16-bit hex groups (the low 32 bits of a v6 address) to dotted v4. */
31
+ function hexPairToV4(hiHex, loHex) {
32
+ const hi = parseInt(hiHex, 16), lo = parseInt(loHex, 16);
33
+ return `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;
34
+ }
35
+ /** Is `ip` (a valid v4/v6 literal) in a loopback/private/link-local/ULA/CGNAT/NAT64 range? */
36
+ export function isPrivateIp(ip) {
37
+ const v = isIP(ip);
38
+ if (v === 4) {
39
+ const p = ip.split('.').map(Number);
40
+ if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
41
+ return true; // malformed ⇒ unsafe
42
+ const [a, b] = p;
43
+ if (a === 10)
44
+ return true; // 10.0.0.0/8
45
+ if (a === 127)
46
+ return true; // loopback
47
+ if (a === 0)
48
+ return true; // 0.0.0.0/8 unspecified
49
+ if (a === 169 && b === 254)
50
+ return true; // link-local
51
+ if (a === 172 && b >= 16 && b <= 31)
52
+ return true; // 172.16.0.0/12
53
+ if (a === 192 && b === 168)
54
+ return true; // 192.168.0.0/16
55
+ if (a === 100 && b >= 64 && b <= 127)
56
+ return true; // 100.64.0.0/10 carrier-grade NAT (RFC 6598)
57
+ if (a === 198 && (b === 18 || b === 19))
58
+ return true; // 198.18.0.0/15 benchmarking
59
+ if (a >= 224)
60
+ return true; // multicast / reserved
61
+ return false;
62
+ }
63
+ if (v === 6) {
64
+ const ip6 = ip.toLowerCase().replace(/^\[|\]$/g, '');
65
+ if (ip6 === '::1' || ip6 === '::')
66
+ return true; // loopback / unspecified
67
+ if (ip6.startsWith('fe80'))
68
+ return true; // link-local
69
+ if (ip6.startsWith('fc') || ip6.startsWith('fd'))
70
+ return true; // ULA fc00::/7
71
+ // IPv4-mapped (::ffff:a.b.c.d AND its hex-quad form ::ffff:7f00:1) — re-check the v4.
72
+ const mapped = ip6.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
73
+ if (mapped)
74
+ return isPrivateIp(mapped[1]);
75
+ const hexMapped = ip6.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
76
+ if (hexMapped)
77
+ return isPrivateIp(hexPairToV4(hexMapped[1], hexMapped[2]));
78
+ // NAT64 well-known prefix (64:ff9b::/96) embeds an arbitrary v4 destination — a gateway
79
+ // forwards 64:ff9b::7f00:1 to 127.0.0.1, so the embedded v4 must be re-checked too.
80
+ const nat64Dotted = ip6.match(/^64:ff9b::(\d+\.\d+\.\d+\.\d+)$/);
81
+ if (nat64Dotted)
82
+ return isPrivateIp(nat64Dotted[1]);
83
+ const nat64Hex = ip6.match(/^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
84
+ if (nat64Hex)
85
+ return isPrivateIp(hexPairToV4(nat64Hex[1], nat64Hex[2]));
86
+ if (ip6 === '64:ff9b::')
87
+ return true; // the bare NAT64 prefix
88
+ return false;
89
+ }
90
+ return true; // not a recognizable IP ⇒ treat as unsafe
91
+ }
92
+ /** Validate a URL against the policy; throws a named `SSRF_*` error on any violation. */
93
+ export async function assertSafeUrl(rawUrl, policy) {
94
+ let url;
95
+ try {
96
+ url = new URL(rawUrl);
97
+ }
98
+ catch {
99
+ throw new Error('SSRF_INVALID_URL');
100
+ }
101
+ const allowHttp = policy.allowHttp === true;
102
+ if (url.protocol !== 'https:' && !(allowHttp && url.protocol === 'http:')) {
103
+ throw new Error(`SSRF_SCHEME_FORBIDDEN: ${url.protocol}`);
104
+ }
105
+ const host = url.hostname.toLowerCase();
106
+ const allow = new Set((policy.allowHosts ?? []).map((h) => h.toLowerCase()));
107
+ if (allow.size === 0 || !allow.has(host)) {
108
+ throw new Error(`SSRF_HOST_NOT_ALLOWED: ${host}`);
109
+ }
110
+ // An IP-literal host: check the literal directly.
111
+ if (isIP(host)) {
112
+ if (isPrivateIp(host) && !allowHttp)
113
+ throw new Error(`SSRF_PRIVATE_IP: ${host}`);
114
+ return;
115
+ }
116
+ // A hostname: resolve and reject any answer in an internal range (DNS-rebinding guard).
117
+ if (!allowHttp) {
118
+ let addrs;
119
+ try {
120
+ addrs = await lookup(host, { all: true });
121
+ }
122
+ catch {
123
+ throw new Error(`SSRF_DNS_FAILED: ${host}`);
124
+ }
125
+ if (addrs.length === 0)
126
+ throw new Error(`SSRF_DNS_EMPTY: ${host}`);
127
+ for (const { address } of addrs) {
128
+ if (isPrivateIp(address))
129
+ throw new Error(`SSRF_RESOLVES_PRIVATE: ${host} -> ${address}`);
130
+ }
131
+ }
132
+ }
133
+ /**
134
+ * Read a response body, aborting the moment the running byte total exceeds `maxBytes`.
135
+ * NEVER trusts Content-Length (a hostile/chunked endpoint omits it): the cap is enforced
136
+ * while STREAMING, so heap is bounded to maxBytes + one chunk regardless of the header or
137
+ * transfer encoding. `res.text()` would buffer the whole body BEFORE any size check — the
138
+ * exact DoS this avoids.
139
+ */
140
+ async function readCappedText(res, maxBytes, controller) {
141
+ const reader = res.body?.getReader();
142
+ if (!reader) { // no stream available — still cap, never unbounded
143
+ const buf = Buffer.from(await res.arrayBuffer());
144
+ if (buf.length > maxBytes)
145
+ throw new Error(`SSRF_BODY_TOO_LARGE: > ${maxBytes}`);
146
+ return buf.toString('utf8');
147
+ }
148
+ const chunks = [];
149
+ let total = 0;
150
+ try {
151
+ for (;;) {
152
+ const { done, value } = await reader.read();
153
+ if (done)
154
+ break;
155
+ if (!value)
156
+ continue;
157
+ total += value.byteLength;
158
+ if (total > maxBytes) {
159
+ controller.abort(); // stop the transfer immediately
160
+ throw new Error(`SSRF_BODY_TOO_LARGE: > ${maxBytes}`);
161
+ }
162
+ chunks.push(value);
163
+ }
164
+ }
165
+ finally {
166
+ try {
167
+ reader.releaseLock();
168
+ }
169
+ catch { /* already released on abort */ }
170
+ }
171
+ return Buffer.concat(chunks).toString('utf8');
172
+ }
173
+ /** A fetch that enforces the SSRF policy, blocks redirects, and caps body + time.
174
+ * Returns the parsed JSON body (or throws on a non-2xx / oversize / timeout). */
175
+ export async function safeFetchJson(rawUrl, init, policy) {
176
+ const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES;
177
+ const controller = new AbortController();
178
+ const timer = setTimeout(() => controller.abort(), policy.timeoutMs ?? DEFAULT_TIMEOUT_MS);
179
+ try {
180
+ // The DNS resolve inside assertSafeUrl is NOT abortable (node:dns/promises.lookup ignores
181
+ // AbortSignal), so race it against the SAME timeout — otherwise a slow-resolving (but
182
+ // allowlisted) host blows past the per-request ceiling before fetch even starts.
183
+ const aborted = new Promise((_, reject) => {
184
+ controller.signal.addEventListener('abort', () => reject(new Error('SSRF_TIMEOUT')), { once: true });
185
+ });
186
+ await Promise.race([assertSafeUrl(rawUrl, policy), aborted]);
187
+ const res = await fetch(rawUrl, { ...init, redirect: 'error', signal: controller.signal });
188
+ if (res.status === 204)
189
+ return undefined;
190
+ if (!res.ok)
191
+ throw new Error(`HTTP_${res.status}`);
192
+ // A declared oversized Content-Length is a cheap fast-fail; but a MISSING/zero header is
193
+ // "unknown size", NOT "safe" — the streaming reader enforces the real cap either way.
194
+ const declared = Number(res.headers.get('content-length'));
195
+ if (Number.isFinite(declared) && declared > maxBytes) {
196
+ throw new Error(`SSRF_BODY_TOO_LARGE: ${declared} > ${maxBytes}`);
197
+ }
198
+ const text = await readCappedText(res, maxBytes, controller);
199
+ return text ? JSON.parse(text) : undefined;
200
+ }
201
+ finally {
202
+ clearTimeout(timer);
203
+ }
204
+ }
205
+ //# sourceMappingURL=ssrf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssrf.js","sourceRoot":"","sources":["../src/ssrf.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@kyaki/witness",
3
+ "version": "0.1.0",
4
+ "description": "KYA external witness — a durable, independent co-signing party; gossip monitor, pollination, accountability, and SSRF-safe transport.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "import": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ }
14
+ },
15
+ "files": ["dist", "src"],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.build.json"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "default": "./dist/index.js"
27
+ }
28
+ }
29
+ },
30
+ "dependencies": {
31
+ "@kyaki/core": "^0.1.0"
32
+ }
33
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * accountability.ts — witness accountability (Phase 7). Makes witness COLLUSION
3
+ * ACCOUNTABLE (never prevented): when co-signatures by the SAME witness over the SAME
4
+ * (logId, treeSize) with DIFFERENT roots are CO-LOCATED at one observer, it forms a
5
+ * non-repudiable `WitnessEquivocationProof` naming that witness.
6
+ *
7
+ * THE CO-LOCATION PRECONDITION (the honest boundary — same class as the operator monitor):
8
+ * a proof forms only if BOTH divergent co-signatures reach this corpus. A witness co-signs
9
+ * honestly via its durable store (which never co-signs two roots at one size), so the two
10
+ * divergent co-signatures come from a COMPROMISED key / faulty store and are observed by
11
+ * DIFFERENT relying parties (e.g. two victims that pollinated the same witness at the same
12
+ * size and got different roots) or via gossip. If a colluder never lets its two
13
+ * observations meet at one extractor, NO proof forms. Absence of a proof proves nothing.
14
+ * Quorum intersection (k > n/2) guarantees a successful two-quorum split SHARES a
15
+ * double-signer — but extraction still requires both quorums' co-signatures to co-locate.
16
+ *
17
+ * SPENDS NEVER DEPEND ON THIS — it is the same liveness-only, opportunistic aggregation as
18
+ * the operator equivocation monitor. The sole spend-time safety control stays
19
+ * verifyWitnessedTreeHead at quorum >= 2 fail-closed.
20
+ */
21
+ import {
22
+ detectWitnessEquivocation, verifyWitnessCosignature, verifyWitnessEquivocation,
23
+ type WitnessConflictStore, type WitnessCosigStore, type WitnessCosignature, type WitnessEquivocationProof,
24
+ } from '@kyaki/core';
25
+
26
+ export interface WitnessAccountabilityConfig {
27
+ /** Durable corpus of observed witness co-signatures (keyed so two divergent roots coexist). */
28
+ observed: WitnessCosigStore;
29
+ /** Durable store of detected proofs (named, faulty witnesses). */
30
+ conflicts: WitnessConflictStore;
31
+ }
32
+
33
+ export class WitnessAccountabilityMonitor {
34
+ constructor(private readonly cfg: WitnessAccountabilityConfig) {}
35
+
36
+ /**
37
+ * Ingest one observed witness co-signature (the store verifies it before recording) and,
38
+ * if it now completes a same-witness / same-size / different-root pair, form + persist the
39
+ * proof. Returns the proof iff one is available for that witness (newly formed or already
40
+ * recorded). Idempotent — re-observing the same co-signature never duplicates or errors.
41
+ */
42
+ async observe(cosig: WitnessCosignature): Promise<WitnessEquivocationProof | undefined> {
43
+ // Verify-before-record at the monitor TOO (defense in depth — a non-authentic co-signature
44
+ // must never reach the corpus, where it could PK-squat and shadow a genuine root). The store
45
+ // also re-verifies, so neither layer can be the single point that lets junk in.
46
+ if (!cosig || !verifyWitnessCosignature(cosig)) return undefined;
47
+ await this.cfg.observed.record(cosig);
48
+ return this.detectFor(cosig.witnessId);
49
+ }
50
+
51
+ /** Re-scan every observed witness and persist any equivocation proof. Returns the proofs held. */
52
+ async sweep(): Promise<WitnessEquivocationProof[]> {
53
+ const out: WitnessEquivocationProof[] = [];
54
+ for (const w of await this.cfg.observed.witnesses()) {
55
+ const p = await this.detectFor(w);
56
+ if (p) out.push(p);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /** A witness's proof, re-detected from the corpus (and persisted) or read back from store. */
62
+ private async detectFor(witnessId: string): Promise<WitnessEquivocationProof | undefined> {
63
+ const cosigs = await this.cfg.observed.byWitness(witnessId);
64
+ const proof = detectWitnessEquivocation(cosigs, witnessId);
65
+ if (proof && verifyWitnessEquivocation(proof, witnessId)) {
66
+ await this.cfg.conflicts.record(proof); // monotone, idempotent
67
+ return proof;
68
+ }
69
+ // No fresh pair (or only one root seen so far) ⇒ surface any already-recorded proof.
70
+ return this.cfg.conflicts.forWitness(witnessId);
71
+ }
72
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * adapters/monitor-http.ts — the WitnessMonitor's HTTP edge.
3
+ *
4
+ * One read route, `GET /conflict`, serving the durable EquivocationProof (the klaxon):
5
+ * - 200 { version, proof } — a detected, self-verifying proof. The CONSUMER MUST
6
+ * re-verify it with verifyEquivocation(proof, PINNED_OPERATOR_DID); this endpoint is
7
+ * an UNTRUSTED aggregator — a 200 status is not evidence, the proof bytes are.
8
+ * - 204 — no conflict OBSERVED by this monitor (NOT "no fork
9
+ * exists" — absence proves nothing; detection is liveness-bounded).
10
+ * - 500 { error } — a monitor-internal fault.
11
+ *
12
+ * Mirrors witness-http hardening: a thrown store/DB fault becomes a WRITTEN response,
13
+ * never a hung socket; the served body is a single fixed-shape proof, not unbounded data.
14
+ */
15
+ import { createServer as nodeCreateServer, type Server } from 'node:http';
16
+ import type { ConflictStore } from '@kyaki/core';
17
+
18
+ /** Bumped if the wire shape of the /conflict body changes (consumer compatibility). */
19
+ export const CONFLICT_WIRE_VERSION = 1;
20
+
21
+ export interface MonitorHttpRequest {
22
+ method: string;
23
+ path: string;
24
+ }
25
+ export interface MonitorHttpResponse {
26
+ status: number;
27
+ body: unknown;
28
+ }
29
+ export type MonitorHttpHandler = (req: MonitorHttpRequest) => Promise<MonitorHttpResponse>;
30
+
31
+ export function createMonitorHttpHandler(conflicts: ConflictStore): MonitorHttpHandler {
32
+ return async (req) => {
33
+ if (req.method === 'GET' && req.path === '/conflict') {
34
+ try {
35
+ const proof = await conflicts.latest();
36
+ if (!proof) return { status: 204, body: undefined };
37
+ return { status: 200, body: { version: CONFLICT_WIRE_VERSION, proof } };
38
+ } catch {
39
+ return { status: 500, body: { error: 'INTERNAL' } };
40
+ }
41
+ }
42
+ return { status: 404, body: { error: 'NOT_FOUND' } };
43
+ };
44
+ }
45
+
46
+ /** Bind the pure handler to node:http. No request body is read (read-only routes). */
47
+ export function createMonitorServer(conflicts: ConflictStore): Server {
48
+ const handle = createMonitorHttpHandler(conflicts);
49
+ return nodeCreateServer((req, res) => {
50
+ req.on('error', () => {});
51
+ res.on('error', () => {});
52
+ void (async () => {
53
+ const path = (req.url ?? '/').split('?')[0] ?? '/';
54
+ try {
55
+ const result = await handle({ method: req.method ?? 'GET', path });
56
+ if (result.status === 204) {
57
+ res.writeHead(204);
58
+ res.end();
59
+ return;
60
+ }
61
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
62
+ res.end(JSON.stringify(result.body));
63
+ } catch {
64
+ res.writeHead(500, { 'Content-Type': 'application/json' });
65
+ res.end(JSON.stringify({ error: 'INTERNAL' }));
66
+ }
67
+ })();
68
+ });
69
+ }