/`, 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();