@fedify/fedify
Version:
An ActivityPub server framework
293 lines (292 loc) • 12.9 kB
JavaScript
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;
}