// 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 };