105 lines
4.4 KiB
JavaScript
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
|
|
};
|