gateway+Theseus: pin BNS ip-record fetch to on-chain tls fingerprint

Uncovered by the 2026-08-13 subdomain-inheritance fix: once
`checkers.game.x` correctly picked the parent's `ip` record instead of
`s3`, the ip branch itself failed. Two reasons:

  1. `fetch("http://<ip>/", { headers: { host: name } })` follows the
     site's :80→:443 redirect into `https://<name>.<tld>/`, which isn't
     in ICANN DNS → "fetch failed".
  2. The site's cert is signed by a per-machine BNS root, not a public
     CA; standard TLS validation rejects it.

Both are fixed by connecting to the IP with SNI = name, pinning the
presented cert's SHA-256 against the on-chain `tls` record, and only
then issuing the HTTPS request over the same socket. The on-chain
fingerprint is the trust anchor BNS uses everywhere else (see
Argus/src/lib/ca.js).

Gateway (public-gateway.mjs): new pinnedHttpsGet + httpGet + ipRequest
helpers; case "ip" delegates. No silent HTTP fallback on pin failure
(a mismatch means "not the site the chain says it is").

Theseus (main.js): parallel port of the same helpers, Tor-aware
(routes through SocksProxyAgent when Tor is on). serveBns's inner
serveIp() delegates to ipRequest.

Verified live: `curl -sI https://navigate.st/bns/checkers.game.x/`
returns 200 OK with the checkers game (1,179,215 bytes, apex
`game.x` unchanged, served from Sia).
This commit is contained in:
Local Dev 2026-08-16 20:28:58 +02:00
parent c2578d4017
commit 47601fcbdb

View file

@ -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) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code>${h}</code> is not registered on the Bitcoin Cash chain.</p></div>`;
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons:
// 1. It follows the site's HTTP→HTTPS 301 into `https://<name>.<tld>/`, 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) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>
// 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: <ip>. 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();