diff --git a/CHANGES.md b/CHANGES.md index 8b4a6560e..be51ce130 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -73,6 +73,17 @@ To be released. - Added sender payload projection support for the published FEP-171b context, preserving blind-recipient redaction. [[#1250], [#1396] by Jiwon Kwon\] + - Added support for Ed25519 keys when signing requests with HTTP Message + Signatures (RFC 9421). `signRequest()` now accepts an Ed25519 private + key with `spec: "rfc9421"` and marks the signature with the `ed25519` + algorithm, and an `Accept-Signature` challenge that asks for `ed25519` can + now be fulfilled when the request is signed with an Ed25519 key. + Previously, Fedify could verify Ed25519 signatures but could produce only + `rsa-v1_5-sha256` ones. Outgoing activities are still signed with an + RSA-PKCS\#1-v1.5 key pair, because the draft cavage HTTP Signatures that + Fedify falls back to support only that key type, and a request signed with + an Ed25519 key is not retried with them. + [[#906], [#1438] by Jae-Hyuk-Jang\] - Added the `activity-origin-mismatch` reason to rejected `InboxAuthentication` decisions in `onRequestFinished` reports. Inbox deliveries rejected because the activity's ID does not have the same origin @@ -108,6 +119,7 @@ To be released. [FEP-7aa9]: https://w3id.org/fep/7aa9 [GHSA-4qhx-hhwf-8685]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-4qhx-hhwf-8685 +[#906]: https://github.com/fedify-dev/fedify/issues/906 [#1030]: https://github.com/fedify-dev/fedify/issues/1030 [#1177]: https://github.com/fedify-dev/fedify/issues/1177 [#1205]: https://github.com/fedify-dev/fedify/pull/1205 @@ -128,6 +140,7 @@ To be released. [#1395]: https://github.com/fedify-dev/fedify/pull/1395 [#1396]: https://github.com/fedify-dev/fedify/pull/1396 [#1415]: https://github.com/fedify-dev/fedify/pull/1415 +[#1438]: https://github.com/fedify-dev/fedify/pull/1438 ### @fedify/backfill diff --git a/changes.d/fedify/ed25519-http-message-signatures.md b/changes.d/fedify/ed25519-http-message-signatures.md new file mode 100644 index 000000000..e26fc7648 --- /dev/null +++ b/changes.d/fedify/ed25519-http-message-signatures.md @@ -0,0 +1,15 @@ +--- +links: + '#1438': https://github.com/fedify-dev/fedify/pull/1438 + '#906': https://github.com/fedify-dev/fedify/issues/906 +--- + - Added support for Ed25519 keys when signing requests with HTTP Message + Signatures (RFC 9421). `signRequest()` now accepts an Ed25519 private + key with `spec: "rfc9421"` and marks the signature with the `ed25519` + algorithm, and an `Accept-Signature` challenge that asks for `ed25519` can + now be fulfilled when the request is signed with an Ed25519 key. + Previously, Fedify could verify Ed25519 signatures but could produce only + `rsa-v1_5-sha256` ones. Outgoing activities are still signed with an + RSA-PKCS#1-v1.5 key pair, because the draft cavage HTTP Signatures that + Fedify falls back to support only that key type, and a request signed with + an Ed25519 key is not retried with them. [[#906], [#1438] by Jae-Hyuk-Jang] diff --git a/docs/manual/send.md b/docs/manual/send.md index 7e6383589..c9269b204 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1486,10 +1486,16 @@ the actor has any RSA-PKCS#1-v1.5 key pair. If there are multiple key pairs, Fedify selects the first RSA-PKCS#1-v1.5 key pair among them. > [!NOTE] -> Although HTTP Message Signatures support other than RSA-PKCS#1-v1.5, -> Fedify currently supports only RSA-PKCS#1-v1.5 key pairs for generating -> HTTP Message Signatures. This limitation will be lifted in the future -> releases. +> Since Fedify 2.5.0, `signRequest()` can also generate HTTP Message +> Signatures with Ed25519 keys, and an `Accept-Signature` challenge that asks +> for the `ed25519` algorithm can be fulfilled when the request is signed with +> an Ed25519 key. However, the draft cavage version still supports only +> RSA-PKCS#1-v1.5 keys, so Fedify keeps signing outgoing activities with +> an RSA-PKCS#1-v1.5 key pair: if the recipient server rejects HTTP Message +> Signatures, Fedify retries with the draft cavage version (see +> [*Double-knocking HTTP Signatures*](#double-knocking-http-signatures)). +> A request signed with an Ed25519 key is not retried with the draft cavage +> version; the recipient server's response is returned as is. [RFC 9421]: https://www.rfc-editor.org/rfc/rfc9421 diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 772ba9a52..ac7c23fb3 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -1178,6 +1178,53 @@ test("verifyRequest() [rfc9421] successful GET verification", async () => { ); }); +test("signRequest() and verifyRequest() [rfc9421] with an Ed25519 key", async () => { + const currentTime = Temporal.Instant.from("2024-03-05T08:09:44Z"); + const [tracerProvider, exporter] = createTestTracerProvider(); + const signed = await signRequest( + new Request("https://example.com/inbox", { + method: "POST", + headers: { "Content-Type": "text/plain; charset=utf-8" }, + body: "Hello, world!", + }), + ed25519PrivateKey, + ed25519PublicKey.id!, + { spec: "rfc9421", currentTime, tracerProvider }, + ); + const { sig1 } = parseRfc9421SignatureInput( + signed.headers.get("Signature-Input")!, + ); + assertEquals(sig1.alg, "ed25519"); + assertEquals(sig1.keyId, ed25519PublicKey.id?.href); + const span = exporter.getSpan("http_signatures.sign"); + assertEquals(span?.attributes["http_signatures.algorithm"], "ed25519"); + + const key = await verifyRequest(signed, { + contextLoader: mockDocumentLoader, + documentLoader: mockDocumentLoader, + spec: "rfc9421", + currentTime, + }); + assertEquals(key?.id?.href, ed25519PublicKey.id?.href); +}); + +test("signRequest() [draft-cavage] still rejects an Ed25519 key", async () => { + await assertRejects( + () => + signRequest( + new Request("https://example.com/inbox", { + method: "POST", + body: "Hello, world!", + }), + ed25519PrivateKey, + ed25519PublicKey.id!, + { spec: "draft-cavage-http-signatures-12" }, + ), + TypeError, + "Unsupported algorithm: Ed25519", + ); +}); + for (const failure of [ "missing key", "invalid signature", @@ -4400,6 +4447,112 @@ test("doubleKnock(): Accept-Signature challenge retry succeeds", async () => { fetchMock.hardReset(); }); +test("doubleKnock(): Accept-Signature challenge for ed25519 is fulfilled with an Ed25519 key", async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-challenge-ed25519", (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri" "@authority" "content-digest")' + + ';alg="ed25519";created;nonce="challenge-nonce-ed25519"', + }, + }); + } + const sigInput = req.headers.get("Signature-Input") ?? ""; + if ( + sigInput.includes('alg="ed25519"') && + sigInput.includes("challenge-nonce-ed25519") + ) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }); + + const request = new Request("https://example.com/inbox-challenge-ed25519", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: ed25519PublicKey.id!, + privateKey: ed25519PrivateKey, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); +}); + +test("doubleKnock(): an Ed25519 key returns an error response without draft-cavage fallback", async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-ed25519-not-found", () => { + requestCount++; + return new Response("Not Found", { status: 404 }); + }); + + const request = new Request("https://example.com/inbox-ed25519-not-found", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: ed25519PublicKey.id!, + privateKey: ed25519PrivateKey, + }); + + assertEquals(response.status, 404); + assertEquals(requestCount, 1); + + fetchMock.hardReset(); +}); + +test("doubleKnock(): an Ed25519 key returns a 401 left after the challenge retry", async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-ed25519-still-unauthorized", () => { + requestCount++; + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri" "@authority" "content-digest")' + + ';alg="ed25519";created', + }, + }); + }); + + const request = new Request( + "https://example.com/inbox-ed25519-still-unauthorized", + { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }, + ); + + const response = await doubleKnock(request, { + keyId: ed25519PublicKey.id!, + privateKey: ed25519PrivateKey, + }); + + assertEquals(response.status, 401); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); +}); + test("doubleKnock(): validates redirects after Accept-Signature challenge retry", async () => { fetchMock.spyGlobal(); let requestCount = 0; diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 1c54da330..3661f28d2 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -161,11 +161,14 @@ export interface Rfc9421SignRequestOptions { /** * Signs a request using the given private key. * @param request The request to sign. - * @param privateKey The private key to use for signing. + * @param privateKey The private key to use for signing. An RSASSA-PKCS1-v1_5 + * key can sign with either specification, while an Ed25519 + * key can sign only with RFC 9421 (since Fedify 2.5.0). * @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. + * @throws {TypeError} If the private key is invalid or unsupported by + * the specification. */ export async function signRequest( request: Request, @@ -606,6 +609,27 @@ export function selectRequestSignature( return new Request(copy, { headers }); } +/** + * Determines the RFC 9421 algorithm identifier to sign with the given private + * key. + * @param privateKey The private key to sign with. + * @returns The RFC 9421 algorithm identifier, or `null` if the key's algorithm + * is not supported for signing. + */ +function getRfc9421SigningAlgorithm( + privateKey: CryptoKey, +): "rsa-v1_5-sha256" | "ed25519" | null { + switch (privateKey.algorithm.name) { + case "RSASSA-PKCS1-v1_5": + // validateCryptoKey() admits only SHA-256 for RSASSA-PKCS1-v1_5 keys: + return "rsa-v1_5-sha256"; + case "Ed25519": + return "ed25519"; + default: + return null; + } +} + async function signRequestRfc9421( request: Request, privateKey: CryptoKey, @@ -615,7 +639,8 @@ async function signRequestRfc9421( bodyBuffer?: ArrayBuffer | null, rfc9421Options?: Rfc9421SignRequestOptions, ): Promise { - if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") { + const alg = getRfc9421SigningAlgorithm(privateKey); + if (alg == null) { throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); } @@ -668,7 +693,7 @@ async function signRequestRfc9421( ? ((currentTime.epochMilliseconds / 1000) | 0) + 3600 : undefined; const signatureParams = formatRfc9421SignatureParameters({ - algorithm: "rsa-v1_5-sha256", + algorithm: alg, keyId, created, expires, @@ -694,7 +719,7 @@ async function signRequestRfc9421( // Sign the signature base const signatureBytes = await crypto.subtle.sign( - "RSASSA-PKCS1-v1_5", + rfc9421AlgorithmMap[alg], privateKey, new TextEncoder().encode(signatureBase), ); @@ -728,7 +753,7 @@ async function signRequestRfc9421( ); if (span.isRecording()) { - span.setAttribute("http_signatures.algorithm", "rsa-v1_5-sha256"); + span.setAttribute("http_signatures.algorithm", alg); span.setAttribute("http_signatures.signature", encodeHex(signatureBytes)); span.setAttribute("http_signatures.created", created.toString()); } @@ -2444,12 +2469,12 @@ async function doubleKnockCore( // RFC 9421 §5: If the response includes an Accept-Signature header, // attempt a challenge-driven retry before falling back to spec-swap. const acceptSigHeader = response.headers.get("Accept-Signature"); - if (acceptSigHeader != null) { + const localAlg = getRfc9421SigningAlgorithm(identity.privateKey); + if (acceptSigHeader != null && localAlg != null) { const entries = validateAcceptSignature( parseAcceptSignature(acceptSigHeader), ); const localKeyId = identity.keyId.href; - const localAlg = "rsa-v1_5-sha256"; // RFC 9421 §5: "The target message of an Accept-Signature field MUST // include all labeled signatures indicated in the Accept-Signature // field." We therefore accumulate every compatible entry's signature @@ -2523,7 +2548,11 @@ async function doubleKnockCore( // Otherwise use the legacy fallback, or follow the redirect below. } - if (!isRedirectResponse(response)) { + // The draft-cavage fallback can sign only with RSASSA-PKCS1-v1_5 keys: + if ( + !isRedirectResponse(response) && + identity.privateKey.algorithm.name === "RSASSA-PKCS1-v1_5" + ) { observation?.response(response, "intermediate"); // verification failed; retry with the other spec of HTTP Signatures // (double-knocking; see https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions)