diff --git a/bcnr-preload.js b/bcnr-preload.js index efcd483..6fcceae 100644 --- a/bcnr-preload.js +++ b/bcnr-preload.js @@ -20,6 +20,11 @@ contextBridge.exposeInMainWorld("bcnr", { isRegistered: (name) => ipcRenderer.invoke("bcnr:isRegistered", name), getBcnrTlds: () => ipcRenderer.invoke("bcnr:getBcnrTlds"), getRecordVersion: (name) => ipcRenderer.invoke("bcnr:getRecordVersion", name), + // Owner-signed DNS records (A/AAAA/MX/TXT/CNAME/NS) published beside the + // name's Sia content and verified by the gateway against the current NFT + // holder. `{ name, dns, seq, updatedAt, owner }`, or null when the name is + // unregistered or has published no manifest. Waits ≤ 3 s for a fetch. + dnsRecords: (name) => ipcRenderer.invoke("bcnr:dnsRecords", name), // Diagnostic — the eTLD+1 permission origin Theseus computes for THIS page. // dApp devs use this to see how their subdomains bucket under one grant. // Returns null for opaque origins (data:, blob:) which never hold grants. diff --git a/main.js b/main.js index 1831469..a43f6bc 100644 --- a/main.js +++ b/main.js @@ -150,6 +150,77 @@ const SEARCH = (q) => engineUrl(settings.searchEngine, q); // Public content relay (secret-free): serves s3/ip/h/u without shipping keys. const GATEWAY = "https://navigate.st"; +// ---- Signed DNS records ---------------------------------------------------- +// Owners can publish an owner-signed `_records.json` manifest on Sia with +// classic DNS data (A/AAAA/MX/TXT/CNAME/NS). The gateway verifies the +// signature against the current NFT holder and serves the verified `dns` +// block as GET /api/dns/ (Decentralized.DNS/INTEGRATION-signed-records- +// clients.md). These records EXTEND on-chain records and never override them: +// h/s3/ip/p/u stay authoritative for content. We fetch them in the background +// on every BCDN resolution with a 3 s cap and hang the result on the entry as +// `entry.dns`; a navigation never waits for the fetch, except when a name has +// no on-chain content record at all and a signed A record is the only way to +// reach it. Only registered names are looked up, so ICANN hosts the user +// visits are never sent to the gateway. +const DNS_RECORDS_TTL = 30_000; // matches the gateway's Cache-Control max-age=30 +const dnsRecordsCache = new Map(); // name -> { value, at, seq, pending } +function dnsRecordsCached(name) { + const c = dnsRecordsCache.get(name); + return c && Date.now() - c.at < DNS_RECORDS_TTL ? c.value : undefined; +} +function fetchDnsRecords(name) { + const c = dnsRecordsCache.get(name); + if (c?.pending) return c.pending; + if (c && Date.now() - c.at < DNS_RECORDS_TTL) return Promise.resolve(c.value); + const prev = c?.value ?? null; + const pending = (async () => { + let value = prev; + try { + const r = await fetch(`${GATEWAY}/api/dns/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(3000), cache: "no-store" }); + if (r.ok) { + const j = await r.json(); + const seq = Number(j?.seq) || 0; + // Rollback guard: a manifest with a lower seq than one already seen + // for this name is stale (or replayed) — keep what we had. + if (j && j.dns && typeof j.dns === "object" && seq >= (c?.seq ?? -1)) { + value = { dns: j.dns, seq, updatedAt: j.updated_at || null, owner: j.verified_owner || null }; + } + } else if (r.status === 404 || r.status === 409) { + value = null; // no manifest declared / nowhere to keep one + } + } catch { /* offline, timeout, bad JSON — records are optional */ } + dnsRecordsCache.set(name, { value, at: Date.now(), seq: Math.max(c?.seq ?? -1, value?.seq ?? -1), pending: null }); + return value; + })(); + dnsRecordsCache.set(name, { value: prev, at: c?.at ?? 0, seq: c?.seq ?? -1, pending }); + return pending; +} +// Kick off the fetch for a resolved entry and attach the answer when it lands. +// `entry.dns` is undefined while unknown, null when the owner published no +// manifest, or { dns, seq, updatedAt, owner }. +function attachDnsRecords(entry) { + if (!entry || !entry.name) return; + const cached = dnsRecordsCached(entry.name); + if (cached !== undefined) { entry.dns = cached; return; } + fetchDnsRecords(entry.name).then((v) => { entry.dns = v; }, () => {}); +} +// Record types the manifest actually carries (for the site-info popover). +function dnsRecordKinds(entry) { + const d = entry?.dns?.dns; + if (!d || typeof d !== "object") return []; + return Object.keys(d).filter((k) => Array.isArray(d[k]) ? d[k].length > 0 : d[k] != null && d[k] !== ""); +} +// First signed IPv4 address for a name — the reachability fallback when the +// chain carries no content record. Waits for an in-flight fetch (≤ 3 s) only +// because there is nothing else to serve. +async function dnsAddressFor(entry) { + if (!entry?.name) return null; + const v = entry.dns !== undefined ? entry.dns : await fetchDnsRecords(entry.name); + const a = v?.dns?.A; + const ip = Array.isArray(a) ? a.find((x) => typeof x === "string" && /^\d{1,3}(\.\d{1,3}){3}$/.test(x)) : null; + return ip || null; +} + // ---- BNS name detection (multi-TLD) -------------------------------------- // Theseus is a BNS-native browser: BCNR is the priority registry for EVERY // dotted host, regardless of TLD. The engine (resolver-web.js) resolves any @@ -1362,6 +1433,9 @@ async function resolveHost(host) { } } if (entry) entries.set(host.toLowerCase(), { entry, host: host.toLowerCase() }); + // Signed DNS records ride alongside the on-chain answer — started here, + // never awaited (see attachDnsRecords). + if (entry) attachDnsRecords(entry); maybeRefreshElectrum(); return entry; } @@ -1433,6 +1507,14 @@ async function serveBns(request) { if (r.ip) return await serveIp(); if (!isSubdomain && r.p) return await serveP(); if (r.u) return Response.redirect(r.u, 302); + // No on-chain content record. If the owner published a signed DNS A + // record, that server is the only way to reach the name — same Host-header + // semantics as an `ip` record (and the on-chain `tls` pin still applies). + const dnsIp = await dnsAddressFor(rec.entry); + if (dnsIp) { + const up = await ipRequest(dnsIp, reqPath + url.search, host, r.tls); + return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } }); + } return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } }); } catch (e) { // The upstream fetch failed — relay unreachable, DNS stalling, site's @@ -3140,7 +3222,7 @@ async function loadBns(t, id, host, rest, tld) { : (!_isSub && entry.records.p) ? "mirror" : entry.records.u ? "redirect" : "record"; - t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry }; + t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), dns: dnsRecordKinds(entry), tld, registry }; if (id === activeId) pushNav(t.prov); emitTabs(); } @@ -3905,7 +3987,7 @@ async function switchRegistry(arg) { : (!_isSub && r.p) ? "mirror" : r.u ? "redirect" : "record"; - t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), tld, registry }; + t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), dns: dnsRecordKinds(rec?.entry), tld, registry }; } } catch (e) { console.warn("collision-switch load failed:", e?.message); } finally { t.internalNav = false; setLoading(t, false); } @@ -5119,6 +5201,9 @@ function serializeEntry(entry) { txid: entry.txid, height: entry.height, updatedTxid: entry.updatedTxid ?? null, + // Signed DNS records as last fetched: undefined → not known yet (call + // bcnr:dnsRecords to wait for them), null → none published. + dns: entry.dns === undefined ? undefined : entry.dns, }; } ipcMain.handle("bcnr:resolveName", async (_e, name) => { @@ -5126,6 +5211,18 @@ ipcMain.handle("bcnr:resolveName", async (_e, name) => { try { return serializeEntry(await resolveHost(name)); } catch { return null; } }); +// Signed DNS records for a registered name, waiting (≤ 3 s) for the fetch. +// For add-ons that need TXT (verification, cert pins), MX (mail bridges) or +// A/AAAA. Null when the name is unregistered or has no manifest. +ipcMain.handle("bcnr:dnsRecords", async (_e, name) => { + if (typeof name !== "string" || !name) return null; + try { + const entry = await resolveHost(name); + if (!entry) return null; + const v = entry.dns !== undefined ? entry.dns : await fetchDnsRecords(entry.name); + return v ? { name: entry.name, ...v } : null; + } catch { return null; } +}); ipcMain.handle("bcnr:isRegistered", async (_e, name) => { if (typeof name !== "string" || !name) return false; try { return (await resolveHost(name)) != null; } diff --git a/popover.html b/popover.html index e4d3e8b..12f4775 100644 --- a/popover.html +++ b/popover.html @@ -103,6 +103,7 @@ details = row("Served from", esc(d.source) || "chain") + row("Registry", "BCDN — Bitcoin Cash Domain Names") + (d.records && d.records.length ? row("Records", `${esc(d.records.join(", "))}`) : "") + + (d.dns && d.dns.length ? row("Signed DNS", `${esc(d.dns.join(", "))}`) : "") + (d.category ? row("Certificate", `${esc(String(d.category).slice(0, 20))}…`) : ""); } hero.className = cls; $("ico").innerHTML = ico; $("status").textContent = status; $("sub").innerHTML = sub;