129 lines
4 KiB
JavaScript
129 lines
4 KiB
JavaScript
// 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
|
|
};
|