114 lines
6.7 KiB
JavaScript
114 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"]);
|
||
|
|
});
|