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:
parent
c2578d4017
commit
47601fcbdb
1 changed files with 81 additions and 21 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue