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:
parent
8904c1d7ba
commit
98232def96
4 changed files with 385 additions and 97 deletions
|
|
@ -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
136
src/lib/manifest.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
185
test/manifest-hosts.test.mjs
Normal file
185
test/manifest-hosts.test.mjs
Normal 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}');
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue