Ownership of a name is proven by holding a token of its category: an update is accepted when some output carries it. Nothing required that the certificate be the only such token. Whoever registered a name could mint a second one in the same transaction, a minting token for instance, sell the certificate on the market, and later publish an update the resolvers would accept. The buyer would hold the certificate and lose the name. A registration now counts only if its category is created by that very transaction, exactly one output carries it, and that output is an NFT with no capability and no fungible amount. Nothing meeting this can ever have a sibling. The same holds for TLD certificates, whose category proves the right to set a TLD's policy and to co-sign registrations under it. One definition, in resolver-web.js; bns.js and the TLD snapshot builder call it. Every valid registration on chipnet already satisfied the rule: the live registry is the same 71 names and 21 TLDs before and after. Found while writing the delivery escrow, which had to forbid exactly this.
113 lines
6.7 KiB
JavaScript
113 lines
6.7 KiB
JavaScript
// "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"]);
|
|
});
|