gateway: switch /bns/ tenant isolation to CSP sandbox, retire subdomain form
The old 4-layer credential blocker (sealed-off password rewriter + runtime guards) + wildcard-subdomain-origin plan is replaced by a single CSP `sandbox` directive on every /bns/ response. The browser assigns each document a unique opaque origin, so localStorage, IndexedDB, cookies, WebAuthn, service workers and the Credential Management API are all unavailable to the page — nothing to store, nothing to steal, no cross- tenant anything. No runtime guards required; the browser does it. Changes: - Add `sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation allow-modals allow-downloads` to RELAY_SECURITY_HEADERS. Never `allow-same-origin` — that would hand the real origin back. - Rip out SUB_BASE / SUB_BASE_ALL / nameToSubdomainLabel / subdomainLabelToName / inbound `*.bns.navigate.st` Host-routing / apex catch-all / pathForm plumbing. No more wildcard DNS or wildcard cert needed; the sandbox gives us the same isolation on the path form. - Drop the static `rewritePasswordFields` HTML transform. With sandbox, there are no credentials to protect at the field level. - injectRelayChrome collapses to the bottom-right info chip plus one passive sign-in nudge. The nudge appears the first time the visitor focuses `<input type="password">` or pastes a BIP39 seed phrase / xprv / WIF / long hex key. It explains why sign-in won't persist (sealed view) and offers one CTA: install Theseus. Does not block typing or submit; the sandbox has already prevented the storage side. - MutationObserver wires dynamically-added password fields: wireOne matches the node itself (not just descendants), DOMContentLoaded hook catches late body content. Chip + nudge chrome stay wrapped in the existing sentinel markers so native BCDN clients can still strip them. - Chip pop text + home() landing both now point visitors at /about on navigate.st for the sealed-view explanation (shipped alongside this). - Startup log line reads "(path /bns)" only, matching the retired subdomain form.
This commit is contained in:
parent
f61b88f942
commit
7bd87f2458
1 changed files with 193 additions and 356 deletions
|
|
@ -1,15 +1,18 @@
|
|||
// BNS public gateway (the "remote relay", convenience tier).
|
||||
// Runs on the VPS. Resolves .bch from the chain (zero-dep resolver) and serves
|
||||
// the content over plain HTTPS so ANY device's browser can view .bch sites with
|
||||
// no install. Two URL forms:
|
||||
// path form: https://navigate.st/bns/<name>.bch/... (<base> injected)
|
||||
// subdomain form: https://<name-flattened>.bch.navigate.st/ (own origin, no <base>)
|
||||
// where dots in a multi-level name become dashes: coinspectrum.deviant.bch
|
||||
// -> coinspectrum-deviant.bch.navigate.st
|
||||
// Runs on the VPS. Resolves BCDN names from the chain (zero-dep resolver) and
|
||||
// serves the content over plain HTTPS so ANY device's browser can view BCDN
|
||||
// sites with no install. One URL form:
|
||||
// path form: https://navigate.st/bns/<name>.<tld>/... (<base> injected)
|
||||
// Every response carries a CSP `sandbox` directive so each page gets a UNIQUE
|
||||
// OPAQUE ORIGIN — tenants can't share state, store credentials, or reach each
|
||||
// other's cookies / storage / passkeys. See RELAY_SECURITY_HEADERS below.
|
||||
// A previous drop also served *.bch.navigate.st (own origin per name); it has
|
||||
// been retired — the sandbox header gives us the same isolation guarantee on
|
||||
// the path form, without wildcard DNS or wildcard certs.
|
||||
//
|
||||
// Honest disclosure (convenience tier, not the trustless one): the operator can
|
||||
// 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.
|
||||
// see which BCDN sites you request. No logs — but that's trust. Install
|
||||
// Theseus for trustless resolution; use Tor for IP privacy.
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import tls from "tls";
|
||||
|
|
@ -257,22 +260,6 @@ async function activeListings() {
|
|||
return keep;
|
||||
}
|
||||
const S3_ENDPOINT = process.env.BNS_S3 || "http://127.0.0.1:8500";
|
||||
// Subdomain suffix for the relay. "bns.navigate.st" because "bns"
|
||||
// (Bitcoin Cash Name Service — the protocol) is accurate on every BCDN
|
||||
// TLD; the old "bch.navigate.st" lied about non-.bch names (sirius.x
|
||||
// was served as sirius-x.bch.navigate.st, reading like a .bch name).
|
||||
// The .bch form has been dropped — URL bookmarks or links that hit
|
||||
// *.bch.navigate.st 404 at the nginx layer. Fallback env var keeps
|
||||
// one knob available if an operator deploys with the old suffix.
|
||||
const SUB_BASE = (process.env.BNS_SUB_BASE || "bns.navigate.st").toLowerCase();
|
||||
// Operator-side override: comma-separated extra suffixes to accept on
|
||||
// inbound. Empty by default (dropping .bch means dropping it). Set
|
||||
// BNS_SUB_BASE_LEGACY=bch.navigate.st for a temporary grace period.
|
||||
const SUB_BASE_ALL = Array.from(new Set([
|
||||
SUB_BASE,
|
||||
...(process.env.BNS_SUB_BASE_LEGACY || "")
|
||||
.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean),
|
||||
]));
|
||||
|
||||
// ---------- VPN exit catalogue + session leases ----------
|
||||
// The pool file holds, per exit, the Reality parameters a client needs AND a
|
||||
|
|
@ -464,28 +451,6 @@ function rewriteBnsLinks(html) {
|
|||
}
|
||||
);
|
||||
}
|
||||
// Disable every <input type="password"> in served HTML on the path form.
|
||||
// The gateway's shared origin means a hostile BCDN co-tenant could read
|
||||
// anything a password manager autofills, so the only safe behaviour is
|
||||
// to not accept passwords at all through /bns/. Credentials belong on the
|
||||
// isolated subdomain form; the gateway's `rewritePasswordFields` makes
|
||||
// that posture explicit at first paint. Runtime also catches dynamic
|
||||
// adds — see injectRelayChrome below.
|
||||
function rewritePasswordFields(html) {
|
||||
return html.replace(
|
||||
/<input\b([^>]*?)\btype\s*=\s*(["']?)password\2([^>]*)>/gi,
|
||||
(m, pre, _q, post) => {
|
||||
// Keep the input in the DOM so the page's layout doesn't collapse,
|
||||
// but disable it, blank the autocomplete hint, and overlay a title
|
||||
// the user sees on hover. The runtime guard will mirror this on
|
||||
// elements added after load.
|
||||
return `<input${pre}type="password" disabled readonly autocomplete="off" ` +
|
||||
`placeholder="Blocked on the navigate.st relay — use the isolated view" ` +
|
||||
`title="Credentials cannot be entered through the relay — scripts here run under the gateway's shared origin. Open this site in its isolated view to sign in." ` +
|
||||
`data-nav-relay-blocked="password"${post}>`;
|
||||
}
|
||||
);
|
||||
}
|
||||
// Strip `.html` from relative link targets in served HTML — so a link
|
||||
// written as href="tld.html" reads as href="tld" in the browser bar.
|
||||
// Only touches non-absolute URLs (leaves `https://…/x.html` alone —
|
||||
|
|
@ -501,13 +466,31 @@ function stripHtmlExt(html) {
|
|||
}
|
||||
);
|
||||
}
|
||||
// Security headers applied to every relayed HTML response — both the /bns/
|
||||
// path form and the subdomain form. The CSP stays permissive on scripts /
|
||||
// styles because real BCDN sites use inline scripts today, but closes the
|
||||
// highest-impact HTML smuggling vectors: no framing (defeats clickjacking),
|
||||
// no <object>/<embed>, no <base> poisoning, no cross-origin form submits.
|
||||
// Permissions-Policy denies the most dangerous feature requests by default
|
||||
// so a malicious BCDN page cannot ask the user for camera / mic / geolocation.
|
||||
// Security headers applied to every relayed HTML response under /bns/.
|
||||
//
|
||||
// The sandbox directive is the real isolation primitive. It assigns every
|
||||
// /bns/<name>/ document a UNIQUE OPAQUE ORIGIN, computed per document load
|
||||
// by the browser. Consequences the browser enforces, not us:
|
||||
// - localStorage / sessionStorage / IndexedDB: SecurityError on access
|
||||
// - document.cookie: partitioned to opaque origin, unreadable/unwritable
|
||||
// - WebAuthn / navigator.credentials.*: rp.id check fails against opaque
|
||||
// - Service workers: registration refused (opaque can't own a scope)
|
||||
// - fetch("/bns/other/private"): cross-origin from opaque → CORS blocked
|
||||
// - window.open() to another /bns/ page: new tab gets its OWN fresh
|
||||
// opaque origin — same-URL ≠ same-origin, so no cross-tenant state
|
||||
// Tenant A's page and tenant B's page cannot share credentials, cookies,
|
||||
// storage, or any API-level state. By construction. No runtime guard needed.
|
||||
//
|
||||
// Tokens we grant: enough for ordinary content browsing (scripts, forms,
|
||||
// popups, user-click top-nav, modals, downloads). The one we NEVER grant
|
||||
// is allow-same-origin — adding it defeats the whole mechanism by handing
|
||||
// the document back its real navigate.st origin.
|
||||
//
|
||||
// The rest of the CSP stays permissive on scripts/styles because real BCDN
|
||||
// sites use inline scripts today, but closes the highest-impact smuggling
|
||||
// vectors (object-src 'none', base-uri 'self', form-action 'self' https:).
|
||||
// Permissions-Policy is kept as defense-in-depth even though an opaque
|
||||
// origin already can't satisfy most of these features' origin checks.
|
||||
const RELAY_SECURITY_HEADERS = Object.freeze({
|
||||
"content-security-policy":
|
||||
"default-src 'self' data: blob: https:; " +
|
||||
|
|
@ -516,10 +499,12 @@ const RELAY_SECURITY_HEADERS = Object.freeze({
|
|||
"img-src 'self' data: blob: https:; " +
|
||||
"font-src 'self' data: https:; " +
|
||||
"connect-src 'self' https: wss:; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"frame-ancestors 'self'; " +
|
||||
"form-action 'self' https:; " +
|
||||
"base-uri 'self'; " +
|
||||
"object-src 'none'",
|
||||
"object-src 'none'; " +
|
||||
"sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox " +
|
||||
"allow-top-navigation-by-user-activation allow-modals allow-downloads",
|
||||
"permissions-policy":
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=(), " +
|
||||
"accelerometer=(), gyroscope=(), magnetometer=(), autoplay=(), " +
|
||||
|
|
@ -537,89 +522,63 @@ function withSecurityHeaders(extra = {}) {
|
|||
return { ...RELAY_SECURITY_HEADERS, ...extra };
|
||||
}
|
||||
|
||||
// Subdomain-form name <-> host encoding.
|
||||
// Scheme: dashes in the label become "--" (so they survive the round-trip),
|
||||
// then dots become "-". Decoding reverses it: a "--" pair is a literal "-",
|
||||
// a single "-" is a ".".
|
||||
// hello.bch <-> hello-bch
|
||||
// sirius.x <-> sirius-x
|
||||
// coinspectrum.deviant.bch <-> coinspectrum-deviant-bch
|
||||
// hello-world.bch <-> hello--world-bch
|
||||
// The previous handler hardcoded "+ .bch" on reverse, which only worked for
|
||||
// 2-level .bch names; non-.bch TLDs and multi-level names fell through to
|
||||
// NXDOMAIN. This encoding is lossless for every BCDN name.
|
||||
function nameToSubdomainLabel(name) {
|
||||
return String(name).replace(/-/g, "--").replace(/\./g, "-");
|
||||
}
|
||||
function subdomainLabelToName(label) {
|
||||
let out = "", i = 0;
|
||||
while (i < label.length) {
|
||||
if (label[i] === "-" && label[i + 1] === "-") { out += "-"; i += 2; }
|
||||
else if (label[i] === "-") { out += "."; i += 1; }
|
||||
else { out += label[i]; i += 1; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Trust chrome injected into every relayed HTML response. Two surfaces,
|
||||
// shared helper markup:
|
||||
// Trust chrome injected into every relayed HTML response.
|
||||
//
|
||||
// PATH FORM (/bns/<name>/...) — the risk form: scripts share navigate.st
|
||||
// origin with every other relayed BCDN site, so a signed-in session on one
|
||||
// is reachable from any other. Shows the full banner at top of page +
|
||||
// COUNTDOWN auto-redirect to the subdomain form (default 10s, cancelled
|
||||
// instantly on any user interaction or if the visitor clicks Stay here),
|
||||
// AND a persistent bottom-right chip that survives banner dismissal so
|
||||
// the trust context never goes fully invisible.
|
||||
// A single small persistent chip in the bottom-right, nothing else. The
|
||||
// chip is a passive info indicator: it names the BCDN name being viewed,
|
||||
// says the site is being relayed through navigate.st, and expands on
|
||||
// click to a short explanation + an Install-Theseus link for trustless
|
||||
// resolution.
|
||||
//
|
||||
// SUBDOMAIN FORM (<flattened>.bch.navigate.st) — the safer form: each
|
||||
// name has its own browser origin, nothing crosses between relayed sites.
|
||||
// Shows only the small persistent chip so a visitor on mobile (where the
|
||||
// URL bar hides subdomains) can still tell which name they're on and that
|
||||
// it's being relayed through navigate.st.
|
||||
// Isolation is provided by the sandbox directive in RELAY_SECURITY_HEADERS
|
||||
// (opaque origin per document), not by any runtime JS. The old
|
||||
// credential-detection + auto-redirect runtime is gone — with sandbox,
|
||||
// the credential-storing APIs (localStorage, cookies, IndexedDB,
|
||||
// WebAuthn, service workers) are unavailable to the page, so there is
|
||||
// nothing to detect and nothing to steal.
|
||||
//
|
||||
// All inline, `all:initial` on every node so the host page's CSS cannot
|
||||
// style the chrome into oblivion; dismissible for 24h via localStorage;
|
||||
// click-away collapses the expanded chip tooltip.
|
||||
function injectRelayChrome(html, name, reqPath, subBase, opts) {
|
||||
const pathForm = !!(opts && opts.pathForm);
|
||||
const subUrl = `https://${nameToSubdomainLabel(name)}.${subBase}${reqPath}`;
|
||||
// style the chrome into oblivion; click-away collapses the tooltip.
|
||||
function injectRelayChrome(html, name) {
|
||||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&":"&", "<":"<", ">":">", "\"":""", "'":"'" }[c]));
|
||||
|
||||
// Persistent chip — shown on both forms. On the subdomain form it's the
|
||||
// only trust indicator; on the path form it stays after the banner is
|
||||
// dismissed so the user still knows they're on a relay.
|
||||
const chip = `<!--nav-relay-chip-start--><div id="__nav_relay_chip" style="all:initial;position:fixed;bottom:12px;right:12px;z-index:2147483646;font:12.5px/1.3 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">
|
||||
<button type="button" id="__nav_relay_chip_btn" aria-expanded="false" aria-controls="__nav_relay_chip_pop" style="all:initial;display:inline-flex;align-items:center;gap:7px;background:rgba(26,32,48,.92);color:#e7eaf1;padding:7px 12px;border-radius:999px;border:1px solid rgba(214,255,61,.25);cursor:pointer;font:inherit;backdrop-filter:blur(8px);box-shadow:0 2px 10px rgba(0,0,0,.28)">
|
||||
<span style="all:initial;color:#d6ff3d;font:inherit">📡</span>
|
||||
<span style="all:initial;color:#fff;font:inherit;font-weight:600">${esc(name)}</span>
|
||||
<span style="all:initial;color:#8b98a9;font:inherit">· via navigate.st</span>
|
||||
</button>
|
||||
<div id="__nav_relay_chip_pop" role="dialog" aria-label="Relay info" hidden style="all:initial;display:none;position:absolute;bottom:calc(100% + 8px);right:0;width:280px;background:#1a2030;color:#e7eaf1;border:1px solid rgba(214,255,61,.3);border-radius:12px;padding:14px 16px;box-shadow:0 10px 28px rgba(0,0,0,.45);font:13px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">
|
||||
<div id="__nav_relay_chip_pop" role="dialog" aria-label="Relay info" hidden style="all:initial;display:none;position:absolute;bottom:calc(100% + 8px);right:0;width:300px;background:#1a2030;color:#e7eaf1;border:1px solid rgba(214,255,61,.3);border-radius:12px;padding:14px 16px;box-shadow:0 10px 28px rgba(0,0,0,.45);font:13px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">
|
||||
<div style="all:initial;display:block;color:#d6ff3d;font-weight:600;margin-bottom:6px;font:inherit">Relayed site</div>
|
||||
<div style="all:initial;display:block;color:#e7eaf1;margin-bottom:10px;font:inherit"><b style="all:initial;color:#fff;font-weight:600;font:inherit">${esc(name)}</b> is being served through <b style="all:initial;color:#fff;font-weight:600;font:inherit">navigate.st</b>. ${pathForm ? "Scripts here run under the gateway's shared origin. <b style=\"all:initial;color:#ff8f9c;font:inherit;font-weight:600\">Password fields, seed phrases, and private keys are refused here</b> — open the isolated view to sign in." : "Each name has its own origin here. Scripts can't reach other BCDN sites."}</div>
|
||||
${pathForm ? `<a href="${esc(subUrl)}" style="all:initial;display:inline-block;background:#d6ff3d;color:#0b0e14;padding:6px 12px;border-radius:999px;text-decoration:none;font-weight:600;margin-right:8px;font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer">Isolated view ↗</a>` : ""}
|
||||
<a href="https://silentmode.st/theseus" style="all:initial;color:#a9b8d0;text-decoration:underline;cursor:pointer;font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">Install Theseus for trustless resolution</a>
|
||||
<div style="all:initial;display:block;color:#e7eaf1;margin-bottom:10px;font:inherit"><b style="all:initial;color:#fff;font-weight:600;font:inherit">${esc(name)}</b> is being served through <b style="all:initial;color:#fff;font-weight:600;font:inherit">navigate.st</b>. This site runs in a sealed browser view — it <b style="all:initial;color:#d6ff3d;font:inherit;font-weight:600">cannot store passwords, cookies, keys or credentials</b> by design. To sign in or use features that need an account, install Theseus.</div>
|
||||
<a href="https://silentmode.st/theseus" style="all:initial;display:inline-block;background:#d6ff3d;color:#0b0e14;padding:7px 14px;border-radius:999px;text-decoration:none;font-weight:600;font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer">Install Theseus ↗</a>
|
||||
</div></div><!--nav-relay-chip-end-->`;
|
||||
|
||||
// Full banner — path form only.
|
||||
const banner = pathForm ? `<!--nav-relay-banner-start--><div id="__nav_relay_banner" style="all:initial;display:block;position:fixed;top:0;left:0;right:0;z-index:2147483647;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#1a2030;color:#e7eaf1;border-bottom:2px solid #d6ff3d;padding:10px 14px;box-shadow:0 2px 10px rgba(0,0,0,.3);box-sizing:border-box">
|
||||
<div style="all:initial;display:flex;max-width:1200px;margin:0 auto;align-items:center;gap:14px;flex-wrap:wrap;color:inherit;font:inherit">
|
||||
<span style="all:initial;color:#d6ff3d;font-weight:600;font:inherit">⚠ Relayed site</span>
|
||||
<span style="all:initial;color:#e7eaf1;flex:1;min-width:220px;font:inherit"><b style="all:initial;color:#fff;font:inherit;font-weight:600">${esc(name)}</b> is being served through <b style="all:initial;color:#fff;font:inherit;font-weight:600">navigate.st</b>. Scripts here run under the gateway's origin — do not sign in or paste a recovery phrase.</span>
|
||||
<span id="__nav_relay_countdown" style="all:initial;color:#8b98a9;font:12.5px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;display:inline-flex;align-items:center;gap:6px">Moving in <b id="__nav_relay_countdown_n" style="all:initial;color:#d6ff3d;font-weight:700;font:inherit">10</b>s</span>
|
||||
<a href="${esc(subUrl)}" id="__nav_relay_go" style="all:initial;display:inline-block;background:#d6ff3d;color:#0b0e14;padding:6px 14px;border-radius:999px;text-decoration:none;font-weight:600;font:14px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer">Isolated view ↗</a>
|
||||
<button type="button" id="__nav_relay_stay" style="all:initial;color:#a9b8d0;text-decoration:underline;cursor:pointer;background:transparent;border:none;font:14px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:0">Stay here</button>
|
||||
<a href="https://silentmode.st/theseus" style="all:initial;color:#a9b8d0;text-decoration:underline;cursor:pointer;font:14px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">Install Theseus</a>
|
||||
<button type="button" id="__nav_relay_dismiss" aria-label="Dismiss" style="all:initial;color:#8b98a9;cursor:pointer;padding:4px 8px;font:14px/1 system-ui;background:transparent;border:none">✕</button>
|
||||
</div></div><!--nav-relay-banner-end-->` : "";
|
||||
// The sign-in nudge. Shown once per page load the first time the user
|
||||
// interacts with a password field (focus is enough — we don't wait for
|
||||
// them to type). The sandbox CSP header already prevents credential
|
||||
// STORAGE (cookies, localStorage, IndexedDB, WebAuthn, service workers
|
||||
// all fail), so even if they typed a password and submitted it, no
|
||||
// session could persist and the login would silently not work. This
|
||||
// nudge explains that in advance, before they waste the attempt, and
|
||||
// points at Theseus as the single supported path for credential UX.
|
||||
// Also fires on a paste of a seed phrase / xprv / WIF / hex key into
|
||||
// ANY field, since those are the high-stakes secrets we most want to
|
||||
// keep off a relayed origin.
|
||||
const nudge = `<!--nav-relay-nudge-start--><div id="__nav_relay_nudge" hidden style="all:initial;display:none;position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:min(460px,calc(100vw - 24px));background:#1a2030;color:#e7eaf1;border:1px solid #d6ff3d;border-radius:14px;padding:16px 18px;box-shadow:0 14px 36px rgba(0,0,0,.5);font:13.5px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">
|
||||
<div style="all:initial;display:flex;align-items:flex-start;gap:12px;font:inherit">
|
||||
<div style="all:initial;font-size:22px;line-height:1">🔒</div>
|
||||
<div style="all:initial;flex:1;font:inherit">
|
||||
<div style="all:initial;display:block;font-weight:700;color:#fff;margin-bottom:4px;font:inherit">Sign-in doesn't work here</div>
|
||||
<div id="__nav_relay_nudge_body" style="all:initial;display:block;color:#c3cad8;margin-bottom:12px;font:inherit">The <b style="all:initial;color:#d6ff3d;font-weight:600;font:inherit">navigate.st</b> relay runs <b style="all:initial;color:#fff;font-weight:600;font:inherit">${esc(name)}</b> in a sealed view, so passwords, cookies and keys <b style="all:initial;color:#fff;font-weight:600;font:inherit">cannot be stored</b>. Even a successful sign-in won't persist. Install Theseus to use this site with full functionality.</div>
|
||||
<a href="https://silentmode.st/theseus" style="all:initial;display:inline-block;background:#d6ff3d;color:#0b0e14;padding:8px 16px;border-radius:999px;text-decoration:none;font-weight:600;font:13px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer;margin-right:8px">Install Theseus</a>
|
||||
<button type="button" id="__nav_relay_nudge_dismiss" style="all:initial;color:#8b98a9;cursor:pointer;font:12.5px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:8px 4px">Dismiss</button>
|
||||
</div></div></div><!--nav-relay-nudge-end-->`;
|
||||
|
||||
const script = `<!--nav-relay-script-start--><script>(function(){
|
||||
var SUB = ${JSON.stringify(subUrl)};
|
||||
var PATH_FORM = ${pathForm ? "true" : "false"};
|
||||
var COUNTDOWN_SECONDS = 10;
|
||||
|
||||
// Chip wiring (both forms).
|
||||
// Chip wiring: click to expand/collapse the info tooltip, click-away
|
||||
// closes it. Isolation itself is browser-side via CSP sandbox — this
|
||||
// script does not enforce anything, only informs.
|
||||
var chip = document.getElementById("__nav_relay_chip");
|
||||
var chipBtn = document.getElementById("__nav_relay_chip_btn");
|
||||
var chipPop = document.getElementById("__nav_relay_chip_pop");
|
||||
|
|
@ -641,224 +600,124 @@ ${pathForm ? `<a href="${esc(subUrl)}" style="all:initial;display:inline-block;b
|
|||
}, true);
|
||||
}
|
||||
|
||||
if (!PATH_FORM) return; // subdomain form stops here — only the chip is shown.
|
||||
|
||||
// ====================================================================
|
||||
// Credential-rejection guard — path form only.
|
||||
//
|
||||
// Static HTML rewrite disables <input type=password> at first paint.
|
||||
// This runtime side catches:
|
||||
// * dynamically-added password inputs (SPA renders)
|
||||
// * inputs re-enabled by site JS
|
||||
// * paste or typed-in material that looks like a seed phrase, hex
|
||||
// private key, WIF key, or long hex secret
|
||||
// * WebAuthn API calls (also blocked at Permissions-Policy level,
|
||||
// but shim gives a human-readable error if a site tries anyway)
|
||||
// Every rejection shows a floating warning so the user understands
|
||||
// why their input vanished.
|
||||
// ====================================================================
|
||||
function flashWarning(message) {
|
||||
var w = document.getElementById("__nav_relay_warn");
|
||||
if (!w) {
|
||||
w = document.createElement("div");
|
||||
w.id = "__nav_relay_warn";
|
||||
w.setAttribute("role", "alert");
|
||||
w.setAttribute("style", "all:initial;position:fixed;bottom:72px;right:12px;z-index:2147483645;max-width:320px;background:#2a1a1f;color:#fff;border:1px solid #ff6b7a;border-left:4px solid #ff6b7a;border-radius:10px;padding:12px 14px;font:13px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;box-shadow:0 10px 28px rgba(0,0,0,.45)");
|
||||
document.body && document.body.appendChild(w);
|
||||
// Sign-in nudge: show once per page load on first credential interaction.
|
||||
// Triggers:
|
||||
// - focus on <input type="password">
|
||||
// - paste of a BIP39 seed phrase / xprv / WIF / long hex key into any field
|
||||
// Does NOT block typing or submission — sandbox already prevents the
|
||||
// credential STORE, so this is purely explanatory. Dismiss sticks for
|
||||
// the lifetime of this document (no storage to persist it longer).
|
||||
var nudge = document.getElementById("__nav_relay_nudge");
|
||||
var nudgeBody = document.getElementById("__nav_relay_nudge_body");
|
||||
var nudgeDismiss = document.getElementById("__nav_relay_nudge_dismiss");
|
||||
var nudgeShown = false, nudgeDismissed = false;
|
||||
function showNudge(subtitle) {
|
||||
if (nudgeShown || nudgeDismissed || !nudge) return;
|
||||
nudgeShown = true;
|
||||
if (subtitle && nudgeBody) {
|
||||
try { nudgeBody.firstChild && nudgeBody.insertBefore(document.createTextNode(subtitle + " "), nudgeBody.firstChild); } catch (_) {}
|
||||
}
|
||||
w.innerHTML = "<b style=\"all:initial;color:#ff8f9c;font:inherit;font-weight:600;display:block;margin-bottom:4px\">⚠ Blocked on the relay</b><span style=\"all:initial;color:#f3e9ea;font:inherit\">" + message + "</span>";
|
||||
w.style.display = "block";
|
||||
clearTimeout(flashWarning._t);
|
||||
flashWarning._t = setTimeout(function() { if (w) w.style.display = "none"; }, 8000);
|
||||
nudge.hidden = false;
|
||||
nudge.style.display = "block";
|
||||
}
|
||||
if (nudgeDismiss) {
|
||||
nudgeDismiss.addEventListener("click", function() {
|
||||
nudgeDismissed = true;
|
||||
nudge.hidden = true;
|
||||
nudge.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function neuterPasswordInput(el) {
|
||||
if (!el || el.dataset && el.dataset.navRelayBlocked === "password") return;
|
||||
// Password field focus — static or dynamic. wireOne wires a single
|
||||
// element if it is a password input; wireTree scans descendants AND
|
||||
// the root itself (querySelectorAll ignores self, so an SPA that
|
||||
// appends an input directly to body would otherwise be missed).
|
||||
function wireOne(el) {
|
||||
try {
|
||||
el.disabled = true;
|
||||
el.readOnly = true;
|
||||
el.value = "";
|
||||
el.setAttribute("autocomplete", "off");
|
||||
el.setAttribute("data-nav-relay-blocked", "password");
|
||||
el.setAttribute("placeholder", "Blocked on the navigate.st relay — use the isolated view");
|
||||
el.setAttribute("title", "Credentials cannot be entered through the relay");
|
||||
} catch (e) {}
|
||||
if (!el || el.nodeType !== 1) return;
|
||||
if (el.matches && el.matches('input[type="password"]')) {
|
||||
el.addEventListener("focus", function() { showNudge(""); }, { once: true });
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Classify a string for sensitive-looking content. Returns a label or
|
||||
// null. Checks are deliberately conservative (chosen to catch BCH /
|
||||
// ETH / BIP39 shapes without flagging URLs or UUIDs).
|
||||
function sensitiveKind(raw) {
|
||||
if (!raw) return null;
|
||||
var s = String(raw).trim();
|
||||
if (s.length < 12) return null;
|
||||
// Hex ≥ 64 chars: private keys (32 bytes = 64 hex), seed hex (64 or 128).
|
||||
if (/^[0-9a-fA-F]{64,}$/.test(s)) return "a hex private key";
|
||||
// WIF (compressed starts K/L, 52 chars; uncompressed starts 5, 51 chars).
|
||||
if (/^[KL][1-9A-HJ-NP-Za-km-z]{51}$/.test(s)) return "a WIF private key";
|
||||
if (/^5[1-9A-HJ-NP-Za-km-z]{50}$/.test(s)) return "a WIF private key";
|
||||
// xprv / zprv / tprv etc. — BIP32 extended private keys.
|
||||
if (/^(xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}$/.test(s)) return "an extended private key";
|
||||
// BIP39 seed phrase heuristic: 12 or 24 lowercase words, each 3-8
|
||||
// letters, no numbers or punctuation. Covers English wordlist shape
|
||||
// without shipping the 16KB wordlist itself.
|
||||
var words = s.toLowerCase().split(/\s+/);
|
||||
if ((words.length === 12 || words.length === 24) &&
|
||||
words.every(function(w) { return /^[a-z]{3,8}$/.test(w); })) {
|
||||
return "a recovery seed phrase";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scanAndNeuter(root) {
|
||||
function wireTree(root) {
|
||||
try {
|
||||
(root && root.querySelectorAll ? root : document).querySelectorAll('input[type="password"]').forEach(neuterPasswordInput);
|
||||
} catch (e) {}
|
||||
wireOne(root);
|
||||
var fields = (root || document).querySelectorAll('input[type="password"]');
|
||||
for (var i = 0; i < fields.length; i++) wireOne(fields[i]);
|
||||
} catch (_) {}
|
||||
}
|
||||
// Initial scan: body may not be parsed yet (this script sits right after
|
||||
// <body>, so later elements aren't in the DOM yet). Run now AND on
|
||||
// DOMContentLoaded to cover both the "already parsed" and "still parsing"
|
||||
// cases; the MutationObserver below handles everything added later.
|
||||
wireTree(document);
|
||||
if (document.readyState !== "complete" && document.readyState !== "interactive") {
|
||||
document.addEventListener("DOMContentLoaded", function() { wireTree(document); }, { once: true });
|
||||
}
|
||||
scanAndNeuter(document);
|
||||
|
||||
// Dynamic adds (SPAs render after load, or a site may toggle disabled off).
|
||||
try {
|
||||
var mo = new MutationObserver(function(muts) {
|
||||
for (var i = 0; i < muts.length; i++) {
|
||||
var m = muts[i];
|
||||
if (m.type === "attributes" && m.target && m.target.matches && m.target.matches('input[type="password"]')) {
|
||||
neuterPasswordInput(m.target);
|
||||
} else if (m.addedNodes) {
|
||||
m.addedNodes.forEach(function(n) { if (n && n.nodeType === 1) scanAndNeuter(n); });
|
||||
if (m.addedNodes) {
|
||||
for (var j = 0; j < m.addedNodes.length; j++) wireTree(m.addedNodes[j]);
|
||||
}
|
||||
if (m.type === "attributes") wireOne(m.target);
|
||||
}
|
||||
});
|
||||
mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["type", "disabled", "readonly"] });
|
||||
} catch (e) {}
|
||||
|
||||
// Paste intercept — catches someone pasting a seed phrase or private
|
||||
// key into ANY field. Blocks the paste before it lands.
|
||||
document.addEventListener("paste", function(e) {
|
||||
try {
|
||||
var text = (e.clipboardData || window.clipboardData).getData("text");
|
||||
var kind = sensitiveKind(text);
|
||||
if (kind) {
|
||||
e.preventDefault();
|
||||
flashWarning("Rejected a paste that looks like " + kind + ". Secrets must never be entered through the public relay — open this site in its isolated view first.");
|
||||
}
|
||||
} catch (_) {}
|
||||
}, true);
|
||||
|
||||
// Typed-input intercept — catches slow typing of a seed phrase or
|
||||
// a key manually. Runs on every input/change event.
|
||||
document.addEventListener("input", function(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.value) return;
|
||||
if (t.tagName !== "INPUT" && t.tagName !== "TEXTAREA") return;
|
||||
var kind = sensitiveKind(t.value);
|
||||
if (kind) {
|
||||
t.value = "";
|
||||
flashWarning("Rejected " + kind + " entered on the relay. Open this site in its isolated view before entering secrets.");
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Credential APIs — defence in depth on top of the Permissions-Policy
|
||||
// header. If a site tries anyway, give it a readable rejection.
|
||||
try {
|
||||
if (navigator.credentials) {
|
||||
var reject = function() { return Promise.reject(new DOMException("Credentials API blocked on the navigate.st relay — use the isolated view.", "NotAllowedError")); };
|
||||
Object.defineProperty(navigator.credentials, "create", { value: reject, writable: false, configurable: false });
|
||||
Object.defineProperty(navigator.credentials, "get", { value: reject, writable: false, configurable: false });
|
||||
}
|
||||
mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["type"] });
|
||||
} catch (_) {}
|
||||
|
||||
// Full-banner wiring (path form only).
|
||||
var banner = document.getElementById("__nav_relay_banner");
|
||||
var dismissBtn = document.getElementById("__nav_relay_dismiss");
|
||||
var stayBtn = document.getElementById("__nav_relay_stay");
|
||||
var countdownEl = document.getElementById("__nav_relay_countdown");
|
||||
var countdownN = document.getElementById("__nav_relay_countdown_n");
|
||||
var tick = null;
|
||||
var stayed = false;
|
||||
|
||||
// Hide banner (not chip) if the visitor dismissed within the last 24h.
|
||||
try {
|
||||
var raw = localStorage.getItem("nav.relay.dismissed_at");
|
||||
if (raw && (Date.now() - Number(raw)) < 86400000) {
|
||||
if (banner) banner.style.display = "none";
|
||||
return;
|
||||
// High-stakes secret pasted into any field — surface the nudge immediately
|
||||
// with a subtitle naming the thing we think they just pasted. We do NOT
|
||||
// intercept the paste; the sandbox already prevents any script on the
|
||||
// page from exfiltrating it anywhere persistent, and we don't want to
|
||||
// break legitimate uses on native clients (Theseus / Ariadne) that strip
|
||||
// this script before rendering.
|
||||
function classifyPaste(raw) {
|
||||
if (!raw) return null;
|
||||
var s = String(raw).trim();
|
||||
if (s.length < 12) return null;
|
||||
if (/^[0-9a-fA-F]{64,}$/.test(s)) return "That looks like a hex key.";
|
||||
if (/^[KL5][1-9A-HJ-NP-Za-km-z]{50,51}$/.test(s)) return "That looks like a WIF private key.";
|
||||
if (/^(xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}$/.test(s)) return "That looks like an extended private key.";
|
||||
var words = s.toLowerCase().split(/\\s+/);
|
||||
if ((words.length === 12 || words.length === 24) &&
|
||||
words.every(function(w) { return /^[a-z]{3,8}$/.test(w); })) {
|
||||
return "That looks like a recovery phrase.";
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
function stopCountdown() {
|
||||
if (tick) { clearInterval(tick); tick = null; }
|
||||
if (countdownEl) { countdownEl.style.display = "none"; }
|
||||
stayed = true;
|
||||
try { sessionStorage.setItem("nav.relay.stayed", "1"); } catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
// Honour previous Stay-here within this tab/session.
|
||||
try { if (sessionStorage.getItem("nav.relay.stayed") === "1") { if (countdownEl) countdownEl.style.display = "none"; return; } } catch (e) {}
|
||||
var left = COUNTDOWN_SECONDS;
|
||||
tick = setInterval(function() {
|
||||
left -= 1;
|
||||
if (left <= 0) {
|
||||
clearInterval(tick); tick = null;
|
||||
try { window.location.replace(SUB); } catch (e) { window.location.href = SUB; }
|
||||
return;
|
||||
}
|
||||
if (countdownN) countdownN.textContent = String(left);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Any sign of user activity cancels the auto-redirect — we don't yank
|
||||
// someone away from a page they're already reading.
|
||||
["click","keydown","wheel","touchstart","scroll","pointerdown"].forEach(function(ev) {
|
||||
window.addEventListener(ev, function() { if (tick && !stayed) stopCountdown(); }, { once: true, passive: true, capture: true });
|
||||
});
|
||||
|
||||
if (stayBtn) stayBtn.addEventListener("click", function() { stopCountdown(); });
|
||||
if (dismissBtn) dismissBtn.addEventListener("click", function() {
|
||||
stopCountdown();
|
||||
try { localStorage.setItem("nav.relay.dismissed_at", String(Date.now())); } catch (e) {}
|
||||
if (banner) banner.remove();
|
||||
repad();
|
||||
});
|
||||
|
||||
// Push the page down so the banner doesn't overlap it. Re-measure on
|
||||
// resize and after removal so the layout stays correct.
|
||||
function repad() {
|
||||
if (!document.body) return;
|
||||
var b = document.getElementById("__nav_relay_banner");
|
||||
document.body.style.paddingTop = (b ? b.offsetHeight + 4 : 0) + "px";
|
||||
}
|
||||
if (document.readyState !== "loading") { repad(); startCountdown(); }
|
||||
else document.addEventListener("DOMContentLoaded", function() { repad(); startCountdown(); });
|
||||
window.addEventListener("resize", repad);
|
||||
document.addEventListener("paste", function(e) {
|
||||
try {
|
||||
var raw = (e.clipboardData || window.clipboardData).getData("text");
|
||||
var subtitle = classifyPaste(raw);
|
||||
if (subtitle) showNudge(subtitle);
|
||||
} catch (_) {}
|
||||
}, true);
|
||||
})();</script><!--nav-relay-script-end-->`;
|
||||
|
||||
const combined = banner + chip + script;
|
||||
const combined = chip + nudge + script;
|
||||
// Insert just after <body> if present, otherwise at the start of the document.
|
||||
if (/<body[^>]*>/i.test(html)) return html.replace(/<body[^>]*>/i, (m) => m + combined);
|
||||
return combined + html;
|
||||
}
|
||||
|
||||
// Apply the three transforms (base first, link rewrite, extension strip),
|
||||
// then the banner if we're on the PATH form (opts.pathForm). Order doesn't
|
||||
// matter across the first three — they touch disjoint constructs — but
|
||||
// grouping them here means every text/html response goes through the same
|
||||
// path. The banner always runs last so it isn't rewritten by stripHtmlExt
|
||||
// or rewriteBnsLinks.
|
||||
// then inject the chip. Order doesn't matter across the first three —
|
||||
// they touch disjoint constructs — but grouping them here means every
|
||||
// text/html response goes through the same path. The chip always runs
|
||||
// last so it isn't rewritten by stripHtmlExt or rewriteBnsLinks.
|
||||
//
|
||||
// Isolation itself is header-side: RELAY_SECURITY_HEADERS carries a
|
||||
// `sandbox` directive that gives every /bns/<name>/ response its own
|
||||
// opaque origin. No runtime credential detector any more — the APIs
|
||||
// that would store credentials simply don't exist in a sandboxed
|
||||
// document, so there's nothing to detect.
|
||||
function transformHtml(html, baseHref, opts) {
|
||||
let out = stripHtmlExt(rewriteBnsLinks(injectBase(html, baseHref)));
|
||||
// Credential rejection on the path form only — subdomain form has its
|
||||
// own origin and can safely hold passwords/keys. Static rewrite covers
|
||||
// the first-paint case; the runtime guard inside injectRelayChrome
|
||||
// catches dynamic adds and seed-phrase paste.
|
||||
if (opts && opts.pathForm) {
|
||||
out = rewritePasswordFields(out);
|
||||
}
|
||||
// Relay chrome: full banner + chip on the path form, chip only on the
|
||||
// subdomain form. Always runs last so later transforms can't rewrite it.
|
||||
if (opts && opts.name) {
|
||||
out = injectRelayChrome(out, opts.name, opts.reqPath || "/", SUB_BASE, { pathForm: !!opts.pathForm });
|
||||
out = injectRelayChrome(out, opts.name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -992,16 +851,14 @@ async function serve(res, name, reqPath, baseHref, req = null) {
|
|||
// Short lifetime: without it browsers apply heuristic caching to files from
|
||||
// Sia (no ETag) and keep old scripts for hours after a publish.
|
||||
res.setHeader("cache-control", "public, max-age=120");
|
||||
// baseHref !== null means the PATH form (/bns/<name>/...) — the full banner
|
||||
// is injected only there. The trust chip is injected on BOTH forms (chip
|
||||
// is purely informative and useful on mobile where URL bars hide the
|
||||
// subdomain structure).
|
||||
// Resolver clients (Theseus, Ariadne) fetch a page through the path form and
|
||||
// show it under the name's own origin, so the relay chrome would be a false
|
||||
// warning inside them. They say who they are with "x-bns-policy: client"; a
|
||||
// browser navigation cannot send that header, so visitors still get it.
|
||||
// Resolver clients (Theseus, Ariadne) fetch a page through this gateway
|
||||
// and render it under the name's own origin, so the relay chip would be a
|
||||
// false warning inside them. They identify themselves with the header
|
||||
// "x-bns-policy: client"; a browser navigation cannot send that header,
|
||||
// so visitors still get the chip + nudge. When the chip is suppressed,
|
||||
// injectRelayChrome is a no-op (opts.name is null).
|
||||
const fromClient = String(req?.headers?.["x-bns-policy"] || "").toLowerCase() === "client";
|
||||
const htmlOpts = { pathForm: baseHref !== null, name: fromClient ? null : name, reqPath };
|
||||
const htmlOpts = { name: fromClient ? null : name };
|
||||
// Security headers for every HTML response below. Also strips the broad
|
||||
// `access-control-allow-origin: *` set at the top of serve() — HTML pages
|
||||
// being readable cross-origin is a surveillance surface (any site can
|
||||
|
|
@ -2664,36 +2521,10 @@ const server = http.createServer(async (req, res) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// --- subdomain form: <flattened>.<SUB_BASE> ---
|
||||
// Encoding: dashes in the label become "--" (so they survive the round
|
||||
// trip), then dots become "-". `subdomainLabelToName` reverses both.
|
||||
// hello-bch -> hello.bch
|
||||
// sirius-x -> sirius.x
|
||||
// coinspectrum-deviant-bch -> coinspectrum.deviant.bch
|
||||
// hello--world-bch -> hello-world.bch
|
||||
// Legacy form (back-compat): a dot-less single label implicitly meant
|
||||
// "<label>.bch" — `hello.bch.navigate.st` used to resolve "hello.bch".
|
||||
// We preserve that so existing bookmarks keep working.
|
||||
const matchedBase = SUB_BASE_ALL.find((b) => hostHeader.endsWith("." + b));
|
||||
if (matchedBase) {
|
||||
const inner = hostHeader.slice(0, -("." + matchedBase).length);
|
||||
if (inner) {
|
||||
let name = subdomainLabelToName(inner).toLowerCase();
|
||||
if (!name.includes(".") && /^[a-z0-9-]+$/i.test(name)) {
|
||||
name = name + ".bch"; // legacy short form
|
||||
}
|
||||
// Only forward names that match the gateway's own name regex (label.tld).
|
||||
// Anything else — stray wildcards hitting the subdomain cert — falls to
|
||||
// 404 instead of attempting to resolve nonsense on chain.
|
||||
if (/^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i.test(name)) {
|
||||
return serve(res, name, req.url || "/", null, req); // own origin: no <base>
|
||||
}
|
||||
}
|
||||
}
|
||||
// bare apex of any accepted SUB_BASE -> home
|
||||
if (SUB_BASE_ALL.includes(hostHeader)) { res.writeHead(200, { "content-type": "text/html" }); return res.end(home()); }
|
||||
|
||||
// --- path form: /bns/<name>.bch/... ---
|
||||
// --- path form: /bns/<name>.<tld>/... ---
|
||||
// (The subdomain form has been retired — CSP sandbox on path-form
|
||||
// responses provides per-page opaque origins, so a wildcard subdomain
|
||||
// with its own cert is no longer needed for tenant isolation.)
|
||||
let p = req.url;
|
||||
if (p === MOUNT || p === MOUNT + "/") { res.writeHead(200, { "content-type": "text/html" }); return res.end(home()); }
|
||||
if (!p.startsWith(MOUNT + "/")) { res.writeHead(404); return res.end("bns gateway"); }
|
||||
|
|
@ -2713,18 +2544,24 @@ const server = http.createServer(async (req, res) => {
|
|||
return serve(res, name, rest, `${MOUNT}/${name}${restDir}`, req);
|
||||
} catch (e) { res.writeHead(500); res.end("gateway error: " + e.message); }
|
||||
});
|
||||
server.listen(PORT, "127.0.0.1", () => console.log(`bns public gateway on 127.0.0.1:${PORT} (path ${MOUNT}, subdomain *.${SUB_BASE})`));
|
||||
server.listen(PORT, "127.0.0.1", () => console.log(`bns public gateway on 127.0.0.1:${PORT} (path ${MOUNT})`));
|
||||
|
||||
function home() {
|
||||
return `<!doctype html><meta charset=utf-8><title>BNS Gateway</title>
|
||||
<body style="font:16px system-ui;max-width:640px;margin:3rem auto;padding:0 1rem;color:#e7eaf1;background:#0f1420">
|
||||
<h1 style="color:#d6ff3d">⛓ BNS Gateway</h1>
|
||||
<p>View Bitcoin Cash <b>.bch</b> names in any browser — no install.</p>
|
||||
<p>View Bitcoin Cash Name Service sites in any browser — no install.</p>
|
||||
<ul>
|
||||
<li><a href="/bns/hello.bch">/bns/hello.bch</a> · or <a href="https://hello.${SUB_BASE}/">hello.${SUB_BASE}</a></li>
|
||||
<li><a href="/bns/hello.bch">/bns/hello.bch</a></li>
|
||||
<li><a href="/bns/sirius.x">/bns/sirius.x</a></li>
|
||||
<li><a href="/bns/coinspectrum.deviant.bch">/bns/coinspectrum.deviant.bch</a></li>
|
||||
</ul>
|
||||
<p style="color:#8b98a9;font-size:13px">This gateway resolves names for you, so it can see which .bch
|
||||
sites you request (it keeps no logs — you're trusting that). For trustless resolution install the local
|
||||
resolver/extension; for IP privacy use Tor. By Silent Mode (a Deviant project).</p></body>`;
|
||||
<p style="color:#8b98a9;font-size:13px">Each relayed site runs in a sealed browser view
|
||||
(no cookies, no passwords, no credentials — isolation by design;
|
||||
<a href="/about" style="color:#d6ff3d">how this works</a>). To sign in or use features
|
||||
that need an account, install <a href="https://silentmode.st/theseus" style="color:#d6ff3d">Theseus</a>
|
||||
— it resolves names trustlessly from the chain, no relay in the middle.</p>
|
||||
<p style="color:#8b98a9;font-size:13px">This gateway resolves names for you, so it can see which
|
||||
BCDN sites you request (it keeps no logs — you're trusting that). For IP privacy use Tor.
|
||||
By Silent Mode (a Deviant project).</p></body>`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue