@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 @@
|
|
|
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
|
package/dist/ssrf.js.map
ADDED
|
@@ -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
|
+
}
|