diff --git a/CHANGES.md b/CHANGES.md index 6ca206c5b..9bf1398fa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -34,8 +34,19 @@ To be released. caused a `500 Internal Server Error` when interoperating with servers like GoToSocial that have authorized fetch enabled. [[#473], [#589]] + - Added RFC 9421 §5 `Accept-Signature` negotiation for both outbound and + inbound paths. On the outbound side, `doubleKnock()` now parses + `Accept-Signature` challenges from `401` responses and retries with a + compatible RFC 9421 signature before falling back to legacy spec-swap. + On the inbound side, a new `InboxChallengePolicy` option in + `FederationOptions` enables emitting `Accept-Signature` headers on + inbox `401` responses, with optional one-time nonce support for replay + protection. [[#583], [#584] by ChanHaeng Lee] + [#472]: https://github.com/fedify-dev/fedify/issues/472 [#473]: https://github.com/fedify-dev/fedify/issues/473 +[#583]: https://github.com/fedify-dev/fedify/issues/583 +[#584]: https://github.com/fedify-dev/fedify/issues/584 [#589]: https://github.com/fedify-dev/fedify/pull/589 [#611]: https://github.com/fedify-dev/fedify/pull/611 diff --git a/docs/manual/inbox.md b/docs/manual/inbox.md index 37201c7c5..b1a258d28 100644 --- a/docs/manual/inbox.md +++ b/docs/manual/inbox.md @@ -37,6 +37,48 @@ why some activities are rejected, you can turn on [logging](./log.md) for [Linked Data Signatures]: https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/ [FEP-8b32]: https://w3id.org/fep/8b32 +### `Accept-Signature` challenges + +*This API is available since Fedify 2.1.0.* + +You can optionally enable [`Accept-Signature`] challenge emission on inbox +`401` responses by setting the `inboxChallengePolicy` option when creating +a `Federation`: + +~~~~ typescript +import { createFederation } from "@fedify/fedify"; + +const federation = createFederation({ + // ... other options ... + inboxChallengePolicy: { + enabled: true, + // Optional: customize covered components (defaults shown below) + // components: ["@method", "@target-uri", "@authority", "content-digest"], + // Optional: require a one-time nonce for replay protection + // requestNonce: false, + // Optional: nonce TTL in seconds (default: 300) + // nonceTtlSeconds: 300, + }, +}); +~~~~ + +When enabled, if HTTP Signature verification fails, the `401` response will +include an `Accept-Signature` header telling the sender which components and +parameters to include in a new signature. Senders that support [RFC 9421 §5] +(including Fedify 2.1.0+) will automatically retry with the requested +parameters. + +Note that actor/key mismatch `401` responses are *not* challenged, since +re-signing with different parameters does not resolve an impersonation issue. + +When `requestNonce` is enabled, a cryptographically random nonce is included +in each challenge and must be echoed back in the retry signature. The nonce +is stored in the key-value store and consumed on use, providing replay +protection. Nonces expire after `nonceTtlSeconds` (default: 5 minutes). + +[`Accept-Signature`]: https://www.rfc-editor.org/rfc/rfc9421#section-5.1 +[RFC 9421 §5]: https://www.rfc-editor.org/rfc/rfc9421#section-5 + Handling unverified activities ------------------------------ diff --git a/docs/manual/send.md b/docs/manual/send.md index 2d58b4360..51b4805cf 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -984,6 +984,39 @@ to the draft cavage version and remembers it for the next time. [double-knocking]: https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions +### `Accept-Signature` negotiation + +*This API is available since Fedify 2.1.0.* + +In addition to double-knocking, Fedify supports the [`Accept-Signature`] +challenge-response negotiation defined in [RFC 9421 §5]. When a recipient +server responds with a `401` status and includes an `Accept-Signature` header, +Fedify automatically parses the challenge, validates it, and retries the +request with the requested signature parameters (e.g., specific covered +components, a nonce, or a tag). + +Safety constraints prevent abuse: + + - The requested algorithm (`alg`) must match the local private key's + algorithm; otherwise the challenge entry is skipped. + - The requested key identifier (`keyid`) must match the local key; otherwise + the challenge entry is skipped. + - Fedify's minimum covered component set (`@method`, `@target-uri`, + `@authority`) is always included, even if the challenge does not request + them. + +If the challenge cannot be fulfilled (e.g., incompatible algorithm), +Fedify falls through to the existing double-knocking spec-swap fallback. +At most three signed request attempts are made to the final URL per delivery +attempt (redirects may add extra HTTP requests): + +1. Initial signed request +2. Challenge-driven retry (if `Accept-Signature` is present) +3. Legacy spec-swap retry (if the challenge retry also fails) + +[`Accept-Signature`]: https://www.rfc-editor.org/rfc/rfc9421#section-5.1 +[RFC 9421 §5]: https://www.rfc-editor.org/rfc/rfc9421#section-5 + Linked Data Signatures ---------------------- diff --git a/examples/astro/deno.json b/examples/astro/deno.json index 052c758ba..030a9ebea 100644 --- a/examples/astro/deno.json +++ b/examples/astro/deno.json @@ -1,4 +1,7 @@ { + "compilerOptions": { + "moduleResolution": "nodenext" + }, "imports": { "@deno/astro-adapter": "npm:@deno/astro-adapter@^0.3.2" }, diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index d45678fa7..2623d8e55 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -775,6 +775,43 @@ export interface FederationBuilder ): Promise>; } +/** + * Policy for emitting `Accept-Signature` challenges on inbox `401` + * responses, as defined in + * [RFC 9421 §5](https://www.rfc-editor.org/rfc/rfc9421#section-5). + * @since 2.1.0 + */ +export interface InboxChallengePolicy { + /** + * Whether to emit `Accept-Signature` headers on `401` responses + * caused by HTTP Signature verification failures. + */ + enabled: boolean; + + /** + * The covered component identifiers to request. Only request-applicable + * identifiers should be used (`@status` is automatically excluded). + * @default `["@method", "@target-uri", "@authority", "content-digest"]` + */ + components?: string[]; + + /** + * Whether to generate and require a one-time nonce for replay protection. + * When enabled, a cryptographically random nonce is included in each + * challenge and verified on subsequent requests. Requires a + * {@link KvStore}. + * @default `false` + */ + requestNonce?: boolean; + + /** + * The time-to-live (in seconds) for stored nonces. After this period, + * nonces expire and are no longer accepted. + * @default `300` (5 minutes) + */ + nonceTtlSeconds?: number; +} + /** * Options for creating a {@link Federation} object. * @template TContextData The context data to pass to the {@link Context}. @@ -931,6 +968,17 @@ export interface FederationOptions { */ firstKnock?: HttpMessageSignaturesSpec; + /** + * The policy for emitting `Accept-Signature` challenges on inbox `401` + * responses (RFC 9421 §5). When enabled, failed HTTP Signature + * verification responses will include an `Accept-Signature` header + * telling the sender which components and parameters to include. + * + * Disabled by default (no `Accept-Signature` header is emitted). + * @since 2.1.0 + */ + inboxChallengePolicy?: InboxChallengePolicy; + /** * The retry policy for sending activities to recipients' inboxes. * By default, this uses an exponential backoff strategy with a maximum of diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 41c4e55f6..da00524c8 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -12,6 +12,7 @@ import { } from "@fedify/vocab"; import { FetchError } from "@fedify/vocab-runtime"; import { assert, assertEquals } from "@std/assert"; +import { parseAcceptSignature } from "../sig/accept.ts"; import { signRequest } from "../sig/http.ts"; import { createInboxContext, @@ -1082,6 +1083,7 @@ test("handleInbox()", async () => { kvPrefixes: { activityIdempotence: ["_fedify", "activityIdempotence"], publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], }, actorDispatcher, onNotFound, @@ -1350,6 +1352,7 @@ test("handleInbox() - authentication bypass vulnerability", async () => { kvPrefixes: { activityIdempotence: ["_fedify", "activityIdempotence"], publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], }, actorDispatcher, inboxListeners, @@ -1894,6 +1897,7 @@ test("handleInbox() records OpenTelemetry span events", async () => { kvPrefixes: { activityIdempotence: ["activityIdempotence"], publicKey: ["publicKey"], + acceptSignatureNonce: ["acceptSignatureNonce"], }, actorDispatcher, inboxListeners: listeners, @@ -2008,6 +2012,7 @@ test("handleInbox() records unverified HTTP signature details", async () => { kvPrefixes: { activityIdempotence: ["activityIdempotence"], publicKey: ["publicKey"], + acceptSignatureNonce: ["acceptSignatureNonce"], }, actorDispatcher, inboxListeners: new InboxListenerSet(), @@ -2047,3 +2052,781 @@ test("handleInbox() records unverified HTTP signature details", async () => { ); assertEquals(event.attributes["http_signatures.key_fetch_status"], 410); }); + +test("handleInbox() challenge policy enabled + unsigned request", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/challenge-1"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/challenge-1"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const unsignedRequest = new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: unsignedRequest, + url: new URL(unsignedRequest.url), + data: undefined, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(unsignedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { enabled: true }, + }); + assertEquals(response.status, 401); + const acceptSig = response.headers.get("Accept-Signature"); + assert(acceptSig != null, "Accept-Signature header must be present"); + const parsed = parseAcceptSignature(acceptSig); + assert(parsed.length > 0, "Accept-Signature must have at least one entry"); + assertEquals(parsed[0].label, "sig1"); + assert( + parsed[0].components.includes("@method"), + "Must include @method component", + ); + assertEquals( + response.headers.get("Cache-Control"), + "no-store", + ); + assertEquals( + response.headers.get("Vary"), + "Accept, Signature", + ); +}); + +test("handleInbox() challenge policy enabled + invalid signature", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/challenge-2"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/challenge-2"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + // Sign with a key, then tamper with the body to invalidate the signature + const originalRequest = new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }); + const signedRequest = await signRequest( + originalRequest, + rsaPrivateKey3, + rsaPublicKey3.id!, + ); + // Reconstruct with a different body but same signature headers + const jsonLd = await activity.toJsonLd() as Record; + const tamperedBody = JSON.stringify({ + ...jsonLd, + "https://example.com/tampered": true, + }); + const tamperedRequest = new Request(signedRequest.url, { + method: signedRequest.method, + headers: signedRequest.headers, + body: tamperedBody, + }); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: tamperedRequest, + url: new URL(tamperedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(tamperedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { enabled: true }, + }); + assertEquals(response.status, 401); + const acceptSig = response.headers.get("Accept-Signature"); + assert(acceptSig != null, "Accept-Signature header must be present"); + assertEquals(response.headers.get("Cache-Control"), "no-store"); +}); + +test("handleInbox() challenge policy enabled + valid signature", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/challenge-3"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/challenge-3"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const federation = createFederation({ kv: new MemoryKvStore() }); + const signedRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + ); + const context = createRequestContext({ + federation, + request: signedRequest, + url: new URL(signedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(signedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { enabled: true }, + }); + assertEquals(response.status, 202); + assertEquals( + response.headers.get("Accept-Signature"), + null, + "No Accept-Signature header on successful request", + ); +}); + +test("handleInbox() challenge policy disabled + unsigned request", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/challenge-4"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/challenge-4"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const unsignedRequest = new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: unsignedRequest, + url: new URL(unsignedRequest.url), + data: undefined, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(unsignedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + // No inboxChallengePolicy — disabled by default + }); + assertEquals(response.status, 401); + assertEquals( + response.headers.get("Accept-Signature"), + null, + "No Accept-Signature header when challenge policy is disabled", + ); +}); + +test("handleInbox() actor/key mismatch → plain 401 (no challenge)", async () => { + // Sign with attacker's key but claim to be a different actor + const maliciousActivity = new Create({ + id: new URL("https://attacker.example.com/activities/challenge-5"), + actor: new URL("https://victim.example.com/users/alice"), + object: new Note({ + id: new URL("https://attacker.example.com/notes/challenge-5"), + attribution: new URL("https://victim.example.com/users/alice"), + content: "Forged message!", + }), + }); + const maliciousRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await maliciousActivity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + ); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: maliciousRequest, + url: new URL(maliciousRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(maliciousRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { enabled: true }, + }); + assertEquals(response.status, 401); + assertEquals( + response.headers.get("Accept-Signature"), + null, + "Actor/key mismatch should not emit Accept-Signature challenge", + ); + assertEquals( + await response.text(), + "The signer and the actor do not match.", + ); +}); + +test("handleInbox() nonce issuance in challenge", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/nonce-1"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/nonce-1"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const unsignedRequest = new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: unsignedRequest, + url: new URL(unsignedRequest.url), + data: undefined, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(unsignedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { + enabled: true, + requestNonce: true, + nonceTtlSeconds: 300, + }, + }); + assertEquals(response.status, 401); + const acceptSig = response.headers.get("Accept-Signature"); + assert(acceptSig != null, "Accept-Signature header must be present"); + const parsed = parseAcceptSignature(acceptSig); + assert(parsed.length > 0); + assert( + parsed[0].parameters.nonce != null, + "Nonce must be present in Accept-Signature parameters", + ); + assertEquals(response.headers.get("Cache-Control"), "no-store"); + // Verify the nonce was stored in KV + const nonceKey = [ + "_fedify", + "acceptSignatureNonce", + parsed[0].parameters.nonce!, + ] as const; + const stored = await kv.get(nonceKey); + assertEquals(stored, true, "Nonce must be stored in KV store"); +}); + +test("handleInbox() nonce consumption on valid signed request", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/nonce-2"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/nonce-2"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const kv = new MemoryKvStore(); + const noncePrefix = ["_fedify", "acceptSignatureNonce"] as const; + // Pre-store a nonce in KV + const nonce = "test-nonce-abc123"; + await kv.set( + ["_fedify", "acceptSignatureNonce", nonce] as const, + true, + { ttl: Temporal.Duration.from({ seconds: 300 }) }, + ); + // Sign request with the nonce included via rfc9421 + const signedRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + { spec: "rfc9421", rfc9421: { nonce } }, + ); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: signedRequest, + url: new URL(signedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const response = await handleInbox(signedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: noncePrefix, + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { + enabled: true, + requestNonce: true, + nonceTtlSeconds: 300, + }, + }); + assertEquals(response.status, 202); + // Nonce must have been consumed (deleted from KV) + const stored = await kv.get( + ["_fedify", "acceptSignatureNonce", nonce] as const, + ); + assertEquals(stored, undefined, "Nonce must be consumed after use"); +}); + +test("handleInbox() nonce replay prevention", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/nonce-3"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/nonce-3"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const kv = new MemoryKvStore(); + const noncePrefix = ["_fedify", "acceptSignatureNonce"] as const; + const nonce = "replay-nonce-xyz"; + // Do NOT store the nonce — simulate it was already consumed or never issued + const signedRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + { spec: "rfc9421", rfc9421: { nonce } }, + ); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: signedRequest, + url: new URL(signedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const response = await handleInbox(signedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: noncePrefix, + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { + enabled: true, + requestNonce: true, + nonceTtlSeconds: 300, + }, + }); + assertEquals(response.status, 401); + // Should return a fresh challenge with a new nonce + const acceptSig = response.headers.get("Accept-Signature"); + assert(acceptSig != null, "Must emit fresh Accept-Signature challenge"); + const parsed = parseAcceptSignature(acceptSig); + assert(parsed.length > 0); + assert( + parsed[0].parameters.nonce != null, + "Fresh challenge must include a new nonce", + ); + assert( + parsed[0].parameters.nonce !== nonce, + "Fresh nonce must differ from the replayed one", + ); + assertEquals( + response.headers.get("Cache-Control"), + "no-store", + "Challenge response must have Cache-Control: no-store", + ); +}); + +test( + "handleInbox() nonce bypass: valid sig without nonce + invalid sig with nonce", + async () => { + // This test demonstrates a vulnerability where verifySignatureNonce() scans + // ALL Signature-Input entries for a nonce, but verifyRequestDetailed() does + // not report which signature label was verified. An attacker can bypass + // nonce enforcement by submitting: + // 1. A valid signature (sig1) WITHOUT a nonce + // 2. A bogus signature (sig2) that carries a stored nonce + // verifyRequestDetailed() succeeds on sig1, then verifySignatureNonce() + // finds and consumes the nonce from sig2, so the request is accepted even + // though the *verified* signature never carried a nonce. + + const activity = new Create({ + id: new URL("https://example.com/activities/nonce-bypass-1"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/nonce-bypass-1"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + + const kv = new MemoryKvStore(); + const noncePrefix = ["_fedify", "acceptSignatureNonce"] as const; + + // Pre-store a nonce that the attacker knows (e.g., from a prior challenge) + const storedNonce = "bypass-nonce-abc123"; + await kv.set( + ["_fedify", "acceptSignatureNonce", storedNonce] as const, + true, + { ttl: Temporal.Duration.from({ seconds: 300 }) }, + ); + + // Step 1: Create a legitimately signed request (sig1) WITHOUT a nonce + const signedRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + { spec: "rfc9421" }, // no nonce + ); + + // Step 2: Manually inject a second bogus signature entry (sig2) that carries + // the stored nonce. The signature bytes are garbage — it will never verify — + // but verifySignatureNonce() doesn't check validity, only presence. + const existingSignatureInput = signedRequest.headers.get( + "Signature-Input", + )!; + const existingSignature = signedRequest.headers.get("Signature")!; + const bogusSigInput = `sig2=("@method" "@target-uri");` + + `alg="rsa-v1_5-sha256";keyid="${rsaPublicKey3.id!.href}";` + + `created=${Math.floor(Date.now() / 1000)};` + + `nonce="${storedNonce}"`; + const bogusSigValue = `sig2=:AAAA:`; // garbage base64 + + const tamperedHeaders = new Headers(signedRequest.headers); + tamperedHeaders.set( + "Signature-Input", + `${existingSignatureInput}, ${bogusSigInput}`, + ); + tamperedHeaders.set( + "Signature", + `${existingSignature}, ${bogusSigValue}`, + ); + + const tamperedRequest = new Request(signedRequest.url, { + method: signedRequest.method, + headers: tamperedHeaders, + body: await signedRequest.clone().arrayBuffer(), + }); + + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: tamperedRequest, + url: new URL(tamperedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + + const response = await handleInbox(tamperedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: noncePrefix, + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { + enabled: true, + requestNonce: true, + nonceTtlSeconds: 300, + }, + }); + + // The verified signature (sig1) has no nonce. The nonce was only in the + // bogus sig2. A correct implementation MUST reject this request because + // the *verified* signature did not carry a valid nonce. + assertEquals( + response.status, + 401, + "Request with nonce only in a non-verified signature must be rejected " + + "(nonce verification must be bound to the verified signature label)", + ); + + // The stored nonce should NOT have been consumed by a bogus signature + const stored = await kv.get( + ["_fedify", "acceptSignatureNonce", storedNonce] as const, + ); + assertEquals( + stored, + true, + "Nonce must not be consumed when it comes from a non-verified signature", + ); + }, +); + +test( + "handleInbox() actor/key mismatch does not consume nonce", + async () => { + // A request that has a valid RFC 9421 signature with a nonce, but the + // signing key does not belong to the claimed actor. The nonce must NOT be + // consumed so the legitimate sender can still use it. + const maliciousActivity = new Create({ + id: new URL("https://attacker.example.com/activities/mismatch-nonce-1"), + actor: new URL("https://victim.example.com/users/alice"), + object: new Note({ + id: new URL("https://attacker.example.com/notes/mismatch-nonce-1"), + attribution: new URL("https://victim.example.com/users/alice"), + content: "Forged message with nonce!", + }), + }); + const kv = new MemoryKvStore(); + const noncePrefix = ["_fedify", "acceptSignatureNonce"] as const; + const nonce = "mismatch-nonce-xyz"; + await kv.set( + ["_fedify", "acceptSignatureNonce", nonce] as const, + true, + { ttl: Temporal.Duration.from({ seconds: 300 }) }, + ); + // Sign with rsaPrivateKey3 (associated with example.com/person2, not + // victim.example.com/users/alice), and include the stored nonce. + const maliciousRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await maliciousActivity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + { spec: "rfc9421", rfc9421: { nonce } }, + ); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: maliciousRequest, + url: new URL(maliciousRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const response = await handleInbox(maliciousRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: noncePrefix, + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { + enabled: true, + requestNonce: true, + nonceTtlSeconds: 300, + }, + }); + assertEquals(response.status, 401); + assertEquals( + await response.text(), + "The signer and the actor do not match.", + ); + // The nonce must NOT have been consumed — the actor/key mismatch should + // reject before nonce consumption so the nonce remains usable. + const stored = await kv.get( + ["_fedify", "acceptSignatureNonce", nonce] as const, + ); + assertEquals( + stored, + true, + "Nonce must not be consumed when actor/key ownership check fails", + ); + }, +); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 18703eaba..36e64fd05 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -20,7 +20,12 @@ import type { } from "@opentelemetry/api"; import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import metadata from "../../deno.json" with { type: "json" }; -import { verifyRequestDetailed } from "../sig/http.ts"; +import type { AcceptSignatureMember } from "../sig/accept.ts"; +import { formatAcceptSignature } from "../sig/accept.ts"; +import { + parseRfc9421SignatureInput, + verifyRequestDetailed, +} from "../sig/http.ts"; import { detachSignature, verifyJsonLd } from "../sig/ld.ts"; import { doesActorOwnKey } from "../sig/owner.ts"; import { verifyObject } from "../sig/proof.ts"; @@ -44,6 +49,7 @@ import type { ConstructorWithTypeId, IdempotencyKeyCallback, IdempotencyStrategy, + InboxChallengePolicy, } from "./federation.ts"; import { type InboxListenerSet, routeActivity } from "./inbox.ts"; import { KvKeyCache } from "./keycache.ts"; @@ -461,6 +467,7 @@ export interface InboxHandlerParameters { kvPrefixes: { activityIdempotence: KvKey; publicKey: KvKey; + acceptSignatureNonce: KvKey; }; queue?: MessageQueue; actorDispatcher?: ActorDispatcher; @@ -470,6 +477,7 @@ export interface InboxHandlerParameters { onNotFound(request: Request): Response | Promise; signatureTimeWindow: Temporal.Duration | Temporal.DurationLike | false; skipSignatureVerification: boolean; + inboxChallengePolicy?: InboxChallengePolicy; idempotencyStrategy?: | IdempotencyStrategy | IdempotencyKeyCallback; @@ -538,6 +546,7 @@ async function handleInboxInternal( onNotFound, signatureTimeWindow, skipSignatureVerification, + inboxChallengePolicy, tracerProvider, } = parameters; const logger = getLogger(["fedify", "federation", "inbox"]); @@ -677,6 +686,9 @@ async function handleInboxInternal( } } let httpSigKey: CryptographicKey | null = null; + // Nonce verification is deferred until after actor/key ownership is checked + // to avoid consuming nonces on requests that will be rejected anyway. + let pendingNonceLabel: string | undefined | null = null; if (activity == null) { if (!skipSignatureVerification) { const verification = await verifyRequestDetailed(request, { @@ -701,12 +713,21 @@ async function handleInboxInternal( message: `Failed to verify the request's HTTP Signatures.`, }); if (unverifiedActivityHandler == null) { + const headers: Record = { + "Content-Type": "text/plain; charset=utf-8", + }; + if (inboxChallengePolicy?.enabled) { + headers["Accept-Signature"] = await buildAcceptSignatureHeader( + inboxChallengePolicy, + kv, + kvPrefixes.acceptSignatureNonce, + ); + headers["Cache-Control"] = "no-store"; + headers["Vary"] = "Accept, Signature"; + } return new Response( "Failed to verify the request signature.", - { - status: 401, - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }, + { status: 401, headers }, ); } try { @@ -797,6 +818,13 @@ async function handleInboxInternal( }, ); } else { + if ( + inboxChallengePolicy?.enabled && inboxChallengePolicy.requestNonce + ) { + // Defer nonce consumption until after actor/key ownership check to + // avoid burning nonces on requests that will be rejected anyway. + pendingNonceLabel = verification.signatureLabel; + } logger.debug("HTTP Signatures are verified.", { recipient }); activityVerified = true; } @@ -840,6 +868,35 @@ async function handleInboxInternal( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } + // Perform deferred nonce verification now that actor/key ownership is confirmed. + if (pendingNonceLabel != null) { + const nonceValid = await verifySignatureNonce( + request, + kv, + kvPrefixes.acceptSignatureNonce, + pendingNonceLabel, + ); + if (!nonceValid) { + logger.error( + "Signature nonce verification failed (missing, expired, or replayed).", + { recipient }, + ); + const headers: Record = { + "Content-Type": "text/plain; charset=utf-8", + }; + headers["Accept-Signature"] = await buildAcceptSignatureHeader( + inboxChallengePolicy!, + kv, + kvPrefixes.acceptSignatureNonce, + ); + headers["Cache-Control"] = "no-store"; + headers["Vary"] = "Accept, Signature"; + return new Response( + "Signature nonce verification failed.", + { status: 401, headers }, + ); + } + } const routeResult = await routeActivity({ context: ctx, json, @@ -1630,3 +1687,89 @@ export async function respondWithObjectIfAcceptable( response.headers.set("Vary", "Accept"); return response; } + +const DEFAULT_CHALLENGE_COMPONENTS = [ + "@method", + "@target-uri", + "@authority", + "content-digest", +]; + +// Minimum set of components that must always appear in a challenge to ensure +// basic request binding. These are merged with any caller-supplied components. +const MINIMUM_CHALLENGE_COMPONENTS = [ + "@method", + "@target-uri", + "@authority", +]; + +function generateNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + // Base64url encoding without padding + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function verifySignatureNonce( + request: Request, + kv: KvStore, + noncePrefix: KvKey, + verifiedLabel?: string, +): Promise { + const signatureInput = request.headers.get("Signature-Input"); + if (signatureInput == null) return false; + const parsed = parseRfc9421SignatureInput(signatureInput); + // Only check the nonce from the verified signature label to prevent bypass + // attacks where a bogus signature carries a valid nonce while a different + // signature (without a nonce) is the one that actually verified. + // Nonces are only supported for RFC 9421 signatures. If no verified label + // is available (e.g., draft-cavage), skip nonce verification entirely to + // prevent a decoupled-check bypass via a non-RFC-9421 path. + if (verifiedLabel == null) return false; + const sig = parsed[verifiedLabel]; + if (sig == null) return false; + const nonce = sig.nonce; + if (nonce == null) return false; + const key = [...noncePrefix, nonce] as unknown as KvKey; + const stored = await kv.get(key); + if (stored != null) { + await kv.delete(key); + return true; + } + return false; +} + +async function buildAcceptSignatureHeader( + policy: InboxChallengePolicy, + kv: KvStore, + noncePrefix: KvKey, +): Promise { + const params: AcceptSignatureMember["parameters"] = {}; + params.created = true; + if (policy.requestNonce) { + const nonce = generateNonce(); + const ttl = Temporal.Duration.from({ + seconds: policy.nonceTtlSeconds ?? 300, + }); + const key = [...noncePrefix, nonce] as unknown as KvKey; + await kv.set(key, true, { ttl }); + params.nonce = nonce; + } + const baseComponents = policy.components ?? DEFAULT_CHALLENGE_COMPONENTS; + // Always include the minimum required components to ensure basic request + // binding, then deduplicate and exclude response-only @status. + const components = [ + ...new globalThis.Set([ + ...MINIMUM_CHALLENGE_COMPONENTS, + ...baseComponents, + ]), + ].filter((c) => c !== "@status"); + return formatAcceptSignature([{ + label: "sig1", + components, + parameters: params, + }]); +} diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 7fe1b91ab..fb901ec26 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -83,6 +83,7 @@ import type { FederationFetchOptions, FederationOptions, FederationStartQueueOptions, + InboxChallengePolicy, } from "./federation.ts"; import { handleActor, @@ -172,6 +173,14 @@ export interface FederationKvPrefixes { * @since 1.6.0 */ readonly httpMessageSignaturesSpec: KvKey; + + /** + * The key prefix used for storing `Accept-Signature` challenge nonces. + * Only used when {@link InboxChallengePolicy.requestNonce} is `true`. + * @default `["_fedify", "acceptSignatureNonce"]` + * @since 2.1.0 + */ + readonly acceptSignatureNonce: KvKey; } /** @@ -233,6 +242,7 @@ export class FederationImpl activityTransformers: readonly ActivityTransformer[]; _tracerProvider: TracerProvider | undefined; firstKnock?: HttpMessageSignaturesSpec; + inboxChallengePolicy?: InboxChallengePolicy; constructor(options: FederationOptions) { super(); @@ -243,6 +253,7 @@ export class FederationImpl remoteDocument: ["_fedify", "remoteDocument"], publicKey: ["_fedify", "publicKey"], httpMessageSignaturesSpec: ["_fedify", "httpMessageSignaturesSpec"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], } satisfies FederationKvPrefixes), ...(options.kvPrefixes ?? {}), }; @@ -369,6 +380,7 @@ export class FederationImpl [404, 410]; this.signatureTimeWindow = options.signatureTimeWindow ?? { hours: 1 }; this.skipSignatureVerification = options.skipSignatureVerification ?? false; + this.inboxChallengePolicy = options.inboxChallengePolicy; this.outboxRetryPolicy = options.outboxRetryPolicy ?? createExponentialBackoffPolicy(); this.inboxRetryPolicy = options.inboxRetryPolicy ?? @@ -1485,6 +1497,7 @@ export class FederationImpl onNotFound, signatureTimeWindow: this.signatureTimeWindow, skipSignatureVerification: this.skipSignatureVerification, + inboxChallengePolicy: this.inboxChallengePolicy, tracerProvider: this.tracerProvider, idempotencyStrategy: this.idempotencyStrategy, }); diff --git a/packages/fedify/src/sig/accept.test.ts b/packages/fedify/src/sig/accept.test.ts new file mode 100644 index 000000000..43e1e2c76 --- /dev/null +++ b/packages/fedify/src/sig/accept.test.ts @@ -0,0 +1,292 @@ +import { test } from "@fedify/fixture"; +import { deepStrictEqual, strictEqual } from "node:assert/strict"; +import { + type AcceptSignatureMember, + formatAcceptSignature, + fulfillAcceptSignature, + parseAcceptSignature, + validateAcceptSignatureForRequest, +} from "./accept.ts"; + +// --------------------------------------------------------------------------- +// parseAcceptSignature() +// --------------------------------------------------------------------------- + +test("parseAcceptSignature(): single entry", () => { + const result = parseAcceptSignature( + 'sig1=("@method" "@target-uri")', + ); + strictEqual(result.length, 1); + strictEqual(result[0].label, "sig1"); + deepStrictEqual(result[0].components, ["@method", "@target-uri"]); + deepStrictEqual(result[0].parameters, {}); +}); + +test("parseAcceptSignature(): multiple entries", () => { + const result = parseAcceptSignature( + 'sig1=("@method"), sig2=("@authority")', + ); + strictEqual(result.length, 2); + strictEqual(result[0].label, "sig1"); + deepStrictEqual(result[0].components, ["@method"]); + strictEqual(result[1].label, "sig2"); + deepStrictEqual(result[1].components, ["@authority"]); +}); + +test("parseAcceptSignature(): all six parameters", () => { + const result = parseAcceptSignature( + 'sig1=("@method");keyid="k1";alg="rsa-v1_5-sha256"' + + ';created;expires;nonce="abc";tag="t1"', + ); + strictEqual(result.length, 1); + deepStrictEqual(result[0].parameters, { + keyid: "k1", + alg: "rsa-v1_5-sha256", + created: true, + expires: true, + nonce: "abc", + tag: "t1", + }); +}); + +test("parseAcceptSignature(): no parameters", () => { + const result = parseAcceptSignature( + 'sig1=("@method" "@target-uri")', + ); + deepStrictEqual(result[0].parameters, {}); +}); + +test("parseAcceptSignature(): malformed header", () => { + deepStrictEqual(parseAcceptSignature("not a valid structured field"), []); +}); + +test("parseAcceptSignature(): empty string", () => { + deepStrictEqual(parseAcceptSignature(""), []); +}); + +// --------------------------------------------------------------------------- +// formatAcceptSignature() +// --------------------------------------------------------------------------- + +test("formatAcceptSignature(): single entry with created", () => { + const members: AcceptSignatureMember[] = [{ + label: "sig1", + components: ["@method", "@target-uri", "@authority"], + parameters: { created: true }, + }]; + const header = formatAcceptSignature(members); + // Output must be a valid structured field that can be round-tripped. + const parsed = parseAcceptSignature(header); + strictEqual(parsed.length, 1); + strictEqual(parsed[0].label, "sig1"); + deepStrictEqual(parsed[0].components, [ + "@method", + "@target-uri", + "@authority", + ]); + strictEqual(parsed[0].parameters.created, true); +}); + +test("formatAcceptSignature(): created + nonce", () => { + const members: AcceptSignatureMember[] = [{ + label: "sig1", + components: ["@method"], + parameters: { + created: true, + nonce: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + }, + }]; + const header = formatAcceptSignature(members); + const parsed = parseAcceptSignature(header); + strictEqual( + parsed[0].parameters.nonce, + "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + ); + strictEqual(parsed[0].parameters.created, true); +}); + +test("formatAcceptSignature(): multiple entries", () => { + const members: AcceptSignatureMember[] = [ + { + label: "sig1", + components: ["@method"], + parameters: {}, + }, + { + label: "sig2", + components: ["@authority", "content-digest"], + parameters: { tag: "app-123" }, + }, + ]; + const header = formatAcceptSignature(members); + const parsed = parseAcceptSignature(header); + strictEqual(parsed.length, 2); + strictEqual(parsed[0].label, "sig1"); + strictEqual(parsed[1].label, "sig2"); + strictEqual(parsed[1].parameters.tag, "app-123"); +}); + +test("formatAcceptSignature(): round-trip with all parameters", () => { + const input: AcceptSignatureMember[] = [{ + label: "sig1", + components: [ + "@method", + "@target-uri", + "@authority", + "content-digest", + ], + parameters: { + keyid: "test-key-rsa-pss", + alg: "rsa-pss-sha512", + created: true, + expires: true, + nonce: "abc123", + tag: "app-123", + }, + }]; + const roundTripped = parseAcceptSignature( + formatAcceptSignature(input), + ); + deepStrictEqual(roundTripped, input); +}); + +// --------------------------------------------------------------------------- +// validateAcceptSignatureForRequest() +// --------------------------------------------------------------------------- + +test("validateAcceptSignatureForRequest(): filters out @status", () => { + const members: AcceptSignatureMember[] = [{ + label: "sig1", + components: ["@method", "@status"], + parameters: {}, + }]; + deepStrictEqual(validateAcceptSignatureForRequest(members), []); +}); + +test("validateAcceptSignatureForRequest(): passes valid entries", () => { + const members: AcceptSignatureMember[] = [{ + label: "sig1", + components: ["@method", "@target-uri"], + parameters: {}, + }]; + deepStrictEqual(validateAcceptSignatureForRequest(members), members); +}); + +test( + "validateAcceptSignatureForRequest(): mixed valid and invalid", + () => { + const valid: AcceptSignatureMember = { + label: "sig1", + components: ["@method", "@target-uri"], + parameters: {}, + }; + const invalid: AcceptSignatureMember = { + label: "sig2", + components: ["@method", "@status"], + parameters: {}, + }; + const result = validateAcceptSignatureForRequest([valid, invalid]); + deepStrictEqual(result, [valid]); + }, +); + +// --------------------------------------------------------------------------- +// fulfillAcceptSignature() +// --------------------------------------------------------------------------- + +test("fulfillAcceptSignature(): compatible alg and keyid", () => { + const entry: AcceptSignatureMember = { + label: "sig1", + components: ["@method", "@target-uri", "content-digest"], + parameters: { + alg: "rsa-v1_5-sha256", + keyid: "https://example.com/key", + nonce: "abc", + tag: "t1", + }, + }; + const result = fulfillAcceptSignature( + entry, + "https://example.com/key", + "rsa-v1_5-sha256", + ); + strictEqual(result != null, true); + strictEqual(result!.label, "sig1"); + deepStrictEqual(result!.components, [ + "@method", + "@target-uri", + "content-digest", + "@authority", + ]); + strictEqual(result!.nonce, "abc"); + strictEqual(result!.tag, "t1"); +}); + +test("fulfillAcceptSignature(): incompatible alg", () => { + const entry: AcceptSignatureMember = { + label: "sig1", + components: ["@method"], + parameters: { alg: "ecdsa-p256-sha256" }, + }; + const result = fulfillAcceptSignature( + entry, + "https://example.com/key", + "rsa-v1_5-sha256", + ); + strictEqual(result, null); +}); + +test("fulfillAcceptSignature(): incompatible keyid", () => { + const entry: AcceptSignatureMember = { + label: "sig1", + components: ["@method"], + parameters: { keyid: "https://other.example/key" }, + }; + const result = fulfillAcceptSignature( + entry, + "https://example.com/key", + "rsa-v1_5-sha256", + ); + strictEqual(result, null); +}); + +test("fulfillAcceptSignature(): minimum component set preserved", () => { + const entry: AcceptSignatureMember = { + label: "sig1", + components: ["content-digest"], + parameters: {}, + }; + const result = fulfillAcceptSignature( + entry, + "https://example.com/key", + "rsa-v1_5-sha256", + ); + strictEqual(result != null, true); + // Minimum set should be merged in + strictEqual(result!.components.includes("@method"), true); + strictEqual(result!.components.includes("@target-uri"), true); + strictEqual(result!.components.includes("@authority"), true); + strictEqual(result!.components.includes("content-digest"), true); +}); + +test("fulfillAcceptSignature(): no alg/keyid constraints", () => { + const entry: AcceptSignatureMember = { + label: "custom", + components: ["@method", "@target-uri", "@authority"], + parameters: {}, + }; + const result = fulfillAcceptSignature( + entry, + "https://example.com/key", + "rsa-v1_5-sha256", + ); + strictEqual(result != null, true); + strictEqual(result!.label, "custom"); + deepStrictEqual(result!.components, [ + "@method", + "@target-uri", + "@authority", + ]); + strictEqual(result!.nonce, undefined); + strictEqual(result!.tag, undefined); +}); diff --git a/packages/fedify/src/sig/accept.ts b/packages/fedify/src/sig/accept.ts new file mode 100644 index 000000000..1555ad445 --- /dev/null +++ b/packages/fedify/src/sig/accept.ts @@ -0,0 +1,314 @@ +/** + * `Accept-Signature` header parsing, serialization, and validation utilities + * for RFC 9421 §5 challenge-response negotiation. + * + * @module + */ +import { + compactObject, + concat, + entries, + evolve, + filter, + fromEntries, + isArray, + map, + pick, + pipe, + toArray, + uniq, +} from "@fxts/core"; +import { getLogger, type Logger } from "@logtape/logtape"; +import { + decodeDict, + type Dictionary, + encodeDict, + Item, +} from "structured-field-values"; + +/** + * Signature metadata parameters that may appear in an + * `Accept-Signature` member, as defined in + * [RFC 9421 §5.1](https://www.rfc-editor.org/rfc/rfc9421#section-5.1). + * + * @since 2.1.0 + */ +export interface AcceptSignatureParameters { + /** + * If present, the signer is requested to use the indicated key + * material to create the target signature. + */ + keyid?: string; + + /** + * If present, the signer is requested to use the indicated algorithm + * from the HTTP Signature Algorithms registry. + */ + alg?: string; + + /** + * If `true`, the signer is requested to generate and include a + * creation timestamp. This parameter has no associated value in the + * wire format. + */ + created?: true; + + /** + * If `true`, the signer is requested to generate and include an + * expiration timestamp. This parameter has no associated value in + * the wire format. + */ + expires?: true; + + /** + * If present, the signer is requested to include this value as the + * signature nonce in the target signature. + */ + nonce?: string; + + /** + * If present, the signer is requested to include this value as the + * signature tag in the target signature. + */ + tag?: string; +} + +/** + * Represents a single member of the `Accept-Signature` Dictionary + * Structured Field, as defined in + * [RFC 9421 §5.1](https://www.rfc-editor.org/rfc/rfc9421#section-5.1). + * + * @since 2.1.0 + */ +export interface AcceptSignatureMember { + /** + * The label that uniquely identifies the requested message signature + * within the context of the target HTTP message (e.g., `"sig1"`). + */ + label: string; + + /** + * The set of covered component identifiers for the target message + * (e.g., `["@method", "@target-uri", "@authority", + * "content-digest"]`). + */ + components: string[]; + + /** + * Optional signature metadata parameters requested by the verifier. + */ + parameters: AcceptSignatureParameters; +} + +/** + * Parses an `Accept-Signature` header value (RFC 9421 §5.1) into an + * array of {@link AcceptSignatureMember} objects. + * + * The `Accept-Signature` field is a Dictionary Structured Field + * (RFC 8941 §3.2). Each dictionary member describes a single + * requested message signature. + * + * On parse failure (malformed or empty header), returns an empty array. + * + * @param header The raw `Accept-Signature` header value string. + * @returns An array of parsed members. Empty if the header is + * malformed or empty. + * @since 2.1.0 + */ +export function parseAcceptSignature( + header: string, +): AcceptSignatureMember[] { + try { + return pipe( + header, + decodeDict, + parseEachSignature, + toArray, + ) as AcceptSignatureMember[]; + } catch { + return []; + } +} + +const parseEachSignature = ( + dict: Dictionary, +): IterableIterator => + pipe( + dict, + entries, + filter(([_, item]) => isArray(item.value)), + map(([label, item]) => + ({ + label, + components: item.value + .map((subitem: Item) => subitem.value) + .filter((v: unknown): v is string => typeof v === "string"), + parameters: extractParams(item), + }) as AcceptSignatureMember + ), + ) as IterableIterator; + +const extractParams = ( + item: { params: AcceptSignatureParameters }, +): AcceptSignatureParameters => + pipe( + item.params ?? {}, + pick(["keyid", "alg", "created", "expires", "nonce", "tag"]), + evolve({ + keyid: stringOrUndefined, + alg: stringOrUndefined, + created: trueOrUndefined, + expires: trueOrUndefined, + nonce: stringOrUndefined, + tag: stringOrUndefined, + }), + compactObject, + ) as AcceptSignatureParameters; + +const stringOrUndefined = (v: unknown): string | undefined => + typeof v === "string" ? v : undefined; +const trueOrUndefined = ( + v: unknown, +): true | undefined => (v === true ? true : undefined); + +/** + * Serializes an array of {@link AcceptSignatureMember} objects into an + * `Accept-Signature` header value string (RFC 9421 §5.1). + * + * The output is a Dictionary Structured Field (RFC 8941 §3.2). + * + * @param members The members to serialize. + * @returns The serialized header value string. + * @since 2.1.0 + */ +export function formatAcceptSignature( + members: AcceptSignatureMember[], +): string { + return pipe( + members, + map((member) => + [ + member.label, + new Item( + extractComponents(member), + extractParameters(member), + ), + ] as const + ), + fromEntries, + encodeDict, + ); +} + +const extractComponents = (member: AcceptSignatureMember): Item[] => + member.components.map((c) => new Item(c, {})); +const extractParameters = ( + member: AcceptSignatureMember, +): AcceptSignatureParameters => + pipe( + member.parameters, + pick(["keyid", "alg", "created", "expires", "nonce", "tag"]), + compactObject, + ); + +/** + * Filters out {@link AcceptSignatureMember} entries whose covered + * components include response-only identifiers (`@status`) that are + * not applicable to request-target messages, as required by + * [RFC 9421 §5](https://www.rfc-editor.org/rfc/rfc9421#section-5). + * + * A warning is logged for each discarded entry. + * + * @param members The parsed `Accept-Signature` entries to validate. + * @returns Only entries that are valid for request-target messages. + * @since 2.1.0 + */ +export function validateAcceptSignatureForRequest( + members: AcceptSignatureMember[], +): AcceptSignatureMember[] { + const logger = getLogger(["fedify", "sig", "http"]); + return members.filter((member) => + !member.components.includes("@status") + ? true + : logLabel(logger, member.label) || false + ); +} + +const logLabel = (logger: Logger, label: string): undefined => + logger.warn( + "Discarding Accept-Signature member {label}: " + + "covered components include response-only identifier @status.", + { label }, + ) as undefined; + +/** + * The result of {@link fulfillAcceptSignature}. This can be used directly + * as the `rfc9421` option of {@link SignRequestOptions}. + * @since 2.1.0 + */ +export interface FulfillAcceptSignatureResult { + /** The label for the signature. */ + label: string; + /** The merged set of covered component identifiers. */ + components: string[]; + /** The nonce requested by the challenge, if any. */ + nonce?: string; + /** The tag requested by the challenge, if any. */ + tag?: string; +} + +/** + * The minimum set of covered component identifiers that Fedify always + * includes in RFC 9421 signatures for security. + */ +const MINIMUM_COMPONENTS = ["@method", "@target-uri", "@authority"]; + +/** + * Attempts to translate an {@link AcceptSignatureMember} challenge into + * RFC 9421 signing options that the local signer can fulfill. + * + * Returns `null` if the challenge cannot be fulfilled—for example, if + * the requested `alg` or `keyid` is incompatible with the local key. + * + * Safety constraints: + * - `alg`: only honored if it matches `localAlg`. + * - `keyid`: only honored if it matches `localKeyId`. + * - `components`: merged with the minimum required set + * (`@method`, `@target-uri`, `@authority`). + * - `nonce` and `tag` are passed through directly. + * + * @param entry The challenge entry from the `Accept-Signature` header. + * @param localKeyId The local key identifier (e.g., the actor key URL). + * @param localAlg The algorithm of the local private key + * (e.g., `"rsa-v1_5-sha256"`). + * @returns Signing options if the challenge can be fulfilled, or `null`. + * @since 2.1.0 + */ +export function fulfillAcceptSignature( + entry: AcceptSignatureMember, + localKeyId: string, + localAlg: string, +): FulfillAcceptSignatureResult | null { + // Check algorithm compatibility + if (entry.parameters.alg != null && entry.parameters.alg !== localAlg) { + return null; + } + // Check key ID compatibility + if ( + entry.parameters.keyid != null && entry.parameters.keyid !== localKeyId + ) { + return null; + } + return { + label: entry.label, + components: concatMinimumComponents(entry.components), + nonce: entry.parameters.nonce, + tag: entry.parameters.tag, + }; +} + +/** Merge components: challenge components + minimum required set */ +const concatMinimumComponents = (components: string[]): string[] => + pipe(MINIMUM_COMPONENTS, concat(components), uniq, toArray); + +// cspell: ignore keyid diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 2c95b18b2..8dee79b4d 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -2178,3 +2178,486 @@ test("signRequest() and verifyRequest() cancellation", { fetchMock.hardReset(); }); + +// --------------------------------------------------------------------------- +// signRequest() with rfc9421 options +// --------------------------------------------------------------------------- + +test("signRequest() with custom label", async () => { + const request = new Request("https://example.com/api", { + method: "POST", + body: "test", + headers: { "Content-Type": "text/plain" }, + }); + const signed = await signRequest( + request, + rsaPrivateKey2, + new URL("https://example.com/key2"), + { + spec: "rfc9421", + rfc9421: { label: "mysig" }, + }, + ); + const sigInput = signed.headers.get("Signature-Input")!; + assertStringIncludes(sigInput, "mysig="); + const sig = signed.headers.get("Signature")!; + assertStringIncludes(sig, "mysig="); +}); + +test("signRequest() with custom components", async () => { + const request = new Request("https://example.com/api", { + method: "POST", + body: "test", + headers: { + "Content-Type": "text/plain", + "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", + rfc9421: { + components: ["@method", "@target-uri", "@authority"], + }, + }, + ); + const sigInput = signed.headers.get("Signature-Input")!; + assertStringIncludes(sigInput, '"@method"'); + assertStringIncludes(sigInput, '"@target-uri"'); + assertStringIncludes(sigInput, '"@authority"'); + // content-digest should be auto-added when body is present + assertStringIncludes(sigInput, '"content-digest"'); +}); + +test("signRequest() with nonce and tag", async () => { + const request = new Request("https://example.com/api", { + method: "GET", + headers: { + "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", + rfc9421: { nonce: "test-nonce-123", tag: "app-v1" }, + }, + ); + const sigInput = signed.headers.get("Signature-Input")!; + assertStringIncludes(sigInput, 'nonce="test-nonce-123"'); + assertStringIncludes(sigInput, 'tag="app-v1"'); +}); + +// --------------------------------------------------------------------------- +// doubleKnock() with Accept-Signature challenge +// --------------------------------------------------------------------------- + +test( + "doubleKnock(): Accept-Signature challenge retry succeeds", + async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-challenge-ok", (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // First attempt fails with Accept-Signature challenge + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri" "@authority" "content-digest")' + + ';created;nonce="challenge-nonce-1"', + }, + }); + } + // Second attempt (challenge retry) succeeds + const sigInput = req.headers.get("Signature-Input") ?? ""; + if (sigInput.includes("challenge-nonce-1")) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }); + + const request = new Request("https://example.com/inbox-challenge-ok", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): unfulfillable Accept-Signature falls to legacy fallback", + async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-unfulfillable", (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // Challenge with incompatible algorithm + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": 'sig1=("@method");alg="ecdsa-p256-sha256"', + }, + }); + } + // Legacy fallback (draft-cavage) succeeds + if (req.headers.has("Signature") && !req.headers.has("Signature-Input")) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }); + + const request = new Request("https://example.com/inbox-unfulfillable", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): no Accept-Signature falls to legacy fallback", + async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-no-challenge", (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + return new Response("Not Authorized", { status: 401 }); + } + if (req.headers.has("Signature")) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }); + + const request = new Request("https://example.com/inbox-no-challenge", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): challenge retry also fails → legacy fallback attempted", + async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-challenge-fails", (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": 'sig1=("@method" "@target-uri");created', + }, + }); + } + if (requestCount === 2) { + // Challenge retry also fails + return new Response("Still Not Authorized", { status: 401 }); + } + // Legacy fallback (3rd attempt) + if (req.headers.has("Signature") && !req.headers.has("Signature-Input")) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }); + + const request = new Request( + "https://example.com/inbox-challenge-fails", + { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }, + ); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 3); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): challenge retry returns another challenge → not followed", + async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post( + "https://example.com/inbox-challenge-loop", + (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // First attempt: returns Accept-Signature challenge + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri");created;nonce="nonce-1"', + }, + }); + } + if (requestCount === 2) { + // Challenge retry: returns ANOTHER Accept-Signature challenge + // (should NOT be followed — loop prevention) + return new Response("Still Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri");created;nonce="nonce-2"', + }, + }); + } + // Legacy fallback (3rd attempt, spec-swap to draft-cavage) + if ( + req.headers.has("Signature") && + !req.headers.has("Signature-Input") + ) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }, + ); + + const request = new Request( + "https://example.com/inbox-challenge-loop", + { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }, + ); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + // Should have made exactly 3 requests: + // 1. Initial → 401 + Accept-Signature + // 2. Challenge retry → 401 + Accept-Signature (NOT followed again) + // 3. Legacy fallback (draft-cavage) → 202 + assertEquals(response.status, 202); + assertEquals(requestCount, 3); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): Accept-Signature with unsupported component falls to legacy fallback", + async () => { + // Regression test for missing error guard in doubleKnock() challenge retry. + // When a server sends an Accept-Signature challenge containing a component + // that causes signRequest() to throw (e.g., a header not present on the + // request), the error should be caught so that doubleKnock() falls through + // to the legacy spec-swap fallback instead of propagating the TypeError. + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post( + "https://example.com/inbox-bad-challenge", + (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // Challenge with a header component ("x-custom-required") that is + // absent from the request — createRfc9421SignatureBase() will throw + // "Missing header: x-custom-required". + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri" "x-custom-required");created', + }, + }); + } + // Legacy fallback (draft-cavage) should still be reached + if ( + req.headers.has("Signature") && !req.headers.has("Signature-Input") + ) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }, + ); + + const request = new Request("https://example.com/inbox-bad-challenge", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + // The challenge retry should fail gracefully and fall through to legacy + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): Accept-Signature with unsupported derived component falls to legacy fallback", + async () => { + // Similar to the above test, but with an unsupported derived component + // (e.g., "@query-param") instead of a missing header. + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post( + "https://example.com/inbox-bad-derived", + (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // Challenge with "@query-param" — a derived component that throws + // in createRfc9421SignatureBase() because it requires special params. + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": 'sig1=("@method" "@query-param");created', + }, + }); + } + // Legacy fallback should be reached + if ( + req.headers.has("Signature") && !req.headers.has("Signature-Input") + ) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }, + ); + + const request = new Request("https://example.com/inbox-bad-derived", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): Accept-Signature with multiple entries where first throws falls to next entry", + async () => { + // When Accept-Signature contains multiple entries, if the first entry + // causes signRequest() to throw, the loop should catch the error and + // try the next entry (or fall through to legacy fallback). + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post( + "https://example.com/inbox-multi-challenge", + (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // First entry has a missing header; second entry is valid + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": 'sig1=("@method" "x-nonexistent");created,' + + 'sig2=("@method" "@target-uri" "@authority");created', + }, + }); + } + // Challenge retry with valid sig2 should succeed + if (req.headers.has("Signature-Input")) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }, + ); + + const request = new Request( + "https://example.com/inbox-multi-challenge", + { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }, + ); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 0c7c7129f..3ee1a79b6 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -14,6 +14,7 @@ import { } from "@opentelemetry/semantic-conventions"; import { decodeBase64, encodeBase64 } from "byte-encodings/base64"; import { encodeHex } from "byte-encodings/hex"; +import { uniq } from "es-toolkit"; import { decodeDict, type Dictionary, @@ -21,6 +22,11 @@ import { Item, } from "structured-field-values"; import metadata from "../../deno.json" with { type: "json" }; +import { + fulfillAcceptSignature, + parseAcceptSignature, + validateAcceptSignatureForRequest, +} from "./accept.ts"; import { fetchKeyDetailed, type FetchKeyErrorResult, @@ -74,6 +80,44 @@ export interface SignRequestOptions { * is used. */ tracerProvider?: TracerProvider; + + /** + * Options specific to the RFC 9421 signing path. These options are + * ignored when `spec` is `"draft-cavage-http-signatures-12"`. + * @since 2.1.0 + */ + rfc9421?: Rfc9421SignRequestOptions; +} + +/** + * Options for customizing the RFC 9421 signature label, covered components, + * and metadata parameters. These are typically derived from an + * `Accept-Signature` challenge. + * @since 2.1.0 + */ +export interface Rfc9421SignRequestOptions { + /** + * The label for the signature in `Signature-Input` and `Signature` headers. + * @default `"sig1"` + */ + label?: string; + + /** + * The covered component identifiers. When omitted, the default set + * `["@method", "@target-uri", "@authority", "host", "date"]` + * (plus `"content-digest"` when a body is present) is used. + */ + components?: string[]; + + /** + * A nonce value to include in the signature parameters. + */ + nonce?: string; + + /** + * A tag value to include in the signature parameters. + */ + tag?: string; } /** @@ -114,6 +158,7 @@ export async function signRequest( span, options.currentTime, options.body, + options.rfc9421, ); } else { // Default to draft-cavage @@ -217,12 +262,22 @@ export interface Rfc9421SignatureParameters { algorithm: string; keyId: URL; created: number; + nonce?: string; + tag?: string; } export function formatRfc9421SignatureParameters( params: Rfc9421SignatureParameters, ): string { - return `alg="${params.algorithm}";keyid="${params.keyId.href}";created=${params.created}`; + return Array.from(iterRfc9421(params)).join(";"); +} + +function* iterRfc9421(params: Rfc9421SignatureParameters): Iterable { + yield `alg="${params.algorithm}"`; + yield `keyid="${params.keyId.href}"`; + yield `created=${params.created}`; + if (params.nonce != null) yield `nonce="${params.nonce}"`; + if (params.tag != null) yield `tag="${params.tag}"`; } /** @@ -237,55 +292,48 @@ export function createRfc9421SignatureBase( components: string[], parameters: string, ): string { - const url = new URL(request.url); - // Build the base string - const baseComponents: string[] = []; - - for (const component of components) { - let value: string; - - // Process special derived components - if (component === "@method") { - value = request.method.toUpperCase(); - } else if (component === "@target-uri") { - value = request.url; - } else if (component === "@authority") { - value = url.host; - } else if (component === "@scheme") { - value = url.protocol.slice(0, -1); // Remove the trailing ':' - } else if (component === "@request-target") { - value = `${request.method.toLowerCase()} ${url.pathname}${url.search}`; - } else if (component === "@path") { - value = url.pathname; - } else if (component === "@query") { - value = url.search.startsWith("?") ? url.search.slice(1) : url.search; - } else if (component === "@query-param") { - throw new Error("@query-param requires a parameter name"); - } else if (component === "@status") { - throw new Error("@status is only valid for responses"); - } else if (component.startsWith("@")) { + return components.map((component) => { + const derived = derivedComponents[component]?.(request); + if (derived != null) return `"${component}": ${derived}`; + if (component.startsWith("@")) { throw new Error(`Unsupported derived component: ${component}`); - } else { - // Regular header - const header = request.headers.get(component); - if (header == null) throw new Error(`Missing header: ${component}`); - value = header; } - + const header = request.headers.get(component); + if (header == null) { + throw new Error(`Missing header: ${component}`); + } // Format the component as per RFC 9421 Section 2.1 - baseComponents.push(`"${component}": ${value}`); - } - - // Add the signature parameters component at the end - const sigComponents = components.map((c) => `"${c}"`).join(" "); - baseComponents.push( - `"@signature-params": (${sigComponents});${parameters}`, - ); - - return baseComponents.join("\n"); + return `"${component}": ${header}`; + }).concat([ + `"@signature-params": (${ + components.map((c) => `"${c}"`).join(" ") + });${parameters}`, + ]).join("\n"); } +const derivedComponents: Record string> = { + "@method": (request) => request.method.toUpperCase(), + "@target-uri": (request) => request.url, + "@authority": (request) => new URL(request.url).host, + "@scheme": (request) => new URL(request.url).protocol.slice(0, -1), + "@request-target": (request) => { + const url = new URL(request.url); + return `${request.method.toLowerCase()} ${url.pathname}${url.search}`; + }, + "@path": (request) => new URL(request.url).pathname, + "@query": (request) => { + const search = new URL(request.url).search; + return search.startsWith("?") ? search.slice(1) : search; + }, + "@query-param": () => { + throw new Error("@query-param requires a parameter name"); + }, + "@status": () => { + throw new Error("@status is only valid for responses"); + }, +}; + /** * Formats a signature using rfc9421 format. * @param signature The raw signature bytes. @@ -297,11 +345,12 @@ export function formatRfc9421Signature( signature: ArrayBuffer | Uint8Array, components: string[], parameters: string, + label = "sig1", ): [string, string] { - const signatureInputValue = `sig1=("${ + const signatureInputValue = `${label}=("${ components.join('" "') }");${parameters}`; - const signatureValue = `sig1=:${encodeBase64(signature)}:`; + const signatureValue = `${label}=:${encodeBase64(signature)}:`; return [signatureInputValue, signatureValue]; } @@ -318,6 +367,8 @@ export function parseRfc9421SignatureInput( keyId: string; alg?: string; created: number; + nonce?: string; + tag?: string; components: string[]; parameters: string; } @@ -338,6 +389,8 @@ export function parseRfc9421SignatureInput( keyId: string; alg?: string; created: number; + nonce?: string; + tag?: string; components: string[]; parameters: string; } @@ -356,6 +409,10 @@ export function parseRfc9421SignatureInput( keyId: item.params.keyid, alg: item.params.alg, created: item.params.created, + nonce: typeof item.params.nonce === "string" + ? item.params.nonce + : undefined, + tag: typeof item.params.tag === "string" ? item.params.tag : undefined, components, parameters: params.slice(params.indexOf(";") + 1), }; @@ -398,6 +455,7 @@ async function signRequestRfc9421( span: Span, currentTime?: Temporal.Instant, bodyBuffer?: ArrayBuffer | null, + rfc9421Options?: Rfc9421SignRequestOptions, ): Promise { if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") { throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); @@ -433,23 +491,25 @@ async function signRequestRfc9421( } // Define components to include in the signature - const components = [ - "@method", - "@target-uri", - "@authority", - "host", - "date", - ]; - - if (body != null) { - components.push("content-digest"); - } + const label = rfc9421Options?.label ?? "sig1"; + const components: string[] = uniq([ + ...(rfc9421Options?.components ?? [ + "@method", + "@target-uri", + "@authority", + "host", + "date", + ]), + ...(body != null ? ["content-digest"] : []), + ]); // Generate the signature base using the headers const signatureParams = formatRfc9421SignatureParameters({ algorithm: "rsa-v1_5-sha256", keyId, created, + nonce: rfc9421Options?.nonce, + tag: rfc9421Options?.tag, }); let signatureBase: string; try { @@ -480,6 +540,7 @@ async function signRequestRfc9421( signatureBytes, components, signatureParams, + label, ); // Add the signature headers @@ -577,6 +638,7 @@ export type VerifyRequestDetailedResult = | { readonly verified: true; readonly key: CryptographicKey; + readonly signatureLabel?: string; } | { readonly verified: false; @@ -1356,7 +1418,7 @@ async function verifyRequestRfc9421( ); if (verified) { - return { verified: true, key }; + return { verified: true, key, signatureLabel: sigName }; } else if (cached) { // If we used a cached key and verification failed, try fetching fresh key logger.debug( @@ -1551,12 +1613,76 @@ export async function doubleKnock( // fixes their RFC 9421 implementation and affected servers are updated. response.status === 400 || response.status === 401 || response.status > 401 ) { - // 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) + const logger = getLogger(["fedify", "sig", "http"]); + + // 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 entries = validateAcceptSignatureForRequest( + parseAcceptSignature(acceptSigHeader), + ); + const localKeyId = identity.keyId.href; + const localAlg = "rsa-v1_5-sha256"; + let fulfilled = false; + for (const entry of entries) { + const rfc9421 = fulfillAcceptSignature(entry, localKeyId, localAlg); + if (rfc9421 == null) continue; + logger.debug( + "Received Accept-Signature challenge; retrying with " + + "label {label} and components {components}.", + { label: rfc9421.label, components: rfc9421.components }, + ); + try { + signedRequest = await signRequest( + request, + identity.privateKey, + identity.keyId, + { spec: "rfc9421", tracerProvider, body, rfc9421 }, + ); + log?.(signedRequest); + response = await fetch(signedRequest, { + redirect: "manual", + signal, + }); + } catch (error) { + logger.debug( + "Failed to fulfill Accept-Signature challenge entry " + + "{label}: {error}", + { label: entry.label, error }, + ); + continue; + } + // Follow redirects manually: + if ( + response.status >= 300 && response.status < 400 && + response.headers.has("Location") + ) { + const location = response.headers.get("Location")!; + return doubleKnock( + createRedirectRequest(request, location, body), + identity, + { ...options, body }, + ); + } + fulfilled = true; + break; + } + // If the challenge retry succeeded, remember spec and return + if ( + fulfilled && response.status < 300 + ) { + await specDeterminer?.rememberSpec(origin, "rfc9421"); + return response; + } + // Otherwise fall through to legacy spec-swap fallback + } + + // Legacy double-knocking: swap between RFC 9421 and draft-cavage const spec = firstTrySpec === "draft-cavage-http-signatures-12" ? "rfc9421" : "draft-cavage-http-signatures-12"; - getLogger(["fedify", "sig", "http"]).debug( + logger.debug( "Failed to verify with the spec {spec} ({status} {statusText}); retrying with spec {secondSpec}... (double-knocking)", { spec: firstTrySpec, diff --git a/packages/fedify/src/sig/mod.ts b/packages/fedify/src/sig/mod.ts index 8f7342f9c..f410de51c 100644 --- a/packages/fedify/src/sig/mod.ts +++ b/packages/fedify/src/sig/mod.ts @@ -3,9 +3,19 @@ * * @module */ +export { + type AcceptSignatureMember, + type AcceptSignatureParameters, + formatAcceptSignature, + fulfillAcceptSignature, + type FulfillAcceptSignatureResult, + parseAcceptSignature, + validateAcceptSignatureForRequest, +} from "./accept.ts"; export { type HttpMessageSignaturesSpec, type HttpMessageSignaturesSpecDeterminer, + type Rfc9421SignRequestOptions, signRequest, type SignRequestOptions, verifyRequest,