feat(theseus): consume owner-signed DNS records alongside on-chain records
Owners can now publish a signed _records.json (A/AAAA/MX/TXT/CNAME/NS) beside their Sia content; the gateway verifies it against the current NFT holder and serves it as GET /api/dns/<name>. Every BCDN resolution now starts a background fetch of that answer (3 s cap, 30 s cache, seq rollback guard) and attaches it to the entry as entry.dns. Navigation never waits for it — on-chain h/s3/ip/p/u stay authoritative — except when a name has no content record at all and a signed A is the only way to reach it. Only registered names are looked up, so ICANN hosts never reach the gateway. Exposed as window.bcnr.dnsRecords(name) for add-ons (TXT verification, MX for mail bridges), on resolveName() as .dns, and as a "Signed DNS" row in the site-info popover.
This commit is contained in:
parent
621758834a
commit
27a1243e4d
3 changed files with 105 additions and 2 deletions
|
|
@ -20,6 +20,11 @@ contextBridge.exposeInMainWorld("bcnr", {
|
||||||
isRegistered: (name) => ipcRenderer.invoke("bcnr:isRegistered", name),
|
isRegistered: (name) => ipcRenderer.invoke("bcnr:isRegistered", name),
|
||||||
getBcnrTlds: () => ipcRenderer.invoke("bcnr:getBcnrTlds"),
|
getBcnrTlds: () => ipcRenderer.invoke("bcnr:getBcnrTlds"),
|
||||||
getRecordVersion: (name) => ipcRenderer.invoke("bcnr:getRecordVersion", name),
|
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.
|
// Diagnostic — the eTLD+1 permission origin Theseus computes for THIS page.
|
||||||
// dApp devs use this to see how their subdomains bucket under one grant.
|
// dApp devs use this to see how their subdomains bucket under one grant.
|
||||||
// Returns null for opaque origins (data:, blob:) which never hold grants.
|
// Returns null for opaque origins (data:, blob:) which never hold grants.
|
||||||
|
|
|
||||||
101
main.js
101
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.
|
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
|
||||||
const GATEWAY = "https://navigate.st";
|
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/<name> (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) --------------------------------------
|
// ---- BNS name detection (multi-TLD) --------------------------------------
|
||||||
// Theseus is a BNS-native browser: BCNR is the priority registry for EVERY
|
// 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
|
// 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() });
|
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();
|
maybeRefreshElectrum();
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
@ -1433,6 +1507,14 @@ async function serveBns(request) {
|
||||||
if (r.ip) return await serveIp();
|
if (r.ip) return await serveIp();
|
||||||
if (!isSubdomain && r.p) return await serveP();
|
if (!isSubdomain && r.p) return await serveP();
|
||||||
if (r.u) return Response.redirect(r.u, 302);
|
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" } });
|
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// The upstream fetch failed — relay unreachable, DNS stalling, site's
|
// 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"
|
: (!_isSub && entry.records.p) ? "mirror"
|
||||||
: entry.records.u ? "redirect"
|
: entry.records.u ? "redirect"
|
||||||
: "record";
|
: "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);
|
if (id === activeId) pushNav(t.prov);
|
||||||
emitTabs();
|
emitTabs();
|
||||||
}
|
}
|
||||||
|
|
@ -3905,7 +3987,7 @@ async function switchRegistry(arg) {
|
||||||
: (!_isSub && r.p) ? "mirror"
|
: (!_isSub && r.p) ? "mirror"
|
||||||
: r.u ? "redirect"
|
: r.u ? "redirect"
|
||||||
: "record";
|
: "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); }
|
} catch (e) { console.warn("collision-switch load failed:", e?.message); }
|
||||||
finally { t.internalNav = false; setLoading(t, false); }
|
finally { t.internalNav = false; setLoading(t, false); }
|
||||||
|
|
@ -5119,6 +5201,9 @@ function serializeEntry(entry) {
|
||||||
txid: entry.txid,
|
txid: entry.txid,
|
||||||
height: entry.height,
|
height: entry.height,
|
||||||
updatedTxid: entry.updatedTxid ?? null,
|
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) => {
|
ipcMain.handle("bcnr:resolveName", async (_e, name) => {
|
||||||
|
|
@ -5126,6 +5211,18 @@ ipcMain.handle("bcnr:resolveName", async (_e, name) => {
|
||||||
try { return serializeEntry(await resolveHost(name)); }
|
try { return serializeEntry(await resolveHost(name)); }
|
||||||
catch { return null; }
|
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) => {
|
ipcMain.handle("bcnr:isRegistered", async (_e, name) => {
|
||||||
if (typeof name !== "string" || !name) return false;
|
if (typeof name !== "string" || !name) return false;
|
||||||
try { return (await resolveHost(name)) != null; }
|
try { return (await resolveHost(name)) != null; }
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@
|
||||||
details = row("Served from", esc(d.source) || "chain")
|
details = row("Served from", esc(d.source) || "chain")
|
||||||
+ row("Registry", "BCDN — Bitcoin Cash Domain Names")
|
+ row("Registry", "BCDN — Bitcoin Cash Domain Names")
|
||||||
+ (d.records && d.records.length ? row("Records", `<code>${esc(d.records.join(", "))}</code>`) : "")
|
+ (d.records && d.records.length ? row("Records", `<code>${esc(d.records.join(", "))}</code>`) : "")
|
||||||
|
+ (d.dns && d.dns.length ? row("Signed DNS", `<code>${esc(d.dns.join(", "))}</code>`) : "")
|
||||||
+ (d.category ? row("Certificate", `<code>${esc(String(d.category).slice(0, 20))}…</code>`) : "");
|
+ (d.category ? row("Certificate", `<code>${esc(String(d.category).slice(0, 20))}…</code>`) : "");
|
||||||
}
|
}
|
||||||
hero.className = cls; $("ico").innerHTML = ico; $("status").textContent = status; $("sub").innerHTML = sub;
|
hero.className = cls; $("ico").innerHTML = ico; $("status").textContent = status; $("sub").innerHTML = sub;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue