@fedify/fedify
Version:
An ActivityPub server framework
1,186 lines (1,182 loc) • 45.3 kB
JavaScript
import { Temporal } from "@js-temporal/polyfill";
import { URLPattern } from "urlpattern-polyfill";
globalThis.addEventListener = () => {};
import { assertEquals } from "../assert_equals-CTYbeopb.js";
import { assert } from "../assert-DmFG7ppO.js";
import "../assert_instance_of-CF09JHYM.js";
import "../docloader-C8mmLN-Y.js";
import "../url-kTAI6_KP.js";
import "../multibase-DeCHcK8L.js";
import { exportSpki } from "../vocab-dDpPQ0fF.js";
import "../langstr-DbWheeIS.js";
import "../lookup-Ozuw-rxN.js";
import "../type-D2s5lmbZ.js";
import "../actor-BP-NTJ2m.js";
import { exportJwk } from "../key-CPSlDkCj.js";
import { createRfc9421SignatureBase, doubleKnock, formatRfc9421Signature, formatRfc9421SignatureParameters, parseRfc9421Signature, parseRfc9421SignatureInput, signRequest, timingSafeEqual, verifyRequest } from "../http-BmfI6U39.js";
import { test } from "../testing-BZ0dJ4qn.js";
import { assertExists, assertStringIncludes } from "../std__assert-vp0TKMS1.js";
import { assertFalse } from "../assert_rejects-C-sxEMM5.js";
import "../assert_is_error-nrwA1GeT.js";
import "../assert_not_equals-Dc7y-V5Q.js";
import { assertThrows } from "../assert_throws-Cn9C6Jur.js";
import { mockDocumentLoader } from "../docloader-09nVWLAZ.js";
import { rsaPrivateKey2, rsaPublicKey1, rsaPublicKey2, rsaPublicKey5 } from "../keys-CtFc865v.js";
import { esm_default } from "../esm-Db4De7AS.js";
import { encodeBase64 } from "byte-encodings/base64";
//#region sig/http.test.ts
test("signRequest() [draft-cavage]", async () => {
const request = new Request("https://example.com/", {
method: "POST",
body: "Hello, world!",
headers: {
"Content-Type": "text/plain; charset=utf-8",
Accept: "text/plain"
}
});
const signed = await signRequest(request, rsaPrivateKey2, new URL("https://example.com/key2"));
assertEquals(await verifyRequest(signed, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader
}), rsaPublicKey2);
});
test("verifyRequest() [draft-cavage]", async () => {
const request = new Request("https://example.com/", {
method: "POST",
body: "Hello, world!",
headers: {
Accept: "text/plain",
"Content-Type": "text/plain; charset=utf-8",
Date: "Tue, 05 Mar 2024 07:49:44 GMT",
Digest: "sha-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=",
Signature: "keyId=\"https://example.com/key\",headers=\"(request-target) accept content-type date digest host\",signature=\"ZDeMzjBKPfJvkv4QaxAdOQxKCJ96pOzOCFhhGgGnlsw4N80oN4GEZ/n8nNKjpoW95Bcs8N0dZVSQHj3g08AReKIOXpun0tgmaWGKRcRT4kEhAW+uP1wVZPbuOIvVCEhMYv6+SbnttgX0GvN365BTZpxh7+gRrRC4mns5qV69cv45I5iJB0aw24GJW9u7lUAm6yDEh4N0aXfNqNRq3LHiuPqlDzSenfXbHr0UnAMaGuI4v9/uflu/jNi3hRX4Y/T+ngM1zvLvi/BjKK4I1rh520qnkrWpxz9ikLCjIMO7Dwh1nOsPzrZE2t43XHD3evdvm1RM5Ppes+M6DrfkfQuUBw==\""
}
});
const cache = {};
const options = {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
currentTime: Temporal.Instant.from("2024-03-05T07:49:44Z"),
keyCache: {
get(keyId) {
return Promise.resolve(cache[keyId.href]);
},
set(keyId, key$1) {
cache[keyId.href] = key$1;
return Promise.resolve();
}
}
};
let key = await verifyRequest(request, options);
assertEquals(key, rsaPublicKey1);
assertEquals(cache, { "https://example.com/key": rsaPublicKey1 });
cache["https://example.com/key"] = rsaPublicKey2;
key = await verifyRequest(request, options);
assertEquals(key, rsaPublicKey1);
assertEquals(cache, { "https://example.com/key": rsaPublicKey1 });
assertEquals(await verifyRequest(new Request("https://example.com/"), {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader
}), null);
assertEquals(await verifyRequest(new Request("https://example.com/", { headers: { Date: "Tue, 05 Mar 2024 07:49:44 GMT" } }), {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader
}), null);
assertEquals(await verifyRequest(new Request("https://example.com/", {
method: "POST",
headers: {
Date: "Tue, 05 Mar 2024 07:49:44 GMT",
Signature: "asdf"
}
}), {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader
}), null);
assertEquals(await verifyRequest(new Request("https://example.com/", {
method: "POST",
headers: {
Date: "Tue, 05 Mar 2024 07:49:44 GMT",
Signature: "asdf",
Digest: "invalid"
},
body: ""
}), {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader
}), null);
assertEquals(await verifyRequest(new Request("https://example.com/", {
method: "POST",
headers: {
Date: "Tue, 05 Mar 2024 07:49:44 GMT",
Signature: "asdf",
Digest: "sha-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM="
},
body: ""
}), {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader
}), null);
assertEquals(await verifyRequest(request, {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader,
currentTime: Temporal.Instant.from("2024-03-05T06:49:43.9999Z")
}), null);
assertEquals(await verifyRequest(request, {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader,
currentTime: Temporal.Instant.from("2024-03-05T07:49:13.9999Z"),
timeWindow: { seconds: 30 }
}), null);
assertEquals(await verifyRequest(request, {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader,
currentTime: Temporal.Instant.from("2024-03-05T08:49:44.0001Z")
}), null);
assertEquals(await verifyRequest(request, {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader,
currentTime: Temporal.Instant.from("2024-03-05T07:50:14.0001Z"),
timeWindow: { seconds: 30 }
}), null);
assertEquals(await verifyRequest(request, {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader,
currentTime: Temporal.Instant.from("2024-01-01T00:00:00.0000Z"),
timeWindow: false
}), rsaPublicKey1);
assertEquals(await verifyRequest(request, {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader,
currentTime: Temporal.Instant.from("2025-01-01T00:00:00.0000Z"),
timeWindow: false
}), rsaPublicKey1);
});
test("signRequest() and verifyRequest() [rfc9421] implementation", async () => {
const currentTimestamp = 1709626184;
const currentTime = Temporal.Instant.from("2024-03-05T08:09:44Z");
const requestBody = "Test content for signature verification";
const request = new Request("https://example.com/api/resource", {
method: "POST",
body: requestBody,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT"
}
});
const signed = await signRequest(request, rsaPrivateKey2, new URL("https://example.com/key2"), {
spec: "rfc9421",
currentTime
});
assertEquals(signed.headers.has("Signature-Input"), true);
assertEquals(signed.headers.has("Signature"), true);
const signatureInput = signed.headers.get("Signature-Input");
assertExists(signatureInput);
assertStringIncludes(signatureInput, "sig1=", "Should have a signature ID");
assertStringIncludes(signatureInput, "keyid=\"https://example.com/key2\"", "Should contain the exact keyId");
assertStringIncludes(signatureInput, "alg=\"rsa-v1_5-sha256\"", "Should specify the correct algorithm");
assertStringIncludes(signatureInput, `created=${currentTimestamp}`, "Should contain the exact timestamp");
const expectedComponents = [
"@method",
"@target-uri",
"@authority",
"host",
"date",
"content-digest"
];
for (const component of expectedComponents) assertStringIncludes(signatureInput, `"${component}"`, `Should include component: ${component}`);
assertEquals(signed.headers.has("Content-Digest"), true, "Should include Content-Digest for POST with body");
const contentDigest = signed.headers.get("Content-Digest");
assertExists(contentDigest);
assert(contentDigest.startsWith("sha-256=:"), "Content-Digest should use RFC 9421 format");
const expectedDigest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(requestBody));
const expectedDigestBase64 = encodeBase64(expectedDigest);
assertEquals(contentDigest, `sha-256=:${expectedDigestBase64}:`, "Content-Digest should have correct value");
const signature = signed.headers.get("Signature");
assertExists(signature);
const sigFormat = /^sig1=:([A-Za-z0-9+/]+=*):/;
assert(sigFormat.test(signature), `Signature format (${signature}) should match RFC 9421 format`);
const sigMatch = signature.match(sigFormat);
assertExists(sigMatch);
const sigValue = sigMatch[1];
assert(sigValue.length > 10, "Signature value should be a substantial base64 string");
const parsedInput = parseRfc9421SignatureInput(signatureInput);
assertExists(parsedInput.sig1);
assertEquals(parsedInput.sig1.keyId, "https://example.com/key2");
assertEquals(parsedInput.sig1.alg, "rsa-v1_5-sha256");
assertEquals(parsedInput.sig1.created, currentTimestamp);
assertEquals(parsedInput.sig1.components.length, expectedComponents.length, "Should have all expected components");
for (const component of expectedComponents) assert(parsedInput.sig1.components.includes(component), `Components should include ${component}`);
const parsedSig = parseRfc9421Signature(signature);
assertExists(parsedSig.sig1);
assertEquals(parsedSig.sig1.byteLength > 0, true, "Signature value should be a non-empty Uint8Array");
const verifyHeaders = new Headers();
for (const [name, value] of signed.headers.entries()) if (name !== "Signature" && name !== "Signature-Input") verifyHeaders.set(name, value);
const reconstructedRequest = new Request(request.url, {
method: request.method,
headers: verifyHeaders
});
const reconstructedBase = createRfc9421SignatureBase(reconstructedRequest, parsedInput.sig1.components, parsedInput.sig1.parameters);
const signatureBytes = new Uint8Array(parsedSig.sig1);
const signatureVerifies = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", rsaPublicKey2.publicKey, signatureBytes, new TextEncoder().encode(reconstructedBase));
assert(signatureVerifies, "Manual verification of signature should succeed");
});
test("createRfc9421SignatureBase()", () => {
const request = new Request("https://example.com/path?query=value", {
method: "POST",
headers: {
Host: "example.com",
Date: "Tue, 05 Mar 2024 07:49:44 GMT",
"Content-Type": "text/plain"
}
});
const components = [
"@method",
"@target-uri",
"host",
"date"
];
const created = 1709626184;
const signatureBase = createRfc9421SignatureBase(request, components, formatRfc9421SignatureParameters({
algorithm: "rsa-v1_5-sha256",
keyId: new URL("https://example.com/key"),
created
}));
const expected = [
`"@method": POST`,
`"@target-uri": https://example.com/path?query=value`,
`"host": example.com`,
`"date": Tue, 05 Mar 2024 07:49:44 GMT`,
`"@signature-params": ("@method" "@target-uri" "host" "date");alg="rsa-v1_5-sha256";keyid="https://example.com/key";created=1709626184`
].join("\n");
assertEquals(signatureBase, expected);
});
test("formatRfc9421Signature()", () => {
const signature = new Uint8Array([
1,
2,
3,
4
]);
const keyId = new URL("https://example.com/key");
const algorithm = "rsa-v1_5-sha256";
const components = [
"@method",
"@target-uri",
"host"
];
const created = 1709626184;
const [signatureInput, signatureHeader] = formatRfc9421Signature(signature, components, formatRfc9421SignatureParameters({
algorithm,
keyId,
created
}));
assertEquals(signatureInput, `sig1=("@method" "@target-uri" "host");alg="rsa-v1_5-sha256";keyid="https://example.com/key";created=1709626184`);
assertEquals(signatureHeader, `sig1=:AQIDBA==:`);
});
test("parseRfc9421SignatureInput()", () => {
const signatureInput = `sig1=("@method" "@target-uri" "host" "date");keyid="https://example.com/key";alg="rsa-v1_5-sha256";created=1709626184`;
const parsed = parseRfc9421SignatureInput(signatureInput);
assertEquals(parsed.sig1.keyId, "https://example.com/key");
assertEquals(parsed.sig1.alg, "rsa-v1_5-sha256");
assertEquals(parsed.sig1.created, 1709626184);
assertEquals(parsed.sig1.components, [
"@method",
"@target-uri",
"host",
"date"
]);
assertEquals(parsed.sig1.parameters, "keyid=\"https://example.com/key\";alg=\"rsa-v1_5-sha256\";created=1709626184");
});
test("parseRfc9421Signature()", () => {
const signature = `sig1=:AQIDBA==:,sig2=:Zm9vYmFy:`;
const parsed = parseRfc9421Signature(signature);
assertExists(parsed.sig1);
assertExists(parsed.sig2);
const sig1Bytes = new Uint8Array(parsed.sig1);
assertEquals(sig1Bytes.length, 4);
assertEquals(sig1Bytes[0], 1);
assertEquals(sig1Bytes[1], 2);
assertEquals(sig1Bytes[2], 3);
assertEquals(sig1Bytes[3], 4);
const sig2Text = new TextDecoder().decode(parsed.sig2);
assertEquals(sig2Text, "foobar");
});
test("verifyRequest() [rfc9421] successful GET verification", async () => {
const currentTimestamp = 1709626184;
const currentTime = Temporal.Instant.from("2024-03-05T08:09:44Z");
const validRequest = new Request("https://example.com/api/resource", {
method: "GET",
headers: {
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT"
}
});
const signedRequest = await signRequest(validRequest, rsaPrivateKey2, new URL("https://example.com/key2"), {
spec: "rfc9421",
currentTime
});
const verifiedKey = await verifyRequest(signedRequest, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
spec: "rfc9421",
currentTime: Temporal.Instant.from(`${(/* @__PURE__ */ new Date(currentTimestamp * 1e3)).toISOString()}`)
});
assertEquals(verifiedKey, rsaPublicKey2, "Valid signature should verify to the correct public key");
});
test("verifyRequest() [rfc9421] manual POST verification", async () => {
const currentTimestamp = 1709626184;
const currentTime = Temporal.Instant.from("2024-03-05T08:09:44Z");
const postBody = "Test content for signature verification";
const postRequest = new Request("https://example.com/api/resource", {
method: "POST",
body: postBody,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT"
}
});
const signedPostRequest = await signRequest(postRequest, rsaPrivateKey2, new URL("https://example.com/key2"), {
spec: "rfc9421",
currentTime
});
const signedKey = await verifyRequest(signedPostRequest, {
spec: "rfc9421",
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader,
currentTime
});
assertExists(signedKey);
assertEquals(signedKey, rsaPublicKey2);
assertExists(signedKey.publicKey);
assertEquals(await exportJwk(signedKey.publicKey), await exportJwk(rsaPublicKey2.publicKey));
const signatureInputHeader = signedPostRequest.headers.get("Signature-Input") || "";
const signatureHeader = signedPostRequest.headers.get("Signature") || "";
const parsedInput = parseRfc9421SignatureInput(signatureInputHeader);
const parsedSignature = parseRfc9421Signature(signatureHeader);
assertExists(parsedInput.sig1, "Should have a valid signature input");
assertExists(parsedSignature.sig1, "Should have a valid signature value");
assertEquals(parsedInput.sig1.keyId, "https://example.com/key2", "Signature should have the correct key ID");
assertEquals(parsedInput.sig1.created, currentTimestamp, "Signature should have the correct timestamp");
const manualRequest = new Request("https://example.com/api/resource", {
method: "POST",
body: postBody,
headers: new Headers(signedPostRequest.headers)
});
const signatureBase = createRfc9421SignatureBase(manualRequest, parsedInput.sig1.components, parsedInput.sig1.parameters);
const signatureVerified = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", rsaPublicKey2.publicKey, parsedSignature.sig1, new TextEncoder().encode(signatureBase));
assert(signatureVerified, "Manual verification of POST signature should succeed");
});
test("verifyRequest() [rfc9421] error cases and edge cases", async () => {
const currentTimestamp = 1709626184;
const currentTime = Temporal.Instant.from("2024-03-05T08:09:44Z");
const validRequest = new Request("https://example.com/api/resource", {
method: "GET",
headers: {
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT"
}
});
const signedRequest = await signRequest(validRequest, rsaPrivateKey2, new URL("https://example.com/key2"), {
spec: "rfc9421",
currentTime
});
const validSignatureInput = signedRequest.headers.get("Signature-Input") || "";
const validSignature = signedRequest.headers.get("Signature") || "";
const missingInputHeader = new Request("https://example.com/api/resource", {
method: "GET",
headers: new Headers({
"Accept": "application/json",
"Host": "example.com",
"Signature": validSignature
})
});
const missingInputResult = await verifyRequest(missingInputHeader, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
spec: "rfc9421"
});
assertEquals(missingInputResult, null, "Should fail verification when Signature-Input header is missing");
const missingSignatureHeader = new Request("https://example.com/api/resource", {
method: "GET",
headers: new Headers({
"Accept": "application/json",
"Host": "example.com",
"Signature-Input": validSignatureInput
})
});
const missingSignatureResult = await verifyRequest(missingSignatureHeader, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
spec: "rfc9421"
});
assertEquals(missingSignatureResult, null, "Should fail verification when Signature header is missing");
const tamperedRequest = new Request("https://example.com/api/resource", {
method: "GET",
headers: new Headers({
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT",
"Signature-Input": validSignatureInput,
"Signature": "sig1=:AAAAAA==:"
})
});
const tamperedResult = await verifyRequest(tamperedRequest, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
spec: "rfc9421"
});
assertEquals(tamperedResult, null, "Should fail verification when signature is tampered");
const expiredRequest = new Request("https://example.com/api/resource", {
method: "GET",
headers: new Headers({
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT",
"Signature-Input": validSignatureInput,
"Signature": validSignature
})
});
const expiredResult = await verifyRequest(expiredRequest, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
spec: "rfc9421",
currentTime: Temporal.Instant.from(`${(/* @__PURE__ */ new Date((currentTimestamp + 2592e3) * 1e3)).toISOString()}`),
timeWindow: { hours: 1 }
});
assertEquals(expiredResult, null, "Should fail verification when signature timestamp is too old");
const futureRequest = new Request("https://example.com/api/resource", {
method: "GET",
headers: new Headers({
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT",
"Signature-Input": validSignatureInput,
"Signature": validSignature
})
});
const futureResult = await verifyRequest(futureRequest, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
spec: "rfc9421",
currentTime: Temporal.Instant.from(`${(/* @__PURE__ */ new Date((currentTimestamp - 2592e3) * 1e3)).toISOString()}`),
timeWindow: { hours: 1 }
});
assertEquals(futureResult, null, "Should fail verification when signature timestamp is in the future");
const timeCheckRequest = new Request("https://example.com/api/resource", {
method: "GET",
headers: new Headers({
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT",
"Signature-Input": validSignatureInput,
"Signature": validSignature
})
});
const timeDisabledResult = await verifyRequest(timeCheckRequest, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
spec: "rfc9421",
currentTime: Temporal.Instant.from(`${(/* @__PURE__ */ new Date((currentTimestamp + 31536e3) * 1e3)).toISOString()}`),
timeWindow: false
});
assertEquals(timeDisabledResult, rsaPublicKey2, "Should verify signature when time checking is disabled");
const postRequest = new Request("https://example.com/api/resource", {
method: "POST",
body: "Test content for signature verification",
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT"
}
});
const freshSignedPostRequest = await signRequest(postRequest, rsaPrivateKey2, new URL("https://example.com/key2"), {
spec: "rfc9421",
currentTime
});
const postSignatureInput = freshSignedPostRequest.headers.get("Signature-Input") || "";
const postSignature = freshSignedPostRequest.headers.get("Signature") || "";
const postContentDigest = freshSignedPostRequest.headers.get("Content-Digest") || "";
const tamperDigestRequest = new Request("https://example.com/api/resource", {
method: "POST",
body: "This content won't match the digest",
headers: new Headers({
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT",
"Signature-Input": postSignatureInput,
"Signature": postSignature,
"Content-Digest": postContentDigest
})
});
const tamperDigestResult = await verifyRequest(tamperDigestRequest, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
spec: "rfc9421",
currentTime: Temporal.Instant.from(`${(/* @__PURE__ */ new Date(currentTimestamp * 1e3)).toISOString()}`)
});
assertEquals(tamperDigestResult, null, "Should fail verification with invalid Content-Digest");
const testRequest = new Request("https://example.com/", { headers: new Headers({
"Date": "Tue, 05 Mar 2024 07:49:44 GMT",
"Host": "example.com",
"Signature-Input": `sig1=("@method" "@target-uri" "host" "date");keyid="https://example.com/key";alg="rsa-v1_5-sha256";created=1709626184`,
"Signature": `sig1=:YXNkZmprc2RmaGprc2RoZmprc2hkZmtqaHNkZg==:`
}) });
const signatureInput = testRequest.headers.get("Signature-Input") || "";
const parsedInput = parseRfc9421SignatureInput(signatureInput);
assertExists(parsedInput.sig1);
assertEquals(parsedInput.sig1.keyId, "https://example.com/key");
assertEquals(parsedInput.sig1.alg, "rsa-v1_5-sha256");
assertEquals(parsedInput.sig1.created, 1709626184);
assertEquals(parsedInput.sig1.components, [
"@method",
"@target-uri",
"host",
"date"
]);
const signature = testRequest.headers.get("Signature") || "";
const parsedSig = parseRfc9421Signature(signature);
assertExists(parsedSig.sig1);
assert(new TextDecoder().decode(parsedSig.sig1).length > 0, "Signature base64 should decode to non-empty string");
const complexSignatureInput = "sig1=(\"@method\" \"@target-uri\" \"host\" \"content-type\" \"value with \\\"quotes\\\" and spaces\");keyid=\"https://example.com/key with spaces\";alg=\"rsa-v1_5-sha256\";created=1709626184";
const complexParsedInput = parseRfc9421SignatureInput(complexSignatureInput);
assertExists(complexParsedInput.sig1);
assertEquals(complexParsedInput.sig1.keyId, "https://example.com/key with spaces");
assertEquals(complexParsedInput.sig1.alg, "rsa-v1_5-sha256");
assertEquals(complexParsedInput.sig1.created, 1709626184);
assert(complexParsedInput.sig1.components.includes("content-type"));
assert(complexParsedInput.sig1.components.includes("value with \"quotes\" and spaces"));
const multiSigRequest = new Request("https://example.com/", { headers: new Headers({
"Signature-Input": `sig1=("@method");keyid="key1";alg="rsa-v1_5-sha256";created=1709626184,sig2=("@target-uri");keyid="key2";alg="rsa-pss-sha512";created=1709626185`,
"Signature": `sig1=:AQIDBA==:,sig2=:Zm9vYmFy:`
}) });
const multiParsedInput = parseRfc9421SignatureInput(multiSigRequest.headers.get("Signature-Input") || "");
assertEquals(Object.keys(multiParsedInput).length, 2, "Should parse multiple signatures");
assertEquals(multiParsedInput.sig1.keyId, "key1");
assertEquals(multiParsedInput.sig2.keyId, "key2");
assertEquals(multiParsedInput.sig1.alg, "rsa-v1_5-sha256");
assertEquals(multiParsedInput.sig2.alg, "rsa-pss-sha512");
const multiParsedSig = parseRfc9421Signature(multiSigRequest.headers.get("Signature") || "");
assertEquals(Object.keys(multiParsedSig).length, 2, "Should parse multiple signature values");
const invalidInputFormat = "this is not a valid signature-input format";
const parsedInvalidInput = parseRfc9421SignatureInput(invalidInputFormat);
assertEquals(Object.keys(parsedInvalidInput).length, 0, "Should handle invalid Signature-Input format");
const invalidSigFormat = "this is not a valid signature format";
const parsedInvalidSig = parseRfc9421Signature(invalidSigFormat);
assertEquals(Object.keys(parsedInvalidSig).length, 0, "Should handle invalid Signature format");
const invalidBase64Sig = "sig1=:!@#$%%^&*():";
const parsedInvalidBase64 = parseRfc9421Signature(invalidBase64Sig);
assertEquals(Object.keys(parsedInvalidBase64).length, 0, "Should handle invalid base64 in signature");
const mixedRequest = new Request("https://example.com/api/resource", {
method: "GET",
headers: new Headers({
"Accept": "application/json",
"Host": "example.com",
"Date": "Tue, 05 Mar 2024 07:49:44 GMT",
"Signature-Input": `${validSignatureInput},sig2=("@method" "@target-uri" "host" "date");keyid="https://example.com/invalid-key";alg="rsa-v1_5-sha256";created=${currentTimestamp}`,
"Signature": `${validSignature},sig2=:AAAAAA==:`
})
});
const mixedResult = await verifyRequest(mixedRequest, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
spec: "rfc9421",
currentTime: Temporal.Instant.from(`${(/* @__PURE__ */ new Date(currentTimestamp * 1e3)).toISOString()}`)
});
assertEquals(mixedResult, rsaPublicKey2, "Should verify when at least one signature is valid");
});
test("verifyRequest() [rfc9421] test vector from Mastodon", async () => {
const signedRequest = new Request("https://www.example.com/activitypub/success", {
method: "GET",
headers: {
Host: "www.example.com",
"Signature-Input": "sig1=(\"@method\" \"@target-uri\");created=1703066400;keyid=\"https://remote.domain/users/bob#main-key\"",
Signature: "sig1=:WfM6q/qBqhUyqPUDt9metjadJGtLLpmMTBzk/t+R3byKe4/TGAXC6vBB/M6NsD5qv8GCmQGtisCMQxJQO0IGODGzi+Jv+eqDJ50agMVXNV6nUOzY44c4/XTPoI98qyx1oEMa4Hefy3vSYKq96iDVAc+RDLCMTeGP3wn9wizjD1SNmU0RZI1bTB+eCkywMP9mM5zXzUOYF+Qkuf+WdEpPR1XUGPlnqfdvPalcKVfaI/VThBjI91D/lmUGoa69x4EBEHM+aJmW6086e7/dVh+FndKkdGfXslZXFZKi2flTGQZgEWLn948SqAaJQROkJg8B14Sb1NONS1qZBhK3Mum8Pg==:"
}
});
const result = await verifyRequest(signedRequest, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
currentTime: Temporal.Instant.from("2023-12-20T10:00:00.0000Z"),
spec: "rfc9421"
});
assertExists(result);
assertExists(result.publicKey);
assertEquals(result, rsaPublicKey5);
assertEquals(await exportSpki(result.publicKey), await exportSpki(rsaPublicKey5.publicKey));
const result2 = await verifyRequest(signedRequest, {
contextLoader: mockDocumentLoader,
documentLoader: mockDocumentLoader,
currentTime: Temporal.Instant.from("2023-12-20T10:00:00.0000Z")
});
assertExists(result2);
assertExists(result2.publicKey);
assertEquals(result2, rsaPublicKey5);
assertEquals(await exportSpki(result2.publicKey), await exportSpki(rsaPublicKey5.publicKey));
});
test("doubleKnock() function with successful first attempt", async () => {
esm_default.spyGlobal();
let requestCount = 0;
let firstRequestSpec = null;
esm_default.post("https://example.com/inbox-accepts-rfc9421", (cl) => {
requestCount++;
const req = cl.request;
const signatureInputHeader = req.headers.get("Signature-Input");
const signatureHeader = req.headers.get("Signature");
if (signatureInputHeader && signatureHeader) {
firstRequestSpec = "rfc9421";
return new Response("", { status: 202 });
} else return new Response("Unauthorized", { status: 401 });
});
const request = new Request("https://example.com/inbox-accepts-rfc9421", {
method: "POST",
body: "Hello, world!",
headers: { "Content-Type": "text/plain" }
});
const specDeterminer = {
usedSpec: null,
determineSpec(_origin) {
return "rfc9421";
},
rememberSpec(_origin, spec) {
this.usedSpec = spec;
}
};
let loggedRequest;
const logFunction = (req) => {
loggedRequest = req;
};
const response = await doubleKnock(request, {
keyId: rsaPublicKey2.id,
privateKey: rsaPrivateKey2
}, {
specDeterminer,
log: logFunction
});
assertEquals(response.status, 202, "Response status should be 202 Accepted");
assertEquals(requestCount, 1, "Only one request should have been made");
assertEquals(firstRequestSpec, "rfc9421", "First attempt should use RFC 9421");
assertEquals(specDeterminer.usedSpec, "rfc9421", "Spec should be remembered");
assertExists(loggedRequest, "Request should be logged");
assert(loggedRequest?.headers.has("Signature-Input"), "Logged request should have RFC 9421 Signature-Input header");
assert(loggedRequest?.headers.has("Signature"), "Logged request should have RFC 9421 Signature header");
esm_default.hardReset();
});
test("doubleKnock() function with fallback to draft-cavage", async () => {
esm_default.spyGlobal();
let requestCount = 0;
let firstSpec = null;
let secondSpec = null;
esm_default.post("https://example.com/inbox-accepts-draft-cavage", (cl) => {
const req = cl.request;
requestCount++;
if (req.headers.has("Signature-Input")) {
firstSpec = "rfc9421";
return new Response("Not Authorized", { status: 401 });
} else if (req.headers.has("Signature")) {
secondSpec = "draft-cavage-http-signatures-12";
return new Response("", { status: 202 });
} else return new Response("Bad Request", { status: 400 });
});
const request = new Request("https://example.com/inbox-accepts-draft-cavage", {
method: "POST",
body: "Test message for double-knocking",
headers: { "Content-Type": "text/plain" }
});
const specDeterminer = {
rememberedOrigin: null,
rememberedSpec: null,
determineSpec(_origin) {
return "rfc9421";
},
rememberSpec(origin, spec) {
this.rememberedOrigin = origin;
this.rememberedSpec = spec;
}
};
const response = await doubleKnock(request, {
keyId: rsaPublicKey2.id,
privateKey: rsaPrivateKey2
}, { specDeterminer });
assertEquals(response.status, 202, "Response status should be 202 Accepted");
assertEquals(requestCount, 2, "Two requests should have been made");
assertEquals(firstSpec, "rfc9421", "First attempt should use RFC 9421");
assertEquals(secondSpec, "draft-cavage-http-signatures-12", "Second attempt should use draft-cavage");
assertEquals(specDeterminer.rememberedOrigin, "https://example.com", "Origin should be remembered");
assertEquals(specDeterminer.rememberedSpec, "draft-cavage-http-signatures-12", "Successful spec should be remembered");
esm_default.hardReset();
});
test("doubleKnock() function with redirect handling", async () => {
esm_default.spyGlobal();
const requestedUrls = [];
const responseCodes = [];
esm_default.post("https://example.com/redirect-endpoint", (cl) => {
requestedUrls.push(cl.url);
responseCodes.push(302);
return Response.redirect("https://example.com/final-endpoint", 302);
});
esm_default.post("https://example.com/final-endpoint", (cl) => {
requestedUrls.push(cl.url);
responseCodes.push(202);
return new Response("", { status: 202 });
});
const request = new Request("https://example.com/redirect-endpoint", {
method: "POST",
body: "Test message that will be redirected",
headers: { "Content-Type": "text/plain" }
});
const response = await doubleKnock(request, {
keyId: rsaPublicKey2.id,
privateKey: rsaPrivateKey2
});
assertEquals(response.status, 202, "Final response status should be 202 Accepted");
assertEquals(requestedUrls.length, 2, "Two URLs should have been requested");
assertEquals(requestedUrls[0], "https://example.com/redirect-endpoint", "First request should be to redirect-endpoint");
assertEquals(requestedUrls[1], "https://example.com/final-endpoint", "Second request should be to final-endpoint");
assertEquals(responseCodes, [302, 202], "Response status codes should match expected sequence");
esm_default.hardReset();
});
test("doubleKnock() function with both specs rejected", async () => {
esm_default.spyGlobal();
let requestCount = 0;
const attempts = [];
esm_default.post("https://example.com/inbox-rejects-all", (cl) => {
const req = cl.request;
requestCount++;
if (req.headers.has("Signature-Input")) attempts.push("rfc9421");
else if (req.headers.has("Signature")) attempts.push("draft-cavage");
else attempts.push("unknown");
return new Response("Unauthorized", { status: 401 });
});
const request = new Request("https://example.com/inbox-rejects-all", {
method: "POST",
body: "Test message that will be rejected regardless of signature format",
headers: { "Content-Type": "text/plain" }
});
const response = await doubleKnock(request, {
keyId: rsaPublicKey2.id,
privateKey: rsaPrivateKey2
});
assertEquals(response.status, 401, "Final response status should be 401 Unauthorized");
assertEquals(requestCount, 2, "Two requests should have been made");
assertEquals(attempts.length, 2, "Two signature attempts should have been made");
assertEquals(attempts[0], "rfc9421", "First attempt should use RFC 9421");
assertEquals(attempts[1], "draft-cavage", "Second attempt should use draft-cavage");
esm_default.hardReset();
});
test("doubleKnock() function with specDeterminer choosing draft-cavage first", async () => {
esm_default.spyGlobal();
let requestCount = 0;
let firstSpec = null;
esm_default.post("https://example.com/inbox-accepts-any", (cl) => {
const req = cl.request;
requestCount++;
if (req.headers.has("Signature-Input")) firstSpec = "rfc9421";
else if (req.headers.has("Signature")) firstSpec = "draft-cavage";
return new Response("", { status: 202 });
});
const specDeterminer = {
determineSpec(_origin) {
return "draft-cavage-http-signatures-12";
},
rememberSpec(_origin, _spec) {}
};
const request = new Request("https://example.com/inbox-accepts-any", {
method: "POST",
body: "Test message with draft-cavage preference",
headers: { "Content-Type": "text/plain" }
});
const response = await doubleKnock(request, {
keyId: rsaPublicKey2.id,
privateKey: rsaPrivateKey2
}, { specDeterminer });
assertEquals(response.status, 202, "Response status should be 202 Accepted");
assertEquals(requestCount, 1, "Only one request should have been made");
assertEquals(firstSpec, "draft-cavage", "First attempt should use draft-cavage");
esm_default.hardReset();
});
test("doubleKnock() complex redirect chain test", async () => {
esm_default.spyGlobal();
const requestedUrls = [];
esm_default.post("https://example.com/redirect1", (cl) => {
requestedUrls.push(cl.url);
return Response.redirect("https://example.com/redirect2", 302);
});
esm_default.post("https://example.com/redirect2", (cl) => {
requestedUrls.push(cl.url);
return Response.redirect("https://example.com/redirect3", 307);
});
esm_default.post("https://example.com/redirect3", (cl) => {
requestedUrls.push(cl.url);
return Response.redirect("https://example.com/final", 301);
});
esm_default.post("https://example.com/final", (cl) => {
requestedUrls.push(cl.url);
return new Response("Success", { status: 200 });
});
const request = new Request("https://example.com/redirect1", {
method: "POST",
body: "Test message for redirect chain",
headers: { "Content-Type": "text/plain" }
});
const logs = [];
const logFunction = (req) => {
logs.push(req);
};
const response = await doubleKnock(request, {
keyId: rsaPublicKey2.id,
privateKey: rsaPrivateKey2
}, { log: logFunction });
assertEquals(response.status, 200, "Final response status should be 200 OK");
assertEquals(await response.text(), "Success", "Response body should be 'Success'");
assertEquals(requestedUrls.length, 4, "Four URLs should have been requested");
assertEquals(requestedUrls[0], "https://example.com/redirect1", "First request should be to redirect1");
assertEquals(requestedUrls[1], "https://example.com/redirect2", "Second request should be to redirect2");
assertEquals(requestedUrls[2], "https://example.com/redirect3", "Third request should be to redirect3");
assertEquals(requestedUrls[3], "https://example.com/final", "Fourth request should be to final");
assertEquals(logs.length, 4, "Four requests should have been logged");
for (const loggedReq of logs) assert(loggedReq.headers.has("Signature-Input") || loggedReq.headers.has("Signature"), "Each request should be signed with either RFC 9421 or draft-cavage");
esm_default.hardReset();
});
test("doubleKnock() async specDeterminer test", async () => {
esm_default.spyGlobal();
let requestCount = 0;
let specUsed = null;
esm_default.post("https://example.com/inbox-async-determiner", (cl) => {
const req = cl.request;
requestCount++;
if (req.headers.has("Signature-Input")) specUsed = "rfc9421";
else if (req.headers.has("Signature")) specUsed = "draft-cavage-http-signatures-12";
return new Response("", { status: 202 });
});
const specDeterminer = {
async determineSpec(_origin) {
await new Promise((resolve) => setTimeout(resolve, 10));
return "draft-cavage-http-signatures-12";
},
async rememberSpec(_origin, _spec) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
};
const request = new Request("https://example.com/inbox-async-determiner", {
method: "POST",
body: "Test message with async spec determiner",
headers: { "Content-Type": "text/plain" }
});
const response = await doubleKnock(request, {
keyId: rsaPublicKey2.id,
privateKey: rsaPrivateKey2
}, { specDeterminer });
assertEquals(response.status, 202, "Response status should be 202 Accepted");
assertEquals(requestCount, 1, "Only one request should have been made");
assertEquals(specUsed, "draft-cavage-http-signatures-12", "Should use spec from async determiner");
esm_default.hardReset();
});
test("timingSafeEqual()", async (t) => {
await t.step("should return true for equal empty arrays", () => {
const a = new Uint8Array([]);
const b = new Uint8Array([]);
assert(timingSafeEqual(a, b));
});
await t.step("should return true for equal non-empty arrays", async (t2) => {
const testCases = [
{
a: [
1,
2,
3
],
b: [
1,
2,
3
],
name: "simple sequence"
},
{
a: [
0,
0,
0
],
b: [
0,
0,
0
],
name: "sequence of zeros"
},
{
a: [
255,
128,
0,
42
],
b: [
255,
128,
0,
42
],
name: "varied bytes"
},
{
a: Array.from({ length: 100 }, (_, i) => i),
b: Array.from({ length: 100 }, (_, i) => i),
name: "longer sequence (0-99)"
}
];
for (const tc of testCases) await t2.step(tc.name, () => {
assert(timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b)));
});
});
await t.step("should return true for reference equality", () => {
const arr = new Uint8Array([
10,
20,
30,
99,
100,
0
]);
assert(timingSafeEqual(arr, arr), "Array should be equal to itself by reference");
});
await t.step("should return false for arrays with same length but different content", async (t2) => {
const testCases = [
{
a: [
1,
2,
3
],
b: [
0,
2,
3
],
name: "difference at start"
},
{
a: [
1,
2,
3
],
b: [
1,
0,
3
],
name: "difference in middle"
},
{
a: [
1,
2,
3
],
b: [
1,
2,
0
],
name: "difference at end"
},
{
a: [0],
b: [1],
name: "single byte difference"
},
{
a: [
255,
0,
255
],
b: [
255,
1,
255
],
name: "middle byte differs with edge values"
}
];
for (const tc of testCases) await t2.step(tc.name, () => {
assertFalse(timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b)));
});
});
await t.step("should return false for arrays with different lengths", async (t2) => {
const testCases = [
{
a: [
1,
2,
3
],
b: [1, 2],
name: "b shorter"
},
{
a: [1, 2],
b: [
1,
2,
3
],
name: "a shorter"
},
{
a: [],
b: [
1,
2,
3
],
name: "a empty, b non-empty"
},
{
a: [
1,
2,
3
],
b: [],
name: "a non-empty, b empty"
}
];
for (const tc of testCases) await t2.step(tc.name, () => {
assertFalse(timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b)));
});
});
await t.step("should return false where content matches up to shorter length", async (t2) => {
const testCases = [
{
a: [1, 2],
b: [
1,
2,
0
],
name: "a is prefix, b has trailing zero"
},
{
a: [
1,
2,
0
],
b: [1, 2],
name: "b is prefix, a has trailing zero"
},
{
a: [0],
b: [0, 0],
name: "single zero vs two zeros"
},
{
a: [0, 0],
b: [0],
name: "two zeros vs single zero"
}
];
for (const tc of testCases) await t2.step(tc.name, () => {
assertFalse(timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b)));
});
});
await t.step("should correctly handle comparisons involving padding bytes", async (t2) => {
await t2.step("a=[1], b=[1,0] (b longer with trailing zero)", () => {
const a1 = new Uint8Array([1]);
const b1 = new Uint8Array([1, 0]);
assertFalse(timingSafeEqual(a1, b1));
});
await t2.step("a=[1,0], b=[1] (a longer with trailing zero)", () => {
const a2 = new Uint8Array([1, 0]);
const b2 = new Uint8Array([1]);
assertFalse(timingSafeEqual(a2, b2));
});
});
});
test("signRequest() [rfc9421] error handling for invalid signature base creation", async () => {
const request = new Request("https://example.com/test", {
method: "POST",
body: "test body"
});
await assertThrows(() => {
createRfc9421SignatureBase(request, ["@unsupported"], "alg=\"rsa-pss-sha256\";keyid=\"https://example.com/key2\";created=1234567890");
}, Error, "Unsupported derived component: @unsupported");
const signedRequest = await signRequest(request, rsaPrivateKey2, new URL("https://example.com/key2"), { spec: "rfc9421" });
assertExists(signedRequest.headers.get("Signature-Input"));
assertExists(signedRequest.headers.get("Signature"));
});
test("verifyRequest() [rfc9421] error handling for invalid signature base creation", async () => {
const request = new Request("https://example.com/test", {
method: "GET",
headers: {
"Accept": "application/json",
"Signature-Input": "sig1=(\"@unsupported\");alg=\"rsa-pss-sha256\";keyid=\"https://example.com/key2\";created=1234567890",
"Signature": "sig1=:invalid_signature_data:"
}
});
const result = await verifyRequest(request, {
spec: "rfc9421",
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader
});
assertEquals(result, null, "Verification should fail gracefully for malformed signature inputs");
});
test("doubleKnock() regression test for TypeError: unusable bug #294", async () => {
esm_default.spyGlobal();
let requestCount = 0;
esm_default.post("https://example.com/inbox-retry-redirect", (_cl) => {
requestCount++;
if (requestCount === 1) return new Response("Unauthorized", { status: 401 });
else if (requestCount === 2) return Response.redirect("https://example.com/final-destination", 302);
return new Response("Should not reach here", { status: 500 });
});
esm_default.post("https://example.com/final-destination", () => {
return new Response("Success", { status: 200 });
});
const request = new Request("https://example.com/inbox-retry-redirect", {
method: "POST",
body: "Test activity content",
headers: { "Content-Type": "application/activity+json" }
});
const response = await doubleKnock(request, {
keyId: rsaPublicKey2.id,
privateKey: rsaPrivateKey2
});
assertEquals(response.status, 200);
assertEquals(requestCount, 2, "Should make 2 requests before redirect");
esm_default.hardReset();
});
test("doubleKnock() regression test for redirect handling bug", async () => {
esm_default.spyGlobal();
esm_default.post("https://example.com/inbox-retry-redirect", (_cl) => {
return new Response(null, {
status: 302,
headers: { location: "/final-destination" }
});
});
esm_default.post("https://example.com/final-destination", () => {
return new Response("Success", { status: 200 });
});
const request = new Request("https://example.com/inbox-retry-redirect", {
method: "POST",
body: "Test activity content",
headers: { "Content-Type": "application/activity+json" }
});
const response = await doubleKnock(request, {
keyId: rsaPublicKey2.id,
privateKey: rsaPrivateKey2
});
assertEquals(response.status, 200);
esm_default.hardReset();
});
//#endregion