154 lines
5.6 KiB
JavaScript
154 lines
5.6 KiB
JavaScript
|
|
// 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/<name>/<path>`. 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/<name>. 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 <cashaddress> [--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<name>\n<path>\n<sha256hex(body)>\n<ts>")
|
||
|
|
* 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}`);
|
||
|
|
});
|