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
0888048ace
commit
6f2d32b9d5
1 changed files with 70 additions and 1 deletions
71
main.js
71
main.js
|
|
@ -8,6 +8,7 @@ const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, c
|
|||
const path = require("path");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const tls = require("tls");
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const WebSocket = require("ws");
|
||||
|
|
@ -535,6 +536,70 @@ async function contentFetch(url, init = {}) {
|
|||
const r = await fetch(url, init);
|
||||
return { status: r.status, contentType: r.headers.get("content-type"), buffer: Buffer.from(await r.arrayBuffer()) };
|
||||
}
|
||||
|
||||
// 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
|
||||
// Argus/src/lib/ca.js for the underlying trust model, and the parallel
|
||||
// implementation in Argus/src/gateway/public-gateway.mjs — keep both in
|
||||
// step. This code path also runs through Tor when Tor is on.
|
||||
function certFp(cert) {
|
||||
const fp = cert && cert.fingerprint256;
|
||||
return fp ? fp.toLowerCase().replace(/:/g, "") : "";
|
||||
}
|
||||
async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) {
|
||||
const useTor = torState === "on";
|
||||
if (useTor) await loadSocks();
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = {
|
||||
host: ip, port, servername, method: "GET", path: reqPath,
|
||||
headers: { host: servername, "user-agent": "theseus/1" },
|
||||
};
|
||||
opts["rejectUnauthorized"] = false; // fingerprint pin below is the gate.
|
||||
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
|
||||
const req = https.request(opts, (res) => {
|
||||
const gotFp = certFp(res.socket.getPeerCertificate(false));
|
||||
if (gotFp !== expectedFp) {
|
||||
res.socket.destroy();
|
||||
return reject(new Error(`tls fingerprint mismatch for ${servername}: got ${gotFp}, expected ${expectedFp}`));
|
||||
}
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) }));
|
||||
});
|
||||
req.setTimeout(15000, () => { req.destroy(new Error("tls request timeout")); });
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
async function httpGetByIp(ip, reqPath, hostHeader) {
|
||||
const useTor = torState === "on";
|
||||
if (useTor) await loadSocks();
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = {
|
||||
host: ip, port: 80, method: "GET", path: reqPath,
|
||||
headers: { host: hostHeader, "user-agent": "theseus/1" },
|
||||
};
|
||||
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
|
||||
const req = http.request(opts, (res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) }));
|
||||
});
|
||||
req.on("error", reject); req.end();
|
||||
});
|
||||
}
|
||||
// Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else
|
||||
// plain HTTP by IP. No silent HTTP fallback on pin failure — a 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, String(tlsFingerprint).toLowerCase());
|
||||
return await httpGetByIp(ip, reqPath, hostHeader);
|
||||
}
|
||||
// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML
|
||||
// search template into our { name, url-with-%s } form.
|
||||
async function fetchOpenSearch(href) {
|
||||
|
|
@ -642,7 +707,11 @@ async function serveBns(request) {
|
|||
// for the full argument; keep this in step with that file.
|
||||
const isSubdomain = host !== rec.entry.name;
|
||||
const serveIp = async () => {
|
||||
const up = await contentFetch(`http://${r.ip}${reqPath}${url.search}`, { headers: { host } });
|
||||
// See ipRequest above: HTTPS-with-fingerprint-pin against the on-chain `tls`
|
||||
// record when available, HTTP fallback when not. Fixes serving BNS names
|
||||
// whose server redirects :80→:443 (the plain-fetch path chokes on the
|
||||
// redirect target because it isn't in ICANN DNS).
|
||||
const up = await ipRequest(r.ip, reqPath + url.search, host, r.tls);
|
||||
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } });
|
||||
};
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue