bns: apex content records move off-chain into the signed manifest (v3)

u/s3/ip/h/p for the apex of a BNS name no longer require a chain
transaction to change. They live alongside the subdomain hosts and the
apex DNS block in the owner-signed _records.json on Sia, verified by
the gateway against the current NFT holder. The chain keeps the NFT
identity and one `s3` pointer that anchors the manifest file; every
other edit is a wallet signature with zero satoshis spent.

The trust boundary is unchanged — a signature verified against the
on-chain owner is cryptographically equivalent to a chain update, and
the manifest's seq monotonicity + name-field binding keep replay out.
Legacy names and v1/v2 manifests stay valid; the apex merge is a
no-op when nothing declares it.

Argus:
- manifest.js: SUPPORTED_VERSIONS gains 3; new validateApex and
  effectiveRecords; verifyManifest validates apex and refuses it in v<3
- registrar.js: signRecordsManifest accepts v=3
- public-gateway.mjs: serve() loads manifestFor unconditionally and
  merges chain + apex before pickRecord; /api/dns/<name> response adds
  the verified apex block for clients to merge on their side; a
  successful /api/records POST busts the serve cache so a swap takes
  effect immediately rather than at the 60 s TTL
- test/manifest-apex.test.mjs: 21 tests — validator shape rules,
  merge semantics, v3 round-trip, cross-version refusal, legacy still
  verifies, replay refused

Ariadne daemon:
- cache carries apex alongside the DNS block; mergedRecords(entry, key)
  is the one place the precedence lives. HTTP app and the DNS-answer
  bcnrAnswerFor both read merged records, so an apex ip swap changes
  what the OS gets without waiting on a chain update

Ariadne mobile:
- Entry and DnsCached carry apex; static Bns.mergedRecords(entry) is
  the Java mirror of effectiveRecords; BchFetcher.serve picks from
  the merged view

Hermes.x is the first candidate: chain has `s3: bns/hermes/` + a
redirect `u`, both can collapse to one signed-manifest edit once the
owner publishes their first v3.
This commit is contained in:
Local Dev 2026-10-21 22:18:09 +03:00
parent 7bd87f2458
commit 23757e7a03
4 changed files with 295 additions and 15 deletions

View file

@ -42,7 +42,7 @@ const FULL_ELECTRUM = NETWORK.electrum.filter((s) => !NETWORK.beaconOnly.test(s.
// Sia bucket holding operator-provided hosting: <bucket>/<name>/…
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/<name> 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/<name>/ 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) {

View file

@ -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/<path>" 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" };

View file

@ -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 };

175
test/manifest-apex.test.mjs Normal file
View file

@ -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: "<h1>hi</h1>" }), 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\/<path>/);
assert.match(validateApex({ s3: "bns/../etc/passwd" }), /bns\/<path>/);
});
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);
});