argus/test/sole-certificate.test.mjs

164 lines
9.3 KiB
JavaScript
Raw Permalink Normal View History

// "The certificate must be the only token of its category."
//
// Ownership of a name is proven by holding a token of its category: resolvers
// accept an UPD when some output carries it. That is only sound if a
// registration's certificate is the ONLY such token there is or can ever be.
// These tests pin the rule that guarantees it, and the attack it stops: a
// seller who registers a name while keeping a hidden second token, sells the
// certificate, and rewrites the name afterwards.
//
// Both JavaScript reducers run every case and must agree.
import test from "node:test";
import assert from "node:assert/strict";
import { soleCertificate, buildIndexFromSnapshot, fetchTldMap } from "../src/lib/resolver-web.js";
import { buildTldSnapshot } from "../src/lib/tld-index.js";
import * as legacy from "../src/lib/bns.js";
const hex = (s) => Buffer.from(s, "utf8").toString("hex");
const txid = (n) => String(n).padStart(4, "0").repeat(16);
const opReturn = (payload) => {
const data = Buffer.from("BNS1" + JSON.stringify(payload), "utf8");
const push = data.length <= 75 ? Buffer.from([data.length]) : Buffer.from([0x4c, data.length]);
return "6a" + Buffer.concat([push, data]).toString("hex");
};
const CAT = "c".repeat(64);
const token = (category, commitment, capability = "none", amount = "0") => ({ category, amount, nft: { capability, commitment: hex(commitment) } });
const out = (n, tokenData, address = "bchtest:owner") => ({ n, value: 0.00001, scriptPubKey: { hex: "76a914" + String(n + 1).repeat(40).slice(0, 40) + "88ac", address }, ...(tokenData ? { tokenData } : {}) });
const data = (n, payload) => ({ n, value: 0, scriptPubKey: { hex: opReturn(payload) } });
const GENESIS = { txid: CAT, vout: 0 };
// A registration transaction, with knobs for everything the rule looks at.
function reg(id, name, { op = "REG", vin = [GENESIS], cert = token(CAT, name), extra = [] } = {}) {
return { txid: txid(id), vin, vout: [out(0, cert), data(1, { o: op, n: name, r: { u: "https://honest.example" } }), ...extra.map((t, i) => out(2 + i, t, "bchtest:hidden"))] };
}
const upd = (id, name, r, tokenData, spends) => ({ txid: txid(id), vin: spends, vout: [out(0, tokenData, "bchtest:attacker"), data(1, { o: "UPD", n: name, r })] });
async function names(entries) {
const history = entries.map(([tx, height]) => ({ tx_hash: tx.txid, height }));
const txs = Object.fromEntries(entries.map(([tx]) => [tx.txid, tx]));
const web = buildIndexFromSnapshot({ snapshot: { history, txs } });
const old = await legacy.buildIndex({ getHistory: async () => history.map((h) => ({ ...h })), getRawTransactionObject: async (id) => txs[id] }, { cosignGate: false });
const shape = (idx) => [...idx].map(([k, e]) => `${k}:${JSON.stringify(e.records)}`).sort();
assert.deepEqual(shape(old), shape(web), "bns.js and resolver-web.js disagree");
return web;
}
test("an ordinary registration passes", async () => {
const tx = reg(1, "plain.x");
assert.equal(soleCertificate(tx, tx.vout[0]), true);
assert.ok((await names([[tx, 100]])).has("plain.x"));
// verbose transactions from different servers spell "no capability" and "no amount" differently
for (const cert of [{ category: CAT, nft: { commitment: hex("plain.x") } }, { category: CAT, amount: 0, nft: { capability: "", commitment: hex("plain.x") } }]) {
const t = reg(1, "plain.x", { cert });
assert.equal(soleCertificate(t, t.vout[0]), true);
}
});
const CASES = [
["a second, minting token of the category is kept", { extra: [token(CAT, "", "minting")] }],
["a second plain token of the category is kept", { extra: [token(CAT, "plain.x")] }],
["fungible tokens of the category are issued alongside", { extra: [{ category: CAT, amount: "1000" }] }],
["the certificate itself can mint", { cert: token(CAT, "plain.x", "minting") }],
["the certificate itself can be rewritten", { cert: token(CAT, "plain.x", "mutable") }],
["the certificate carries a fungible amount", { cert: token(CAT, "plain.x", "none", "5") }],
["the category was not created by this transaction", { vin: [{ txid: "d".repeat(64), vout: 0 }] }],
["the category's outpoint is spent, but not at index 0", { vin: [{ txid: CAT, vout: 1 }] }],
["no inputs listed at all", { vin: [] }],
];
for (const [what, knobs] of CASES) {
test(`a registration is void when ${what}`, async () => {
const tx = reg(1, "plain.x", knobs);
assert.equal(soleCertificate(tx, tx.vout[0]), false);
assert.equal((await names([[tx, 100]])).has("plain.x"), false);
});
}
test("the attack: sell the certificate, keep a hidden token, rewrite the name", async () => {
// The seller registers with a minting token tucked into another output.
const tainted = reg(1, "resold.x", { extra: [token(CAT, "", "minting")] });
// Later — after selling the certificate — they publish an update from the hidden token.
const hijack = upd(2, "resold.x", { u: "https://phishing.example" }, token(CAT, "", "minting"), [{ txid: txid(1), vout: 2 }]);
const idx = await names([[tainted, 100], [hijack, 200]]);
assert.equal(idx.has("resold.x"), false, "the tainted registration never existed, so there is nothing to hijack — or to sell");
// And an honest registration cannot be attacked this way at all: its category has one token, the buyer's.
const honest = reg(3, "honest.x");
const forged = upd(4, "honest.x", { u: "https://phishing.example" }, token("e".repeat(64), "honest.x"), [{ txid: "f".repeat(64), vout: 0 }]);
const idx2 = await names([[honest, 100], [forged, 200]]);
assert.deepEqual(idx2.get("honest.x").records, { u: "https://honest.example" });
});
test("a void registration does not block an honest one for the same name", async () => {
const tainted = reg(1, "wanted.x", { extra: [token(CAT, "", "minting")] });
const CAT2 = "a".repeat(64);
const honest = reg(2, "wanted.x", { vin: [{ txid: CAT2, vout: 0 }], cert: token(CAT2, "wanted.x") });
const idx = await names([[tainted, 100], [honest, 101]]);
assert.equal(idx.get("wanted.x").category, CAT2);
});
test("TLD certificates obey the same rule, in every reducer that reads them", async () => {
const good = reg(1, "zz", { op: "TREG" });
const CAT2 = "a".repeat(64);
const bad = reg(2, "yy", { op: "TREG", vin: [{ txid: CAT2, vout: 0 }], cert: token(CAT2, "yy"), extra: [token(CAT2, "", "minting")] });
const history = [{ tx_hash: good.txid, height: 5 }, { tx_hash: bad.txid, height: 6 }];
const txs = new Map([[good.txid, good], [bad.txid, bad]]);
const el = { call: async (m, [a]) => (m === "blockchain.scripthash.get_history" ? history.map((h) => ({ ...h })) : txs.get(a)) };
assert.deepEqual([...(await fetchTldMap(el)).keys()], ["zz"]);
const snap = buildTldSnapshot({ history, txs, beacon: "x" });
assert.deepEqual((snap.tlds ?? []).map((t) => t.tld), ["zz"]);
});
// ---------- one message per transaction ----------
import { onlyPayload } from "../src/lib/resolver-web.js";
test("a transaction with two protocol messages carries none, in every reducer", async () => {
const honest = reg(1, "single.x");
assert.deepEqual(onlyPayload(honest.vout), { o: "REG", n: "single.x", r: { u: "https://honest.example" } });
assert.deepEqual(legacy.onlyPayload(honest.vout), onlyPayload(honest.vout));
// The escrow attack at the resolver: the agreed registration at output 1,
// and a second message after it that used to win.
const second = [
{ o: "REG", n: "single.x", r: { u: "https://deliverer.example" } },
{ o: "UPD", n: "single.x", r: {} },
{ o: "REG", n: "elsewhere.x", r: {} },
];
for (const payload of second) {
const tx = { ...honest, vout: [...honest.vout, data(2, payload)] };
assert.equal(onlyPayload(tx.vout), null);
assert.equal(legacy.onlyPayload(tx.vout), null);
const idx = await names([[tx, 100]]);
assert.equal(idx.size, 0, `nothing registers from a transaction that says two things (${payload.o} ${payload.n})`);
}
// Other data outputs are nobody's business: only BNS messages are counted.
const withMemo = { ...honest, vout: [...honest.vout, { n: 2, value: 0, scriptPubKey: { hex: "6a0568656c6c6f" } }] };
assert.ok((await names([[withMemo, 100]])).has("single.x"));
// TLD certificates and the blocklist read through the same function.
const t = reg(2, "zz", { op: "TREG" });
const doubled = { ...t, vout: [...t.vout, data(2, { o: "TREG", n: "yy", r: {} })] };
const history = [{ tx_hash: doubled.txid, height: 5 }];
const txs = new Map([[doubled.txid, doubled]]);
const el = { call: async (m, [a]) => (m === "blockchain.scripthash.get_history" ? history.map((h) => ({ ...h })) : txs.get(a)) };
assert.equal((await fetchTldMap(el)).size, 0);
assert.deepEqual((buildTldSnapshot({ history, txs, beacon: "x" }).tlds ?? []).length, 0);
});
test("what counts as a message is the same everywhere", () => {
const honest = reg(1, "single.x");
const plus = (...payloads) => [...honest.vout, ...payloads.map((p, i) => data(2 + i, p))];
for (const read of [onlyPayload, legacy.onlyPayload]) {
// list entries are messages too, though no name reducer acts on them
assert.equal(read(plus({ o: "BLK", n: "single.x", c: "scam" })), null);
assert.equal(read(plus({ o: "RPT", n: ["a.x", "b.x"] })), null);
// malformed or unknown ones are not messages, and so do not void the transaction
assert.equal(read(plus({ o: "UPD", n: 5 }, { o: "XYZ", n: "single.x" }, { o: "BLK", n: ["a.x"] }))?.o, "REG");
}
});