186 lines
8.3 KiB
JavaScript
186 lines
8.3 KiB
JavaScript
|
|
// 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}');
|
||
|
|
});
|