From 1a2144d70f2afc049319e5edd05b2e7eb850c804 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 7 Mar 2026 22:33:10 +0000 Subject: [PATCH 01/34] Add utils to handle Accept-Signature When deno check processes the full workspace, examples/astro's tsconfig.json (extending astro/tsconfigs/strict) sets moduleResolution to "Bundler". Since the Astro example imports @fedify/fedify, this setting leaks into the compilation context of accept.ts, causing @fxts/core's internal ReturnPipeType to incorrectly resolve pipe's return type. Override moduleResolution to "nodenext" in examples/astro/deno.json so the Astro tsconfig no longer affects how @fxts/core's types resolve. --- examples/astro/deno.json | 3 + packages/fedify/src/sig/accept.test.ts | 190 +++++++++++++++++++ packages/fedify/src/sig/accept.ts | 242 +++++++++++++++++++++++++ packages/fedify/src/sig/mod.ts | 7 + 4 files changed, 442 insertions(+) create mode 100644 packages/fedify/src/sig/accept.test.ts create mode 100644 packages/fedify/src/sig/accept.ts 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/sig/accept.test.ts b/packages/fedify/src/sig/accept.test.ts new file mode 100644 index 000000000..f07c4c7bc --- /dev/null +++ b/packages/fedify/src/sig/accept.test.ts @@ -0,0 +1,190 @@ +import { test } from "@fedify/fixture"; +import { deepStrictEqual, strictEqual } from "node:assert/strict"; +import { + type AcceptSignatureMember, + formatAcceptSignature, + 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]); + }, +); diff --git a/packages/fedify/src/sig/accept.ts b/packages/fedify/src/sig/accept.ts new file mode 100644 index 000000000..25d97ff11 --- /dev/null +++ b/packages/fedify/src/sig/accept.ts @@ -0,0 +1,242 @@ +/** + * `Accept-Signature` header parsing, serialization, and validation utilities + * for RFC 9421 §5 challenge-response negotiation. + * + * @module + */ +import { + compactObject, + entries, + evolve, + filter, + fromEntries, + isArray, + map, + pick, + pipe, + toArray, +} 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; + +// cspell: ignore keyid diff --git a/packages/fedify/src/sig/mod.ts b/packages/fedify/src/sig/mod.ts index 8f7342f9c..da73452f1 100644 --- a/packages/fedify/src/sig/mod.ts +++ b/packages/fedify/src/sig/mod.ts @@ -3,6 +3,13 @@ * * @module */ +export { + type AcceptSignatureMember, + type AcceptSignatureParameters, + formatAcceptSignature, + parseAcceptSignature, + validateAcceptSignatureForRequest, +} from "./accept.ts"; export { type HttpMessageSignaturesSpec, type HttpMessageSignaturesSpecDeterminer, From 8ea783f93d9d54c1711052df8b8ab15270f626a5 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 9 Mar 2026 05:57:56 +0000 Subject: [PATCH 02/34] Add fulfillAcceptSignature --- packages/fedify/src/sig/accept.test.ts | 102 +++++++++++++++++++++++++ packages/fedify/src/sig/accept.ts | 72 +++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/packages/fedify/src/sig/accept.test.ts b/packages/fedify/src/sig/accept.test.ts index f07c4c7bc..43e1e2c76 100644 --- a/packages/fedify/src/sig/accept.test.ts +++ b/packages/fedify/src/sig/accept.test.ts @@ -3,6 +3,7 @@ import { deepStrictEqual, strictEqual } from "node:assert/strict"; import { type AcceptSignatureMember, formatAcceptSignature, + fulfillAcceptSignature, parseAcceptSignature, validateAcceptSignatureForRequest, } from "./accept.ts"; @@ -188,3 +189,104 @@ test( 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 index 25d97ff11..1555ad445 100644 --- a/packages/fedify/src/sig/accept.ts +++ b/packages/fedify/src/sig/accept.ts @@ -6,6 +6,7 @@ */ import { compactObject, + concat, entries, evolve, filter, @@ -15,6 +16,7 @@ import { pick, pipe, toArray, + uniq, } from "@fxts/core"; import { getLogger, type Logger } from "@logtape/logtape"; import { @@ -239,4 +241,74 @@ const logLabel = (logger: Logger, label: string): undefined => { 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 From 52b8a95b1ebc35a5c4609fd8f4494f99842e5338 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 11 Mar 2026 19:39:50 +0000 Subject: [PATCH 03/34] Add `rfc9421` param and fix related logic --- packages/fedify/src/sig/http.test.ts | 254 +++++++++++++++++++++++++++ packages/fedify/src/sig/http.ts | 233 +++++++++++++++++------- packages/fedify/src/sig/mod.ts | 3 + 3 files changed, 431 insertions(+), 59 deletions(-) diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 2c95b18b2..8c4672eb0 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -2178,3 +2178,257 @@ 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(); + }, +); diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 0c7c7129f..4244ea53b 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -21,6 +21,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 +79,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 +157,7 @@ export async function signRequest( span, options.currentTime, options.body, + options.rfc9421, ); } else { // Default to draft-cavage @@ -217,12 +261,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 +291,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 +344,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 +366,8 @@ export function parseRfc9421SignatureInput( keyId: string; alg?: string; created: number; + nonce?: string; + tag?: string; components: string[]; parameters: string; } @@ -338,6 +388,8 @@ export function parseRfc9421SignatureInput( keyId: string; alg?: string; created: number; + nonce?: string; + tag?: string; components: string[]; parameters: string; } @@ -356,6 +408,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 +454,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 +490,25 @@ async function signRequestRfc9421( } // Define components to include in the signature - const components = [ - "@method", - "@target-uri", - "@authority", - "host", - "date", + const label = rfc9421Options?.label ?? "sig1"; + const components: string[] = [ + ...(rfc9421Options?.components ?? [ + "@method", + "@target-uri", + "@authority", + "host", + "date", + ]), + ...(body != null ? ["content-digest"] : []), ]; - if (body != null) { - components.push("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 +539,7 @@ async function signRequestRfc9421( signatureBytes, components, signatureParams, + label, ); // Add the signature headers @@ -1551,12 +1611,67 @@ 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 }, + ); + signedRequest = await signRequest( + request, + identity.privateKey, + identity.keyId, + { spec: "rfc9421", tracerProvider, body, rfc9421 }, + ); + log?.(signedRequest); + response = await fetch(signedRequest, { + redirect: "manual", + signal, + }); + // 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 !== 400 && response.status !== 401 + ) { + 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 da73452f1..f410de51c 100644 --- a/packages/fedify/src/sig/mod.ts +++ b/packages/fedify/src/sig/mod.ts @@ -7,12 +7,15 @@ 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, From 2d5f4a094f33b19cc91c49de95582da4ff03fa97 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 11 Mar 2026 19:47:42 +0000 Subject: [PATCH 04/34] Add `InboxChallengePolicy` interface and implement Accept-Signature handling --- packages/fedify/src/federation/federation.ts | 54 ++++++++ .../fedify/src/federation/handler.test.ts | 4 + packages/fedify/src/federation/handler.ts | 125 +++++++++++++++++- packages/fedify/src/federation/middleware.ts | 13 ++ 4 files changed, 191 insertions(+), 5 deletions(-) diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index d45678fa7..8a79735d6 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -780,6 +780,49 @@ export interface FederationBuilder * @template TContextData The context data to pass to the {@link Context}. * @since 1.6.0 */ +/** + * 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 request the signer include a `created` timestamp. + * @default `true` + */ + requestCreated?: boolean; + + /** + * 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; +} + export interface FederationOptions { /** * The key–value store used for caching, outbox queues, and inbox idempotence. @@ -931,6 +974,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..5fc20839e 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1082,6 +1082,7 @@ test("handleInbox()", async () => { kvPrefixes: { activityIdempotence: ["_fedify", "activityIdempotence"], publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], }, actorDispatcher, onNotFound, @@ -1350,6 +1351,7 @@ test("handleInbox() - authentication bypass vulnerability", async () => { kvPrefixes: { activityIdempotence: ["_fedify", "activityIdempotence"], publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], }, actorDispatcher, inboxListeners, @@ -1894,6 +1896,7 @@ test("handleInbox() records OpenTelemetry span events", async () => { kvPrefixes: { activityIdempotence: ["activityIdempotence"], publicKey: ["publicKey"], + acceptSignatureNonce: ["acceptSignatureNonce"], }, actorDispatcher, inboxListeners: listeners, @@ -2008,6 +2011,7 @@ test("handleInbox() records unverified HTTP signature details", async () => { kvPrefixes: { activityIdempotence: ["activityIdempotence"], publicKey: ["publicKey"], + acceptSignatureNonce: ["acceptSignatureNonce"], }, actorDispatcher, inboxListeners: new InboxListenerSet(), diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 18703eaba..10b93cd37 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"]); @@ -701,12 +710,22 @@ 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 +816,37 @@ async function handleInboxInternal( }, ); } else { + // Optional nonce verification for Accept-Signature challenges + if ( + inboxChallengePolicy?.enabled && inboxChallengePolicy.requestNonce + ) { + const nonceValid = await verifySignatureNonce( + request, + kv, + kvPrefixes.acceptSignatureNonce, + ); + 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 }, + ); + } + } logger.debug("HTTP Signatures are verified.", { recipient }); activityVerified = true; } @@ -1630,3 +1680,68 @@ export async function respondWithObjectIfAcceptable( response.headers.set("Vary", "Accept"); return response; } + +const DEFAULT_CHALLENGE_COMPONENTS = [ + "@method", + "@target-uri", + "@authority", + "content-digest", +]; + +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, +): Promise { + const signatureInput = request.headers.get("Signature-Input"); + if (signatureInput == null) return false; + const parsed = parseRfc9421SignatureInput(signatureInput); + // Check each signature for a nonce + for (const sig of globalThis.Object.values(parsed)) { + const nonce = sig.nonce; + if (nonce == null) continue; + const key = [...noncePrefix, nonce] as unknown as KvKey; + const stored = await kv.get(key); + if (stored != null) { + // Consume the nonce (one-time use) + await kv.delete(key); + return true; + } + } + return false; +} + +async function buildAcceptSignatureHeader( + policy: InboxChallengePolicy, + kv: KvStore, + noncePrefix: KvKey, +): Promise { + const params: AcceptSignatureMember["parameters"] = {}; + if (policy.requestCreated !== false) { + 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; + } + return formatAcceptSignature([{ + label: "sig1", + components: policy.components ?? DEFAULT_CHALLENGE_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, }); From 07a23f52009e42ae61f3305d51f036e461dacc90 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 11 Mar 2026 19:51:08 +0000 Subject: [PATCH 05/34] =?UTF-8?q?Add=20docs=20about=20RFC=209421=20=C2=A75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/manual/inbox.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ docs/manual/send.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/docs/manual/inbox.md b/docs/manual/inbox.md index 37201c7c5..62134ffa5 100644 --- a/docs/manual/inbox.md +++ b/docs/manual/inbox.md @@ -37,6 +37,50 @@ 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 created timestamp (default: true) + // requestCreated: true, + // 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..f8ef1f8ce 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -984,6 +984,38 @@ 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 total requests are made per delivery attempt: + +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 ---------------------- From 2e415b484c02fc8887036a7c30c85ad0f75b25f6 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 11 Mar 2026 20:25:31 +0000 Subject: [PATCH 06/34] Format --- packages/fedify/src/federation/handler.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 10b93cd37..a9f716921 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -714,12 +714,11 @@ async function handleInboxInternal( "Content-Type": "text/plain; charset=utf-8", }; if (inboxChallengePolicy?.enabled) { - headers["Accept-Signature"] = - await buildAcceptSignatureHeader( - inboxChallengePolicy, - kv, - kvPrefixes.acceptSignatureNonce, - ); + headers["Accept-Signature"] = await buildAcceptSignatureHeader( + inboxChallengePolicy, + kv, + kvPrefixes.acceptSignatureNonce, + ); headers["Cache-Control"] = "no-store"; headers["Vary"] = "Accept, Signature"; } From c7d4fdd6b562fda284d180ba20a2c93db7a82fc0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 14 Mar 2026 07:12:32 +0000 Subject: [PATCH 07/34] Add tests for inbound --- .../fedify/src/federation/handler.test.ts | 553 ++++++++++++++++++ 1 file changed, 553 insertions(+) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 5fc20839e..63a5fd9b9 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, @@ -2051,3 +2052,555 @@ 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", + ); +}); From cb8b43cbe2ccf342a0ecac6a66f8fd9357a390eb Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 14 Mar 2026 07:50:44 +0000 Subject: [PATCH 08/34] Add `doubleKnock()` loop prevention test --- packages/fedify/src/sig/http.test.ts | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 8c4672eb0..554c1d700 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -2432,3 +2432,71 @@ test( 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(); + }, +); From 490ed506888d560d23495c9c0beb87f5394fcc05 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 14 Mar 2026 09:11:39 +0000 Subject: [PATCH 09/34] Fix comments --- packages/fedify/src/federation/federation.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 8a79735d6..4cd000c9a 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -775,11 +775,6 @@ export interface FederationBuilder ): Promise>; } -/** - * Options for creating a {@link Federation} object. - * @template TContextData The context data to pass to the {@link Context}. - * @since 1.6.0 - */ /** * Policy for emitting `Accept-Signature` challenges on inbox `401` * responses, as defined in @@ -823,6 +818,11 @@ export interface InboxChallengePolicy { nonceTtlSeconds?: number; } +/** + * Options for creating a {@link Federation} object. + * @template TContextData The context data to pass to the {@link Context}. + * @since 1.6.0 + */ export interface FederationOptions { /** * The key–value store used for caching, outbox queues, and inbox idempotence. From f37c16237f6740bf278a88e6133e8325461183bd Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 14 Mar 2026 09:34:02 +0000 Subject: [PATCH 10/34] Fix `http.ts` - Remove duplicated components in `signRequestRfc9421` - Filter wider status in `doubleKnock` --- packages/fedify/src/sig/http.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 4244ea53b..450d029f1 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, @@ -491,7 +492,7 @@ async function signRequestRfc9421( // Define components to include in the signature const label = rfc9421Options?.label ?? "sig1"; - const components: string[] = [ + const components: string[] = uniq([ ...(rfc9421Options?.components ?? [ "@method", "@target-uri", @@ -500,7 +501,7 @@ async function signRequestRfc9421( "date", ]), ...(body != null ? ["content-digest"] : []), - ]; + ]); // Generate the signature base using the headers const signatureParams = formatRfc9421SignatureParameters({ @@ -1659,7 +1660,7 @@ export async function doubleKnock( } // If the challenge retry succeeded, remember spec and return if ( - fulfilled && response.status !== 400 && response.status !== 401 + fulfilled && response.status < 300 ) { await specDeterminer?.rememberSpec(origin, "rfc9421"); return response; From cc1c36c7850669f7297009162ea2cbf6b1735b7d Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 16 Mar 2026 12:00:00 +0000 Subject: [PATCH 11/34] Add changes --- CHANGES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index f78c4fabb..a4a7a8f4b 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 From 08823f3b5892d6557243abaf43c0a291a0bdf152 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 16 Mar 2026 15:09:27 +0000 Subject: [PATCH 12/34] Improve nonce verification logic and add test --- .../fedify/src/federation/handler.test.ts | 136 ++++++++++++++++++ packages/fedify/src/federation/handler.ts | 20 ++- packages/fedify/src/sig/http.ts | 3 +- 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 63a5fd9b9..cd14f01f7 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -2604,3 +2604,139 @@ test("handleInbox() nonce replay prevention", async () => { "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", + ); + }, +); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index a9f716921..1106a5c94 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -823,6 +823,7 @@ async function handleInboxInternal( request, kv, kvPrefixes.acceptSignatureNonce, + verification.signatureLabel, ); if (!nonceValid) { logger.error( @@ -1701,11 +1702,28 @@ 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); - // Check each signature for a nonce + // 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. + if (verifiedLabel != null) { + 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; + } + // Fallback: if no verified label is known (e.g., draft-cavage), scan all for (const sig of globalThis.Object.values(parsed)) { const nonce = sig.nonce; if (nonce == null) continue; diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 450d029f1..c4c13ae61 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -638,6 +638,7 @@ export type VerifyRequestDetailedResult = | { readonly verified: true; readonly key: CryptographicKey; + readonly signatureLabel?: string; } | { readonly verified: false; @@ -1417,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( From 828c093a361ef40393ff4148cd02a251ae689880 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 16 Mar 2026 16:27:57 +0000 Subject: [PATCH 13/34] Remove `requestCreated` attribute --- docs/manual/inbox.md | 2 -- packages/fedify/src/federation/federation.ts | 6 ------ packages/fedify/src/federation/handler.ts | 4 +--- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/docs/manual/inbox.md b/docs/manual/inbox.md index 62134ffa5..b1a258d28 100644 --- a/docs/manual/inbox.md +++ b/docs/manual/inbox.md @@ -54,8 +54,6 @@ const federation = createFederation({ enabled: true, // Optional: customize covered components (defaults shown below) // components: ["@method", "@target-uri", "@authority", "content-digest"], - // Optional: require a created timestamp (default: true) - // requestCreated: true, // Optional: require a one-time nonce for replay protection // requestNonce: false, // Optional: nonce TTL in seconds (default: 300) diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 4cd000c9a..2623d8e55 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -795,12 +795,6 @@ export interface InboxChallengePolicy { */ components?: string[]; - /** - * Whether to request the signer include a `created` timestamp. - * @default `true` - */ - requestCreated?: boolean; - /** * Whether to generate and require a one-time nonce for replay protection. * When enabled, a cryptographically random nonce is included in each diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 1106a5c94..3f56815a1 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -1744,9 +1744,7 @@ async function buildAcceptSignatureHeader( noncePrefix: KvKey, ): Promise { const params: AcceptSignatureMember["parameters"] = {}; - if (policy.requestCreated !== false) { - params.created = true; - } + params.created = true; if (policy.requestNonce) { const nonce = generateNonce(); const ttl = Temporal.Duration.from({ From 3db2ddcc35bee7f5b92034500aae969a5e552cec Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 16 Mar 2026 16:50:46 +0000 Subject: [PATCH 14/34] Retry challenge on `TypeError` in `doubleKnock` --- packages/fedify/src/sig/http.test.ts | 161 +++++++++++++++++++++++++++ packages/fedify/src/sig/http.ts | 31 ++++-- 2 files changed, 181 insertions(+), 11 deletions(-) diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 554c1d700..8dee79b4d 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -2500,3 +2500,164 @@ test( 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 c4c13ae61..3ee1a79b6 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -1633,17 +1633,26 @@ export async function doubleKnock( "label {label} and components {components}.", { label: rfc9421.label, components: rfc9421.components }, ); - signedRequest = await signRequest( - request, - identity.privateKey, - identity.keyId, - { spec: "rfc9421", tracerProvider, body, rfc9421 }, - ); - log?.(signedRequest); - response = await fetch(signedRequest, { - redirect: "manual", - signal, - }); + 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 && From 178832349a8e1aa1b35e95fab8fba86bb66181a7 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 16 Mar 2026 17:32:14 +0000 Subject: [PATCH 15/34] Filter `@status` components --- packages/fedify/src/federation/handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 3f56815a1..1ecb699ae 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -1756,7 +1756,8 @@ async function buildAcceptSignatureHeader( } return formatAcceptSignature([{ label: "sig1", - components: policy.components ?? DEFAULT_CHALLENGE_COMPONENTS, + components: (policy.components ?? DEFAULT_CHALLENGE_COMPONENTS) + .filter((c) => c !== "@status"), parameters: params, }]); } From 7810dd041da76c4d446c2b340419b9c24535eaea Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 17 Mar 2026 10:46:55 +0000 Subject: [PATCH 16/34] Fix nonce and challenge component issues in inbox handler - Remove unsafe fallback in `verifySignatureNonce` that scanned all Signature-Input entries when verifiedLabel is absent; non-RFC 9421 signatures do not support nonces so the check is skipped entirely - Defer nonce consumption until after `doesActorOwnKey` to avoid burning nonces on requests that will be rejected due to actor/key mismatch - Enforce a minimum component set (method, target-uri, authority) in `buildAcceptSignatureHeader` regardless of caller-supplied components --- .../fedify/src/federation/handler.test.ts | 90 +++++++++++++ packages/fedify/src/federation/handler.ts | 120 ++++++++++-------- 2 files changed, 156 insertions(+), 54 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index cd14f01f7..da00524c8 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -2740,3 +2740,93 @@ test( ); }, ); + +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 1ecb699ae..9fb215334 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -686,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, { @@ -815,37 +818,12 @@ async function handleInboxInternal( }, ); } else { - // Optional nonce verification for Accept-Signature challenges if ( inboxChallengePolicy?.enabled && inboxChallengePolicy.requestNonce ) { - const nonceValid = await verifySignatureNonce( - request, - kv, - kvPrefixes.acceptSignatureNonce, - verification.signatureLabel, - ); - 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 }, - ); - } + // 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; @@ -890,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, @@ -1688,6 +1695,14 @@ const DEFAULT_CHALLENGE_COMPONENTS = [ "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); @@ -1710,30 +1725,19 @@ async function verifySignatureNonce( // 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. - if (verifiedLabel != null) { - 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; - } - // Fallback: if no verified label is known (e.g., draft-cavage), scan all - for (const sig of globalThis.Object.values(parsed)) { - const nonce = sig.nonce; - if (nonce == null) continue; - const key = [...noncePrefix, nonce] as unknown as KvKey; - const stored = await kv.get(key); - if (stored != null) { - // Consume the nonce (one-time use) - await kv.delete(key); - return true; - } + // 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; } @@ -1754,10 +1758,18 @@ async function buildAcceptSignatureHeader( 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: (policy.components ?? DEFAULT_CHALLENGE_COMPONENTS) - .filter((c) => c !== "@status"), + components, parameters: params, }]); } From 7d91283a5f46fe73a05f4df6f840821ec2532a78 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <61987505+2chanhaeng@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:55:25 +0900 Subject: [PATCH 17/34] Fix minor in docs Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/manual/send.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manual/send.md b/docs/manual/send.md index f8ef1f8ce..b3a4f8871 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1007,7 +1007,7 @@ Safety constraints prevent abuse: If the challenge cannot be fulfilled (e.g., incompatible algorithm), Fedify falls through to the existing double-knocking spec-swap fallback. -At most three total requests are made per delivery attempt: +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) From edcf2ed4fe4b9e96e7c4c1bfeeffb2d3cacb7df9 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 18 Mar 2026 07:57:03 +0000 Subject: [PATCH 18/34] Fix null check --- packages/fedify/src/federation/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 9fb215334..36e64fd05 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -869,7 +869,7 @@ async function handleInboxInternal( }); } // Perform deferred nonce verification now that actor/key ownership is confirmed. - if (pendingNonceLabel !== null) { + if (pendingNonceLabel != null) { const nonceValid = await verifySignatureNonce( request, kv, From f2432e2fda58faadb3a2b981ed0770c3165eb890 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 18 Mar 2026 08:08:36 +0000 Subject: [PATCH 19/34] Lint markdown --- docs/manual/send.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/manual/send.md b/docs/manual/send.md index b3a4f8871..51b4805cf 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1007,7 +1007,8 @@ Safety constraints prevent abuse: 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): +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) From 030f07bf5315f36b72ff91de1a272b4ca0ad00f1 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Mar 2026 23:25:42 +0900 Subject: [PATCH 20/34] Add PR --- CHANGES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index a4a7a8f4b..753907ad0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -41,7 +41,7 @@ To be released. 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] + protection. [[#583], [#584], [#626] by ChanHaeng Lee] [#472]: https://github.com/fedify-dev/fedify/issues/472 [#473]: https://github.com/fedify-dev/fedify/issues/473 @@ -49,6 +49,7 @@ To be released. [#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 +[#626]: https://github.com/fedify-dev/fedify/pull/626 ### @fedify/vocab-runtime From 1257ee800d016b73b3a8494e2dcf41df5b4ab5c2 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Mar 2026 23:31:19 +0900 Subject: [PATCH 21/34] Initialize `pendingNonceLabel` as `undefined` --- packages/fedify/src/federation/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 36e64fd05..033e70551 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -688,7 +688,7 @@ 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; + let pendingNonceLabel: string | undefined; if (activity == null) { if (!skipSignatureVerification) { const verification = await verifyRequestDetailed(request, { From ab7dcdd0c066a32954e4a5139ac864868968954f Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 20 Mar 2026 00:40:49 +0900 Subject: [PATCH 22/34] Add conditional check for `kv.cas` in `verifySignatureNonce` function --- packages/fedify/src/federation/handler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 033e70551..7df863304 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -1734,6 +1734,9 @@ async function verifySignatureNonce( const nonce = sig.nonce; if (nonce == null) return false; const key = [...noncePrefix, nonce] as unknown as KvKey; + if (kv.cas != null) { + return await kv.cas(key, true, undefined); + } const stored = await kv.get(key); if (stored != null) { await kv.delete(key); From 8950cc02ae6e38f3ab59acb1a1ace5fd6a7d2519 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 20 Mar 2026 06:04:48 +0900 Subject: [PATCH 23/34] Add `AcceptSignatureComponent` and fix related code --- packages/fedify/src/federation/handler.ts | 72 ++--- packages/fedify/src/sig/accept.test.ts | 329 +++++++++++++++------- packages/fedify/src/sig/accept.ts | 210 ++++++++------ packages/fedify/src/sig/http.ts | 4 +- packages/fedify/src/sig/mod.ts | 2 +- 5 files changed, 379 insertions(+), 238 deletions(-) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 7df863304..cf214654e 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -1,3 +1,4 @@ +import { AcceptSignatureParameters } from "@fedify/fedify/sig"; import type { Recipient } from "@fedify/vocab"; import { Activity, @@ -19,8 +20,12 @@ import type { TracerProvider, } from "@opentelemetry/api"; import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; +import { uniq, uniqBy } from "es-toolkit"; import metadata from "../../deno.json" with { type: "json" }; -import type { AcceptSignatureMember } from "../sig/accept.ts"; +import type { + AcceptSignatureComponent, + AcceptSignatureMember, +} from "../sig/accept.ts"; import { formatAcceptSignature } from "../sig/accept.ts"; import { parseRfc9421SignatureInput, @@ -1688,21 +1693,6 @@ export async function respondWithObjectIfAcceptable( 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); @@ -1750,29 +1740,39 @@ async function buildAcceptSignatureHeader( kv: KvStore, noncePrefix: KvKey, ): Promise { - const params: AcceptSignatureMember["parameters"] = {}; - params.created = true; + const parameters: AcceptSignatureParameters = { 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 key: KvKey = [...noncePrefix, nonce]; + await setKey(kv, key, policy); + parameters.nonce = nonce; } - const baseComponents = policy.components ?? DEFAULT_CHALLENGE_COMPONENTS; + const baseComponents = policy.components ?? DEF_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, - }]); + const components = uniq(MIN_COMPONENTS.concat(baseComponents)) + .filter((c) => c !== "@status") + .map((v) => ({ value: v, params: {} })); + return formatAcceptSignature([{ label: "sig1", components, parameters }]); } + +async function setKey(kv: KvStore, key: KvKey, policy: InboxChallengePolicy) { + const seconds = policy.nonceTtlSeconds ?? 300; + const ttl = Temporal.Duration.from({ seconds }); + await kv.set(key, true, { ttl }); +} + +const DEF_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 MIN_COMPONENTS = [ + "@method", + "@target-uri", + "@authority", +]; diff --git a/packages/fedify/src/sig/accept.test.ts b/packages/fedify/src/sig/accept.test.ts index 43e1e2c76..f798e4e8e 100644 --- a/packages/fedify/src/sig/accept.test.ts +++ b/packages/fedify/src/sig/accept.test.ts @@ -5,7 +5,7 @@ import { formatAcceptSignature, fulfillAcceptSignature, parseAcceptSignature, - validateAcceptSignatureForRequest, + validateAcceptSignature, } from "./accept.ts"; // --------------------------------------------------------------------------- @@ -16,21 +16,34 @@ 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, {}); + + deepStrictEqual(result, [{ + label: "sig1", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + ], + 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"]); + + deepStrictEqual(result, [ + { + label: "sig1", + components: [{ value: "@method", params: {} }], + parameters: {}, + }, + { + label: "sig2", + components: [{ value: "@authority", params: {} }], + parameters: {}, + }, + ]); }); test("parseAcceptSignature(): all six parameters", () => { @@ -38,29 +51,69 @@ test("parseAcceptSignature(): all six parameters", () => { '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", - }); + + deepStrictEqual(result, [{ + label: "sig1", + components: [{ value: "@method", params: {} }], + parameters: { + keyid: "k1", + alg: "rsa-v1_5-sha256", + created: true, + expires: true, + nonce: "abc", + tag: "t1", + }, + }]); }); -test("parseAcceptSignature(): no parameters", () => { +test("parseAcceptSignature(): preserves string component parameters", () => { const result = parseAcceptSignature( - 'sig1=("@method" "@target-uri")', + 'sig1=("@query-param";name="foo" "@method")', ); - deepStrictEqual(result[0].parameters, {}); + + deepStrictEqual(result, [{ + label: "sig1", + components: [ + { value: "@query-param", params: { name: "foo" } }, + { value: "@method", params: {} }, + ], + parameters: {}, + }]); }); -test("parseAcceptSignature(): malformed header", () => { - deepStrictEqual(parseAcceptSignature("not a valid structured field"), []); +test("parseAcceptSignature(): preserves boolean component parameters", () => { + const result = parseAcceptSignature( + 'sig1=("content-type";sf "content-digest";bs)', + ); + deepStrictEqual(result, [{ + label: "sig1", + components: [ + { value: "content-type", params: { sf: true } }, + { value: "content-digest", params: { bs: true } }, + ], + parameters: {}, + }]); }); -test("parseAcceptSignature(): empty string", () => { +test( + "parseAcceptSignature(): preserves multiple parameters on one component", + () => { + const result = parseAcceptSignature( + 'sig1=("@request-response";key="sig1";req)', + ); + deepStrictEqual(result, [{ + label: "sig1", + components: [{ + value: "@request-response", + params: { key: "sig1", req: true }, + }], + parameters: {}, + }]); + }, +); + +test("parseAcceptSignature(): malformed header", () => { + deepStrictEqual(parseAcceptSignature("not a valid structured field"), []); deepStrictEqual(parseAcceptSignature(""), []); }); @@ -71,26 +124,23 @@ test("parseAcceptSignature(): empty string", () => { test("formatAcceptSignature(): single entry with created", () => { const members: AcceptSignatureMember[] = [{ label: "sig1", - components: ["@method", "@target-uri", "@authority"], + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + ], 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); + + deepStrictEqual(parsed, members); }); test("formatAcceptSignature(): created + nonce", () => { const members: AcceptSignatureMember[] = [{ label: "sig1", - components: ["@method"], + components: [{ value: "@method", params: {} }], parameters: { created: true, nonce: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", @@ -98,42 +148,40 @@ test("formatAcceptSignature(): created + nonce", () => { }]; const header = formatAcceptSignature(members); const parsed = parseAcceptSignature(header); - strictEqual( - parsed[0].parameters.nonce, - "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", - ); - strictEqual(parsed[0].parameters.created, true); + + deepStrictEqual(parsed, members); }); test("formatAcceptSignature(): multiple entries", () => { const members: AcceptSignatureMember[] = [ { label: "sig1", - components: ["@method"], + components: [{ value: "@method", params: {} }], parameters: {}, }, { label: "sig2", - components: ["@authority", "content-digest"], + components: [ + { value: "@authority", params: {} }, + { value: "content-digest", params: {} }, + ], 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"); + + deepStrictEqual(parsed, members); }); test("formatAcceptSignature(): round-trip with all parameters", () => { const input: AcceptSignatureMember[] = [{ label: "sig1", components: [ - "@method", - "@target-uri", - "@authority", - "content-digest", + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + { value: "content-digest", params: {} }, ], parameters: { keyid: "test-key-rsa-pss", @@ -144,49 +192,68 @@ test("formatAcceptSignature(): round-trip with all parameters", () => { tag: "app-123", }, }]; - const roundTripped = parseAcceptSignature( - formatAcceptSignature(input), - ); - deepStrictEqual(roundTripped, input); -}); + const header = formatAcceptSignature(input); + const members = parseAcceptSignature(header); -// --------------------------------------------------------------------------- -// validateAcceptSignatureForRequest() -// --------------------------------------------------------------------------- + deepStrictEqual(members, input); +}); -test("validateAcceptSignatureForRequest(): filters out @status", () => { - const members: AcceptSignatureMember[] = [{ +test("formatAcceptSignature(): round-trip with parameterized components", () => { + const input: AcceptSignatureMember[] = [{ label: "sig1", - components: ["@method", "@status"], - parameters: {}, + components: [ + { value: "@query-param", params: { name: "foo" } }, + { value: "content-type", params: { sf: true } }, + { value: "@method", params: {} }, + ], + parameters: { created: true }, }]; - deepStrictEqual(validateAcceptSignatureForRequest(members), []); + const header = formatAcceptSignature(input); + const members = parseAcceptSignature(header); + deepStrictEqual(members, input); }); -test("validateAcceptSignatureForRequest(): passes valid entries", () => { - const members: AcceptSignatureMember[] = [{ +// --------------------------------------------------------------------------- +// validateAcceptSignature() +// --------------------------------------------------------------------------- + +test("validateAcceptSignature(): filters out @status", () => { + const valid: AcceptSignatureMember = { label: "sig1", - components: ["@method", "@target-uri"], + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + ], parameters: {}, - }]; - deepStrictEqual(validateAcceptSignatureForRequest(members), members); + }; + const invalid: AcceptSignatureMember = { + label: "sig2", + components: [ + { value: "@method", params: {} }, + { value: "@status", params: {} }, + ], + parameters: {}, + }; + const validOnly = [valid]; + deepStrictEqual(validateAcceptSignature(validOnly), [valid]); + const invalidOnly = [invalid]; + deepStrictEqual(validateAcceptSignature(invalidOnly), []); + const mixed = [valid, invalid]; + deepStrictEqual(validateAcceptSignature(mixed), [valid]); }); test( - "validateAcceptSignatureForRequest(): mixed valid and invalid", + "validateAcceptSignature(): passes entries with parameterized components", () => { - const valid: AcceptSignatureMember = { + const members: AcceptSignatureMember[] = [{ label: "sig1", - components: ["@method", "@target-uri"], - parameters: {}, - }; - const invalid: AcceptSignatureMember = { - label: "sig2", - components: ["@method", "@status"], + components: [ + { value: "@query-param", params: { name: "foo" } }, + { value: "@method", params: {} }, + ], parameters: {}, - }; - const result = validateAcceptSignatureForRequest([valid, invalid]); - deepStrictEqual(result, [valid]); + }]; + deepStrictEqual(validateAcceptSignature(members), members); }, ); @@ -197,7 +264,11 @@ test( test("fulfillAcceptSignature(): compatible alg and keyid", () => { const entry: AcceptSignatureMember = { label: "sig1", - components: ["@method", "@target-uri", "content-digest"], + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "content-digest", params: {} }, + ], parameters: { alg: "rsa-v1_5-sha256", keyid: "https://example.com/key", @@ -210,22 +281,24 @@ test("fulfillAcceptSignature(): compatible alg and keyid", () => { "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"); + + deepStrictEqual(result, { + label: "sig1", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + { value: "content-digest", params: {} }, + ], + nonce: "abc", + tag: "t1", + }); }); test("fulfillAcceptSignature(): incompatible alg", () => { const entry: AcceptSignatureMember = { label: "sig1", - components: ["@method"], + components: [{ value: "@method", params: {} }], parameters: { alg: "ecdsa-p256-sha256" }, }; const result = fulfillAcceptSignature( @@ -233,13 +306,14 @@ test("fulfillAcceptSignature(): incompatible alg", () => { "https://example.com/key", "rsa-v1_5-sha256", ); + strictEqual(result, null); }); test("fulfillAcceptSignature(): incompatible keyid", () => { const entry: AcceptSignatureMember = { label: "sig1", - components: ["@method"], + components: [{ value: "@method", params: {} }], parameters: { keyid: "https://other.example/key" }, }; const result = fulfillAcceptSignature( @@ -247,13 +321,14 @@ test("fulfillAcceptSignature(): incompatible keyid", () => { "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"], + components: [{ value: "content-digest", params: {} }], parameters: {}, }; const result = fulfillAcceptSignature( @@ -261,18 +336,25 @@ test("fulfillAcceptSignature(): minimum component set preserved", () => { "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); + const values = result!.components.map((c) => c.value).sort(); + deepStrictEqual(values, [ + "@authority", + "@method", + "@target-uri", + "content-digest", + ]); }); test("fulfillAcceptSignature(): no alg/keyid constraints", () => { const entry: AcceptSignatureMember = { label: "custom", - components: ["@method", "@target-uri", "@authority"], + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + ], parameters: {}, }; const result = fulfillAcceptSignature( @@ -280,13 +362,42 @@ test("fulfillAcceptSignature(): no alg/keyid constraints", () => { "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); + + deepStrictEqual(result, { + label: "custom", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + ], + nonce: undefined, + tag: undefined, + }); }); + +test( + "fulfillAcceptSignature(): preserves component parameters in result", + () => { + const entry: AcceptSignatureMember = { + label: "sig1", + components: [ + { value: "@query-param", params: { name: "foo" } }, + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + ], + parameters: {}, + }; + const result = fulfillAcceptSignature( + entry, + "https://example.com/key", + "rsa-v1_5-sha256", + ); + strictEqual(result != null, true); + // The parameterized component must be preserved intact in the result + const qp = result!.components.find((c) => c.value === "@query-param"); + deepStrictEqual(qp, { value: "@query-param", params: { name: "foo" } }); + }, +); + +// cspell: ignore keyid diff --git a/packages/fedify/src/sig/accept.ts b/packages/fedify/src/sig/accept.ts index 1555ad445..4a15b122c 100644 --- a/packages/fedify/src/sig/accept.ts +++ b/packages/fedify/src/sig/accept.ts @@ -4,21 +4,8 @@ * * @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 { uniqBy } from "es-toolkit"; import { decodeDict, type Dictionary, @@ -73,6 +60,41 @@ export interface AcceptSignatureParameters { tag?: string; } +/** + * A single covered component identifier from an `Accept-Signature` inner list, + * as defined in [RFC 9421 §2.1](https://www.rfc-editor.org/rfc/rfc9421#section-2.1) + * and [§5.1](https://www.rfc-editor.org/rfc/rfc9421#section-5.1). + * + * RFC 9421 §5.1 requires that the list of component identifiers includes + * *all applicable component parameters*. Parameters such as `;sf`, `;bs`, + * `;req`, `;tr`, `;name`, and `;key` narrow the meaning of a component + * identifier and MUST be preserved exactly as received so that the signer + * can cover the same components the verifier requested. + * + * Examples: + * - `{ value: "@method", params: {} }` + * - `{ value: "content-type", params: { sf: true } }` + * - `{ value: "@query-param", params: { name: "foo" } }` + * + * @since 2.1.0 + */ + +export interface AcceptSignatureComponent { + /** + * The component identifier name (e.g., `"@method"`, `"content-digest"`, + * `"@query-param"`). + */ + value: string; + + /** + * Component parameters attached to this identifier (e.g., `{ sf: true }`, + * `{ name: "foo" }`). An empty object means no parameters were present. + * Parameters MUST NOT be dropped; doing so would cause the signer to cover + * a different component than the verifier requested. + */ + params: Record; +} + /** * Represents a single member of the `Accept-Signature` Dictionary * Structured Field, as defined in @@ -88,11 +110,16 @@ export interface AcceptSignatureMember { label: string; /** - * The set of covered component identifiers for the target message - * (e.g., `["@method", "@target-uri", "@authority", - * "content-digest"]`). + * The exact list of covered component identifiers requested for the target + * signature, including all applicable component parameters, as required by + * [RFC 9421 §5.1](https://www.rfc-editor.org/rfc/rfc9421#section-5.1). + * + * Each element is an {@link AcceptSignatureComponent} that preserves + * both the identifier name and any parameters (e.g., `;sf`, `;name="foo"`). + * The signer MUST cover exactly these components—with their parameters—when + * fulfilling the challenge. */ - components: string[]; + components: AcceptSignatureComponent[]; /** * Optional signature metadata parameters requested by the verifier. @@ -119,51 +146,48 @@ export function parseAcceptSignature( header: string, ): AcceptSignatureMember[] { try { - return pipe( - header, - decodeDict, - parseEachSignature, - toArray, - ) as AcceptSignatureMember[]; + return parseEachSignature(decodeDict(header)); } catch { + getLogger(["fedify", "sig", "http"]).warn( + "Failed to parse Accept-Signature header: {header}", + { header }, + ); 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 compactObject = (obj: T): T => + Object.fromEntries( + Object.entries(obj).filter(([_, v]) => v !== undefined), + ) as T; + +const parseEachSignature = (dict: Dictionary): AcceptSignatureMember[] => + Object.entries(dict) + .filter(([_, item]) => Array.isArray(item.value)) + .map(([label, item]) => ({ + label, + components: (item.value as Item[]) + .filter((subitem) => typeof subitem.value === "string") + .map((subitem) => ({ + value: subitem.value as string, + params: subitem.params ?? {}, + })), + parameters: compactParams(item), + })); -const extractParams = ( +const compactParams = ( 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; +): AcceptSignatureParameters => { + const { keyid, alg, created, expires, nonce, tag } = item.params ?? {}; + return compactObject({ + keyid: stringOrUndefined(keyid), + alg: stringOrUndefined(alg), + created: trueOrUndefined(created), + expires: trueOrUndefined(expires), + nonce: stringOrUndefined(nonce), + tag: stringOrUndefined(tag), + }); +}; const stringOrUndefined = (v: unknown): string | undefined => typeof v === "string" ? v : undefined; @@ -184,32 +208,26 @@ const trueOrUndefined = ( export function formatAcceptSignature( members: AcceptSignatureMember[], ): string { - return pipe( - members, - map((member) => - [ - member.label, - new Item( - extractComponents(member), - extractParameters(member), - ), - ] as const - ), - fromEntries, - encodeDict, + const items = members.map((member) => + [ + member.label, + new Item( + compToItems(member), + compactParameters(member), + ), + ] as const ); + return encodeDict(Object.fromEntries(items)); } -const extractComponents = (member: AcceptSignatureMember): Item[] => - member.components.map((c) => new Item(c, {})); -const extractParameters = ( +const compToItems = (member: AcceptSignatureMember): Item[] => + member.components.map((c) => new Item(c.value, c.params)); +const compactParameters = ( member: AcceptSignatureMember, -): AcceptSignatureParameters => - pipe( - member.parameters, - pick(["keyid", "alg", "created", "expires", "nonce", "tag"]), - compactObject, - ); +): AcceptSignatureParameters => { + const { keyid, alg, created, expires, nonce, tag } = member.parameters; + return compactObject({ keyid, alg, created, expires, nonce, tag }); +}; /** * Filters out {@link AcceptSignatureMember} entries whose covered @@ -223,15 +241,15 @@ const extractParameters = ( * @returns Only entries that are valid for request-target messages. * @since 2.1.0 */ -export function validateAcceptSignatureForRequest( +export function validateAcceptSignature( members: AcceptSignatureMember[], ): AcceptSignatureMember[] { const logger = getLogger(["fedify", "sig", "http"]); - return members.filter((member) => - !member.components.includes("@status") - ? true - : logLabel(logger, member.label) || false - ); + return members.filter((member) => { + if (member.components.every((c) => c.value !== "@status")) return true; + logLabel(logger, member.label); + return false; + }); } const logLabel = (logger: Logger, label: string): undefined => @@ -249,8 +267,11 @@ const logLabel = (logger: Logger, label: string): undefined => export interface FulfillAcceptSignatureResult { /** The label for the signature. */ label: string; - /** The merged set of covered component identifiers. */ - components: string[]; + /** + * The merged set of covered component identifiers, including all component + * parameters, ready to be passed to the signer. + */ + components: AcceptSignatureComponent[]; /** The nonce requested by the challenge, if any. */ nonce?: string; /** The tag requested by the challenge, if any. */ @@ -261,7 +282,11 @@ export interface FulfillAcceptSignatureResult { * The minimum set of covered component identifiers that Fedify always * includes in RFC 9421 signatures for security. */ -const MINIMUM_COMPONENTS = ["@method", "@target-uri", "@authority"]; +const MINIMUM_COMPONENTS: AcceptSignatureComponent[] = [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, +]; /** * Attempts to translate an {@link AcceptSignatureMember} challenge into @@ -307,8 +332,13 @@ export function fulfillAcceptSignature( }; } -/** Merge components: challenge components + minimum required set */ -const concatMinimumComponents = (components: string[]): string[] => - pipe(MINIMUM_COMPONENTS, concat(components), uniq, toArray); +/** + * Merge components: minimum required set + challenge components not already + * covered + */ +const concatMinimumComponents = ( + components: AcceptSignatureComponent[], +): AcceptSignatureComponent[] => + uniqBy(MINIMUM_COMPONENTS.concat(components), (c) => c.value); // cspell: ignore keyid diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 3ee1a79b6..ce5a215db 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -25,7 +25,7 @@ import metadata from "../../deno.json" with { type: "json" }; import { fulfillAcceptSignature, parseAcceptSignature, - validateAcceptSignatureForRequest, + validateAcceptSignature, } from "./accept.ts"; import { fetchKeyDetailed, @@ -1619,7 +1619,7 @@ export async function doubleKnock( // attempt a challenge-driven retry before falling back to spec-swap. const acceptSigHeader = response.headers.get("Accept-Signature"); if (acceptSigHeader != null) { - const entries = validateAcceptSignatureForRequest( + const entries = validateAcceptSignature( parseAcceptSignature(acceptSigHeader), ); const localKeyId = identity.keyId.href; diff --git a/packages/fedify/src/sig/mod.ts b/packages/fedify/src/sig/mod.ts index f410de51c..50d653886 100644 --- a/packages/fedify/src/sig/mod.ts +++ b/packages/fedify/src/sig/mod.ts @@ -10,7 +10,7 @@ export { fulfillAcceptSignature, type FulfillAcceptSignatureResult, parseAcceptSignature, - validateAcceptSignatureForRequest, + validateAcceptSignature, } from "./accept.ts"; export { type HttpMessageSignaturesSpec, From 5d4d93db6a7d7c50cad185ac3e073180b55765cb Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 20 Mar 2026 06:38:37 +0900 Subject: [PATCH 24/34] Add `expires` attr --- packages/fedify/src/sig/accept.test.ts | 22 ++++++++++++++++++++++ packages/fedify/src/sig/accept.ts | 6 ++++++ packages/fedify/src/sig/http.ts | 13 +++++++++++++ 3 files changed, 41 insertions(+) diff --git a/packages/fedify/src/sig/accept.test.ts b/packages/fedify/src/sig/accept.test.ts index f798e4e8e..cbc73810c 100644 --- a/packages/fedify/src/sig/accept.test.ts +++ b/packages/fedify/src/sig/accept.test.ts @@ -292,6 +292,7 @@ test("fulfillAcceptSignature(): compatible alg and keyid", () => { ], nonce: "abc", tag: "t1", + expires: undefined, }); }); @@ -372,9 +373,30 @@ test("fulfillAcceptSignature(): no alg/keyid constraints", () => { ], nonce: undefined, tag: undefined, + expires: undefined, }); }); +test("fulfillAcceptSignature(): passes through expires when requested", () => { + const entry: AcceptSignatureMember = { + label: "sig1", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + ], + parameters: { expires: true }, + }; + const result = fulfillAcceptSignature( + entry, + "https://example.com/key", + "rsa-v1_5-sha256", + ); + + strictEqual(result != null, true); + strictEqual(result!.expires, true); +}); + test( "fulfillAcceptSignature(): preserves component parameters in result", () => { diff --git a/packages/fedify/src/sig/accept.ts b/packages/fedify/src/sig/accept.ts index 4a15b122c..52a4ba16b 100644 --- a/packages/fedify/src/sig/accept.ts +++ b/packages/fedify/src/sig/accept.ts @@ -276,6 +276,11 @@ export interface FulfillAcceptSignatureResult { nonce?: string; /** The tag requested by the challenge, if any. */ tag?: string; + /** + * If `true`, the challenger requested that the signer generate and include + * an expiration timestamp in the signature parameters. + */ + expires?: true; } /** @@ -329,6 +334,7 @@ export function fulfillAcceptSignature( components: concatMinimumComponents(entry.components), nonce: entry.parameters.nonce, tag: entry.parameters.tag, + expires: entry.parameters.expires, }; } diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index ce5a215db..8621548ee 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -118,6 +118,13 @@ export interface Rfc9421SignRequestOptions { * A tag value to include in the signature parameters. */ tag?: string; + + /** + * If `true`, an expiration timestamp is generated and included in the + * signature parameters. The expiration time defaults to one hour after + * the signature creation time. + */ + expires?: true; } /** @@ -262,6 +269,7 @@ export interface Rfc9421SignatureParameters { algorithm: string; keyId: URL; created: number; + expires?: number; nonce?: string; tag?: string; } @@ -276,6 +284,7 @@ function* iterRfc9421(params: Rfc9421SignatureParameters): Iterable { yield `alg="${params.algorithm}"`; yield `keyid="${params.keyId.href}"`; yield `created=${params.created}`; + if (params.expires != null) yield `expires=${params.expires}`; if (params.nonce != null) yield `nonce="${params.nonce}"`; if (params.tag != null) yield `tag="${params.tag}"`; } @@ -504,10 +513,14 @@ async function signRequestRfc9421( ]); // Generate the signature base using the headers + const expires = rfc9421Options?.expires === true + ? ((currentTime.epochMilliseconds / 1000) | 0) + 3600 + : undefined; const signatureParams = formatRfc9421SignatureParameters({ algorithm: "rsa-v1_5-sha256", keyId, created, + expires, nonce: rfc9421Options?.nonce, tag: rfc9421Options?.tag, }); From 9243884337e32854c870e54116d6b4cae4918426 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 20 Mar 2026 06:47:05 +0900 Subject: [PATCH 25/34] Remove not requested components --- packages/fedify/src/sig/accept.test.ts | 14 +++++-------- packages/fedify/src/sig/accept.ts | 27 +++----------------------- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/packages/fedify/src/sig/accept.test.ts b/packages/fedify/src/sig/accept.test.ts index cbc73810c..5e9c9e0fc 100644 --- a/packages/fedify/src/sig/accept.test.ts +++ b/packages/fedify/src/sig/accept.test.ts @@ -282,12 +282,12 @@ test("fulfillAcceptSignature(): compatible alg and keyid", () => { "rsa-v1_5-sha256", ); + // Components must be exactly what the challenger requested — no additions. deepStrictEqual(result, { label: "sig1", components: [ { value: "@method", params: {} }, { value: "@target-uri", params: {} }, - { value: "@authority", params: {} }, { value: "content-digest", params: {} }, ], nonce: "abc", @@ -326,7 +326,7 @@ test("fulfillAcceptSignature(): incompatible keyid", () => { strictEqual(result, null); }); -test("fulfillAcceptSignature(): minimum component set preserved", () => { +test("fulfillAcceptSignature(): components returned exactly as requested", () => { const entry: AcceptSignatureMember = { label: "sig1", components: [{ value: "content-digest", params: {} }], @@ -338,13 +338,9 @@ test("fulfillAcceptSignature(): minimum component set preserved", () => { "rsa-v1_5-sha256", ); - // Minimum set should be merged in - const values = result!.components.map((c) => c.value).sort(); - deepStrictEqual(values, [ - "@authority", - "@method", - "@target-uri", - "content-digest", + // Challenger only requested content-digest; no minimum-set components added. + deepStrictEqual(result!.components, [ + { value: "content-digest", params: {} }, ]); }); diff --git a/packages/fedify/src/sig/accept.ts b/packages/fedify/src/sig/accept.ts index 52a4ba16b..202f913d4 100644 --- a/packages/fedify/src/sig/accept.ts +++ b/packages/fedify/src/sig/accept.ts @@ -5,7 +5,6 @@ * @module */ import { getLogger, type Logger } from "@logtape/logtape"; -import { uniqBy } from "es-toolkit"; import { decodeDict, type Dictionary, @@ -283,16 +282,6 @@ export interface FulfillAcceptSignatureResult { expires?: true; } -/** - * The minimum set of covered component identifiers that Fedify always - * includes in RFC 9421 signatures for security. - */ -const MINIMUM_COMPONENTS: AcceptSignatureComponent[] = [ - { value: "@method", params: {} }, - { value: "@target-uri", params: {} }, - { value: "@authority", params: {} }, -]; - /** * Attempts to translate an {@link AcceptSignatureMember} challenge into * RFC 9421 signing options that the local signer can fulfill. @@ -303,9 +292,8 @@ const MINIMUM_COMPONENTS: AcceptSignatureComponent[] = [ * 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. + * - `components`: passed through exactly as requested, per RFC 9421 §5.2. + * - `nonce`, `tag`, and `expires` 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). @@ -331,20 +319,11 @@ export function fulfillAcceptSignature( } return { label: entry.label, - components: concatMinimumComponents(entry.components), + components: entry.components, nonce: entry.parameters.nonce, tag: entry.parameters.tag, expires: entry.parameters.expires, }; } -/** - * Merge components: minimum required set + challenge components not already - * covered - */ -const concatMinimumComponents = ( - components: AcceptSignatureComponent[], -): AcceptSignatureComponent[] => - uniqBy(MINIMUM_COMPONENTS.concat(components), (c) => c.value); - // cspell: ignore keyid From d31f5d6b27dc148e602b868c65938681ba4daf46 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 20 Mar 2026 07:01:49 +0900 Subject: [PATCH 26/34] Refactor `derivedComponents` --- packages/fedify/src/sig/http.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 8621548ee..c8cbad312 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -301,9 +301,10 @@ export function createRfc9421SignatureBase( components: string[], parameters: string, ): string { + const url = new URL(request.url); // Build the base string return components.map((component) => { - const derived = derivedComponents[component]?.(request); + const derived = derivedComponents[component]?.(request, url); if (derived != null) return `"${component}": ${derived}`; if (component.startsWith("@")) { throw new Error(`Unsupported derived component: ${component}`); @@ -321,20 +322,19 @@ export function createRfc9421SignatureBase( ]).join("\n"); } -const derivedComponents: Record string> = { +const derivedComponents: Record< + string, + (request: Request, url: URL) => 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; - }, + "@target-uri": (_, url) => url.href, + "@authority": (_, url) => url.host, + "@scheme": (_, url) => url.protocol.slice(0, -1), + "@request-target": (request, url) => + `${request.method.toLowerCase()} ${url.pathname}${url.search}`, + "@path": (_, url) => url.pathname, + "@query": (_, { search }) => + search.startsWith("?") ? search.slice(1) : search, "@query-param": () => { throw new Error("@query-param requires a parameter name"); }, From fe8a9e3be091000ad1c8c0251ccd7f57f6473b80 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 20 Mar 2026 07:02:30 +0900 Subject: [PATCH 27/34] Fix `rfc9421` components --- packages/fedify/src/sig/http.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index c8cbad312..742a29919 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -1651,7 +1651,15 @@ export async function doubleKnock( request, identity.privateKey, identity.keyId, - { spec: "rfc9421", tracerProvider, body, rfc9421 }, + { + spec: "rfc9421", + tracerProvider, + body, + rfc9421: { + ...rfc9421, + components: rfc9421.components.map((c) => c.value), + }, + }, ); log?.(signedRequest); response = await fetch(signedRequest, { From 69e5048e550c58b7379098a7e96189eced257b74 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Mar 2026 22:39:12 +0000 Subject: [PATCH 28/34] Fix `rfc9421` components --- packages/fedify/src/federation/handler.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index da00524c8..82e656c06 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -2108,7 +2108,7 @@ test("handleInbox() challenge policy enabled + unsigned request", async () => { assert(parsed.length > 0, "Accept-Signature must have at least one entry"); assertEquals(parsed[0].label, "sig1"); assert( - parsed[0].components.includes("@method"), + parsed[0].components.some((c) => c.value === "@method"), "Must include @method component", ); assertEquals( From 15db4641427a839f3439baa32d985ada52ea0790 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Mar 2026 23:35:31 +0000 Subject: [PATCH 29/34] Return non-negotiation failures from challenge retry directly --- packages/fedify/src/sig/http.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 742a29919..7d6674546 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -1690,10 +1690,13 @@ export async function doubleKnock( break; } // If the challenge retry succeeded, remember spec and return + if (fulfilled && response.status < 300) { + await specDeterminer?.rememberSpec(origin, "rfc9421"); + return response; + } if ( - fulfilled && response.status < 300 + fulfilled && response.status !== 400 && response.status !== 401 ) { - await specDeterminer?.rememberSpec(origin, "rfc9421"); return response; } // Otherwise fall through to legacy spec-swap fallback From 38097bfa49842d69c02695ec7a24de2f2cb81ec5 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Mar 2026 23:48:05 +0000 Subject: [PATCH 30/34] Fulfill all compatible Accept-Signature entries --- packages/fedify/src/sig/http.test.ts | 104 +++++++++++++++++++++++++++ packages/fedify/src/sig/http.ts | 50 +++++++++---- 2 files changed, 140 insertions(+), 14 deletions(-) diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 8dee79b4d..6a9ac29a7 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -2255,6 +2255,53 @@ test("signRequest() with nonce and tag", async () => { assertStringIncludes(sigInput, 'tag="app-v1"'); }); +test( + "signRequest() [rfc9421] accumulates multiple signatures when called sequentially", + async () => { + // RFC 9421 §5 requires all labeled signatures from an Accept-Signature + // challenge to be present in the target message. The implementation + // satisfies this by calling signRequest() once per entry, passing the + // result of each call into the next so that Signature-Input and Signature + // headers accumulate Dictionary members rather than being overwritten. + const request = new Request("https://example.com/inbox", { + method: "POST", + body: "Hello", + headers: { "Content-Type": "text/plain" }, + }); + + // First signature + const onceSigned = await signRequest( + request, + rsaPrivateKey2, + new URL("https://example.com/key2"), + { + spec: "rfc9421", + rfc9421: { label: "sig1", components: ["@method", "@target-uri"] }, + }, + ); + + // Second signature appended onto the already-signed request + const twiceSigned = await signRequest( + onceSigned, + rsaPrivateKey2, + new URL("https://example.com/key2"), + { + spec: "rfc9421", + rfc9421: { label: "sig2", components: ["@authority"] }, + }, + ); + + const sigInput = twiceSigned.headers.get("Signature-Input") ?? ""; + const sig = twiceSigned.headers.get("Signature") ?? ""; + + // Both labels must appear in both Dictionary headers + assertStringIncludes(sigInput, "sig1="); + assertStringIncludes(sigInput, "sig2="); + assertStringIncludes(sig, "sig1="); + assertStringIncludes(sig, "sig2="); + }, +); + // --------------------------------------------------------------------------- // doubleKnock() with Accept-Signature challenge // --------------------------------------------------------------------------- @@ -2661,3 +2708,60 @@ test( fetchMock.hardReset(); }, ); + +test( + "doubleKnock(): Accept-Signature with multiple compatible entries fulfills all (RFC 9421 §5 MUST)", + async () => { + // RFC 9421 §5: "The target message of an Accept-Signature field MUST + // include all labeled signatures indicated in the Accept-Signature field." + // When both entries are compatible with the local key, the retry request + // must carry signatures for sig1 AND sig2 — not just the first one. + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post( + "https://example.com/inbox-multi-compat", + (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // Both entries are compatible (no alg/keyid constraint) + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri");created,' + + 'sig2=("@authority");created;nonce="nonce-for-sig2"', + }, + }); + } + // The retry request must include signatures for both labels + const sigInput = req.headers.get("Signature-Input") ?? ""; + const sig = req.headers.get("Signature") ?? ""; + if ( + sigInput.includes("sig1=") && sigInput.includes("sig2=") && + sig.includes("sig1=") && sig.includes("sig2=") + ) { + return new Response("", { status: 202 }); + } + return new Response("Missing signatures", { status: 400 }); + }, + ); + + const request = new Request("https://example.com/inbox-multi-compat", { + 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 7d6674546..cf302f100 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -556,9 +556,25 @@ async function signRequestRfc9421( label, ); - // Add the signature headers - headers.set("Signature-Input", signatureInput); - headers.set("Signature", signature); + // Add (or append to) the signature headers. + // Both Signature-Input and Signature are RFC 8941 Dictionary Structured + // Fields, so multiple labeled members are comma-separated. Appending + // instead of overwriting lets callers accumulate signatures for different + // labels by calling signRequest() sequentially on the same request. + const existingInput = headers.get("Signature-Input"); + headers.set( + "Signature-Input", + existingInput != null + ? `${existingInput}, ${signatureInput}` + : signatureInput, + ); + const existingSignature = headers.get("Signature"); + headers.set( + "Signature", + existingSignature != null + ? `${existingSignature}, ${signature}` + : signature, + ); if (span.isRecording()) { span.setAttribute("http_signatures.algorithm", "rsa-v1_5-sha256"); @@ -1637,18 +1653,26 @@ export async function doubleKnock( ); 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 + // into challengeRequest before sending a single retry, rather than + // stopping at the first success. let fulfilled = false; + let challengeRequest: Request | undefined; for (const entry of entries) { const rfc9421 = fulfillAcceptSignature(entry, localKeyId, localAlg); if (rfc9421 == null) continue; logger.debug( - "Received Accept-Signature challenge; retrying with " + + "Received Accept-Signature challenge; accumulating " + "label {label} and components {components}.", { label: rfc9421.label, components: rfc9421.components }, ); try { - signedRequest = await signRequest( - request, + // Pass the previously-signed request so that Signature-Input / + // Signature headers are appended to rather than overwritten. + challengeRequest = await signRequest( + challengeRequest ?? request, identity.privateKey, identity.keyId, { @@ -1661,19 +1685,19 @@ export async function doubleKnock( }, }, ); - log?.(signedRequest); - response = await fetch(signedRequest, { - redirect: "manual", - signal, - }); + fulfilled = true; } catch (error) { logger.debug( "Failed to fulfill Accept-Signature challenge entry " + "{label}: {error}", { label: entry.label, error }, ); - continue; } + } + if (fulfilled && challengeRequest != null) { + signedRequest = challengeRequest; + log?.(signedRequest); + response = await fetch(signedRequest, { redirect: "manual", signal }); // Follow redirects manually: if ( response.status >= 300 && response.status < 400 && @@ -1686,8 +1710,6 @@ export async function doubleKnock( { ...options, body }, ); } - fulfilled = true; - break; } // If the challenge retry succeeded, remember spec and return if (fulfilled && response.status < 300) { From 3dda5bf50a27fa690260ab0fa7187510771238af Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 20 Mar 2026 00:50:46 +0000 Subject: [PATCH 31/34] Lint --- packages/fedify/src/federation/handler.ts | 8 ++------ packages/fedify/src/sig/http.test.ts | 3 +-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index cf214654e..a52a315fe 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -1,4 +1,4 @@ -import { AcceptSignatureParameters } from "@fedify/fedify/sig"; +import type { AcceptSignatureParameters } from "@fedify/fedify/sig"; import type { Recipient } from "@fedify/vocab"; import { Activity, @@ -20,12 +20,8 @@ import type { TracerProvider, } from "@opentelemetry/api"; import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; -import { uniq, uniqBy } from "es-toolkit"; +import { uniq } from "es-toolkit"; import metadata from "../../deno.json" with { type: "json" }; -import type { - AcceptSignatureComponent, - AcceptSignatureMember, -} from "../sig/accept.ts"; import { formatAcceptSignature } from "../sig/accept.ts"; import { parseRfc9421SignatureInput, diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 6a9ac29a7..5a166afbb 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -2729,8 +2729,7 @@ test( return new Response("Not Authorized", { status: 401, headers: { - "Accept-Signature": - 'sig1=("@method" "@target-uri");created,' + + "Accept-Signature": 'sig1=("@method" "@target-uri");created,' + 'sig2=("@authority");created;nonce="nonce-for-sig2"', }, }); From 71fdcae4ff1188d536db4d8d9146444cef6da272 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 21 Mar 2026 04:13:51 +0000 Subject: [PATCH 32/34] Update components to `AcceptSignatureComponent[]` type --- packages/fedify/src/sig/http.test.ts | 60 ++++++++++++++++------- packages/fedify/src/sig/http.ts | 71 +++++++++++++++------------- 2 files changed, 80 insertions(+), 51 deletions(-) diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 5a166afbb..eb59b7105 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -499,7 +499,7 @@ test("signRequest() and verifyRequest() [rfc9421] implementation", async () => { ); for (const component of expectedComponents) { assert( - parsedInput.sig1.components.includes(component), + parsedInput.sig1.components.some((c) => c.value === component), `Components should include ${component}`, ); } @@ -562,7 +562,12 @@ test("createRfc9421SignatureBase()", () => { }, }); - const components = ["@method", "@target-uri", "host", "date"]; + const components = [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "host", params: {} }, + { value: "date", params: {} }, + ]; const created = 1709626184; // 2024-03-05T08:09:44Z const signatureBase = createRfc9421SignatureBase( @@ -591,7 +596,11 @@ test("formatRfc9421Signature()", () => { const signature = new Uint8Array([1, 2, 3, 4]); const keyId = new URL("https://example.com/key"); const algorithm = "rsa-v1_5-sha256"; - const components = ["@method", "@target-uri", "host"]; + const components = [ + { "value": "@method", params: {} }, + { "value": "@target-uri", params: {} }, + { "value": "host", params: {} }, + ]; const created = 1709626184; const [signatureInput, signatureHeader] = formatRfc9421Signature( @@ -619,10 +628,10 @@ test("parseRfc9421SignatureInput()", () => { assertEquals(parsed.sig1.alg, "rsa-v1_5-sha256"); assertEquals(parsed.sig1.created, 1709626184); assertEquals(parsed.sig1.components, [ - "@method", - "@target-uri", - "host", - "date", + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "host", params: {} }, + { value: "date", params: {} }, ]); assertEquals( parsed.sig1.parameters, @@ -1107,10 +1116,10 @@ test("verifyRequest() [rfc9421] error cases and edge cases", async () => { assertEquals(parsedInput.sig1.alg, "rsa-v1_5-sha256"); assertEquals(parsedInput.sig1.created, 1709626184); assertEquals(parsedInput.sig1.components, [ - "@method", - "@target-uri", - "host", - "date", + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "host", params: {} }, + { value: "date", params: {} }, ]); // Parse and verify signature structure @@ -1139,10 +1148,10 @@ test("verifyRequest() [rfc9421] error cases and edge cases", async () => { ); assertEquals(complexParsedInput.sig1.alg, "rsa-v1_5-sha256"); assertEquals(complexParsedInput.sig1.created, 1709626184); - assert(complexParsedInput.sig1.components.includes("content-type")); + assert(complexParsedInput.sig1.components.some((c) => c.value === "content-type")); assert( - complexParsedInput.sig1.components.includes( - 'value with "quotes" and spaces', + complexParsedInput.sig1.components.some( + (c) => c.value === 'value with "quotes" and spaces', ), ); @@ -1962,7 +1971,7 @@ test("signRequest() [rfc9421] error handling for invalid signature base creation () => { createRfc9421SignatureBase( request, - ["@unsupported"], // This will trigger the "Unsupported derived component" error + [{ value: "@unsupported", params: {} }], // This will trigger the "Unsupported derived component" error 'alg="rsa-pss-sha256";keyid="https://example.com/key2";created=1234567890', ); }, @@ -2221,7 +2230,11 @@ test("signRequest() with custom components", async () => { { spec: "rfc9421", rfc9421: { - components: ["@method", "@target-uri", "@authority"], + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + ], }, }, ); @@ -2276,7 +2289,13 @@ test( new URL("https://example.com/key2"), { spec: "rfc9421", - rfc9421: { label: "sig1", components: ["@method", "@target-uri"] }, + rfc9421: { + label: "sig1", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + ], + }, }, ); @@ -2287,7 +2306,12 @@ test( new URL("https://example.com/key2"), { spec: "rfc9421", - rfc9421: { label: "sig2", components: ["@authority"] }, + rfc9421: { + label: "sig2", + components: [ + { value: "@authority", params: {} }, + ], + }, }, ); diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index cf302f100..7af1fa2a8 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -14,7 +14,6 @@ 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, @@ -23,6 +22,7 @@ import { } from "structured-field-values"; import metadata from "../../deno.json" with { type: "json" }; import { + type AcceptSignatureComponent, fulfillAcceptSignature, parseAcceptSignature, validateAcceptSignature, @@ -107,7 +107,7 @@ export interface Rfc9421SignRequestOptions { * `["@method", "@target-uri", "@authority", "host", "date"]` * (plus `"content-digest"` when a body is present) is used. */ - components?: string[]; + components?: AcceptSignatureComponent[]; /** * A nonce value to include in the signature parameters. @@ -289,6 +289,10 @@ function* iterRfc9421(params: Rfc9421SignatureParameters): Iterable { if (params.tag != null) yield `tag="${params.tag}"`; } +function formatComponentId(component: AcceptSignatureComponent): string { + return encodeItem(new Item(component.value, component.params)); +} + /** * Creates a signature base for a request according to RFC 9421. * @param request The request to create a signature base for. @@ -298,26 +302,27 @@ function* iterRfc9421(params: Rfc9421SignatureParameters): Iterable { */ export function createRfc9421SignatureBase( request: Request, - components: string[], + components: AcceptSignatureComponent[], parameters: string, ): string { const url = new URL(request.url); // Build the base string return components.map((component) => { - const derived = derivedComponents[component]?.(request, url); - if (derived != null) return `"${component}": ${derived}`; - if (component.startsWith("@")) { - throw new Error(`Unsupported derived component: ${component}`); + const id = formatComponentId(component); + const derived = derivedComponents[component.value]?.(request, url); + if (derived != null) return `${id}: ${derived}`; + if (component.value.startsWith("@")) { + throw new Error(`Unsupported derived component: ${component.value}`); } - const header = request.headers.get(component); + const header = request.headers.get(component.value); if (header == null) { - throw new Error(`Missing header: ${component}`); + throw new Error(`Missing header: ${component.value}`); } // Format the component as per RFC 9421 Section 2.1 - return `"${component}": ${header}`; + return `${id}: ${header}`; }).concat([ `"@signature-params": (${ - components.map((c) => `"${c}"`).join(" ") + components.map((c) => formatComponentId(c)).join(" ") });${parameters}`, ]).join("\n"); } @@ -352,13 +357,13 @@ const derivedComponents: Record< */ export function formatRfc9421Signature( signature: ArrayBuffer | Uint8Array, - components: string[], + components: AcceptSignatureComponent[], parameters: string, label = "sig1", ): [string, string] { - const signatureInputValue = `${label}=("${ - components.join('" "') - }");${parameters}`; + const signatureInputValue = `${label}=(${ + components.map((c) => formatComponentId(c)).join(" ") + });${parameters}`; const signatureValue = `${label}=:${encodeBase64(signature)}:`; return [signatureInputValue, signatureValue]; } @@ -378,7 +383,7 @@ export function parseRfc9421SignatureInput( created: number; nonce?: string; tag?: string; - components: string[]; + components: AcceptSignatureComponent[]; parameters: string; } > { @@ -400,7 +405,7 @@ export function parseRfc9421SignatureInput( created: number; nonce?: string; tag?: string; - components: string[]; + components: AcceptSignatureComponent[]; parameters: string; } > = {}; @@ -410,9 +415,12 @@ export function parseRfc9421SignatureInput( typeof item.params.keyid !== "string" || typeof item.params.created !== "number" ) continue; - const components = item.value - .map((subitem: Item) => subitem.value) - .filter((v) => typeof v === "string"); + const components: AcceptSignatureComponent[] = item.value + .filter((subitem: Item) => typeof subitem.value === "string") + .map((subitem: Item) => ({ + value: subitem.value as string, + params: subitem.params ?? {}, + })); const params = encodeItem(new Item(0, item.params)); result[label] = { keyId: item.params.keyid, @@ -501,16 +509,16 @@ async function signRequestRfc9421( // Define components to include in the signature const label = rfc9421Options?.label ?? "sig1"; - const components: string[] = uniq([ + const components: AcceptSignatureComponent[] = [ ...(rfc9421Options?.components ?? [ - "@method", - "@target-uri", - "@authority", - "host", - "date", + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + { value: "host", params: {} }, + { value: "date", params: {} }, ]), - ...(body != null ? ["content-digest"] : []), - ]); + ...(body != null ? [{ value: "content-digest", params: {} }] : []), + ]; // Generate the signature base using the headers const expires = rfc9421Options?.expires === true @@ -1326,7 +1334,7 @@ async function verifyRequestRfc9421( if ( request.method !== "GET" && request.method !== "HEAD" && - sigInput.components.includes("content-digest") + sigInput.components.some((c) => c.value === "content-digest") ) { const contentDigestHeader = request.headers.get("Content-Digest"); if (!contentDigestHeader) { @@ -1679,10 +1687,7 @@ export async function doubleKnock( spec: "rfc9421", tracerProvider, body, - rfc9421: { - ...rfc9421, - components: rfc9421.components.map((c) => c.value), - }, + rfc9421, }, ); fulfilled = true; From 95b6eccc33142ab96fd23ce7368b0f77d40c638c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 21 Mar 2026 04:26:22 +0000 Subject: [PATCH 33/34] Escape structured-field string --- packages/fedify/src/sig/http.test.ts | 43 +++++++++++++++++++++++++++- packages/fedify/src/sig/http.ts | 7 +++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index eb59b7105..22c746e25 100644 --- a/packages/fedify/src/sig/http.test.ts +++ b/packages/fedify/src/sig/http.test.ts @@ -1148,7 +1148,9 @@ test("verifyRequest() [rfc9421] error cases and edge cases", async () => { ); assertEquals(complexParsedInput.sig1.alg, "rsa-v1_5-sha256"); assertEquals(complexParsedInput.sig1.created, 1709626184); - assert(complexParsedInput.sig1.components.some((c) => c.value === "content-type")); + assert( + complexParsedInput.sig1.components.some((c) => c.value === "content-type"), + ); assert( complexParsedInput.sig1.components.some( (c) => c.value === 'value with "quotes" and spaces', @@ -2268,6 +2270,45 @@ test("signRequest() with nonce and tag", async () => { assertStringIncludes(sigInput, 'tag="app-v1"'); }); +test("formatRfc9421SignatureParameters() escapes nonce and tag", () => { + const commonParams = { + algorithm: "rsa-v1_5-sha256", + keyId: new URL("https://example.com/key"), + created: 1709626184, + }; + const slashNonce = formatRfc9421SignatureParameters({ + ...commonParams, + nonce: "x\\y", + }); + assertStringIncludes(slashNonce, 'nonce="x\\\\y"'); + + const quoteNonce = formatRfc9421SignatureParameters({ + ...commonParams, + nonce: 'a"b', + }); + assertStringIncludes(quoteNonce, 'nonce="a\\"b"'); + + const slashTag = formatRfc9421SignatureParameters({ + ...commonParams, + tag: "x\\y", + }); + assertStringIncludes(slashTag, 'tag="x\\\\y"'); + + const quoteTag = formatRfc9421SignatureParameters({ + ...commonParams, + tag: 'a"b', + }); + assertStringIncludes(quoteTag, 'tag="a\\"b"'); + + const mixed = formatRfc9421SignatureParameters({ + ...commonParams, + nonce: 'n"o\\nce', + tag: 't"ag\\value', + }); + assertStringIncludes(mixed, 'nonce="n\\"o\\\\nce"'); + assertStringIncludes(mixed, 'tag="t\\"ag\\\\value"'); +}); + test( "signRequest() [rfc9421] accumulates multiple signatures when called sequentially", async () => { diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 7af1fa2a8..7599d0834 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -285,10 +285,13 @@ function* iterRfc9421(params: Rfc9421SignatureParameters): Iterable { yield `keyid="${params.keyId.href}"`; yield `created=${params.created}`; if (params.expires != null) yield `expires=${params.expires}`; - if (params.nonce != null) yield `nonce="${params.nonce}"`; - if (params.tag != null) yield `tag="${params.tag}"`; + if (params.nonce != null) yield `nonce="${escapeSfString(params.nonce)}"`; + if (params.tag != null) yield `tag="${escapeSfString(params.tag)}"`; } +const escapeSfString = (value: string): string => + value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + function formatComponentId(component: AcceptSignatureComponent): string { return encodeItem(new Item(component.value, component.params)); } From d90f4a5576af8697da2d886c763814c5e727c68c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 21 Mar 2026 09:42:30 +0000 Subject: [PATCH 34/34] Add headers to `unverifiedActivityHandler` when 401 --- .../fedify/src/federation/handler.test.ts | 191 ++++++++++++++++++ packages/fedify/src/federation/handler.ts | 87 ++++---- 2 files changed, 239 insertions(+), 39 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 82e656c06..b7658ca2f 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -2830,3 +2830,194 @@ test( ); }, ); + +test( + "handleInbox() challenge policy enabled + unverifiedActivityHandler " + + "returns undefined", + async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/challenge-unverified"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/challenge-unverified"), + 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!, + ); + 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, + // unverifiedActivityHandler returns undefined (void), not a Response + unverifiedActivityHandler() {}, + 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 when unverifiedActivityHandler " + + "returns undefined and challenge policy is enabled", + ); + const parsed = parseAcceptSignature(acceptSig); + assert( + parsed.length > 0, + "Accept-Signature must have at least one entry", + ); + assertEquals( + response.headers.get("Cache-Control"), + "no-store", + "Cache-Control: no-store must be set for challenge-response", + ); + assertEquals( + response.headers.get("Vary"), + "Accept, Signature", + "Vary header must include Accept and Signature", + ); + }, +); + +test( + "handleInbox() challenge policy enabled + unverifiedActivityHandler " + + "throws error", + async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/challenge-throw"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/challenge-throw"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const originalRequest = new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }); + const signedRequest = await signRequest( + originalRequest, + rsaPrivateKey3, + rsaPublicKey3.id!, + ); + 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, + // unverifiedActivityHandler throws an error + unverifiedActivityHandler() { + throw new Error("handler error"); + }, + 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 when unverifiedActivityHandler " + + "throws and challenge policy is enabled", + ); + const parsed = parseAcceptSignature(acceptSig); + assert( + parsed.length > 0, + "Accept-Signature must have at least one entry", + ); + assertEquals( + response.headers.get("Cache-Control"), + "no-store", + "Cache-Control: no-store must be set for challenge-response", + ); + assertEquals( + response.headers.get("Vary"), + "Accept, Signature", + "Vary header must include Accept and Signature", + ); + }, +); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index a52a315fe..3bbe5b0d7 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -714,21 +714,10 @@ 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 }, + return await getFailedSignatureResponse( + inboxChallengePolicy, + kv, + kvPrefixes, ); } try { @@ -802,21 +791,17 @@ async function handleInboxInternal( { error, activity: json, recipient }, ); } - return new Response( - "Failed to verify the request signature.", - { - status: 401, - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }, + return await getFailedSignatureResponse( + inboxChallengePolicy, + kv, + kvPrefixes, ); } if (response instanceof Response) return response; - return new Response( - "Failed to verify the request signature.", - { - status: 401, - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }, + return await getFailedSignatureResponse( + inboxChallengePolicy, + kv, + kvPrefixes, ); } else { if ( @@ -882,19 +867,10 @@ async function handleInboxInternal( "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!, + return await getFailedSignatureResponse( + inboxChallengePolicy, kv, - kvPrefixes.acceptSignatureNonce, - ); - headers["Cache-Control"] = "no-store"; - headers["Vary"] = "Accept, Signature"; - return new Response( - "Signature nonce verification failed.", - { status: 401, headers }, + kvPrefixes, ); } } @@ -1731,6 +1707,39 @@ async function verifySignatureNonce( return false; } +const getFailedSignatureResponse = async ( + policy: InboxChallengePolicy | undefined, + kv: KvStore, + kvPrefixes: { acceptSignatureNonce: KvKey }, +): Promise => { + const headers = await getFailedSignatureHeaders( + policy, + kv, + kvPrefixes, + ); + return new Response( + "Failed to verify the request signature.", + { status: 401, headers }, + ); +}; + +const getFailedSignatureHeaders = async ( + policy: InboxChallengePolicy | undefined, + kv: KvStore, + kvPrefixes: { acceptSignatureNonce: KvKey }, +) => ({ + "Content-Type": "text/plain; charset=utf-8", + ...(policy?.enabled && { + "Accept-Signature": await buildAcceptSignatureHeader( + policy, + kv, + kvPrefixes.acceptSignatureNonce, + ), + "Cache-Control": "no-store", + "Vary": "Accept, Signature", + }), +}); + async function buildAcceptSignatureHeader( policy: InboxChallengePolicy, kv: KvStore,