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.
This commit is contained in:
parent
7bd87f2458
commit
23757e7a03
4 changed files with 295 additions and 15 deletions
|
|
@ -42,7 +42,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, subLabelsOf, hostOverride, hostAction, subFolderFor } from "./record-picker.js";
|
||||
import { verifyManifest } from "./manifest.js"; // flat at runtime: /opt/bns-gateway/ has no lib/
|
||||
import { verifyManifest, effectiveRecords } 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";
|
||||
|
|
@ -903,12 +903,15 @@ async function serve(res, name, reqPath, baseHref, req = null) {
|
|||
return res.end(blockPage(entry, refuse ? null : `${root}${u.pathname}${u.search}`));
|
||||
}
|
||||
}
|
||||
const r = entry.records;
|
||||
// A subdomain may be governed by the owner's signed manifest: pointed at a
|
||||
// different folder, redirected, or switched off. Only fetched when the
|
||||
// request IS a subdomain, so the apex never pays for the lookup.
|
||||
// The signed manifest can govern BOTH the apex (via `apex` block, v3+) and
|
||||
// every subdomain (via `hosts`). Fetched unconditionally so the apex gets
|
||||
// the same off-chain editing surface subdomains already have — the wallet
|
||||
// signature verifies against the on-chain owner, so the trust boundary is
|
||||
// the key, not the chain. The chain `s3` pointer still anchors where the
|
||||
// manifest file itself lives (read from entry.records.s3, not from the
|
||||
// merged view below).
|
||||
const isSub = isSubdomainOf(name, entry.name);
|
||||
const manifest = isSub ? await manifestFor(entry) : null;
|
||||
const manifest = await manifestFor(entry);
|
||||
if (isSub) {
|
||||
const act = hostAction(hostOverride(manifest, name, entry.name));
|
||||
if (act?.kind === "block") {
|
||||
|
|
@ -920,6 +923,11 @@ async function serve(res, name, reqPath, baseHref, req = null) {
|
|||
return res.end();
|
||||
}
|
||||
}
|
||||
// Merge chain apex records with manifest.apex — manifest wins per field,
|
||||
// chain is the fallback for any field the manifest does not declare. Legacy
|
||||
// names with no manifest (or a v1/v2 manifest) see no behaviour change: the
|
||||
// merge is a no-op when there is nothing to merge.
|
||||
const r = effectiveRecords(entry.records, manifest?.apex);
|
||||
// `pickRecord` is the single source of truth for record priority; keep this
|
||||
// call and the inline copy in TheseusNavigator/main.js:serveBns in step.
|
||||
const pick = pickRecord(r, { isSubdomain: isSub });
|
||||
|
|
@ -1668,6 +1676,11 @@ const server = http.createServer(async (req, res) => {
|
|||
// subsequent GET /api/dns/<name> won't accept a replay under any
|
||||
// circumstances (Sia's read-consistency + our seq cache both agree).
|
||||
_dnsSeqCache.set(name, manifest.seq);
|
||||
// Bust the serve() cache so the next /bns/<name>/ request sees the
|
||||
// new apex block immediately rather than waiting out the 60s TTL.
|
||||
// Without this, an owner publishing an apex record swap would still
|
||||
// see the old content for a minute, which looks like a broken write.
|
||||
_manifestCache.delete(name);
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ ok: true, name, seq: manifest.seq, sia_key: manifestKey, bytes: total }));
|
||||
} catch (e) {
|
||||
|
|
@ -2215,7 +2228,7 @@ const server = http.createServer(async (req, res) => {
|
|||
// an `ip` record rather than proxying through here — need the routing
|
||||
// half too, and must not re-implement manifest verification to get it.
|
||||
const hostAct = askedHost ? hostAction(hostEntry) : null;
|
||||
// Verified. Cache the seq and hand back the DNS block.
|
||||
// Verified. Cache the seq and hand back the DNS block + apex.
|
||||
_dnsSeqCache.set(name, manifest.seq);
|
||||
res.setHeader("Cache-Control", "public, max-age=30");
|
||||
res.writeHead(200);
|
||||
|
|
@ -2230,6 +2243,11 @@ const server = http.createServer(async (req, res) => {
|
|||
// A host that declares none inherits the apex, which is how every
|
||||
// other kind of record behaves on BNS.
|
||||
dns: hostDns ?? manifest.dns ?? {},
|
||||
// The apex content records (h/u/s3/ip/tls/p) from the signed manifest
|
||||
// (v3+). Clients MERGE this over their chain-fed `records` with the
|
||||
// manifest winning per field, then run their own pickRecord. Omitted
|
||||
// keys mean "fall back to chain". `null` = no manifest apex at all.
|
||||
apex: manifest.apex ?? null,
|
||||
verified_owner: entry.owner,
|
||||
}, null, 2));
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,15 @@
|
|||
//
|
||||
// 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.
|
||||
// a v1 blob stays valid forever and needs no migration. v3 (2026-10-21) adds
|
||||
// `apex` — the apex content records h/u/s3/ip/tls/p, which used to live only
|
||||
// on chain. The owner's wallet signature is the trust boundary, verified
|
||||
// against the current NFT holder; moving these off-chain costs zero satoshis
|
||||
// per edit. The chain `s3` pointer still anchors the manifest file itself.
|
||||
|
||||
import { sha256, ripemd160, secp256k1, utf8ToBin, base64ToBin, encodeCashAddress } from "@bitauth/libauth";
|
||||
|
||||
export const SUPPORTED_VERSIONS = [1, 2];
|
||||
export const SUPPORTED_VERSIONS = [1, 2, 3];
|
||||
|
||||
// ---- canonical form -------------------------------------------------------
|
||||
// Sorted keys at every level, no insignificant whitespace, null/undefined
|
||||
|
|
@ -40,9 +44,8 @@ export const canonicalBytes = (manifest) => JSON.stringify(canonicalize(withoutK
|
|||
|
||||
// ---- 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.
|
||||
// "a.b", or "*" for everything not named explicitly. The apex itself is
|
||||
// governed by the top-level `apex` block (v3+), signed by the same wallet.
|
||||
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;
|
||||
|
|
@ -84,6 +87,82 @@ export function validateHosts(hosts) {
|
|||
return null;
|
||||
}
|
||||
|
||||
// ---- apex content records (v3) --------------------------------------------
|
||||
// The content records that used to live only on chain. Field semantics match
|
||||
// the chain exactly so a resolver can swap chain-fed records for manifest-fed
|
||||
// records without any other change. Fields are optional; omit a field to say
|
||||
// "nothing here" — the resolver's record picker (h > s3 > ip > p > u) then
|
||||
// skips to the next populated one. An apex block with no fields is a no-op.
|
||||
//
|
||||
// Size caps are DELIBERATELY conservative:
|
||||
// - `h` is "inline tiny landing page" (on chain it was ≤156 bytes; off chain
|
||||
// we allow 8 KB so a one-pager fits). The gateway + Theseus render it in
|
||||
// a sandboxed response under the name's own origin, so make the author
|
||||
// think twice before stuffing a full SPA in here.
|
||||
// - `u`/`p` are http(s) URLs under 2 KB; same shape as hosts.u.
|
||||
// - `s3` is a Sia bucket path, must stay inside `bns/`.
|
||||
// - `ip` is one address (v4 or v6, strict), with optional `tls` pin.
|
||||
const S3_KEY_RE = /^bns\/[A-Za-z0-9][A-Za-z0-9._\-/]{0,254}$/;
|
||||
const IPV4_RE = /^(25[0-5]|2[0-4]\d|[01]?\d?\d)(\.(25[0-5]|2[0-4]\d|[01]?\d?\d)){3}$/;
|
||||
const IPV6_RE = /^[0-9a-fA-F:]{2,45}$/; // intentionally loose; real check is "does it parse"; browsers do the rest
|
||||
const TLS_FPR_RE = /^[0-9a-f]{64}$/;
|
||||
const URL_RE = /^https?:\/\/\S+$/i;
|
||||
const MAX_H_BYTES = 8 * 1024;
|
||||
const MAX_URL_LEN = 2048;
|
||||
|
||||
/**
|
||||
* Validate the `apex` block. Returns null when fine, an error string when not.
|
||||
* Shape mirrors on-chain records exactly so the resolver can take `apex` as
|
||||
* drop-in input to pickRecord without translation.
|
||||
*/
|
||||
export function validateApex(apex) {
|
||||
if (apex === undefined || apex === null) return null;
|
||||
if (!isPlainObject(apex)) return "apex must be an object";
|
||||
const allowed = new Set(["h", "u", "s3", "ip", "tls", "p"]);
|
||||
for (const k of Object.keys(apex)) {
|
||||
if (!allowed.has(k)) return `apex: unknown field "${k}" (allowed: ${[...allowed].join(", ")})`;
|
||||
}
|
||||
if (apex.h !== undefined) {
|
||||
if (typeof apex.h !== "string") return "apex.h must be a string";
|
||||
if (utf8ToBin(apex.h).length > MAX_H_BYTES) return `apex.h is larger than ${MAX_H_BYTES} bytes`;
|
||||
}
|
||||
if (apex.u !== undefined) {
|
||||
if (typeof apex.u !== "string" || apex.u.length > MAX_URL_LEN) return `apex.u must be an http(s) URL under ${MAX_URL_LEN} chars`;
|
||||
if (!URL_RE.test(apex.u)) return "apex.u must be an http(s) URL";
|
||||
}
|
||||
if (apex.p !== undefined) {
|
||||
if (typeof apex.p !== "string" || apex.p.length > MAX_URL_LEN) return `apex.p must be an http(s) URL under ${MAX_URL_LEN} chars`;
|
||||
if (!URL_RE.test(apex.p)) return "apex.p must be an http(s) URL";
|
||||
}
|
||||
if (apex.s3 !== undefined) {
|
||||
if (typeof apex.s3 !== "string") return "apex.s3 must be a string";
|
||||
if (!S3_KEY_RE.test(apex.s3)) return `apex.s3 must look like "bns/<path>" with path-safe characters`;
|
||||
if (apex.s3.includes("..")) return "apex.s3 may not contain \"..\"";
|
||||
}
|
||||
if (apex.ip !== undefined) {
|
||||
if (typeof apex.ip !== "string") return "apex.ip must be a string";
|
||||
if (!IPV4_RE.test(apex.ip) && !IPV6_RE.test(apex.ip)) return `apex.ip is not a recognisable IPv4 or IPv6 address`;
|
||||
}
|
||||
if (apex.tls !== undefined) {
|
||||
if (typeof apex.tls !== "string" || !TLS_FPR_RE.test(apex.tls)) return "apex.tls must be a 64-char lowercase hex sha256 fingerprint";
|
||||
if (apex.ip === undefined) return "apex.tls has no apex.ip to pin — give it an ip or drop the pin";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the effective record set a resolver should feed to `pickRecord`.
|
||||
* Manifest apex wins per-field over chain; omit a field in the manifest to
|
||||
* fall back to the chain value. Chain `s3` is also the manifest ANCHOR —
|
||||
* callers that need it for that purpose should read it from the chain
|
||||
* record, not from this merged view.
|
||||
*/
|
||||
export function effectiveRecords(chainRecords, manifestApex) {
|
||||
const base = isPlainObject(chainRecords) ? chainRecords : {};
|
||||
const over = isPlainObject(manifestApex) ? manifestApex : {};
|
||||
return { ...base, ...over };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a parsed manifest against the chain.
|
||||
*
|
||||
|
|
@ -111,6 +190,13 @@ export function verifyManifest(manifest, { name, owner, seenSeq, fallbackPrefix
|
|||
}
|
||||
const hostErr = validateHosts(manifest.hosts);
|
||||
if (hostErr) return { ok: false, error: hostErr };
|
||||
// v3 adds `apex`. A v1/v2 manifest with no `apex` block is still valid.
|
||||
// A manifest claiming v<3 but carrying an apex block is a shape error.
|
||||
if (manifest.apex !== undefined && manifest.v < 3) {
|
||||
return { ok: false, error: `manifest carries apex block but claims v=${manifest.v} (apex needs v>=3)` };
|
||||
}
|
||||
const apexErr = validateApex(manifest.apex);
|
||||
if (apexErr) return { ok: false, error: apexErr };
|
||||
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" };
|
||||
|
||||
|
|
|
|||
|
|
@ -1164,9 +1164,10 @@ export async function setTldRecordsWithBuiltInWallet(client, {
|
|||
* See Decentralized.DNS/DESIGN-signed-records-manifest.md.
|
||||
*/
|
||||
export async function signRecordsManifest(wallet, manifest) {
|
||||
// v1 carried apex `dns` only; v2 adds `hosts`. Both sign identically, so the
|
||||
// signer just has to not refuse the newer one.
|
||||
if (!manifest || (manifest.v !== 1 && manifest.v !== 2)) throw new Error("manifest.v must be 1 or 2");
|
||||
// v1 carried apex `dns` only; v2 adds `hosts`; v3 adds `apex` (the apex
|
||||
// content records h/u/s3/ip/tls/p, which used to live only on chain). All
|
||||
// three sign identically — the signer just has to not refuse the newer one.
|
||||
if (!manifest || (manifest.v !== 1 && manifest.v !== 2 && manifest.v !== 3)) throw new Error("manifest.v must be 1, 2 or 3");
|
||||
if (!manifest.name) throw new Error("manifest.name is required");
|
||||
if (typeof manifest.seq !== "number" || manifest.seq < 0) throw new Error("manifest.seq must be a non-negative integer");
|
||||
const withoutSig = { ...manifest };
|
||||
|
|
|
|||
175
test/manifest-apex.test.mjs
Normal file
175
test/manifest-apex.test.mjs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// 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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue