From 731c6bd351a9734b07c6cd3ab46a9fb458b599f8 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Fri, 13 Nov 2026 10:53:56 +0900 Subject: [PATCH 1/5] Sign RFC 9421 requests with Ed25519 keys signRequestRfc9421() rejected every key except RSASSA-PKCS1-v1_5 and always labeled its signatures rsa-v1_5-sha256, although the verifier already accepts ed25519 and other RFC 9421 algorithms. Fedify could therefore verify more peer signatures than it could produce. This commit includes the following changes: - Chose the RFC 9421 algorithm from the private key and signed with the matching parameters from the verifier's algorithm map, so an Ed25519 key now produces an ed25519 signature. RSASSA-PKCS1-v1_5 keys still produce rsa-v1_5-sha256 signatures. - Recorded the chosen algorithm, always one of the known metric values, on the signing span. - Documented in signRequest() which keys each specification accepts. - Left draft-cavage signing limited to RSASSA-PKCS1-v1_5, and added tests for an Ed25519 round trip and for the draft-cavage rejection. https://github.com/fedify-dev/fedify/issues/906 Assisted-by: Claude Code:claude-opus-5-5 --- packages/fedify/src/sig/http.test.ts | 47 ++++++++++++++++++++++++++++ packages/fedify/src/sig/http.ts | 37 ++++++++++++++++++---- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 772ba9a52..0da2b394d 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", diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 1c54da330..577d490a4 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()); } From b59cfb554ba9823bb477705bc2d90ae7c67f77c2 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Fri, 13 Nov 2026 10:54:55 +0900 Subject: [PATCH 2/5] Answer ed25519 Accept-Signature challenges When a request was rejected with an Accept-Signature challenge, the retry always claimed rsa-v1_5-sha256 as the local algorithm, whatever the signing key was. A challenge asking for ed25519 was therefore skipped even for a request signed with an Ed25519 key, and the request fell back to draft-cavage, which cannot sign with that key. Derive the local algorithm from the signing key instead, and add a test for an ed25519 challenge answered with an Ed25519 key. https://github.com/fedify-dev/fedify/issues/906 Assisted-by: Claude Code:claude-opus-5-5 --- packages/fedify/src/sig/http.test.ts | 44 ++++++++++++++++++++++++++++ packages/fedify/src/sig/http.ts | 4 +-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 0da2b394d..a7d618f34 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -4447,6 +4447,50 @@ 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(): 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 577d490a4..3eccf7d1e 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -2469,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 From 954899b327e6de9bbf2321e00dbb231ffb113913 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Fri, 13 Nov 2026 10:55:36 +0900 Subject: [PATCH 3/5] Document Ed25519 HTTP Message Signatures The HTTP Message Signatures section said Fedify could generate them only with RSA-PKCS#1-v1.5 keys. Explain what Ed25519 keys can do now, and that outgoing activities are still signed with an RSA-PKCS#1-v1.5 key pair, because the draft-cavage fallback of double-knocking supports only that key type. Add a changelog entry for the change. https://github.com/fedify-dev/fedify/issues/906 Assisted-by: Claude Code:claude-opus-5-5 --- changes.d/fedify/ed25519-http-message-signatures.md | 13 +++++++++++++ docs/manual/send.md | 12 ++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 changes.d/fedify/ed25519-http-message-signatures.md 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..926a01c18 --- /dev/null +++ b/changes.d/fedify/ed25519-http-message-signatures.md @@ -0,0 +1,13 @@ +--- +links: + '#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. [[#906] by Jae-Hyuk-Jang] diff --git a/docs/manual/send.md b/docs/manual/send.md index 7e6383589..7358801dc 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1486,10 +1486,14 @@ 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)). [RFC 9421]: https://www.rfc-editor.org/rfc/rfc9421 From b19850a8b7482eb52fac31d2064e247f37cbc02b Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Fri, 13 Nov 2026 11:59:27 +0900 Subject: [PATCH 4/5] Skip draft-cavage fallback for non-RSA keys doubleKnock() retried every rejected request with the other spec of HTTP Signatures, whatever the signing key was. With an Ed25519 key, a 404 without Accept-Signature or a 401 left after a challenge retry made it re-sign the request with draft-cavage, which supports only RSASSA-PKCS1-v1_5 keys, so it threw TypeError instead of returning the response. Skip the draft-cavage fallback unless the key is an RSASSA-PKCS1-v1_5 key, and return the response instead; the skipped response is not remembered as the origin's spec either. Also note the behavior in the documentation and the changelog entry, and regenerate CHANGES.md, whose entry for this change was dropped while rebasing onto main. https://github.com/fedify-dev/fedify/pull/1438#pullrequestreview-5885027960 Assisted-by: Claude Code:claude-opus-5-5 --- CHANGES.md | 11 ++++ .../fedify/ed25519-http-message-signatures.md | 3 +- docs/manual/send.md | 2 + packages/fedify/src/sig/http.test.ts | 62 +++++++++++++++++++ packages/fedify/src/sig/http.ts | 6 +- 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8b4a6560e..688fae6ac 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -73,6 +73,16 @@ 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] 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 +118,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 diff --git a/changes.d/fedify/ed25519-http-message-signatures.md b/changes.d/fedify/ed25519-http-message-signatures.md index 926a01c18..01cbe2289 100644 --- a/changes.d/fedify/ed25519-http-message-signatures.md +++ b/changes.d/fedify/ed25519-http-message-signatures.md @@ -10,4 +10,5 @@ links: 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. [[#906] by Jae-Hyuk-Jang] + Fedify falls back to support only that key type, and a request signed with + an Ed25519 key is not retried with them. [[#906] by Jae-Hyuk-Jang] diff --git a/docs/manual/send.md b/docs/manual/send.md index 7358801dc..c9269b204 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1494,6 +1494,8 @@ Fedify selects the first RSA-PKCS#1-v1.5 key pair among them. > 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 a7d618f34..ac7c23fb3 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -4491,6 +4491,68 @@ test("doubleKnock(): Accept-Signature challenge for ed25519 is fulfilled with an 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 3eccf7d1e..3661f28d2 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -2548,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) From ab2b1f57f7e2d058387a1841559b22b044472cd8 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Fri, 13 Nov 2026 12:00:04 +0900 Subject: [PATCH 5/5] Add the pull request to the changelog entry The changelog entry referred only to the issue, so the check:changelog-pr-refs check failed on the pull request. https://github.com/fedify-dev/fedify/pull/1438 Assisted-by: Claude Code:claude-opus-5-5 --- CHANGES.md | 4 +++- changes.d/fedify/ed25519-http-message-signatures.md | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 688fae6ac..be51ce130 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -82,7 +82,8 @@ To be released. `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] by Jae-Hyuk-Jang\] + 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 @@ -139,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 index 01cbe2289..e26fc7648 100644 --- a/changes.d/fedify/ed25519-http-message-signatures.md +++ b/changes.d/fedify/ed25519-http-message-signatures.md @@ -1,5 +1,6 @@ --- 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 @@ -11,4 +12,4 @@ links: `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] by Jae-Hyuk-Jang] + an Ed25519 key is not retried with them. [[#906], [#1438] by Jae-Hyuk-Jang]