Files
archy/docker/dojobay/server/apply-signed-payload.ts
T

182 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
// =============================================================================
// The Dojo Bay — apply operator-signed pairing payload updates.
//
// Takes signed blocks an operator has sent out of band (a re-signed pairing
// payload, a new apikey, a moved onion) and applies them to the store, doing
// exactly what the submission gate would have done had they gone through the
// site:
//
// 1. the block must parse, and its signature must be valid over its own text;
// 2. the BIP47 code inside the signed text must derive the signing address;
// 3. that code must already own a record here, which is how the update is
// matched to a listing;
// 4. the payload written is the one INSIDE the signed block, so what is
// published is exactly what the operator attested to.
//
// The record's id is never changed, so its reliability history survives. Status
// is left alone: an approved listing stays approved, a pending one stays pending.
//
// Usage, on the box:
// cd /var/www/dojobay/server
// node apply-signed-payload.ts blocks/*.txt # dry run
// sudo systemctl stop dojobay-server.service
// node apply-signed-payload.ts --apply blocks/*.txt
// sudo systemctl start dojobay-server.service
// node audit-signed.mjs
//
// Each file holds one signed block. `--id <record-id>` pins the target when a
// payment code owns more than one listing. As with fix-payload-version, --apply
// refuses to run while the service is up, because store.ts holds the store in
// memory as a single writer and would overwrite the edit.
// =============================================================================
import { readFile, writeFile, rename, copyFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
// canonicalPairing is imported, never reimplemented: this tool must accept
// exactly what the submission gate accepts, and a second definition of the
// canonical message would diverge silently. server/selftest.mjs enforces it.
import { parseSignedBlock, verifySignedPayload, notificationAddresses, repairSignedBlock, canonicalPairing } from "./crypto.ts";
import type { StoreRecord } from "../types.js";
const argv = process.argv.slice(2);
const APPLY = argv.includes("--apply");
const FORCE = argv.includes("--force");
const idFlag = argv.indexOf("--id");
const PINNED_ID = idFlag >= 0 ? argv[idFlag + 1] : null;
const FILES = argv.filter((a, i) =>
!a.startsWith("--") && !(idFlag >= 0 && i === idFlag + 1));
const DIR = process.env.SERVER_DATA_DIR
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
const FILE = path.join(DIR, "store.json");
if (!FILES.length) {
console.error("Usage: node apply-signed-payload.ts [--apply] [--id <record-id>] <file>…\n" +
"Each file contains one BEGIN BITCOIN SIGNED MESSAGE block.");
process.exit(2);
}
if (APPLY && !FORCE) {
let active = "";
try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); }
catch (e: any) { active = (e.stdout || "").trim(); }
if (active === "active") {
console.error("REFUSING: dojobay-server.service is running.\n" +
"The store is held in memory by the server and would overwrite this edit.\n" +
" sudo systemctl stop dojobay-server.service\n" +
" node apply-signed-payload.ts --apply <files…>\n" +
" sudo systemctl start dojobay-server.service");
process.exit(2);
}
}
const doc = JSON.parse(await readFile(FILE, "utf8"));
const records: StoreRecord[] = Object.values(doc.submissions || {});
interface Planned { file: string; rec: StoreRecord; payload: any; signed: string; before: string; after: string; note: string | null }
const planned: Planned[] = [];
const refused: [string, string][] = [];
for (const file of FILES) {
let signed: string;
try { signed = await readFile(file, "utf8"); }
catch (e: any) { refused.push([file, "cannot read: " + e.message]); continue; }
// Copying a block through chat, a form or a mail client routinely eats the
// blank line before the BIP47 line, which the signature covers. Repair it if
// a reconstruction verifies cryptographically; nothing is taken on trust.
let note: string | null = null;
const repaired = repairSignedBlock(signed);
if (repaired) { signed = repaired.block; note = repaired.note; }
const parsed = parseSignedBlock(signed);
if (!parsed) { refused.push([file, "not a recognisable signed block"]); continue; }
if (!parsed.paymentCode) { refused.push([file, "the signed text has no BIP47 line, so it cannot be matched to an operator"]); continue; }
// The payload published is the one inside the signed block, never a
// hand-copied version of it.
let payload: any;
try { payload = JSON.parse(parsed.pairingText); }
catch { refused.push([file, "the signed text is not a bare pairing JSON"]); continue; }
if (!payload?.pairing?.url || !payload?.pairing?.type) {
refused.push([file, "the signed payload has no pairing.url/type"]); continue;
}
const addrs = notificationAddresses(parsed.paymentCode);
const v = verifySignedPayload({
signedText: signed,
expectedMessage: canonicalPairing(payload),
expectedAddress: addrs,
});
if (!v.ok) { refused.push([file, v.error]); continue; }
const owned = records.filter((r) => (r.paymentCodes || []).includes(parsed.paymentCode!));
const target = PINNED_ID ? owned.find((r) => r.id === PINNED_ID) : (owned.length === 1 ? owned[0] : undefined);
if (!owned.length) {
refused.push([file, `signature is valid, but ${parsed.paymentCode.slice(0, 12)}… owns no record here`]); continue;
}
if (!target) {
refused.push([file, `that code owns ${owned.length} records (${owned.map((r) => r.id).join(", ")}); re-run with --id`]); continue;
}
planned.push({
file, rec: target, payload, signed: signed.trim(), note,
before: target.payload?.pairing?.url || "(none)",
after: payload.pairing.url,
});
}
console.log(`Store: ${FILE}`);
console.log(`Blocks read: ${FILES.length}\n`);
if (planned.length) {
console.log(`Will update (${planned.length}):`);
for (const p of planned) {
console.log(` ${p.rec.id} (${p.rec.status}) from ${path.basename(p.file)}`);
console.log(` url ${p.before}`);
console.log(` -> ${p.after}`);
const bv = p.rec.payload?.pairing?.version, av = p.payload.pairing.version;
if (bv !== av) console.log(` version ${bv || "(none)"} -> ${av || "(none)"}`);
if (!p.rec.signed) console.log(" (record was UNSIGNED; it gains a verified signature)");
if (p.note) console.log(` note: ${p.note}, and the repaired block verifies`);
}
console.log("");
}
if (refused.length) {
console.log(`Refused (${refused.length}):`);
for (const [f, why] of refused) console.log(` ${path.basename(f)}: ${why}`);
console.log("");
}
if (!planned.length) { console.log("Nothing to apply."); process.exit(refused.length ? 1 : 0); }
if (!APPLY) {
console.log("DRY RUN — nothing written. Re-run with --apply (service stopped) to make these changes.");
process.exit(0);
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backup = `${FILE}.bak-${stamp}`;
await copyFile(FILE, backup);
const nowIso = new Date().toISOString();
for (const p of planned) {
const rec = doc.submissions[p.rec.id];
rec.payload = p.payload; // exactly what was signed
rec.signed = p.signed;
rec.updated_at = nowIso;
}
// A temporary name no other writer can take; see build-public.ts. This tool
// refuses to run while the service holds the store, so a collision needs two
// maintenance tools at once, which is exactly the case nobody plans for.
const tmp = `${FILE}.${process.pid}.tmp`;
await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n");
await rename(tmp, FILE);
console.log(`Backup written: ${backup}`);
console.log(`Applied ${planned.length} update(s).`);
console.log("Start the service again, then run audit-signed.mjs; each updated record\n" +
"should now read VERIFIED. The published dojos.json follows on the next\n" +
"updater cycle, or immediately if you run build-public.mjs.");
process.exit(refused.length ? 1 : 0);