UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

293 lines (292 loc) • 12.9 kB
import * as dntShim from "../_dnt.shims.js"; import { getLogger } from "@logtape/logtape"; import { SpanStatusCode, trace, } from "@opentelemetry/api"; import { ATTR_HTTP_REQUEST_HEADER, ATTR_HTTP_REQUEST_METHOD, ATTR_URL_FULL, } from "@opentelemetry/semantic-conventions"; import { equals } from "../deps/jsr.io/@std/bytes/1.0.5/mod.js"; import { decodeBase64, encodeBase64 } from "../deps/jsr.io/@std/encoding/1.0.7/base64.js"; import { encodeHex } from "../deps/jsr.io/@std/encoding/1.0.7/hex.js"; import metadata from "../deno.js"; import { CryptographicKey } from "../vocab/vocab.js"; import { fetchKey, validateCryptoKey } from "./key.js"; /** * Signs a request using the given private key. * @param request The request to sign. * @param privateKey The private key to use for signing. * @param keyId The key ID to use for the signature. It will be used by the * verifier. * @returns The signed request. * @throws {TypeError} If the private key is invalid or unsupported. */ export async function signRequest(request, privateKey, keyId, options = {}) { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(metadata.name, metadata.version); return await tracer.startActiveSpan("http_signatures.sign", async (span) => { try { const signed = await signRequestInternal(request, privateKey, keyId, span); if (span.isRecording()) { span.setAttribute(ATTR_HTTP_REQUEST_METHOD, signed.method); span.setAttribute(ATTR_URL_FULL, signed.url); for (const [name, value] of signed.headers) { span.setAttribute(ATTR_HTTP_REQUEST_HEADER(name), value); } span.setAttribute("http_signatures.key_id", keyId.href); } return signed; } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(error), }); throw error; } finally { span.end(); } }); } async function signRequestInternal(request, privateKey, keyId, span) { validateCryptoKey(privateKey, "private"); if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") { throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); } const url = new URL(request.url); const body = request.method !== "GET" && request.method !== "HEAD" ? await request.arrayBuffer() : null; const headers = new Headers(request.headers); if (!headers.has("Host")) { headers.set("Host", url.host); } if (!headers.has("Digest") && body != null) { const digest = await dntShim.crypto.subtle.digest("SHA-256", body); headers.set("Digest", `SHA-256=${encodeBase64(digest)}`); if (span.isRecording()) { span.setAttribute("http_signatures.digest.sha-256", encodeHex(digest)); } } if (!headers.has("Date")) { headers.set("Date", new Date().toUTCString()); } const serialized = [ ["(request-target)", `${request.method.toLowerCase()} ${url.pathname}`], ...headers, ]; const headerNames = serialized.map(([name]) => name); const message = serialized .map(([name, value]) => `${name}: ${value.trim()}`).join("\n"); // TODO: support other than RSASSA-PKCS1-v1_5: const signature = await dntShim.crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, new TextEncoder().encode(message)); const sigHeader = `keyId="${keyId.href}",algorithm="rsa-sha256",headers="${headerNames.join(" ")}",signature="${encodeBase64(signature)}"`; headers.set("Signature", sigHeader); if (span.isRecording()) { span.setAttribute("http_signatures.algorithm", "rsa-sha256"); span.setAttribute("http_signatures.signature", encodeHex(signature)); } return new Request(request, { headers, body, }); } const supportedHashAlgorithms = { "sha": "SHA-1", "sha-256": "SHA-256", "sha-512": "SHA-512", }; /** * Verifies the signature of a request. * * Note that this function consumes the request body, so it should not be used * if the request body is already consumed. Consuming the request body after * calling this function is okay, since this function clones the request * under the hood. * * @param request The request to verify. * @param options Options for verifying the request. * @returns The public key of the verified signature, or `null` if the signature * could not be verified. */ export async function verifyRequest(request, options = {}) { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(metadata.name, metadata.version); return await tracer.startActiveSpan("http_signatures.verify", async (span) => { if (span.isRecording()) { span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method); span.setAttribute(ATTR_URL_FULL, request.url); for (const [name, value] of request.headers) { span.setAttribute(ATTR_HTTP_REQUEST_HEADER(name), value); } } try { const key = await verifyRequestInternal(request, span, options); if (key == null) span.setStatus({ code: SpanStatusCode.ERROR }); return key; } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(error), }); throw error; } finally { span.end(); } }); } async function verifyRequestInternal(request, span, { documentLoader, contextLoader, timeWindow, currentTime, keyCache, tracerProvider, } = {}) { const logger = getLogger(["fedify", "sig", "http"]); if (request.bodyUsed) { logger.error("Failed to verify; the request body is already consumed.", { url: request.url }); return null; } else if (request.body?.locked) { logger.error("Failed to verify; the request body is locked.", { url: request.url }); return null; } const originalRequest = request; request = request.clone(); const dateHeader = request.headers.get("Date"); if (dateHeader == null) { logger.debug("Failed to verify; no Date header found.", { headers: Object.fromEntries(request.headers.entries()) }); return null; } const sigHeader = request.headers.get("Signature"); if (sigHeader == null) { logger.debug("Failed to verify; no Signature header found.", { headers: Object.fromEntries(request.headers.entries()) }); return null; } const digestHeader = request.headers.get("Digest"); if (request.method !== "GET" && request.method !== "HEAD" && digestHeader == null) { logger.debug("Failed to verify; no Digest header found.", { headers: Object.fromEntries(request.headers.entries()) }); return null; } let body = null; if (digestHeader != null) { body = await request.arrayBuffer(); const digests = digestHeader.split(",").map((pair) => pair.includes("=") ? pair.split("=", 2) : [pair, ""]); let matched = false; for (let [algo, digestBase64] of digests) { algo = algo.trim().toLowerCase(); if (!(algo in supportedHashAlgorithms)) continue; let digest; try { digest = decodeBase64(digestBase64); } catch (error) { logger.debug("Failed to verify; invalid base64 encoding: {digest}.", { digest: digestBase64, error, }); return null; } if (span.isRecording()) { span.setAttribute(`http_signatures.digest.${algo}`, encodeHex(digest)); } const expectedDigest = await dntShim.crypto.subtle.digest(supportedHashAlgorithms[algo], body); if (!equals(digest, new Uint8Array(expectedDigest))) { logger.debug("Failed to verify; digest mismatch ({algorithm}): " + "{digest} != {expectedDigest}.", { algorithm: algo, digest: digestBase64, expectedDigest: encodeBase64(expectedDigest), }); return null; } matched = true; } if (!matched) { logger.debug("Failed to verify; no supported digest algorithm found. " + "Supported: {supportedAlgorithms}; found: {algorithms}.", { supportedAlgorithms: Object.keys(supportedHashAlgorithms), algorithms: digests.map(([algo]) => algo), }); return null; } } const date = dntShim.Temporal.Instant.from(new Date(dateHeader).toISOString()); const now = currentTime ?? dntShim.Temporal.Now.instant(); if (timeWindow !== false) { const tw = timeWindow ?? { hours: 1 }; if (dntShim.Temporal.Instant.compare(date, now.add(tw)) > 0) { logger.debug("Failed to verify; Date is too far in the future.", { date: date.toString(), now: now.toString() }); return null; } else if (dntShim.Temporal.Instant.compare(date, now.subtract(tw)) < 0) { logger.debug("Failed to verify; Date is too far in the past.", { date: date.toString(), now: now.toString() }); return null; } } const sigValues = Object.fromEntries(sigHeader.split(",").map((pair) => pair.match(/^\s*([A-Za-z]+)="([^"]*)"\s*$/)).filter((m) => m != null).map((m) => m.slice(1, 3))); if (!("keyId" in sigValues)) { logger.debug("Failed to verify; no keyId field found in the Signature header.", { signature: sigHeader }); return null; } else if (!("headers" in sigValues)) { logger.debug("Failed to verify; no headers field found in the Signature header.", { signature: sigHeader }); return null; } else if (!("signature" in sigValues)) { logger.debug("Failed to verify; no signature field found in the Signature header.", { signature: sigHeader }); return null; } const { keyId, headers, signature } = sigValues; span?.setAttribute("http_signatures.key_id", keyId); if ("algorithm" in sigValues) { span?.setAttribute("http_signatures.algorithm", sigValues.algorithm); } const { key, cached } = await fetchKey(new URL(keyId), CryptographicKey, { documentLoader, contextLoader, keyCache, tracerProvider, }); if (key == null) return null; const headerNames = headers.split(/\s+/g); if (!headerNames.includes("(request-target)") || !headerNames.includes("date")) { logger.debug("Failed to verify; required headers missing in the Signature header: " + "{headers}.", { headers }); return null; } if (body != null && !headerNames.includes("digest")) { logger.debug("Failed to verify; required headers missing in the Signature header: " + "{headers}.", { headers }); return null; } const message = headerNames.map((name) => `${name}: ` + (name == "(request-target)" ? `${request.method.toLowerCase()} ${new URL(request.url).pathname}` : name == "host" ? request.headers.get("host") ?? new URL(request.url).host : request.headers.get(name))).join("\n"); const sig = decodeBase64(signature); span?.setAttribute("http_signatures.signature", encodeHex(sig)); // TODO: support other than RSASSA-PKCS1-v1_5: const verified = await dntShim.crypto.subtle.verify("RSASSA-PKCS1-v1_5", key.publicKey, sig, new TextEncoder().encode(message)); if (!verified) { if (cached) { logger.debug("Failed to verify with the cached key {keyId}; signature {signature} " + "is invalid. Retrying with the freshly fetched key...", { keyId, signature, message }); return await verifyRequest(originalRequest, { documentLoader, contextLoader, timeWindow, currentTime, keyCache: { get: () => Promise.resolve(undefined), set: async (keyId, key) => await keyCache?.set(keyId, key), }, }); } logger.debug("Failed to verify with the fetched key {keyId}; signature {signature} " + "is invalid. Check if the key is correct or if the signed message " + "is correct. The message to sign is:\n{message}", { keyId, signature, message }); return null; } return key; }