argus/test/manifest-apex.test.mjs
Local Dev 23757e7a03 bns: apex content records move off-chain into the signed manifest (v3)
u/s3/ip/h/p for the apex of a BNS name no longer require a chain
transaction to change. They live alongside the subdomain hosts and the
apex DNS block in the owner-signed _records.json on Sia, verified by
the gateway against the current NFT holder. The chain keeps the NFT
identity and one `s3` pointer that anchors the manifest file; every
other edit is a wallet signature with zero satoshis spent.

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

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

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

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

Hermes.x is the first candidate: chain has `s3: bns/hermes/` + a
redirect `u`, both can collapse to one signed-manifest edit once the
owner publishes their first v3.
2026-10-21 22:18:09 +03:00

175 lines
8.3 KiB
JavaScript

// Apex content records in the signed `_records.json` manifest (v3).
//
// What these tests exist to pin down:
// 1. The validator rejects shapes a resolver would then serve as the
// owner's declaration — a URL that is not http(s), an s3 key outside
// bns/, an ip without a parseable address, a tls pin with no ip to
// pin, an h larger than the cap.
// 2. The signature machinery works end-to-end with a v3 blob: sign,
// verify, mutate, re-verify fails.
// 3. The merge rule is a no-op when there is nothing to merge, and
// replaces per-field (never a deep merge) when there is.
// 4. v1 and v2 manifests MUST NOT carry an apex block.
// 5. Legacy v1/v2 blobs with no apex still verify — moving things
// off-chain is additive, not a forced migration.
import test from "node:test";
import assert from "node:assert/strict";
import {
validateApex, effectiveRecords, verifyManifest, canonicalBytes, SUPPORTED_VERSIONS,
} from "../src/lib/manifest.js";
import { sha256, utf8ToBin, secp256k1, ripemd160, encodeCashAddress, binToBase64 } from "@bitauth/libauth";
// ---------- validateApex: shape ----------
test("validateApex: null and {} are both valid (nothing declared)", () => {
assert.equal(validateApex(null), null);
assert.equal(validateApex(undefined), null);
assert.equal(validateApex({}), null);
});
test("validateApex: good shapes for every field", () => {
assert.equal(validateApex({ s3: "bns/hermes/" }), null);
assert.equal(validateApex({ u: "https://example.com/" }), null);
assert.equal(validateApex({ h: "<h1>hi</h1>" }), null);
assert.equal(validateApex({ p: "https://backend.example/v1" }), null);
assert.equal(validateApex({ ip: "1.2.3.4" }), null);
assert.equal(validateApex({ ip: "1.2.3.4", tls: "a".repeat(64) }), null);
assert.equal(validateApex({ ip: "2001:db8::1" }), null);
});
test("validateApex: rejects unknown fields — stays strict so a typo isn't silently signed", () => {
assert.match(validateApex({ foo: "bar" }), /unknown field/);
assert.match(validateApex({ ip: "1.2.3.4", dns: {} }), /unknown field/); // dns belongs at manifest root, not apex
});
test("validateApex: u must be http(s)", () => {
assert.match(validateApex({ u: "ftp://x" }), /http\(s\) URL/);
assert.match(validateApex({ u: "javascript:alert(1)" }), /http\(s\) URL/);
});
test("validateApex: s3 must be under bns/, with no .. traversal", () => {
assert.match(validateApex({ s3: "other/hermes/" }), /bns\/<path>/);
assert.match(validateApex({ s3: "bns/../etc/passwd" }), /bns\/<path>/);
});
test("validateApex: ip must parse, tls must be 64 hex AND have an ip to pin", () => {
assert.match(validateApex({ ip: "not an ip" }), /not a recognisable/);
assert.match(validateApex({ ip: "1.2.3.4", tls: "short" }), /64-char/);
assert.match(validateApex({ tls: "a".repeat(64) }), /has no apex\.ip/);
});
test("validateApex: h larger than 8 KB is refused — inline h is for landing pages, not a full site", () => {
assert.equal(validateApex({ h: "x".repeat(8 * 1024) }), null);
assert.match(validateApex({ h: "x".repeat(8 * 1024 + 1) }), /larger than/);
});
// ---------- effectiveRecords: merge rule ----------
test("effectiveRecords: a null/empty manifest apex is a no-op merge", () => {
const chain = { s3: "bns/foo/", u: "https://x" };
assert.deepEqual(effectiveRecords(chain, null), chain);
assert.deepEqual(effectiveRecords(chain, undefined), chain);
assert.deepEqual(effectiveRecords(chain, {}), chain);
});
test("effectiveRecords: manifest wins per field; chain fills in the rest", () => {
const chain = { s3: "bns/old/", u: "https://old" };
const apex = { u: "https://new" };
// s3 falls through from chain; u is overridden by the manifest.
assert.deepEqual(effectiveRecords(chain, apex), { s3: "bns/old/", u: "https://new" });
});
test("effectiveRecords: merge is shallow — a manifest field REPLACES the chain field whole", () => {
const chain = { ip: "1.1.1.1", tls: "a".repeat(64) };
const apex = { ip: "2.2.2.2" };
// tls is NOT inherited: a new ip without a pin means the owner dropped the pin
// deliberately. A deep merge would silently keep an old pin against a new ip.
assert.deepEqual(effectiveRecords(chain, apex), { ip: "2.2.2.2", tls: "a".repeat(64) });
// But if the manifest apex also omits tls intentionally, the chain pin is still
// what shows — this is why callers that care about ip+tls must declare BOTH
// in the apex block, not just one. Documented at the merge call site.
});
test("effectiveRecords: null chainRecords is handled", () => {
assert.deepEqual(effectiveRecords(null, { u: "https://x" }), { u: "https://x" });
});
// ---------- signature machinery, end-to-end, against v3 ----------
const privKey = sha256.hash(utf8ToBin("manifest-apex-test-key"));
const pubKey = secp256k1.derivePublicKeyCompressed(privKey);
const ownerEnc = encodeCashAddress({ prefix: "bchtest", type: "p2pkh", payload: ripemd160.hash(sha256.hash(pubKey)) });
const OWNER = typeof ownerEnc === "string" ? ownerEnc : ownerEnc.address;
function sign(m) {
const digest = sha256.hash(utf8ToBin(canonicalBytes(m)));
const r = secp256k1.signMessageHashRecoverableCompact(privKey, digest);
const sig = new Uint8Array(65);
sig[0] = 27 + r.recoveryId + 4;
sig.set(r.signature, 1);
return { ...m, sig: binToBase64(sig) };
}
test("SUPPORTED_VERSIONS: v3 is announced so clients know apex is live", () => {
assert.ok(SUPPORTED_VERSIONS.includes(3), "v3 should be in SUPPORTED_VERSIONS");
});
test("verifyManifest: accepts a v3 manifest signed by the owner, with apex", () => {
const m = sign({
v: 3, name: "hermes.x", seq: 1,
apex: { s3: "bns/hermes/", u: "https://silentmode.st/hermes-x/" },
});
assert.deepEqual(verifyManifest(m, { name: "hermes.x", owner: OWNER }), { ok: true, seq: 1 });
});
test("verifyManifest: v3 with no apex block is still valid — v3 is a superset, not a demand", () => {
const m = sign({ v: 3, name: "hermes.x", seq: 1, hosts: { www: { path: "web" } } });
assert.equal(verifyManifest(m, { name: "hermes.x", owner: OWNER }).ok, true);
});
test("verifyManifest: apex block in a v2 manifest is refused — v2 does not know apex", () => {
const m = sign({ v: 2, name: "hermes.x", seq: 1, apex: { u: "https://x" } });
const out = verifyManifest(m, { name: "hermes.x", owner: OWNER });
assert.equal(out.ok, false);
assert.match(out.error, /apex needs v>=3/);
});
test("verifyManifest: apex in a v1 manifest is likewise refused", () => {
const m = sign({ v: 1, name: "hermes.x", seq: 1, apex: { u: "https://x" } });
assert.equal(verifyManifest(m, { name: "hermes.x", owner: OWNER }).ok, false);
});
test("verifyManifest: a malformed apex block is caught before the signature is trusted", () => {
const m = sign({ v: 3, name: "hermes.x", seq: 1, apex: { u: "javascript:alert(1)" } });
const out = verifyManifest(m, { name: "hermes.x", owner: OWNER });
assert.equal(out.ok, false);
assert.match(out.error, /apex\.u/);
});
test("verifyManifest: tampering with the apex after signing invalidates the whole manifest", () => {
const m = sign({ v: 3, name: "hermes.x", seq: 1, apex: { u: "https://trusted.example/" } });
const tampered = { ...m, apex: { u: "https://evil.example/" } };
assert.equal(verifyManifest(tampered, { name: "hermes.x", owner: OWNER }).ok, false);
});
test("verifyManifest: a v3 manifest cannot be replayed for a different name", () => {
const m = sign({ v: 3, name: "hermes.x", seq: 1, apex: { s3: "bns/hermes/" } });
const out = verifyManifest(m, { name: "someone-else.x", owner: OWNER });
assert.equal(out.ok, false);
assert.match(out.error, /name field/);
});
test("verifyManifest: an older v3 seq is refused — a published apex cannot be replayed over a newer one", () => {
const m = sign({ v: 3, name: "hermes.x", seq: 2, apex: { u: "https://old/" } });
const stale = verifyManifest(m, { name: "hermes.x", owner: OWNER, seenSeq: 7 });
assert.equal(stale.ok, false);
assert.match(stale.error, /stale manifest/);
});
test("verifyManifest: legacy v1 and v2 (no apex) still verify — moving things off chain is additive", () => {
const v1 = sign({ v: 1, name: "legacy.x", seq: 1, dns: { A: ["1.2.3.4"] } });
const v2 = sign({ v: 2, name: "legacy.x", seq: 1, hosts: { www: { path: "" } } });
assert.equal(verifyManifest(v1, { name: "legacy.x", owner: OWNER }).ok, true);
assert.equal(verifyManifest(v2, { name: "legacy.x", owner: OWNER }).ok, true);
});