// The signed peer-to-peer wire protocol: HMAC-SHA256 over a canonical string, // both ways. // // request canonical: timestamp, nonce, METHOD, path, normalized target host, // sha256hex(raw body bytes) -- joined by "\n" // response canonical: "resp", the REQUEST's nonce, status, sha256hex(body // bytes) -- binds a response to exactly one request (no // replay of an old response); the "resp" label means a // response signature can never pass as a request one // // Header names and the request Content-Type are per plugin (peerServer / // peerClient take them); the canonical contains no header names. const crypto = require("crypto"); const { sha256Hex, constantTimeEqual } = require("./hash"); const HMAC_ALGORITHM = "sha256"; const SECRET_BYTES = 32; const NONCE_BYTES = 16; const SKEW_TOLERANCE_MS = 5 * 60 * 1000; // The acceptance window is 2x SKEW wide (timestampWithinSkew accepts ts in // [now-SKEW, now+SKEW]), and a nonce's seen time is the RECEIVE time, which can // trail ts by up to SKEW when a peer's clock leads -- so a nonce must be retained // for 2x SKEW past receipt to cover the whole window a captured request stays // replayable (pruning at 1x SKEW reopened that gap). const NONCE_RETENTION_MS = 2 * SKEW_TOLERANCE_MS; const RESPONSE_LABEL = "resp"; const randomSecret = () => { return crypto.randomBytes(SECRET_BYTES); }; const randomNonce = () => { return crypto.randomBytes(NONCE_BYTES); }; const buildCanonical = ({ timestamp, nonce, method, path, targetHost, body }) => { return [ String(timestamp), String(nonce), String(method).toUpperCase(), String(path), String(targetHost || ""), sha256Hex(body || "") ].join("\n"); }; const buildResponseCanonical = ({ nonce, status, body }) => { return [ RESPONSE_LABEL, String(nonce), String(status), sha256Hex(body) ].join("\n"); }; // Normalize a host[:port] so the signing side (outbound, the peer base_url's // host) and the verifying side (inbound, Express req.host -- the trust-proxy-aware // host Saltcorn routes the tenant by) produce byte-identical canonicals: // lowercase, trim, and drop the default port (clients omit :80/:443 from the Host // header). Binding it into the canonical stops a request signed for one tenant // being replayed against another tenant on the same multi-tenant server. const normalizeHost = (h) => { return String(h || "").trim().toLowerCase().replace(/:(?:80|443)$/, ""); }; const sign = (secret, canonical) => { return crypto.createHmac(HMAC_ALGORITHM, secret).update(canonical, "utf8").digest("hex"); }; // Timing-safe comparison of providedHex against the expected signature. const verifySignature = (secret, canonical, providedHex) => { if (!providedHex || typeof providedHex !== "string") { return false; } let expectedBuf; let providedBuf; try { expectedBuf = Buffer.from(sign(secret, canonical), "hex"); providedBuf = Buffer.from(providedHex, "hex"); } catch (e) { return false; } return constantTimeEqual(expectedBuf, providedBuf); }; // response = { nonce (of the request), status, body (Buffer) } const signResponse = (secret, response) => { return sign(secret, buildResponseCanonical(response)); }; const verifyResponseSignature = (secret, response, providedHex) => { return verifySignature(secret, buildResponseCanonical(response), providedHex); }; const timestampWithinSkew = (tsString) => { const ts = Number(tsString); if (!Number.isFinite(ts)) { return false; } return Math.abs(Date.now() - ts) <= SKEW_TOLERANCE_MS; }; module.exports = { SKEW_TOLERANCE_MS, NONCE_RETENTION_MS, randomSecret, randomNonce, buildCanonical, buildResponseCanonical, normalizeHost, sign, verifySignature, signResponse, verifyResponseSignature, timestampWithinSkew };