From 98232def9689639aa21378725b9214d17366ae82 Mon Sep 17 00:00:00 2001 From: Silent Mode Date: Mon, 28 Sep 2026 01:01:27 +0200 Subject: [PATCH] refactor(gateway): one definition of what makes a signed manifest valid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signature check was written out twice — once on the read path, once on the write path — and a third copy was about to be needed for serving. Three hand-copies of a verification routine is how a hole gets in, and the dangerous half is subtle: pubkey recovery always succeeds and always yields some key, so the line that actually proves anything is the comparison of the derived address to the on-chain owner. That line is now in one place. The manifest schema gains a hosts block for per-subdomain records and routing, validated strictly here because whatever we accept we then serve under the owner's signature: no path traversal, no non-http redirect targets, no host key that is not a label path. v1 blobs stay valid, so nothing anyone has published needs republishing. Also adds the rule for which hosts entry governs a request. The apex is deliberately never one of them — its content comes from the chain, and letting an off-chain blob redirect the main site would move a name's trust boundary without the owner spending a transaction. --- src/gateway/public-gateway.mjs | 110 +++----------------- src/lib/manifest.js | 136 ++++++++++++++++++++++++ src/lib/record-picker.js | 51 +++++++++ test/manifest-hosts.test.mjs | 185 +++++++++++++++++++++++++++++++++ 4 files changed, 385 insertions(+), 97 deletions(-) create mode 100644 src/lib/manifest.js create mode 100644 test/manifest-hosts.test.mjs diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 08fae06..98352d9 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -39,6 +39,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 } from "./record-picker.js"; +import { verifyManifest } 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"; @@ -312,26 +313,6 @@ const aws = new AwsClient({ // disk alongside the beacon snapshot. const _dnsSeqCache = new Map(); -// ---------- signed records manifest helpers ---------- -// canonicalize(): recursively sort object keys, drop null/undefined so -// signable bytes are reproducible cross-implementation. Arrays keep order. -function canonicalize(x) { - if (x === null || x === undefined) return undefined; - if (Array.isArray(x)) return x.map(canonicalize).filter((v) => v !== undefined); - if (typeof x === "object") { - const out = {}; - for (const k of Object.keys(x).sort()) { - const v = canonicalize(x[k]); - if (v !== undefined) out[k] = v; - } - return out; - } - return x; -} -function withoutKey(obj, k) { - const { [k]: _drop, ...rest } = obj; - return rest; -} const cache = new Map(); async function resolve(host) { const c = cache.get(host); @@ -1181,15 +1162,6 @@ const server = http.createServer(async (req, res) => { let manifest; try { manifest = JSON.parse(bodyText); } catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON" })); return; } - // Schema checks. - if (manifest.v !== 1) { res.writeHead(400); res.end(JSON.stringify({ error: "unsupported v (expected 1)" })); return; } - if (manifest.name !== name) { res.writeHead(400); res.end(JSON.stringify({ error: `name field "${manifest.name}" does not match path "${name}"` })); return; } - if (typeof manifest.seq !== "number" || !Number.isFinite(manifest.seq) || manifest.seq < 0) { - res.writeHead(400); res.end(JSON.stringify({ error: "seq must be a non-negative integer" })); return; - } - if (typeof manifest.sig !== "string" || !manifest.sig) { - res.writeHead(400); res.end(JSON.stringify({ error: "sig required (base64 BCH message signature, 65 bytes)" })); return; - } // Seq monotonicity — read the current manifest if any. try { const cur = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`); @@ -1200,43 +1172,14 @@ const server = http.createServer(async (req, res) => { } } } catch { /* first write */ } - // Signature verification. The signable payload is the manifest - // WITHOUT the sig field, canonicalized: sorted keys, no whitespace, - // nulls omitted. See DESIGN-signed-records-manifest.md. - const canonical = JSON.stringify(canonicalize(withoutKey(manifest, "sig"))); - const digest = sha256.hash(utf8ToBin(canonical)); - let sigBytes; - try { sigBytes = base64ToBin(manifest.sig); } - catch { res.writeHead(400); res.end(JSON.stringify({ error: "sig is not valid base64" })); return; } - if (sigBytes.length !== 65) { - res.writeHead(400); res.end(JSON.stringify({ error: `sig must decode to 65 bytes (got ${sigBytes.length})` })); return; - } - // BCH message-signature envelope: [flag, r|s]. flag = 27+recovery - // (+4 if compressed pubkey). Compressed is the standard. - const flag = sigBytes[0]; - const recovery = (flag - 27) & 3; - const compactSig = sigBytes.slice(1); - const recovered = secp256k1.recoverPublicKeyCompressed(compactSig, recovery, digest); - if (typeof recovered === "string") { - res.writeHead(400); res.end(JSON.stringify({ error: `sig recovery failed: ${recovered}` })); return; - } - // Derive p2pkh CashAddress from recovered pubkey, using the same - // prefix as the on-chain owner address (chipnet = "bchtest", - // mainnet = "bitcoincash"). The prefix comes from the owner - // string itself so we don't have to hardcode chain. - const ownerPrefix = String(entry.owner).split(":")[0] || NETWORK.prefix; - const pubkeyHash = ripemd160.hash(sha256.hash(recovered)); - const _enc = encodeCashAddress({ prefix: ownerPrefix, type: "p2pkh", payload: pubkeyHash }); - // libauth returns { address } in newer versions and a bare string in - // older ones — unwrap either shape. - const derivedAddr = typeof _enc === "string" ? _enc : (_enc?.address ?? ""); - if (derivedAddr !== entry.owner) { - res.writeHead(403); - res.end(JSON.stringify({ - error: "signature does not match current on-chain NFT owner", - derived: derivedAddr, - current: entry.owner, - })); + // Schema, replay and signature in one place — lib/manifest.js is the + // only definition of what makes a manifest valid, shared with the read + // path and with serve(). See DESIGN-signed-records-manifest.md. + const v = verifyManifest(manifest, { name, owner: entry.owner, fallbackPrefix: NETWORK.prefix }); + if (!v.ok) { + const status = /does not match the current NFT owner/.test(v.error) ? 403 : 400; + res.writeHead(status); + res.end(JSON.stringify({ error: v.error, ...(v.detail || {}) })); return; } // Upload signed blob to Sia at `_records.json`. @@ -1788,37 +1731,10 @@ const server = http.createServer(async (req, res) => { let manifest; try { manifest = JSON.parse(bodyText); } catch { res.writeHead(502); res.end(JSON.stringify({ error: "manifest on sia is not valid JSON" })); return; } - if (manifest.v !== 1) { res.writeHead(502); res.end(JSON.stringify({ error: `unsupported manifest v=${manifest.v}` })); return; } - if (manifest.name !== name) { res.writeHead(502); res.end(JSON.stringify({ error: "manifest name field does not match request" })); return; } - // Replay defence — reject anything with a seq we've seen a HIGHER - // value of. Newer manifests are always accepted. - const seenSeq = _dnsSeqCache.get(name); - if (seenSeq !== undefined && manifest.seq < seenSeq) { - res.writeHead(502); res.end(JSON.stringify({ error: `stale manifest — seq ${manifest.seq} < seen ${seenSeq}` })); return; - } - // Signature check — recover pubkey from sig+digest, derive - // CashAddress with the owner's prefix, compare to entry.owner. - if (typeof manifest.sig !== "string") { res.writeHead(502); res.end(JSON.stringify({ error: "manifest has no sig" })); return; } - const canonical = JSON.stringify(canonicalize(withoutKey(manifest, "sig"))); - const digest = sha256.hash(utf8ToBin(canonical)); - let sigBytes; - try { sigBytes = base64ToBin(manifest.sig); } - catch { res.writeHead(502); res.end(JSON.stringify({ error: "sig not valid base64" })); return; } - if (sigBytes.length !== 65) { res.writeHead(502); res.end(JSON.stringify({ error: `sig must be 65 bytes (got ${sigBytes.length})` })); return; } - const flag = sigBytes[0]; - const recovery = (flag - 27) & 3; - const compactSig = sigBytes.slice(1); - const recovered = secp256k1.recoverPublicKeyCompressed(compactSig, recovery, digest); - if (typeof recovered === "string") { res.writeHead(502); res.end(JSON.stringify({ error: `sig recovery failed: ${recovered}` })); return; } - const ownerPrefix = String(entry.owner).split(":")[0] || NETWORK.prefix; - const pubkeyHash = ripemd160.hash(sha256.hash(recovered)); - const _enc = encodeCashAddress({ prefix: ownerPrefix, type: "p2pkh", payload: pubkeyHash }); - const derivedAddr = typeof _enc === "string" ? _enc : (_enc?.address ?? ""); - if (derivedAddr !== entry.owner) { - res.writeHead(502); - res.end(JSON.stringify({ error: "manifest signature does not match current NFT owner", derived: derivedAddr, current: entry.owner })); - return; - } + const v = verifyManifest(manifest, { + name, owner: entry.owner, seenSeq: _dnsSeqCache.get(name), fallbackPrefix: NETWORK.prefix, + }); + if (!v.ok) { res.writeHead(502); res.end(JSON.stringify({ error: v.error, ...(v.detail || {}) })); return; } // Verified. Cache the seq and hand back the DNS block. _dnsSeqCache.set(name, manifest.seq); res.setHeader("Cache-Control", "public, max-age=30"); diff --git a/src/lib/manifest.js b/src/lib/manifest.js new file mode 100644 index 0000000..f513076 --- /dev/null +++ b/src/lib/manifest.js @@ -0,0 +1,136 @@ +// The signed records manifest — `_records.json` in the name's Sia bucket. +// +// The chain says WHO owns a name. This manifest says what the owner declared +// about it, signed with the key that holds the certificate. Verification is +// the whole point, so it lives here as one pure function rather than inline in +// a route handler: the gateway needs it on read (`GET /api/dns`), on write +// (`POST /api/records`) and now on every subdomain request it serves, and +// three hand-copies of a signature check is how a hole gets in. +// +// 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. + +import { sha256, ripemd160, secp256k1, utf8ToBin, base64ToBin, encodeCashAddress } from "@bitauth/libauth"; + +export const SUPPORTED_VERSIONS = [1, 2]; + +// ---- canonical form ------------------------------------------------------- +// Sorted keys at every level, no insignificant whitespace, null/undefined +// omitted. The signer and the verifier must agree byte for byte, so this is +// the only place that decides what "the manifest" means as bytes. +export function canonicalize(x) { + if (x === null || x === undefined) return undefined; + if (Array.isArray(x)) return x.map(canonicalize).filter((v) => v !== undefined); + if (typeof x === "object") { + const out = {}; + for (const k of Object.keys(x).sort()) { + const v = canonicalize(x[k]); + if (v !== undefined) out[k] = v; + } + return out; + } + return x; +} +export function withoutKey(obj, k) { + const { [k]: _drop, ...rest } = obj; + return rest; +} +export const canonicalBytes = (manifest) => JSON.stringify(canonicalize(withoutKey(manifest, "sig"))); + +// ---- 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. +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; +const MAX_HOST_LABELS = 8; + +const isPlainObject = (v) => !!v && typeof v === "object" && !Array.isArray(v); + +/** + * Validate the `hosts` block. Returns null when fine, an error string when not. + * Deliberately strict: a manifest is signed, so a malformed one that we accept + * is a malformed one we then serve under the owner's signature. + */ +export function validateHosts(hosts) { + if (hosts === undefined || hosts === null) return null; + if (!isPlainObject(hosts)) return "hosts must be an object keyed by subdomain label"; + const keys = Object.keys(hosts); + if (keys.length > MAX_HOSTS) return `too many hosts (${keys.length} > ${MAX_HOSTS})`; + for (const key of keys) { + if (!HOST_KEY_RE.test(key)) return `invalid host key "${key}" (expected a label path such as "www" or "a.b", or "*")`; + if (key !== "*" && key.split(".").length > MAX_HOST_LABELS) return `host key "${key}" is deeper than ${MAX_HOST_LABELS} labels`; + const h = hosts[key]; + if (!isPlainObject(h)) return `host "${key}" must be an object`; + if (h.path !== undefined) { + if (typeof h.path !== "string") return `host "${key}": path must be a string`; + if (!PATH_RE.test(h.path)) return `host "${key}": path has characters outside [A-Za-z0-9._-/]`; + if (h.path.includes("..")) return `host "${key}": path may not contain ".."`; + if (h.path.startsWith("/")) return `host "${key}": path is relative to the bucket, drop the leading "/"`; + } + if (h.u !== undefined) { + if (typeof h.u !== "string" || h.u.length > 2048) return `host "${key}": u must be a URL under 2048 characters`; + if (!/^https?:\/\/\S+$/i.test(h.u)) return `host "${key}": u must be an http(s) URL`; + } + if (h.block !== undefined && typeof h.block !== "boolean") return `host "${key}": block must be true or false`; + if (h.dns !== undefined && !isPlainObject(h.dns)) return `host "${key}": dns must be an object`; + if (h.path === undefined && h.u === undefined && h.block === undefined && h.dns === undefined) { + return `host "${key}" declares nothing — give it a path, u, block or dns`; + } + } + return null; +} + +/** + * Verify a parsed manifest against the chain. + * + * @param {object} manifest parsed `_records.json` + * @param {object} ctx + * @param {string} ctx.name the normalised name the manifest must claim + * @param {string} ctx.owner the current on-chain NFT owner CashAddress + * @param {number} [ctx.seenSeq] highest seq seen for this name, for replay defence + * @param {string} [ctx.fallbackPrefix] address prefix when owner carries none + * @returns {{ok: true, seq: number} | {ok: false, error: string, detail?: object}} + */ +export function verifyManifest(manifest, { name, owner, seenSeq, fallbackPrefix = "bchtest" } = {}) { + if (!isPlainObject(manifest)) return { ok: false, error: "manifest is not an object" }; + if (!SUPPORTED_VERSIONS.includes(manifest.v)) { + return { ok: false, error: `unsupported manifest v=${manifest.v} (supported: ${SUPPORTED_VERSIONS.join(", ")})` }; + } + if (manifest.name !== name) return { ok: false, error: "manifest name field does not match the name it was fetched for" }; + if (typeof manifest.seq !== "number" || !Number.isFinite(manifest.seq) || manifest.seq < 0) { + return { ok: false, error: "seq must be a non-negative integer" }; + } + // Replay defence. A manifest older than one we have already accepted is + // refused outright; equal is allowed so a re-read of the current blob works. + if (seenSeq !== undefined && manifest.seq < seenSeq) { + return { ok: false, error: `stale manifest — seq ${manifest.seq} < seen ${seenSeq}` }; + } + const hostErr = validateHosts(manifest.hosts); + if (hostErr) return { ok: false, error: hostErr }; + 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" }; + + const digest = sha256.hash(utf8ToBin(canonicalBytes(manifest))); + let sigBytes; + try { sigBytes = base64ToBin(manifest.sig); } + catch { return { ok: false, error: "sig is not valid base64" }; } + if (sigBytes.length !== 65) return { ok: false, error: `sig must decode to 65 bytes (got ${sigBytes.length})` }; + // BCH message-signature envelope: [flag, r|s]; flag = 27 + recovery (+4 when + // the pubkey is compressed, which is the standard). + const recovery = (sigBytes[0] - 27) & 3; + const recovered = secp256k1.recoverPublicKeyCompressed(sigBytes.slice(1), recovery, digest); + if (typeof recovered === "string") return { ok: false, error: `sig recovery failed: ${recovered}` }; + // Recovery ALWAYS yields some key. It proves nothing until the address it + // derives is compared to the one the chain says owns the name. + const prefix = String(owner).split(":")[0] || fallbackPrefix; + const enc = encodeCashAddress({ prefix, type: "p2pkh", payload: ripemd160.hash(sha256.hash(recovered)) }); + const derived = typeof enc === "string" ? enc : (enc?.address ?? ""); + if (derived !== owner) { + return { ok: false, error: "manifest signature does not match the current NFT owner", detail: { derived, current: owner } }; + } + return { ok: true, seq: manifest.seq }; +} diff --git a/src/lib/record-picker.js b/src/lib/record-picker.js index 1f9071b..8bca780 100644 --- a/src/lib/record-picker.js +++ b/src/lib/record-picker.js @@ -64,3 +64,54 @@ export function isSubdomainOf(requestedHost, entryName) { if (!entryName) return false; return String(requestedHost).toLowerCase() !== String(entryName).toLowerCase(); } + +/** + * The labels ABOVE the registered name, in DNS order — the key a manifest's + * `hosts` block uses. `""` for the apex. + * + * subLabelsOf("www.aegis.x", "aegis.x") === "www" + * subLabelsOf("chipnet.explorer.aegis.x", "aegis.x") === "chipnet.explorer" + * subLabelsOf("aegis.x", "aegis.x") === "" + */ +export function subLabelsOf(requestedHost, entryName) { + const host = String(requestedHost || "").toLowerCase(); + const base = String(entryName || "").toLowerCase(); + if (!base || host === base) return ""; + return host.endsWith("." + base) ? host.slice(0, -(base.length + 1)) : ""; +} + +/** + * Which `hosts` entry governs this request, if any. + * + * Exact key first, then the `*` catch-all — so a wildcard can set the policy + * for a whole name while named hosts opt out of it. The apex never matches: + * its content comes from the chain, and letting a signed blob redirect the + * main site would move the trust boundary without the owner spending a tx. + * + * KEEP IN STEP with the inline copy in TheseusNavigator/main.js `serveBns()`. + */ +export function hostOverride(manifest, requestedHost, entryName) { + const hosts = manifest?.hosts; + if (!hosts || typeof hosts !== "object") return null; + const sub = subLabelsOf(requestedHost, entryName); + if (!sub) return null; + const hit = Object.prototype.hasOwnProperty.call(hosts, sub) ? hosts[sub] + : Object.prototype.hasOwnProperty.call(hosts, "*") ? hosts["*"] + : null; + return hit && typeof hit === "object" ? hit : null; +} + +/** + * What the gateway should do for a subdomain, given its `hosts` entry. + * `block` beats `u` beats `path` — the most restrictive intent wins, so a + * host that is both blocked and redirected stays blocked. + * + * @returns {{kind:'block'} | {kind:'redirect', url:string} | {kind:'path', path:string} | null} + */ +export function hostAction(entry) { + if (!entry || typeof entry !== "object") return null; + if (entry.block === true) return { kind: "block" }; + if (typeof entry.u === "string" && entry.u) return { kind: "redirect", url: entry.u }; + if (typeof entry.path === "string") return { kind: "path", path: entry.path }; + return null; +} diff --git a/test/manifest-hosts.test.mjs b/test/manifest-hosts.test.mjs new file mode 100644 index 0000000..4a9855f --- /dev/null +++ b/test/manifest-hosts.test.mjs @@ -0,0 +1,185 @@ +// Per-host manifest entries: the key rule, the validation, and the signature +// check that makes any of it trustworthy. +// +// Two things these tests exist to stop: +// 1. An apex being governed by `hosts`. The main site's content comes from +// the chain; if a signed blob could redirect it, the trust boundary for +// the name would move without the owner ever spending a transaction. +// 2. A manifest being trusted because pubkey recovery "worked". Recovery +// always yields SOME key — only comparing the derived address to the +// on-chain owner proves anything. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { subLabelsOf, hostOverride, hostAction } from "../src/lib/record-picker.js"; +import { validateHosts, verifyManifest, canonicalBytes, canonicalize } from "../src/lib/manifest.js"; +import { sha256, utf8ToBin, secp256k1, ripemd160, encodeCashAddress, binToBase64 } from "@bitauth/libauth"; + +// ---------- subLabelsOf ---------- + +test("subLabelsOf: apex has no labels above the name", () => { + assert.equal(subLabelsOf("aegis.x", "aegis.x"), ""); + assert.equal(subLabelsOf("Aegis.X", "aegis.x"), ""); +}); + +test("subLabelsOf: one and many labels, in DNS order", () => { + assert.equal(subLabelsOf("www.aegis.x", "aegis.x"), "www"); + assert.equal(subLabelsOf("chipnet.explorer.aegis.x", "aegis.x"), "chipnet.explorer"); +}); + +test("subLabelsOf: a name that merely ends in the same text is not a subdomain", () => { + // "notaegis.x" ends with "aegis.x" as a STRING but not as a label boundary. + assert.equal(subLabelsOf("notaegis.x", "aegis.x"), ""); +}); + +// ---------- hostOverride ---------- + +const manifest = { + v: 2, name: "aegis.x", seq: 1, + hosts: { + www: { path: "" }, + shop: { path: "store/v2/" }, + blog: { u: "https://blog.example.com" }, + old: { block: true }, + "*": { u: "https://aegis.x/" }, + }, +}; + +test("hostOverride: the apex is never governed by hosts", () => { + assert.equal(hostOverride(manifest, "aegis.x", "aegis.x"), null); +}); + +test("hostOverride: exact key wins", () => { + assert.deepEqual(hostOverride(manifest, "shop.aegis.x", "aegis.x"), { path: "store/v2/" }); + assert.deepEqual(hostOverride(manifest, "old.aegis.x", "aegis.x"), { block: true }); +}); + +test("hostOverride: the wildcard catches anything not named", () => { + assert.deepEqual(hostOverride(manifest, "anything.aegis.x", "aegis.x"), { u: "https://aegis.x/" }); +}); + +test("hostOverride: a named host opts out of the wildcard", () => { + assert.deepEqual(hostOverride(manifest, "www.aegis.x", "aegis.x"), { path: "" }); +}); + +test("hostOverride: no hosts block means no override, never a throw", () => { + assert.equal(hostOverride({ v: 1, name: "aegis.x" }, "www.aegis.x", "aegis.x"), null); + assert.equal(hostOverride(null, "www.aegis.x", "aegis.x"), null); +}); + +test("hostOverride: inherited Object properties are not host entries", () => { + // A manifest is attacker-supplied JSON; "constructor" must not resolve. + assert.equal(hostOverride({ hosts: {} }, "constructor.aegis.x", "aegis.x"), null); + assert.equal(hostOverride({ hosts: {} }, "toString.aegis.x", "aegis.x"), null); +}); + +// ---------- hostAction ---------- + +test("hostAction: block beats redirect beats path", () => { + assert.deepEqual(hostAction({ block: true, u: "https://x.test/", path: "a/" }), { kind: "block" }); + assert.deepEqual(hostAction({ u: "https://x.test/", path: "a/" }), { kind: "redirect", url: "https://x.test/" }); + assert.deepEqual(hostAction({ path: "a/" }), { kind: "path", path: "a/" }); +}); + +test("hostAction: an empty path is a real instruction — serve the bucket root", () => { + // Distinct from "no path at all": `{path:""}` points a subdomain at the apex + // folder, which is how `www` is made to mirror the main site. + assert.deepEqual(hostAction({ path: "" }), { kind: "path", path: "" }); + assert.equal(hostAction({ dns: { A: ["1.2.3.4"] } }), null); +}); + +// ---------- validateHosts ---------- + +test("validateHosts: accepts the shapes the dashboard writes", () => { + assert.equal(validateHosts(undefined), null); + assert.equal(validateHosts({ www: { path: "" }, "a.b": { path: "deep/" }, "*": { block: true } }), null); + assert.equal(validateHosts({ mail: { dns: { A: ["1.2.3.4"] } } }), null); +}); + +test("validateHosts: rejects path traversal and absolute paths", () => { + assert.match(validateHosts({ x: { path: "../secrets/" } }), /\.\./); + assert.match(validateHosts({ x: { path: "/etc/passwd" } }), /leading/); +}); + +test("validateHosts: rejects a host key that is not a label path", () => { + assert.match(validateHosts({ "has space": { path: "" } }), /invalid host key/); + assert.match(validateHosts({ "UPPER": { path: "" } }), /invalid host key/); + assert.match(validateHosts({ "-leading": { path: "" } }), /invalid host key/); +}); + +test("validateHosts: rejects a non-http redirect target", () => { + assert.match(validateHosts({ x: { u: "javascript:alert(1)" } }), /http\(s\) URL/); + assert.match(validateHosts({ x: { u: "file:///etc/passwd" } }), /http\(s\) URL/); +}); + +test("validateHosts: rejects an entry that declares nothing", () => { + assert.match(validateHosts({ x: {} }), /declares nothing/); +}); + +// ---------- verifyManifest ---------- + +// A throwaway key, and a manifest genuinely signed with it. +const privKey = sha256.hash(utf8ToBin("manifest-hosts-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; // compressed-pubkey flag + sig.set(r.signature, 1); + return { ...m, sig: binToBase64(sig) }; +} + +test("verifyManifest: accepts a v2 manifest signed by the owner", () => { + const m = sign({ v: 2, name: "aegis.x", seq: 7, hosts: { www: { path: "" } } }); + assert.deepEqual(verifyManifest(m, { name: "aegis.x", owner: OWNER }), { ok: true, seq: 7 }); +}); + +test("verifyManifest: v1 stays valid — no migration is forced on anyone", () => { + const m = sign({ v: 1, name: "aegis.x", seq: 1, dns: { A: ["1.2.3.4"] } }); + assert.equal(verifyManifest(m, { name: "aegis.x", owner: OWNER }).ok, true); +}); + +test("verifyManifest: a signature from the wrong key is refused, not merely recovered", () => { + const m = sign({ v: 2, name: "aegis.x", seq: 7, hosts: { www: { path: "" } } }); + const other = "bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"; + const out = verifyManifest(m, { name: "aegis.x", owner: other }); + assert.equal(out.ok, false); + assert.match(out.error, /does not match the current NFT owner/); +}); + +test("verifyManifest: any tampering after signing invalidates it", () => { + const m = sign({ v: 2, name: "aegis.x", seq: 7, hosts: { www: { path: "" } } }); + const tampered = { ...m, hosts: { www: { u: "https://evil.test/" } } }; + assert.equal(verifyManifest(tampered, { name: "aegis.x", owner: OWNER }).ok, false); +}); + +test("verifyManifest: a manifest cannot be replayed for another name", () => { + const m = sign({ v: 2, name: "aegis.x", seq: 7 }); + const out = verifyManifest(m, { name: "other.x", owner: OWNER }); + assert.equal(out.ok, false); + assert.match(out.error, /name field/); +}); + +test("verifyManifest: an older seq is refused, an equal one is not", () => { + const m = sign({ v: 2, name: "aegis.x", seq: 5 }); + assert.match(verifyManifest(m, { name: "aegis.x", owner: OWNER, seenSeq: 9 }).error, /stale manifest/); + assert.equal(verifyManifest(m, { name: "aegis.x", owner: OWNER, seenSeq: 5 }).ok, true); +}); + +test("verifyManifest: a malformed hosts block is refused before the signature is trusted", () => { + const m = sign({ v: 2, name: "aegis.x", seq: 7, hosts: { x: { path: "../../etc/" } } }); + assert.equal(verifyManifest(m, { name: "aegis.x", owner: OWNER }).ok, false); +}); + +// ---------- canonicalisation ---------- + +test("canonicalize: key order and dropped nulls are stable, so signing is reproducible", () => { + const a = canonicalize({ b: 1, a: { d: null, c: [1, null, 2] } }); + const b = canonicalize({ a: { c: [1, 2], d: undefined }, b: 1 }); + assert.equal(JSON.stringify(a), JSON.stringify(b)); + assert.equal(JSON.stringify(a), '{"a":{"c":[1,2]},"b":1}'); +});