Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
15 changes: 15 additions & 0 deletions changes.d/fedify/ed25519-http-message-signatures.md
Original file line number Diff line number Diff line change
@@ -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]
14 changes: 10 additions & 4 deletions docs/manual/send.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
153 changes: 153 additions & 0 deletions packages/fedify/src/sig/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 38 additions & 9 deletions packages/fedify/src/sig/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -615,7 +639,8 @@ async function signRequestRfc9421(
bodyBuffer?: ArrayBuffer | null,
rfc9421Options?: Rfc9421SignRequestOptions,
): Promise<Request> {
if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") {
const alg = getRfc9421SigningAlgorithm(privateKey);
if (alg == null) {
throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name);
}

Expand Down Expand Up @@ -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,
Expand All @@ -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),
);
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading