sc-site-sync/lib/common/peerClient.js
2026-10-19 01:25:16 -05:00

105 lines
4.4 KiB
JavaScript

// Outbound signed peer requests.
//
// Every request is signed with the pairing's shared secret (peerProtocol
// buildCanonical; the body is JSON-serialized once and used for both the HTTP
// body and the signature). Every response must carry a signature over this
// request's nonce, the status and the exact body bytes; it is verified BEFORE the
// body is parsed or handed to a caller -- the receiving side mutates itself from
// what a pull returns, so an unauthenticated response (an on-path attacker on an
// http:// link) must never be acted on. Bodies are read through a byte cap and
// every exchange is bounded by a timeout.
const {
buildCanonical,
normalizeHost,
sign,
verifyResponseSignature,
randomNonce
} = require("./peerProtocol");
const { readCappedResponse } = require("./cappedBody");
// Unverified text from an unsigned response is quoted only this long.
const UNVERIFIED_DETAIL_BYTES = 200;
// The parsed JSON body of a verified exchange; a body that is empty or not JSON
// is a protocol error.
const parseJson = (result) => {
try {
return JSON.parse(result.bytes.toString("utf8"));
} catch (e) {
throw new Error(`peer ${result.url} returned status ${result.status} with a non-JSON body (${result.contentType}, ${result.bytes.length} bytes)`);
}
};
// opts = { headers: { ENV_ID, TIMESTAMP, NONCE, SIGNATURE, RESPONSE_SIGNATURE }
// names, contentType: the signed request body's Content-Type }
const createPeerClient = ({ headers, contentType }) => {
// The one signed round trip. expectedEnvId (optional) must equal the
// responder's ENV_ID header. Returns { status, ok, bytes, contentType,
// headers, url } only after the response verified; throws otherwise
// (including for an unsigned error reply such as a 401 from the peer's auth
// layer, whose text is reported as unverified).
const signedRequest = async ({ baseUrl, method, path, body, sourceEnvId, secret, requireTls, maxBytes, timeoutMs, expectedEnvId }) => {
const base = new URL(baseUrl);
if (requireTls && base.protocol !== "https:") {
throw new Error(`peer requires TLS but base_url is not https: ${baseUrl}`);
}
const bodyStr = body ? JSON.stringify(body) : "";
const timestamp = String(Date.now());
const nonce = randomNonce().toString("hex");
const canonical = buildCanonical({
timestamp: timestamp,
nonce: nonce,
method: method,
path: path,
targetHost: normalizeHost(base.host),
body: bodyStr
});
const requestHeaders = {
[headers.ENV_ID]: sourceEnvId,
[headers.TIMESTAMP]: timestamp,
[headers.NONCE]: nonce,
[headers.SIGNATURE]: sign(secret, canonical)
};
const init = { method: method, headers: requestHeaders, signal: AbortSignal.timeout(timeoutMs) };
if (bodyStr) {
requestHeaders["Content-Type"] = contentType;
init.body = bodyStr;
}
const url = baseUrl.replace(/\/+$/, "") + path;
const res = await fetch(url, init);
let bytes;
try {
bytes = await readCappedResponse(res, maxBytes);
} catch (e) {
throw new Error(`peer ${url}: ${e.message}`);
}
const responseType = res.headers.get("content-type") || "(none)";
const provided = res.headers.get(headers.RESPONSE_SIGNATURE);
if (!verifyResponseSignature(secret, { nonce: nonce, status: res.status, body: bytes }, provided)) {
const detail = bytes.toString("utf8", 0, UNVERIFIED_DETAIL_BYTES);
throw new Error(`peer ${url} returned status ${res.status} (${responseType}) without a valid response signature; unverified detail: ${detail || "(empty)"}`);
}
if (expectedEnvId) {
const responder = res.headers.get(headers.ENV_ID);
if (responder !== expectedEnvId) {
throw new Error(`peer ${url} answered as env_id ${responder || "(none)"}, expected ${expectedEnvId}`);
}
}
return { status: res.status, ok: res.ok, bytes: bytes, contentType: responseType, headers: res.headers, url: url };
};
return {
signedRequest
};
};
module.exports = {
createPeerClient,
parseJson
};