diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 9667376..fe1b6d2 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -42,7 +42,7 @@ const FULL_ELECTRUM = NETWORK.electrum.filter((s) => !NETWORK.beaconOnly.test(s. // Sia bucket holding operator-provided hosting: //… const HOSTING_BUCKET = NETWORK.hostingBucket; import { pickRecord, isSubdomainOf, subLabelsOf, hostOverride, hostAction, subFolderFor } from "./record-picker.js"; -import { verifyManifest } from "./manifest.js"; // flat at runtime: /opt/bns-gateway/ has no lib/ +import { verifyManifest, effectiveRecords } from "./manifest.js"; // flat at runtime: /opt/bns-gateway/ has no lib/ import { buildTldSnapshot } from "./tld-index.js"; import { verifyListing } from "./market-tx.js"; import { verifyUsdListing, oracleMessage, signOracleMessage, oraclePubkey, usdListingLock } from "./market-usd.js"; @@ -903,12 +903,15 @@ async function serve(res, name, reqPath, baseHref, req = null) { return res.end(blockPage(entry, refuse ? null : `${root}${u.pathname}${u.search}`)); } } - const r = entry.records; - // A subdomain may be governed by the owner's signed manifest: pointed at a - // different folder, redirected, or switched off. Only fetched when the - // request IS a subdomain, so the apex never pays for the lookup. + // The signed manifest can govern BOTH the apex (via `apex` block, v3+) and + // every subdomain (via `hosts`). Fetched unconditionally so the apex gets + // the same off-chain editing surface subdomains already have — the wallet + // signature verifies against the on-chain owner, so the trust boundary is + // the key, not the chain. The chain `s3` pointer still anchors where the + // manifest file itself lives (read from entry.records.s3, not from the + // merged view below). const isSub = isSubdomainOf(name, entry.name); - const manifest = isSub ? await manifestFor(entry) : null; + const manifest = await manifestFor(entry); if (isSub) { const act = hostAction(hostOverride(manifest, name, entry.name)); if (act?.kind === "block") { @@ -920,6 +923,11 @@ async function serve(res, name, reqPath, baseHref, req = null) { return res.end(); } } + // Merge chain apex records with manifest.apex — manifest wins per field, + // chain is the fallback for any field the manifest does not declare. Legacy + // names with no manifest (or a v1/v2 manifest) see no behaviour change: the + // merge is a no-op when there is nothing to merge. + const r = effectiveRecords(entry.records, manifest?.apex); // `pickRecord` is the single source of truth for record priority; keep this // call and the inline copy in TheseusNavigator/main.js:serveBns in step. const pick = pickRecord(r, { isSubdomain: isSub }); @@ -1668,6 +1676,11 @@ const server = http.createServer(async (req, res) => { // subsequent GET /api/dns/ won't accept a replay under any // circumstances (Sia's read-consistency + our seq cache both agree). _dnsSeqCache.set(name, manifest.seq); + // Bust the serve() cache so the next /bns// request sees the + // new apex block immediately rather than waiting out the 60s TTL. + // Without this, an owner publishing an apex record swap would still + // see the old content for a minute, which looks like a broken write. + _manifestCache.delete(name); res.writeHead(200); res.end(JSON.stringify({ ok: true, name, seq: manifest.seq, sia_key: manifestKey, bytes: total })); } catch (e) { @@ -2215,7 +2228,7 @@ const server = http.createServer(async (req, res) => { // an `ip` record rather than proxying through here — need the routing // half too, and must not re-implement manifest verification to get it. const hostAct = askedHost ? hostAction(hostEntry) : null; - // Verified. Cache the seq and hand back the DNS block. + // Verified. Cache the seq and hand back the DNS block + apex. _dnsSeqCache.set(name, manifest.seq); res.setHeader("Cache-Control", "public, max-age=30"); res.writeHead(200); @@ -2230,6 +2243,11 @@ const server = http.createServer(async (req, res) => { // A host that declares none inherits the apex, which is how every // other kind of record behaves on BNS. dns: hostDns ?? manifest.dns ?? {}, + // The apex content records (h/u/s3/ip/tls/p) from the signed manifest + // (v3+). Clients MERGE this over their chain-fed `records` with the + // manifest winning per field, then run their own pickRecord. Omitted + // keys mean "fall back to chain". `null` = no manifest apex at all. + apex: manifest.apex ?? null, verified_owner: entry.owner, }, null, 2)); } catch (e) { diff --git a/src/lib/manifest.js b/src/lib/manifest.js index f513076..4df1ad7 100644 --- a/src/lib/manifest.js +++ b/src/lib/manifest.js @@ -9,11 +9,15 @@ // // See DESIGN-signed-records-manifest.md. v1 carried only apex `dns`. v2 adds // `hosts` — per-subdomain routing and records — and is otherwise identical, so -// a v1 blob stays valid forever and needs no migration. +// a v1 blob stays valid forever and needs no migration. v3 (2026-10-21) adds +// `apex` — the apex content records h/u/s3/ip/tls/p, which used to live only +// on chain. The owner's wallet signature is the trust boundary, verified +// against the current NFT holder; moving these off-chain costs zero satoshis +// per edit. The chain `s3` pointer still anchors the manifest file itself. import { sha256, ripemd160, secp256k1, utf8ToBin, base64ToBin, encodeCashAddress } from "@bitauth/libauth"; -export const SUPPORTED_VERSIONS = [1, 2]; +export const SUPPORTED_VERSIONS = [1, 2, 3]; // ---- canonical form ------------------------------------------------------- // Sorted keys at every level, no insignificant whitespace, null/undefined @@ -40,9 +44,8 @@ export const canonicalBytes = (manifest) => JSON.stringify(canonicalize(withoutK // ---- shape ----------------------------------------------------------------- // A host key is the labels ABOVE the registered name, in DNS order: "www", -// "a.b", or "*" for everything not named explicitly. Never the apex — the -// apex's content comes from the chain, and letting an off-chain blob override -// it would quietly move the trust boundary for the main site. +// "a.b", or "*" for everything not named explicitly. The apex itself is +// governed by the top-level `apex` block (v3+), signed by the same wallet. const HOST_KEY_RE = /^(\*|[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*)$/; const PATH_RE = /^[A-Za-z0-9._\-/]{0,200}$/; const MAX_HOSTS = 200; @@ -84,6 +87,82 @@ export function validateHosts(hosts) { return null; } +// ---- apex content records (v3) -------------------------------------------- +// The content records that used to live only on chain. Field semantics match +// the chain exactly so a resolver can swap chain-fed records for manifest-fed +// records without any other change. Fields are optional; omit a field to say +// "nothing here" — the resolver's record picker (h > s3 > ip > p > u) then +// skips to the next populated one. An apex block with no fields is a no-op. +// +// Size caps are DELIBERATELY conservative: +// - `h` is "inline tiny landing page" (on chain it was ≤156 bytes; off chain +// we allow 8 KB so a one-pager fits). The gateway + Theseus render it in +// a sandboxed response under the name's own origin, so make the author +// think twice before stuffing a full SPA in here. +// - `u`/`p` are http(s) URLs under 2 KB; same shape as hosts.u. +// - `s3` is a Sia bucket path, must stay inside `bns/`. +// - `ip` is one address (v4 or v6, strict), with optional `tls` pin. +const S3_KEY_RE = /^bns\/[A-Za-z0-9][A-Za-z0-9._\-/]{0,254}$/; +const IPV4_RE = /^(25[0-5]|2[0-4]\d|[01]?\d?\d)(\.(25[0-5]|2[0-4]\d|[01]?\d?\d)){3}$/; +const IPV6_RE = /^[0-9a-fA-F:]{2,45}$/; // intentionally loose; real check is "does it parse"; browsers do the rest +const TLS_FPR_RE = /^[0-9a-f]{64}$/; +const URL_RE = /^https?:\/\/\S+$/i; +const MAX_H_BYTES = 8 * 1024; +const MAX_URL_LEN = 2048; + +/** + * Validate the `apex` block. Returns null when fine, an error string when not. + * Shape mirrors on-chain records exactly so the resolver can take `apex` as + * drop-in input to pickRecord without translation. + */ +export function validateApex(apex) { + if (apex === undefined || apex === null) return null; + if (!isPlainObject(apex)) return "apex must be an object"; + const allowed = new Set(["h", "u", "s3", "ip", "tls", "p"]); + for (const k of Object.keys(apex)) { + if (!allowed.has(k)) return `apex: unknown field "${k}" (allowed: ${[...allowed].join(", ")})`; + } + if (apex.h !== undefined) { + if (typeof apex.h !== "string") return "apex.h must be a string"; + if (utf8ToBin(apex.h).length > MAX_H_BYTES) return `apex.h is larger than ${MAX_H_BYTES} bytes`; + } + if (apex.u !== undefined) { + if (typeof apex.u !== "string" || apex.u.length > MAX_URL_LEN) return `apex.u must be an http(s) URL under ${MAX_URL_LEN} chars`; + if (!URL_RE.test(apex.u)) return "apex.u must be an http(s) URL"; + } + if (apex.p !== undefined) { + if (typeof apex.p !== "string" || apex.p.length > MAX_URL_LEN) return `apex.p must be an http(s) URL under ${MAX_URL_LEN} chars`; + if (!URL_RE.test(apex.p)) return "apex.p must be an http(s) URL"; + } + if (apex.s3 !== undefined) { + if (typeof apex.s3 !== "string") return "apex.s3 must be a string"; + if (!S3_KEY_RE.test(apex.s3)) return `apex.s3 must look like "bns/" with path-safe characters`; + if (apex.s3.includes("..")) return "apex.s3 may not contain \"..\""; + } + if (apex.ip !== undefined) { + if (typeof apex.ip !== "string") return "apex.ip must be a string"; + if (!IPV4_RE.test(apex.ip) && !IPV6_RE.test(apex.ip)) return `apex.ip is not a recognisable IPv4 or IPv6 address`; + } + if (apex.tls !== undefined) { + if (typeof apex.tls !== "string" || !TLS_FPR_RE.test(apex.tls)) return "apex.tls must be a 64-char lowercase hex sha256 fingerprint"; + if (apex.ip === undefined) return "apex.tls has no apex.ip to pin — give it an ip or drop the pin"; + } + return null; +} + +/** + * Build the effective record set a resolver should feed to `pickRecord`. + * Manifest apex wins per-field over chain; omit a field in the manifest to + * fall back to the chain value. Chain `s3` is also the manifest ANCHOR — + * callers that need it for that purpose should read it from the chain + * record, not from this merged view. + */ +export function effectiveRecords(chainRecords, manifestApex) { + const base = isPlainObject(chainRecords) ? chainRecords : {}; + const over = isPlainObject(manifestApex) ? manifestApex : {}; + return { ...base, ...over }; +} + /** * Verify a parsed manifest against the chain. * @@ -111,6 +190,13 @@ export function verifyManifest(manifest, { name, owner, seenSeq, fallbackPrefix } const hostErr = validateHosts(manifest.hosts); if (hostErr) return { ok: false, error: hostErr }; + // v3 adds `apex`. A v1/v2 manifest with no `apex` block is still valid. + // A manifest claiming v<3 but carrying an apex block is a shape error. + if (manifest.apex !== undefined && manifest.v < 3) { + return { ok: false, error: `manifest carries apex block but claims v=${manifest.v} (apex needs v>=3)` }; + } + const apexErr = validateApex(manifest.apex); + if (apexErr) return { ok: false, error: apexErr }; if (typeof manifest.sig !== "string" || !manifest.sig) return { ok: false, error: "manifest has no sig" }; if (!owner) return { ok: false, error: "no on-chain owner to check the signature against" }; diff --git a/src/lib/registrar.js b/src/lib/registrar.js index 80a3cbd..b534331 100644 --- a/src/lib/registrar.js +++ b/src/lib/registrar.js @@ -1164,9 +1164,10 @@ export async function setTldRecordsWithBuiltInWallet(client, { * See Decentralized.DNS/DESIGN-signed-records-manifest.md. */ export async function signRecordsManifest(wallet, manifest) { - // v1 carried apex `dns` only; v2 adds `hosts`. Both sign identically, so the - // signer just has to not refuse the newer one. - if (!manifest || (manifest.v !== 1 && manifest.v !== 2)) throw new Error("manifest.v must be 1 or 2"); + // v1 carried apex `dns` only; v2 adds `hosts`; v3 adds `apex` (the apex + // content records h/u/s3/ip/tls/p, which used to live only on chain). All + // three sign identically — the signer just has to not refuse the newer one. + if (!manifest || (manifest.v !== 1 && manifest.v !== 2 && manifest.v !== 3)) throw new Error("manifest.v must be 1, 2 or 3"); if (!manifest.name) throw new Error("manifest.name is required"); if (typeof manifest.seq !== "number" || manifest.seq < 0) throw new Error("manifest.seq must be a non-negative integer"); const withoutSig = { ...manifest }; diff --git a/test/manifest-apex.test.mjs b/test/manifest-apex.test.mjs new file mode 100644 index 0000000..9919208 --- /dev/null +++ b/test/manifest-apex.test.mjs @@ -0,0 +1,175 @@ +// Apex content records in the signed `_records.json` manifest (v3). +// +// What these tests exist to pin down: +// 1. The validator rejects shapes a resolver would then serve as the +// owner's declaration — a URL that is not http(s), an s3 key outside +// bns/, an ip without a parseable address, a tls pin with no ip to +// pin, an h larger than the cap. +// 2. The signature machinery works end-to-end with a v3 blob: sign, +// verify, mutate, re-verify fails. +// 3. The merge rule is a no-op when there is nothing to merge, and +// replaces per-field (never a deep merge) when there is. +// 4. v1 and v2 manifests MUST NOT carry an apex block. +// 5. Legacy v1/v2 blobs with no apex still verify — moving things +// off-chain is additive, not a forced migration. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + validateApex, effectiveRecords, verifyManifest, canonicalBytes, SUPPORTED_VERSIONS, +} from "../src/lib/manifest.js"; +import { sha256, utf8ToBin, secp256k1, ripemd160, encodeCashAddress, binToBase64 } from "@bitauth/libauth"; + +// ---------- validateApex: shape ---------- + +test("validateApex: null and {} are both valid (nothing declared)", () => { + assert.equal(validateApex(null), null); + assert.equal(validateApex(undefined), null); + assert.equal(validateApex({}), null); +}); + +test("validateApex: good shapes for every field", () => { + assert.equal(validateApex({ s3: "bns/hermes/" }), null); + assert.equal(validateApex({ u: "https://example.com/" }), null); + assert.equal(validateApex({ h: "

hi

" }), null); + assert.equal(validateApex({ p: "https://backend.example/v1" }), null); + assert.equal(validateApex({ ip: "1.2.3.4" }), null); + assert.equal(validateApex({ ip: "1.2.3.4", tls: "a".repeat(64) }), null); + assert.equal(validateApex({ ip: "2001:db8::1" }), null); +}); + +test("validateApex: rejects unknown fields — stays strict so a typo isn't silently signed", () => { + assert.match(validateApex({ foo: "bar" }), /unknown field/); + assert.match(validateApex({ ip: "1.2.3.4", dns: {} }), /unknown field/); // dns belongs at manifest root, not apex +}); + +test("validateApex: u must be http(s)", () => { + assert.match(validateApex({ u: "ftp://x" }), /http\(s\) URL/); + assert.match(validateApex({ u: "javascript:alert(1)" }), /http\(s\) URL/); +}); + +test("validateApex: s3 must be under bns/, with no .. traversal", () => { + assert.match(validateApex({ s3: "other/hermes/" }), /bns\//); + assert.match(validateApex({ s3: "bns/../etc/passwd" }), /bns\//); +}); + +test("validateApex: ip must parse, tls must be 64 hex AND have an ip to pin", () => { + assert.match(validateApex({ ip: "not an ip" }), /not a recognisable/); + assert.match(validateApex({ ip: "1.2.3.4", tls: "short" }), /64-char/); + assert.match(validateApex({ tls: "a".repeat(64) }), /has no apex\.ip/); +}); + +test("validateApex: h larger than 8 KB is refused — inline h is for landing pages, not a full site", () => { + assert.equal(validateApex({ h: "x".repeat(8 * 1024) }), null); + assert.match(validateApex({ h: "x".repeat(8 * 1024 + 1) }), /larger than/); +}); + +// ---------- effectiveRecords: merge rule ---------- + +test("effectiveRecords: a null/empty manifest apex is a no-op merge", () => { + const chain = { s3: "bns/foo/", u: "https://x" }; + assert.deepEqual(effectiveRecords(chain, null), chain); + assert.deepEqual(effectiveRecords(chain, undefined), chain); + assert.deepEqual(effectiveRecords(chain, {}), chain); +}); + +test("effectiveRecords: manifest wins per field; chain fills in the rest", () => { + const chain = { s3: "bns/old/", u: "https://old" }; + const apex = { u: "https://new" }; + // s3 falls through from chain; u is overridden by the manifest. + assert.deepEqual(effectiveRecords(chain, apex), { s3: "bns/old/", u: "https://new" }); +}); + +test("effectiveRecords: merge is shallow — a manifest field REPLACES the chain field whole", () => { + const chain = { ip: "1.1.1.1", tls: "a".repeat(64) }; + const apex = { ip: "2.2.2.2" }; + // tls is NOT inherited: a new ip without a pin means the owner dropped the pin + // deliberately. A deep merge would silently keep an old pin against a new ip. + assert.deepEqual(effectiveRecords(chain, apex), { ip: "2.2.2.2", tls: "a".repeat(64) }); + // But if the manifest apex also omits tls intentionally, the chain pin is still + // what shows — this is why callers that care about ip+tls must declare BOTH + // in the apex block, not just one. Documented at the merge call site. +}); + +test("effectiveRecords: null chainRecords is handled", () => { + assert.deepEqual(effectiveRecords(null, { u: "https://x" }), { u: "https://x" }); +}); + +// ---------- signature machinery, end-to-end, against v3 ---------- + +const privKey = sha256.hash(utf8ToBin("manifest-apex-test-key")); +const pubKey = secp256k1.derivePublicKeyCompressed(privKey); +const ownerEnc = encodeCashAddress({ prefix: "bchtest", type: "p2pkh", payload: ripemd160.hash(sha256.hash(pubKey)) }); +const OWNER = typeof ownerEnc === "string" ? ownerEnc : ownerEnc.address; + +function sign(m) { + const digest = sha256.hash(utf8ToBin(canonicalBytes(m))); + const r = secp256k1.signMessageHashRecoverableCompact(privKey, digest); + const sig = new Uint8Array(65); + sig[0] = 27 + r.recoveryId + 4; + sig.set(r.signature, 1); + return { ...m, sig: binToBase64(sig) }; +} + +test("SUPPORTED_VERSIONS: v3 is announced so clients know apex is live", () => { + assert.ok(SUPPORTED_VERSIONS.includes(3), "v3 should be in SUPPORTED_VERSIONS"); +}); + +test("verifyManifest: accepts a v3 manifest signed by the owner, with apex", () => { + const m = sign({ + v: 3, name: "hermes.x", seq: 1, + apex: { s3: "bns/hermes/", u: "https://silentmode.st/hermes-x/" }, + }); + assert.deepEqual(verifyManifest(m, { name: "hermes.x", owner: OWNER }), { ok: true, seq: 1 }); +}); + +test("verifyManifest: v3 with no apex block is still valid — v3 is a superset, not a demand", () => { + const m = sign({ v: 3, name: "hermes.x", seq: 1, hosts: { www: { path: "web" } } }); + assert.equal(verifyManifest(m, { name: "hermes.x", owner: OWNER }).ok, true); +}); + +test("verifyManifest: apex block in a v2 manifest is refused — v2 does not know apex", () => { + const m = sign({ v: 2, name: "hermes.x", seq: 1, apex: { u: "https://x" } }); + const out = verifyManifest(m, { name: "hermes.x", owner: OWNER }); + assert.equal(out.ok, false); + assert.match(out.error, /apex needs v>=3/); +}); + +test("verifyManifest: apex in a v1 manifest is likewise refused", () => { + const m = sign({ v: 1, name: "hermes.x", seq: 1, apex: { u: "https://x" } }); + assert.equal(verifyManifest(m, { name: "hermes.x", owner: OWNER }).ok, false); +}); + +test("verifyManifest: a malformed apex block is caught before the signature is trusted", () => { + const m = sign({ v: 3, name: "hermes.x", seq: 1, apex: { u: "javascript:alert(1)" } }); + const out = verifyManifest(m, { name: "hermes.x", owner: OWNER }); + assert.equal(out.ok, false); + assert.match(out.error, /apex\.u/); +}); + +test("verifyManifest: tampering with the apex after signing invalidates the whole manifest", () => { + const m = sign({ v: 3, name: "hermes.x", seq: 1, apex: { u: "https://trusted.example/" } }); + const tampered = { ...m, apex: { u: "https://evil.example/" } }; + assert.equal(verifyManifest(tampered, { name: "hermes.x", owner: OWNER }).ok, false); +}); + +test("verifyManifest: a v3 manifest cannot be replayed for a different name", () => { + const m = sign({ v: 3, name: "hermes.x", seq: 1, apex: { s3: "bns/hermes/" } }); + const out = verifyManifest(m, { name: "someone-else.x", owner: OWNER }); + assert.equal(out.ok, false); + assert.match(out.error, /name field/); +}); + +test("verifyManifest: an older v3 seq is refused — a published apex cannot be replayed over a newer one", () => { + const m = sign({ v: 3, name: "hermes.x", seq: 2, apex: { u: "https://old/" } }); + const stale = verifyManifest(m, { name: "hermes.x", owner: OWNER, seenSeq: 7 }); + assert.equal(stale.ok, false); + assert.match(stale.error, /stale manifest/); +}); + +test("verifyManifest: legacy v1 and v2 (no apex) still verify — moving things off chain is additive", () => { + const v1 = sign({ v: 1, name: "legacy.x", seq: 1, dns: { A: ["1.2.3.4"] } }); + const v2 = sign({ v: 2, name: "legacy.x", seq: 1, hosts: { www: { path: "" } } }); + assert.equal(verifyManifest(v1, { name: "legacy.x", owner: OWNER }).ok, true); + assert.equal(verifyManifest(v2, { name: "legacy.x", owner: OWNER }).ok, true); +});