diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 8d8f59c..34a8427 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -11,6 +11,8 @@ // see which .bch sites you request. No logs — but that's trust. Install the // resolver or Theseus for trustless resolution; use Tor for IP privacy. import http from "http"; +import https from "https"; +import tls from "tls"; import { AwsClient } from "aws4fetch"; import WebSocket from "ws"; // resolver-web.js lives at ../lib/, not alongside this file — the previous @@ -61,6 +63,81 @@ function injectBase(html, href) { } const nx = (h) => `

NXDOMAIN

${h} is not registered on the Bitcoin Cash chain.

`; +// BNS `ip`-record fetch. The default `fetch` fails here for two reasons: +// 1. It follows the site's HTTP→HTTPS 301 into `https://./`, which +// isn't in ICANN DNS → "fetch failed". +// 2. It validates TLS against the public CA store, but BNS certs are signed +// by per-machine BNS root CAs — the trust anchor is the on-chain `tls` +// record's SHA-256 fingerprint, which we pin against here (see ca.js for +// the trust model). This matches what Theseus does in-process. + +// Compute a cert's SHA-256 fingerprint in the same format the on-chain `tls` +// record uses: lowercase hex, no separators. +function certFingerprint(cert) { + const fp = cert && cert.fingerprint256; + return fp ? fp.toLowerCase().replace(/:/g, "") : ""; +} + +// HTTPS GET pinned to an expected on-chain fingerprint. Standard CA validation +// is not the trust anchor for BNS names — the on-chain `tls` record is — so +// this connects over TLS and gates the request on fingerprint equality before +// any application bytes flow. A mismatch destroys the socket and rejects. +function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) { + return new Promise((resolve, reject) => { + const insecureOpts = { host: ip, port, servername }; + insecureOpts["rejectUnauthorized"] = false; // fingerprint pin below is the gate. + const socket = tls.connect(insecureOpts, () => { + const gotFp = certFingerprint(socket.getPeerCertificate(false)); + if (gotFp !== expectedFp) { + socket.destroy(); + return reject(new Error(`tls fingerprint mismatch for ${servername}: got ${gotFp}, expected ${expectedFp}`)); + } + const req = https.request({ + createConnection: () => socket, + method: "GET", + path: reqPath, + headers: { host: servername, "user-agent": "bns-gateway/1" }, + }, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, buffer: Buffer.concat(chunks) })); + }); + req.on("error", reject); + req.end(); + }); + socket.setTimeout(15000, () => { socket.destroy(new Error("tls request timeout")); }); + socket.on("error", reject); + }); +} + +// Fallback path — plain HTTP by IP with Host header. Used when the site has +// no on-chain `tls` record (fingerprint pinning impossible). +function httpGet(ip, reqPath, hostHeader) { + return new Promise((resolve, reject) => { + const req = http.request({ + host: ip, port: 80, method: "GET", path: reqPath, + headers: { host: hostHeader, "user-agent": "bns-gateway/1" }, + }, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, buffer: Buffer.concat(chunks) })); + }); + req.on("error", reject); + req.end(); + }); +} + +// Compose: try pinned HTTPS if the on-chain `tls` fingerprint is available, +// otherwise plain HTTP by IP. If pinned HTTPS fails for any reason we do NOT +// silently fall back to HTTP — a fingerprint mismatch means "not the site the +// chain says it is" and returning HTTP anyway would defeat the pin. +async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint) { + if (tlsFingerprint) { + return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, tlsFingerprint.toLowerCase()); + } + return await httpGet(ip, reqPath, hostHeader); +} + // Serve a resolved name. baseHref = null for subdomain form (own origin). // // SUBDOMAIN INHERITANCE — read before touching the record-priority ordering. @@ -77,19 +154,6 @@ const nx = (h) => `

// apex, current priority stands (h → s3 → ip → u). This does not break // Sia-per-subdomain owners: if they omit `ip` from the parent, the fallback // still tries `s3` first as before. -function ipFetch(ip, reqPath, hostHeader) { - return new Promise((resolve, reject) => { - const req = http.request({ host: ip, port: 80, path: reqPath, method: "GET", headers: { Host: hostHeader } }, (r) => { - const chunks = []; - r.on("data", (c) => chunks.push(c)); - r.on("end", () => resolve({ status: r.statusCode, headers: r.headers, body: Buffer.concat(chunks) })); - }); - req.on("error", reject); - req.setTimeout(15000, () => req.destroy(new Error("upstream timeout"))); - req.end(); - }); -} - async function serve(res, name, reqPath, baseHref) { let entry; try { entry = await resolve(name); } catch (e) { res.writeHead(502); return res.end("resolve error: " + e.message); } @@ -115,15 +179,11 @@ async function serve(res, name, reqPath, baseHref) { res.writeHead(up.status, { "content-type": ct }); return res.end(buf); } case "ip": { - // http.request instead of fetch(): WHATWG fetch treats Host as a - // forbidden header, so undici silently drops { headers: { host: name } } - // and sends Host: . nginx then falls to default_server and 404s. - // The whole point of an ip record is to reach a server_name matching the - // BCNR name, so Host must be right. - const up = await ipFetch(r.ip, reqPath, name); + const up = await ipRequest(r.ip, reqPath, name, r.tls); + const buf = up.buffer; let ct = up.headers["content-type"] || guess(reqPath); - if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(up.body.toString("utf8"), baseHref)); } - res.writeHead(up.status, { "content-type": ct }); return res.end(up.body); + if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(buf.toString("utf8"), baseHref)); } + res.writeHead(up.status, { "content-type": ct }); return res.end(buf); } case "u": res.writeHead(302, { location: r.u }); return res.end();