refactor(gateway): one definition of what makes a signed manifest valid

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.
This commit is contained in:
Silent Mode 2026-09-28 01:01:27 +02:00
parent 8904c1d7ba
commit 98232def96
4 changed files with 385 additions and 97 deletions

View file

@ -39,6 +39,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 } 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");

136
src/lib/manifest.js Normal file
View file

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

View file

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

View file

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