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.
175 lines
8.3 KiB
JavaScript
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);
|
|
});
|