// A stand-in for the BNS gateway, for testing the publishing path. // // It implements exactly one thing, and implements it the way the real gateway // does: the signature check on `PUT /api/site//`. The verification // below is transcribed from Argus/src/gateway/public-gateway.mjs — same // envelope, same digest, same recovery, same comparison against the name's // owner — so a request this accepts is one the real gateway accepts, and a // request it rejects would have been rejected there too. // // That is the whole point. Publishing cannot be tested end to end without a // registered name and a funded key, but the part that actually breaks — the // bytes being signed — can be checked against the real verifier. // // node tests/mock-gateway.mjs --owner bchtest:qq… [--port 8799] // // Needs @bitauth/libauth resolvable from this file — deliberately, because // verifying with the same library the real gateway uses is what makes this // worth running at all. In the Silent Mode monorepo, copy it next to // Argus/package.json and run it there; standalone, `npm i @bitauth/libauth` // in this directory. // // Uploads are kept in memory and listed on GET /api/site/. Nothing is // written to disk and nothing leaves the machine. import { createServer } from "node:http"; import { createHash } from "node:crypto"; import { secp256k1, sha256, ripemd160, encodeCashAddress, base64ToBin, utf8ToBin } from "@bitauth/libauth"; const args = process.argv.slice(2); const argOf = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 && args[i + 1] ? args[i + 1] : fallback; }; const OWNER = argOf("owner", ""); const PORT = Number(argOf("port", "8799")); if (!OWNER) { console.error("usage: node tests/mock-gateway.mjs --owner [--port 8799]"); process.exit(2); } const PREFIX = OWNER.split(":")[0] || "bchtest"; /** path -> {bytes, type} */ const store = new Map(); let accepted = 0; let rejected = 0; /** * The real gateway's check, transcribed. * * digest = sha256("BNS-SITE1\n\n\n\n") * then recover the compressed public key from the 65-byte signature and * compare the address it controls with the name's current on-chain owner. */ function verify({ name, path, body, ts, sigB64 }) { if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) { return "x-bns-ts missing or more than 10 minutes off"; } let sig; try { sig = base64ToBin(sigB64); } catch { return "x-bns-sig is not valid base64"; } if (sig.length !== 65) return `x-bns-sig must decode to 65 bytes (got ${sig.length})`; const bodyHex = createHash("sha256").update(body).digest("hex"); const digest = sha256.hash(utf8ToBin(`BNS-SITE1\n${name}\n${path}\n${bodyHex}\n${ts}`)); const recovered = secp256k1.recoverPublicKeyCompressed(sig.slice(1), (sig[0] - 27) & 3, digest); if (typeof recovered === "string") return `signature recovery failed: ${recovered}`; const encoded = encodeCashAddress({ prefix: PREFIX, type: "p2pkh", payload: ripemd160.hash(sha256.hash(recovered)), }); const derived = typeof encoded === "string" ? encoded : encoded?.address ?? ""; if (derived !== OWNER) { return `signature does not match current on-chain NFT owner (derived ${derived})`; } return null; } const server = createServer((req, res) => { const json = (code, obj) => { res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": "*" }); res.end(JSON.stringify(obj)); }; const url = new URL(req.url, "http://x"); if (!url.pathname.startsWith("/api/site/")) { return json(404, { error: "only /api/site is implemented" }); } const rest = url.pathname.slice("/api/site/".length); const slash = rest.indexOf("/"); const name = decodeURIComponent(slash < 0 ? rest : rest.slice(0, slash)); const path = slash < 0 ? "" : decodeURIComponent(rest.slice(slash + 1)); if (req.method === "GET" && !path) { return json(200, { name, prefix: `bns/${name}/`, files: [...store].map(([p, v]) => ({ path: p, size: v.bytes.length, modified: null })), }); } if (req.method === "GET") { const hit = store.get(path); if (!hit) return json(404, { error: "not found" }); res.writeHead(200, { "content-type": hit.type }); return res.end(hit.bytes); } const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { const body = Buffer.concat(chunks); const problem = verify({ name, path, body: req.method === "PUT" ? body : Buffer.alloc(0), ts: Number(req.headers["x-bns-ts"] || 0), sigB64: String(req.headers["x-bns-sig"] || ""), }); if (problem) { rejected++; console.log(` reject ${req.method} ${path} — ${problem}`); return json(problem.includes("owner") ? 403 : 401, { error: problem }); } if (req.method === "DELETE") { store.delete(path); accepted++; console.log(` delete ${path}`); return json(200, { ok: true, name, path, deleted: true }); } store.set(path, { bytes: body, type: String(req.headers["content-type"] || "") }); accepted++; console.log(` accept ${path} (${body.length} bytes)`); return json(200, { ok: true, name, path, bytes: body.length, sia_key: `bns/${name}/${path}` }); }); }); process.on("SIGTERM", () => process.exit(0)); process.on("SIGINT", () => { console.log(`\naccepted ${accepted}, rejected ${rejected}, holding ${store.size} files`); process.exit(0); }); server.listen(PORT, "127.0.0.1", () => { console.log(`mock gateway on http://127.0.0.1:${PORT}, owner ${OWNER}`); });