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/43] 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/43] 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/43] 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/43] 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/43] =?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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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, From 386e7b0ca181f6426d294c12decf55f3e60d303a Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 22 Mar 2026 02:38:04 +0000 Subject: [PATCH 35/43] Add RFC 9421 interoperability field test example --- deno.json | 3 +- deno.lock | 267 +++++++++++++-------------- examples/rfc-9421-test/README.md | 132 +++++++++++++ examples/rfc-9421-test/app.ts | 212 +++++++++++++++++++++ examples/rfc-9421-test/const.ts | 1 + examples/rfc-9421-test/deno.json | 9 + examples/rfc-9421-test/dev.ts | 42 +++++ examples/rfc-9421-test/federation.ts | 173 +++++++++++++++++ examples/rfc-9421-test/logging.ts | 20 ++ examples/rfc-9421-test/main.ts | 95 ++++++++++ examples/rfc-9421-test/tunnel.ts | 85 +++++++++ 11 files changed, 904 insertions(+), 135 deletions(-) create mode 100644 examples/rfc-9421-test/README.md create mode 100644 examples/rfc-9421-test/app.ts create mode 100644 examples/rfc-9421-test/const.ts create mode 100644 examples/rfc-9421-test/deno.json create mode 100644 examples/rfc-9421-test/dev.ts create mode 100644 examples/rfc-9421-test/federation.ts create mode 100644 examples/rfc-9421-test/logging.ts create mode 100644 examples/rfc-9421-test/main.ts create mode 100644 examples/rfc-9421-test/tunnel.ts diff --git a/deno.json b/deno.json index f51641b02..0e9bcc140 100644 --- a/deno.json +++ b/deno.json @@ -29,7 +29,8 @@ "./packages/webfinger", "./examples/astro", "./examples/fresh", - "./examples/hono-sample" + "./examples/hono-sample", + "./examples/rfc-9421-test" ], "imports": { "@cloudflare/workers-types": "npm:@cloudflare/workers-types@^4.20250529.0", diff --git a/deno.lock b/deno.lock index 9608d537f..a440cdda9 100644 --- a/deno.lock +++ b/deno.lock @@ -68,17 +68,17 @@ "jsr:@std/yaml@^1.0.8": "1.0.10", "jsr:@valibot/valibot@^1.2.0": "1.2.0", "npm:@alinea/suite@~0.6.3": "0.6.3", - "npm:@astrojs/node@^9.5.4": "9.5.4_astro@5.17.3__rollup@4.57.1__ioredis@5.9.2__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__vite@6.4.1___@types+node@22.19.10___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__zod@3.25.76_rollup@4.57.1_ioredis@5.9.2_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2", + "npm:@astrojs/node@^9.5.4": "9.5.4_astro@5.17.3__@types+node@24.10.12_@types+node@24.10.12", "npm:@babel/core@^7.28.0": "7.29.0", "npm:@babel/preset-react@^7.27.1": "7.28.5_@babel+core@7.29.0", "npm:@cfworker/json-schema@^4.1.1": "4.1.1", - "npm:@cloudflare/vitest-pool-workers@~0.8.31": "0.8.71_@vitest+runner@3.2.4_@vitest+snapshot@3.2.4_vitest@3.2.4__@types+node@22.19.10__vite@7.3.1___@types+node@22.19.10___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__tsx@4.21.0__yaml@2.8.2_@types+node@22.19.10_@cloudflare+workers-types@4.20260210.0_tsx@4.21.0_yaml@2.8.2", + "npm:@cloudflare/vitest-pool-workers@~0.8.31": "0.8.71_vitest@3.2.4__@types+node@24.10.12_@types+node@24.10.12", "npm:@cloudflare/workers-types@^4.20250529.0": "4.20260210.0", "npm:@cloudflare/workers-types@^4.20250906.0": "4.20260210.0", - "npm:@deno/astro-adapter@~0.3.2": "0.3.2_@opentelemetry+api@1.9.0_astro@5.17.3__rollup@4.57.1__ioredis@5.9.2__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__vite@6.4.1___@types+node@22.19.10___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__zod@3.25.76_rollup@4.57.1_ioredis@5.9.2_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2", + "npm:@deno/astro-adapter@~0.3.2": "0.3.2_@opentelemetry+api@1.9.0_astro@5.17.3__@types+node@24.10.12_@types+node@24.10.12", "npm:@fxts/core@^1.21.1": "1.25.0", "npm:@hongminhee/localtunnel@0.3": "0.3.0", - "npm:@inquirer/prompts@^7.8.4": "7.10.1_@types+node@22.19.10", + "npm:@inquirer/prompts@^7.8.4": "7.10.1_@types+node@24.10.12", "npm:@jimp/core@^1.6.0": "1.6.0", "npm:@jimp/wasm-webp@^1.6.0": "1.6.0", "npm:@js-temporal/polyfill@~0.5.1": "0.5.1", @@ -92,11 +92,11 @@ "npm:@opentelemetry/sdk-trace-base@^2.5.0": "2.5.0_@opentelemetry+api@1.9.0", "npm:@opentelemetry/semantic-conventions@^1.39.0": "1.39.0", "npm:@poppanator/http-constants@^1.1.1": "1.1.1", - "npm:@preact/signals@^2.2.1": "2.7.1_preact@10.19.6", - "npm:@preact/signals@^2.3.2": "2.7.1_preact@10.19.6", - "npm:@prefresh/vite@^2.4.8": "2.4.11_preact@10.19.6_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2", + "npm:@preact/signals@^2.2.1": "2.7.1_preact@10.28.3", + "npm:@preact/signals@^2.3.2": "2.7.1_preact@10.28.3", + "npm:@prefresh/vite@^2.4.8": "2.4.11_preact@10.28.3_vite@7.3.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12", "npm:@standard-schema/spec@^1.1.0": "1.1.0", - "npm:@sveltejs/kit@2": "2.50.2_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@6.2.4__svelte@5.50.1___acorn@8.15.0__vite@7.3.1___@types+node@22.19.10___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2_svelte@5.50.1__acorn@8.15.0_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_acorn@8.15.0_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2", + "npm:@sveltejs/kit@2": "2.50.2_@opentelemetry+api@1.9.0_vite@7.3.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12", "npm:@types/amqplib@*": "0.10.8", "npm:@types/amqplib@~0.10.7": "0.10.8", "npm:@types/eslint@9": "9.6.1", @@ -108,8 +108,8 @@ "npm:amqplib@~0.10.9": "0.10.9", "npm:asn1js@^3.0.6": "3.0.7", "npm:asn1js@^3.0.7": "3.0.7", - "npm:astro@*": "5.17.3_rollup@4.57.1_ioredis@5.9.2_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_vite@6.4.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_zod@3.25.76", - "npm:astro@^5.17.3": "5.17.3_rollup@4.57.1_ioredis@5.9.2_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_vite@6.4.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_zod@3.25.76", + "npm:astro@*": "5.17.3_@types+node@24.10.12", + "npm:astro@^5.17.3": "5.17.3_@types+node@24.10.12", "npm:byte-encodings@^1.0.11": "1.0.11", "npm:chalk@^5.6.2": "5.6.2", "npm:cli-highlight@^2.1.11": "2.1.11", @@ -135,7 +135,7 @@ "npm:html-to-text@^9.0.5": "9.0.5", "npm:icojs@~0.19.5": "0.19.5", "npm:inquirer-toggle@^1.0.1": "1.0.1", - "npm:inquirer@^12.9.4": "12.11.1_@types+node@22.19.10", + "npm:inquirer@^12.9.4": "12.11.1_@types+node@24.10.12", "npm:ioredis@^5.8.2": "5.9.2", "npm:jimp@^1.6.0": "1.6.0", "npm:json-canon@^1.0.1": "1.0.1", @@ -144,12 +144,12 @@ "npm:koa@2": "2.16.3", "npm:miniflare@^4.20250523.0": "4.20250906.0", "npm:multicodec@^3.2.1": "3.2.1", - "npm:mysql2@^3.18.0": "3.18.2_@types+node@22.19.10", + "npm:mysql2@^3.18.0": "3.18.2_@types+node@24.10.12", "npm:ora@^8.2.0": "8.2.0", "npm:pkijs@^3.2.5": "3.3.3", "npm:pkijs@^3.3.3": "3.3.3", "npm:postgres@^3.4.7": "3.4.8", - "npm:preact-render-to-string@^6.6.3": "6.6.5_preact@10.19.6", + "npm:preact-render-to-string@^6.6.3": "6.6.5_preact@10.28.3", "npm:preact@10.19.6": "10.19.6", "npm:preact@^10.27.0": "10.28.3", "npm:preact@^10.27.2": "10.28.3", @@ -163,9 +163,9 @@ "npm:uri-template-router@1": "1.0.0", "npm:url-template@^3.1.1": "3.1.1", "npm:valibot@^1.2.0": "1.2.0", - "npm:vite@^7.1.3": "7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3", - "npm:vite@^7.1.4": "7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3", - "npm:vitest@3.2": "3.2.4_@types+node@22.19.10_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_tsx@4.21.0_yaml@2.8.2", + "npm:vite@^7.1.3": "7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2", + "npm:vite@^7.1.4": "7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2", + "npm:vitest@3.2": "3.2.4_@types+node@24.10.12", "npm:wrangler@^4.17.0": "4.35.0_@cloudflare+workers-types@4.20260210.0_unenv@2.0.0-rc.21_workerd@1.20250906.0", "npm:wrangler@^4.21.1": "4.35.0_@cloudflare+workers-types@4.20260210.0_unenv@2.0.0-rc.21_workerd@1.20250906.0", "npm:yaml@^2.8.1": "2.8.2" @@ -489,7 +489,7 @@ "vfile" ] }, - "@astrojs/node@9.5.4_astro@5.17.3__rollup@4.57.1__ioredis@5.9.2__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__vite@6.4.1___@types+node@22.19.10___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__zod@3.25.76_rollup@4.57.1_ioredis@5.9.2_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2": { + "@astrojs/node@9.5.4_astro@5.17.3__@types+node@24.10.12_@types+node@24.10.12": { "integrity": "sha512-AbPSZsMGu8hXPR2XxV79RaKy8h6wijhtoqZGeUf4OXg2w1mxXlx4VnIc1D+QvtsgauSz7P5PLhmvf6w/J41GJg==", "dependencies": [ "@astrojs/internal-helpers", @@ -728,7 +728,7 @@ "workerd" ] }, - "@cloudflare/vitest-pool-workers@0.8.71_@vitest+runner@3.2.4_@vitest+snapshot@3.2.4_vitest@3.2.4__@types+node@22.19.10__vite@7.3.1___@types+node@22.19.10___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__tsx@4.21.0__yaml@2.8.2_@types+node@22.19.10_@cloudflare+workers-types@4.20260210.0_tsx@4.21.0_yaml@2.8.2": { + "@cloudflare/vitest-pool-workers@0.8.71_vitest@3.2.4__@types+node@24.10.12_@types+node@24.10.12": { "integrity": "sha512-keu2HCLQfRNwbmLBCDXJgCFpANTaYnQpE01fBOo4CNwiWHUT7SZGN7w64RKiSWRHyYppStXBuE5Ng7F42+flpg==", "dependencies": [ "@vitest/runner", @@ -780,7 +780,7 @@ "@jridgewell/trace-mapping@0.3.9" ] }, - "@deno/astro-adapter@0.3.2_@opentelemetry+api@1.9.0_astro@5.17.3__rollup@4.57.1__ioredis@5.9.2__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__vite@6.4.1___@types+node@22.19.10___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__zod@3.25.76_rollup@4.57.1_ioredis@5.9.2_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2": { + "@deno/astro-adapter@0.3.2_@opentelemetry+api@1.9.0_astro@5.17.3__@types+node@24.10.12_@types+node@24.10.12": { "integrity": "sha512-nN0kQGobRs2XE3R+O/DWYQanEWpteJNsIf5TD65787qFEw2CrqkFNcNolZFJiKUF/2Y/TKyOLRjMS3F6auECVg==", "dependencies": [ "@opentelemetry/api", @@ -1728,38 +1728,38 @@ "@inquirer/ansi@1.0.2": { "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==" }, - "@inquirer/checkbox@4.3.2_@types+node@22.19.10": { + "@inquirer/checkbox@4.3.2_@types+node@24.10.12": { "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", "dependencies": [ "@inquirer/ansi", - "@inquirer/core@10.3.2_@types+node@22.19.10", + "@inquirer/core@10.3.2_@types+node@24.10.12", "@inquirer/figures", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12", "yoctocolors-cjs" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, - "@inquirer/confirm@5.1.21_@types+node@22.19.10": { + "@inquirer/confirm@5.1.21_@types+node@24.10.12": { "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "dependencies": [ - "@inquirer/core@10.3.2_@types+node@22.19.10", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10" + "@inquirer/core@10.3.2_@types+node@24.10.12", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, - "@inquirer/core@10.3.2_@types+node@22.19.10": { + "@inquirer/core@10.3.2_@types+node@24.10.12": { "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "dependencies": [ "@inquirer/ansi", "@inquirer/figures", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12", "cli-width", "mute-stream@2.0.0", "signal-exit", @@ -1767,7 +1767,7 @@ "yoctocolors-cjs" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, "@inquirer/core@8.2.4": { @@ -1788,79 +1788,79 @@ "wrap-ansi@6.2.0" ] }, - "@inquirer/editor@4.2.23_@types+node@22.19.10": { + "@inquirer/editor@4.2.23_@types+node@24.10.12": { "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", "dependencies": [ - "@inquirer/core@10.3.2_@types+node@22.19.10", + "@inquirer/core@10.3.2_@types+node@24.10.12", "@inquirer/external-editor", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10" + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, - "@inquirer/expand@4.0.23_@types+node@22.19.10": { + "@inquirer/expand@4.0.23_@types+node@24.10.12": { "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", "dependencies": [ - "@inquirer/core@10.3.2_@types+node@22.19.10", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10", + "@inquirer/core@10.3.2_@types+node@24.10.12", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12", "yoctocolors-cjs" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, - "@inquirer/external-editor@1.0.3_@types+node@22.19.10": { + "@inquirer/external-editor@1.0.3_@types+node@24.10.12": { "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", "dependencies": [ - "@types/node@22.19.10", + "@types/node@24.10.12", "chardet", "iconv-lite@0.7.2" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, "@inquirer/figures@1.0.15": { "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==" }, - "@inquirer/input@4.3.1_@types+node@22.19.10": { + "@inquirer/input@4.3.1_@types+node@24.10.12": { "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", "dependencies": [ - "@inquirer/core@10.3.2_@types+node@22.19.10", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10" + "@inquirer/core@10.3.2_@types+node@24.10.12", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, - "@inquirer/number@3.0.23_@types+node@22.19.10": { + "@inquirer/number@3.0.23_@types+node@24.10.12": { "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", "dependencies": [ - "@inquirer/core@10.3.2_@types+node@22.19.10", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10" + "@inquirer/core@10.3.2_@types+node@24.10.12", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, - "@inquirer/password@4.0.23_@types+node@22.19.10": { + "@inquirer/password@4.0.23_@types+node@24.10.12": { "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", "dependencies": [ "@inquirer/ansi", - "@inquirer/core@10.3.2_@types+node@22.19.10", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10" + "@inquirer/core@10.3.2_@types+node@24.10.12", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, - "@inquirer/prompts@7.10.1_@types+node@22.19.10": { + "@inquirer/prompts@7.10.1_@types+node@24.10.12": { "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "dependencies": [ "@inquirer/checkbox", @@ -1873,49 +1873,49 @@ "@inquirer/rawlist", "@inquirer/search", "@inquirer/select", - "@types/node@22.19.10" + "@types/node@24.10.12" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, - "@inquirer/rawlist@4.1.11_@types+node@22.19.10": { + "@inquirer/rawlist@4.1.11_@types+node@24.10.12": { "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", "dependencies": [ - "@inquirer/core@10.3.2_@types+node@22.19.10", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10", + "@inquirer/core@10.3.2_@types+node@24.10.12", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12", "yoctocolors-cjs" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, - "@inquirer/search@3.2.2_@types+node@22.19.10": { + "@inquirer/search@3.2.2_@types+node@24.10.12": { "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", "dependencies": [ - "@inquirer/core@10.3.2_@types+node@22.19.10", + "@inquirer/core@10.3.2_@types+node@24.10.12", "@inquirer/figures", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12", "yoctocolors-cjs" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, - "@inquirer/select@4.4.2_@types+node@22.19.10": { + "@inquirer/select@4.4.2_@types+node@24.10.12": { "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", "dependencies": [ "@inquirer/ansi", - "@inquirer/core@10.3.2_@types+node@22.19.10", + "@inquirer/core@10.3.2_@types+node@24.10.12", "@inquirer/figures", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12", "yoctocolors-cjs" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, "@inquirer/type@1.5.5": { @@ -1924,13 +1924,13 @@ "mute-stream@1.0.0" ] }, - "@inquirer/type@3.0.10_@types+node@22.19.10": { + "@inquirer/type@3.0.10_@types+node@24.10.12": { "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", "dependencies": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, "@ioredis/commands@1.5.0": { @@ -2386,26 +2386,26 @@ "@preact/signals-core@1.13.0": { "integrity": "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==" }, - "@preact/signals@2.7.1_preact@10.19.6": { + "@preact/signals@2.7.1_preact@10.28.3": { "integrity": "sha512-mP2+wMYHqDXVKFGzjqkL6CiHj3okB8eVTTJUZBrSVGozi/XfA+zZRCEALKKZYRoSoqLyT4J6qM4lhwT9155s1Q==", "dependencies": [ "@preact/signals-core", - "preact@10.19.6" + "preact@10.28.3" ] }, "@prefresh/babel-plugin@0.5.2": { "integrity": "sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==" }, - "@prefresh/core@1.5.9_preact@10.19.6": { + "@prefresh/core@1.5.9_preact@10.28.3": { "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", "dependencies": [ - "preact@10.19.6" + "preact@10.28.3" ] }, "@prefresh/utils@1.2.1": { "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==" }, - "@prefresh/vite@2.4.11_preact@10.19.6_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2": { + "@prefresh/vite@2.4.11_preact@10.28.3_vite@7.3.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12": { "integrity": "sha512-/XjURQqdRiCG3NpMmWqE9kJwrg9IchIOWHzulCfqg2sRe/8oQ1g5De7xrk9lbqPIQLn7ntBkKdqWXIj4E9YXyg==", "dependencies": [ "@babel/core", @@ -2413,8 +2413,8 @@ "@prefresh/core", "@prefresh/utils", "@rollup/pluginutils@4.2.1", - "preact@10.19.6", - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3" + "preact@10.28.3", + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2" ] }, "@quansync/fs@1.0.0": { @@ -2823,7 +2823,7 @@ "acorn@8.15.0" ] }, - "@sveltejs/kit@2.50.2_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@6.2.4__svelte@5.50.1___acorn@8.15.0__vite@7.3.1___@types+node@22.19.10___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2_svelte@5.50.1__acorn@8.15.0_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_acorn@8.15.0_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2": { + "@sveltejs/kit@2.50.2_@opentelemetry+api@1.9.0_vite@7.3.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12": { "integrity": "sha512-875hTUkEbz+MyJIxWbQjfMaekqdmEKUUfR7JyKcpfMRZqcGyrO9Gd+iS1D/Dx8LpE5FEtutWGOtlAh4ReSAiOA==", "dependencies": [ "@opentelemetry/api", @@ -2842,23 +2842,23 @@ "set-cookie-parser@3.0.1", "sirv", "svelte", - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3" + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2" ], "optionalPeers": [ "@opentelemetry/api" ], "bin": true }, - "@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.4__svelte@5.50.1___acorn@8.15.0__vite@7.3.1___@types+node@22.19.10___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2_svelte@5.50.1__acorn@8.15.0_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2": { + "@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.4__svelte@5.50.1___acorn@8.15.0__vite@7.3.1___@types+node@24.10.12___tsx@4.21.0___yaml@2.8.2__@types+node@24.10.12_svelte@5.50.1__acorn@8.15.0_vite@7.3.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12": { "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", "dependencies": [ "@sveltejs/vite-plugin-svelte", "obug", "svelte", - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3" + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2" ] }, - "@sveltejs/vite-plugin-svelte@6.2.4_svelte@5.50.1__acorn@8.15.0_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2": { + "@sveltejs/vite-plugin-svelte@6.2.4_svelte@5.50.1__acorn@8.15.0_vite@7.3.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12": { "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dependencies": [ "@sveltejs/vite-plugin-svelte-inspector", @@ -2866,8 +2866,8 @@ "magic-string", "obug", "svelte", - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3", - "vitefu@1.1.1_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2" + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2", + "vitefu@1.1.1_vite@7.3.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12" ] }, "@tokenizer/inflate@0.4.1": { @@ -3064,16 +3064,16 @@ "tinyrainbow" ] }, - "@vitest/mocker@3.2.4_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2": { + "@vitest/mocker@3.2.4_vite@7.3.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12": { "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dependencies": [ "@vitest/spy", "estree-walker@3.0.3", "magic-string", - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3" + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2" ], "optionalPeers": [ - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3" + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2" ] }, "@vitest/pretty-format@3.2.4": { @@ -3255,7 +3255,7 @@ "pathe" ] }, - "astro@5.17.3_rollup@4.57.1_ioredis@5.9.2_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_vite@6.4.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_zod@3.25.76": { + "astro@5.17.3_@types+node@24.10.12": { "integrity": "sha512-69dcfPe8LsHzklwj+hl+vunWUbpMB6pmg35mACjetxbJeUNNys90JaBM8ZiwsPK689SAj/4Zqb1ayaANls9/MA==", "dependencies": [ "@astrojs/compiler", @@ -3313,8 +3313,8 @@ "unist-util-visit", "unstorage", "vfile", - "vite@6.4.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3", - "vitefu@1.1.1_vite@6.4.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2", + "vite@6.4.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2", + "vitefu@1.1.1_vite@6.4.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12", "xxhash-wasm", "yargs-parser@21.1.1", "yocto-spinner", @@ -4744,20 +4744,20 @@ "@inquirer/core@8.2.4" ] }, - "inquirer@12.11.1_@types+node@22.19.10": { + "inquirer@12.11.1_@types+node@24.10.12": { "integrity": "sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw==", "dependencies": [ "@inquirer/ansi", - "@inquirer/core@10.3.2_@types+node@22.19.10", + "@inquirer/core@10.3.2_@types+node@24.10.12", "@inquirer/prompts", - "@inquirer/type@3.0.10_@types+node@22.19.10", - "@types/node@22.19.10", + "@inquirer/type@3.0.10_@types+node@24.10.12", + "@types/node@24.10.12", "mute-stream@2.0.0", "run-async", "rxjs" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ] }, "ioredis@5.9.2": { @@ -5599,10 +5599,10 @@ "mute-stream@2.0.0": { "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==" }, - "mysql2@3.18.2_@types+node@22.19.10": { + "mysql2@3.18.2_@types+node@24.10.12": { "integrity": "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==", "dependencies": [ - "@types/node@22.19.10", + "@types/node@24.10.12", "aws-ssl-profiles", "denque", "generate-function", @@ -5957,10 +5957,10 @@ "postgres@3.4.8": { "integrity": "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==" }, - "preact-render-to-string@6.6.5_preact@10.19.6": { + "preact-render-to-string@6.6.5_preact@10.28.3": { "integrity": "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==", "dependencies": [ - "preact@10.19.6" + "preact@10.28.3" ] }, "preact@10.19.6": { @@ -7189,21 +7189,21 @@ "vfile-message" ] }, - "vite-node@3.2.4_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2": { + "vite-node@3.2.4_@types+node@24.10.12": { "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dependencies": [ "cac", "debug@4.4.3", "es-module-lexer", "pathe", - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3" + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2" ], "bin": true }, - "vite@6.4.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3": { + "vite@6.4.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2": { "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dependencies": [ - "@types/node@22.19.10", + "@types/node@24.10.12", "esbuild@0.25.12", "fdir", "picomatch@4.0.3", @@ -7217,16 +7217,16 @@ "fsevents" ], "optionalPeers": [ - "@types/node@22.19.10", + "@types/node@24.10.12", "tsx", "yaml" ], "bin": true }, - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3": { + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2": { "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dependencies": [ - "@types/node@22.19.10", + "@types/node@24.10.12", "esbuild@0.27.3", "fdir", "picomatch@4.0.3", @@ -7240,35 +7240,29 @@ "fsevents" ], "optionalPeers": [ - "@types/node@22.19.10", + "@types/node@24.10.12", "tsx", "yaml" ], "bin": true }, - "vitefu@1.1.1_vite@6.4.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2": { - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "dependencies": [ - "vite@6.4.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3" - ], - "optionalPeers": [ - "vite@6.4.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3" - ] + "vitefu@1.1.1_vite@6.4.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12": { + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==" }, - "vitefu@1.1.1_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2": { + "vitefu@1.1.1_vite@7.3.1__@types+node@24.10.12__tsx@4.21.0__yaml@2.8.2_@types+node@24.10.12": { "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", "dependencies": [ - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3" + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2" ], "optionalPeers": [ - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3" + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2" ] }, - "vitest@3.2.4_@types+node@22.19.10_vite@7.3.1__@types+node@22.19.10__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_tsx@4.21.0_yaml@2.8.2": { + "vitest@3.2.4_@types+node@24.10.12": { "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dependencies": [ "@types/chai", - "@types/node@22.19.10", + "@types/node@24.10.12", "@vitest/expect", "@vitest/mocker", "@vitest/pretty-format", @@ -7288,12 +7282,12 @@ "tinyglobby", "tinypool", "tinyrainbow", - "vite@7.3.1_@types+node@22.19.10_tsx@4.21.0_yaml@2.8.2_picomatch@4.0.3", + "vite@7.3.1_@types+node@24.10.12_tsx@4.21.0_yaml@2.8.2", "vite-node", "why-is-node-running" ], "optionalPeers": [ - "@types/node@22.19.10" + "@types/node@24.10.12" ], "bin": true }, @@ -7579,6 +7573,11 @@ "jsr:@hono/hono@^4.7.1" ] }, + "examples/rfc-9421-test": { + "dependencies": [ + "jsr:@hono/hono@^4.7.1" + ] + }, "packages/amqp": { "dependencies": [ "jsr:@alinea/suite@~0.6.3" diff --git a/examples/rfc-9421-test/README.md b/examples/rfc-9421-test/README.md new file mode 100644 index 000000000..560f0896b --- /dev/null +++ b/examples/rfc-9421-test/README.md @@ -0,0 +1,132 @@ +RFC 9421 Interoperability Field Test +===================================== + +A Fedify-based server for testing RFC 9421 HTTP Message Signatures +interoperability with Bonfire, Mastodon, and other fediverse implementations. + +See [../../plans/field-test.md](../../plans/field-test.md) for the full test +plan. + + +Prerequisites +------------- + + - [Deno] installed + - Run `mise run install` (or `pnpm install`) from the repo root + - A public tunnel for testing (e.g., `fedify tunnel`) + + +Quick start +----------- + +### 1. Start the server + +~~~~sh +# Default (RFC 9421 first knock + Accept-Signature challenge): +deno run -A main.ts + +# With nonce replay protection: +CHALLENGE_NONCE=1 deno run -A main.ts + +# Without challenge (plain signature verification only): +CHALLENGE_ENABLED=0 deno run -A main.ts +~~~~ + +### 2. Expose publicly with `fedify tunnel` + +In a separate terminal, from the repo root: + +~~~~sh +deno task cli tunnel 8000 +~~~~ + +Note the public URL (e.g., `https://xxxxx.tunnel.example`). + +### 3. Send test activities + +Open your browser or use curl. Both GET (query params) and POST (JSON body) +are supported: + +~~~~sh +# Follow a remote actor (GET): +curl 'https://xxxxx.tunnel.example/send/follow?handle=@user@bonfire.example' + +# Follow a remote actor (POST): +curl -X POST -H 'Content-Type: application/json' \ + -d '{"handle":"@user@bonfire.example"}' \ + https://xxxxx.tunnel.example/send/follow + +# Send a note: +curl 'https://xxxxx.tunnel.example/send/note?handle=@user@bonfire.example&content=Hello!' + +# Unfollow: +curl 'https://xxxxx.tunnel.example/send/unfollow?handle=@user@bonfire.example' +~~~~ + + +Configuration +------------- + +All configuration is via environment variables: + +| Variable | Default | Description | +|---------------------|-------------|-------------------------------------------| +| `PORT` | `8000` | Server listen port | +| `FIRST_KNOCK` | `rfc9421` | Initial signature spec (`rfc9421` or `draft-cavage-http-signatures-12`) | +| `CHALLENGE_ENABLED` | (enabled) | Set to `0` to disable `Accept-Signature` on `401` | +| `CHALLENGE_NONCE` | (disabled) | Set to `1` to include one-time nonce | +| `NONCE_TTL` | `300` | Nonce time-to-live in seconds | + + +Endpoints +--------- + +### Monitoring + + - `GET /` — Server info and endpoint list + - `GET /log` — Received activities (newest first) + - `GET /followers-list` — Current followers + +### Sending activities (outbound) + +All send endpoints accept GET (query params) or POST (JSON body). + + - `/send/follow` — Send a Follow activity + - `handle` (required): remote actor handle + - `/send/note` — Send a Create(Note) activity + - `handle` (required): remote actor handle + - `content` (optional): note text + - `/send/unfollow` — Send an Undo(Follow) activity + - `handle` (required): remote actor handle + + +Test scenarios +-------------- + +### Scenario A: Fedify -> Bonfire (outbound) + +1. Start the server and expose via tunnel. +2. Use `/send/follow` and `/send/note` to send activities to a Bonfire actor. +3. Check Bonfire server logs for RFC 9421 signature verification. + +### Scenario B: Bonfire -> Fedify (inbound with challenge) + +1. Start the server with `CHALLENGE_ENABLED=1`. +2. Have Bonfire send a `Follow` to `@test@`. +3. Verify Fedify returns `401` with `Accept-Signature` header. +4. Verify Bonfire retries with a compatible signature and succeeds. +5. Repeat with `CHALLENGE_NONCE=1` for replay protection testing. + +### Scenario C: Fedify -> Mastodon (outbound) + +1. Start the server and expose via tunnel. +2. Use `/send/follow` targeting a Mastodon actor. +3. Monitor logs for double-knock behavior and 5xx workaround. + +### Scenario D: Mastodon -> Fedify (inbound) + +1. Start the server (optionally with challenge enabled). +2. From a Mastodon account, follow `@test@`. +3. Check the `/log` endpoint and server logs. + +[Deno]: https://deno.com/ diff --git a/examples/rfc-9421-test/app.ts b/examples/rfc-9421-test/app.ts new file mode 100644 index 000000000..9041c9f1a --- /dev/null +++ b/examples/rfc-9421-test/app.ts @@ -0,0 +1,212 @@ +import { federation } from "@fedify/hono"; +import { + Create, + Follow, + Note, + Person, + PUBLIC_COLLECTION, + Undo, +} from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import type { Context as HonoContext } from "hono"; +import { Hono } from "hono"; +import { ACTOR_ID } from "./const.ts"; +import type createFedify from "./federation.ts"; +import { activityLog, followersStore } from "./federation.ts"; + +const logger = getLogger(["fedify", "examples", "rfc-9421-test", "send"]); + +type Fedi = ReturnType; + +interface AppConfig { + firstKnock: string; + challengeEnabled: boolean; + challengeNonce: boolean; + nonceTtl: number; +} + +export default function createApp(fedi: Fedi, config: AppConfig) { + const app = new Hono(); + app.use(federation(fedi, () => undefined)); + + app.get("/", root(config)); + app.get("/send/follow", sendFollow(fedi)); + app.post("/send/follow", sendFollow(fedi)); + app.get("/send/note", sendNote(fedi)); + app.post("/send/note", sendNote(fedi)); + app.get("/send/unfollow", sendUnfollow(fedi)); + app.post("/send/unfollow", sendUnfollow(fedi)); + + app.get("/log", (c) => c.json(activityLog.slice().reverse())); + app.get("/followers", (c) => c.json(Array.from(followersStore.entries()))); + + return app; +} + +function root(config: AppConfig) { + return (c: HonoContext) => + c.json({ + name: "RFC 9421 Field Test Server", + actor: `${new URL(c.req.url).origin}/users/${ACTOR_ID}`, + webfinger: `${ + new URL(c.req.url).origin + }/.well-known/webfinger?resource=acct:${ACTOR_ID}@${ + new URL(c.req.url).hostname + }`, + config, + endpoints: { + activityLog: "/log", + followers: "/followers", + sendFollow: "/send/follow?handle=@user@example.com", + sendNote: "/send/note?handle=@user@example.com&content=Hello", + sendUnfollow: "/send/unfollow?handle=@user@example.com", + }, + }); +} + +function sendFollow(fedi: Fedi) { + return async (c: HonoContext) => { + const result = await resolveActor(fedi, c); + if ("error" in result) return result.error; + const { actor, ctx } = result; + + const actorUri = ctx.getActorUri(ACTOR_ID); + const followId = new URL(`#follow/${Date.now()}`, actorUri); + const follow = new Follow({ + id: followId, + actor: actorUri, + object: actor.id, + }); + + logger.info("Sending Follow to {target}", { target: actor.id?.href }); + try { + await ctx.sendActivity({ identifier: ACTOR_ID }, actor, follow); + } catch (e) { + logger.error("Failed: {error}", { error: e }); + return c.json({ ok: false, error: String(e) }, 502); + } + return c.json({ + ok: true, + activityId: followId.href, + target: actor.id?.href, + }); + }; +} + +function sendNote(fedi: Fedi) { + return async (c: HonoContext) => { + const result = await resolveActor(fedi, c); + if ("error" in result) return result.error; + const { actor, ctx } = result; + + const content = (await getParam(c, "content")) ?? + "Hello from Fedify RFC 9421 field test!"; + const actorUri = ctx.getActorUri(ACTOR_ID); + const noteId = new URL( + `/users/${ACTOR_ID}/posts/${Date.now()}`, + ctx.origin, + ); + const note = new Note({ + id: noteId, + attribution: actorUri, + content, + mediaType: "text/plain", + to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), + }); + const create = new Create({ + id: new URL(`#create/${Date.now()}`, actorUri), + actor: actorUri, + object: note, + tos: [PUBLIC_COLLECTION], + }); + + logger.info("Sending Create(Note) to {target}", { + target: actor.id?.href, + }); + try { + await ctx.sendActivity({ identifier: ACTOR_ID }, actor, create); + } catch (e) { + logger.error("Failed: {error}", { error: e }); + return c.json({ ok: false, error: String(e) }, 502); + } + return c.json({ + ok: true, + activityId: noteId.href, + target: actor.id?.href, + }); + }; +} + +function sendUnfollow(fedi: Fedi) { + return async (c: HonoContext) => { + const result = await resolveActor(fedi, c); + if ("error" in result) return result.error; + const { actor, ctx } = result; + + const actorUri = ctx.getActorUri(ACTOR_ID); + const follow = new Follow({ + id: new URL(`#follow/existing`, actorUri), + actor: actorUri, + object: actor.id, + }); + const undo = new Undo({ + id: new URL(`#undo/${Date.now()}`, actorUri), + actor: actorUri, + object: follow, + }); + + logger.info("Sending Undo(Follow) to {target}", { + target: actor.id?.href, + }); + try { + await ctx.sendActivity({ identifier: ACTOR_ID }, actor, undo); + } catch (e) { + logger.error("Failed: {error}", { error: e }); + return c.json({ ok: false, error: String(e) }, 502); + } + return c.json({ ok: true, target: actor.id?.href }); + }; +} + +/** Look up a remote actor by fediverse handle. */ +async function resolveActor(fedi: Fedi, c: HonoContext) { + const handle = await getParam(c, "handle"); + if (!handle) { + return { error: c.json({ ok: false, error: "missing handle" }, 400) }; + } + const ctx = fedi.createContext(c.req.raw); + const obj = await ctx.lookupObject(handle); + if (!obj) { + return { + error: c.json({ ok: false, error: `could not resolve: ${handle}` }, 502), + }; + } + if (!(obj instanceof Person)) { + return { + error: c.json({ + ok: false, + error: `not a Person: ${obj.constructor.name}`, + }, 400), + }; + } + return { actor: obj, ctx }; +} + +/** Read a param from query string (GET) or JSON body (POST). */ +async function getParam( + c: HonoContext, + name: string, +): Promise { + const fromQuery = c.req.query(name); + if (fromQuery != null) return fromQuery; + if (c.req.method === "POST") { + try { + const body = await c.req.json(); + return body[name] ?? undefined; + } catch { + return undefined; + } + } + return undefined; +} diff --git a/examples/rfc-9421-test/const.ts b/examples/rfc-9421-test/const.ts new file mode 100644 index 000000000..2c49fb8b2 --- /dev/null +++ b/examples/rfc-9421-test/const.ts @@ -0,0 +1 @@ +export const ACTOR_ID = "test"; diff --git a/examples/rfc-9421-test/deno.json b/examples/rfc-9421-test/deno.json new file mode 100644 index 000000000..94986343e --- /dev/null +++ b/examples/rfc-9421-test/deno.json @@ -0,0 +1,9 @@ +{ + "imports": { + "hono": "jsr:@hono/hono@^4.7.1" + }, + "tasks": { + "start": "deno run -A main.ts", + "dev": "deno run -A dev.ts" + } +} diff --git a/examples/rfc-9421-test/dev.ts b/examples/rfc-9421-test/dev.ts new file mode 100644 index 000000000..744f3488c --- /dev/null +++ b/examples/rfc-9421-test/dev.ts @@ -0,0 +1,42 @@ +/** + * Development wrapper: starts a tunnel once, then launches main.ts in --watch + * mode. The tunnel survives server restarts so the public URL stays stable. + * + * Usage: deno run -A dev.ts + * (or) deno task dev + */ + +import { getLogger } from "@logtape/logtape"; +import "./logging.ts"; +import startTunnel from "./tunnel.ts"; + +const logger = getLogger(["fedify", "examples", "rfc-9421-test", "dev"]); +const port = parseInt(Deno.env.get("PORT") ?? "8000", 10); + +// 1. Start the tunnel (owned by this process, not the watched child). +const tunnel = await startTunnel(port, 30_000); +if (!tunnel) { + logger.error("Tunnel failed. Aborting dev mode."); + Deno.exit(1); +} +logger.info("Tunnel ready: {url}", { url: tunnel.url }); + +// 2. Spawn the server in --watch mode, passing the tunnel URL via ORIGIN. +const child = new Deno.Command("deno", { + args: ["run", "-A", "--watch", "main.ts"], + cwd: import.meta.dirname!, + env: { ...Deno.env.toObject(), ORIGIN: tunnel.url }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", +}).spawn(); + +// 3. Clean up on SIGINT: kill server, then tunnel. +Deno.addSignalListener("SIGINT", () => { + child.kill("SIGTERM"); + tunnel.child.kill("SIGTERM"); + Deno.exit(0); +}); + +await child.status; +tunnel.child.kill("SIGTERM"); diff --git a/examples/rfc-9421-test/federation.ts b/examples/rfc-9421-test/federation.ts new file mode 100644 index 000000000..d4095a921 --- /dev/null +++ b/examples/rfc-9421-test/federation.ts @@ -0,0 +1,173 @@ +import { + createFederation, + generateCryptoKeyPair, + HttpMessageSignaturesSpec, + type InboxChallengePolicy, + MemoryKvStore, +} from "@fedify/fedify"; +import { + Accept, + Activity, + Create, + Endpoints, + Follow, + Note, + Person, + Undo, +} from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import { ACTOR_ID } from "./const.ts"; + +const keyPairsStore = new Map< + string, + { privateKey: CryptoKey; publicKey: CryptoKey }[] +>(); + +export const followersStore = new Map< + string, + { id: URL; inboxId: URL | null } +>(); + +/** Log of received activities for inspection. */ +export const activityLog: { + timestamp: string; + type: string; + actorId: string | null; + id: string | null; + raw: Record; +}[] = []; + +const logger = getLogger(["fedify", "examples", "rfc-9421-test", "inbound"]); + +export default function createFedify( + firstKnock: HttpMessageSignaturesSpec, + inboxChallengePolicy: InboxChallengePolicy | undefined, +) { + const fedi = createFederation({ + kv: new MemoryKvStore(), + firstKnock, + inboxChallengePolicy, + }); + + fedi + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier !== ACTOR_ID) return null; + const keyPairs = await ctx.getActorKeyPairs(identifier); + return new Person({ + id: ctx.getActorUri(identifier), + name: "RFC 9421 Field Test", + summary: + "A test actor for RFC 9421 HTTP Message Signatures interoperability testing.", + preferredUsername: identifier, + url: new URL("/", ctx.url), + inbox: ctx.getInboxUri(identifier), + endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }), + publicKey: keyPairs[0].cryptographicKey, + assertionMethods: keyPairs.map((kp) => kp.multikey), + }); + }) + .setKeyPairsDispatcher(async (_, identifier) => { + if (identifier !== ACTOR_ID) return []; + const existing = keyPairsStore.get(identifier); + if (existing) return existing; + const rsaPair = await generateCryptoKeyPair(); + const pairs = [rsaPair]; + keyPairsStore.set(identifier, pairs); + return pairs; + }); + + fedi + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (ctx, follow) => { + logger.info( + "Received Follow: actor={actor}, object={object}", + { actor: follow.actorId?.href, object: follow.objectId?.href }, + ); + logActivity("Follow", follow); + + if (!follow.id || !follow.actorId || !follow.objectId) return; + const result = ctx.parseUri(follow.objectId); + if (result?.type !== "actor" || result.identifier !== ACTOR_ID) return; + + const follower = await follow.getActor(ctx); + if (!follower?.id) return; + + // Auto-accept and record follower + await ctx.sendActivity( + { identifier: ACTOR_ID }, + follower, + new Accept({ + id: new URL( + `#accepts/${follower.id.href}`, + ctx.getActorUri(ACTOR_ID), + ), + actor: follow.objectId, + object: follow, + }), + ); + followersStore.set(follower.id.href, { + id: follower.id, + inboxId: follower.inboxId, + }); + logger.info("Accepted follow from {actor}", { + actor: follower.id.href, + }); + }) + .on(Undo, async (ctx, undo) => { + logger.info( + "Received Undo: actor={actor}", + { actor: undo.actorId?.href }, + ); + logActivity("Undo", undo); + const activity = await undo.getObject(ctx); + if (activity instanceof Follow && undo.actorId) { + followersStore.delete(undo.actorId.href); + logger.info("Removed follower {actor}", { actor: undo.actorId.href }); + } + }) + .on(Create, async (ctx, create) => { + logger.info( + "Received Create: actor={actor}, object={object}", + { actor: create.actorId?.href, object: create.objectId?.href }, + ); + logActivity("Create", create); + const object = await create.getObject(ctx); + if (object instanceof Note) { + logger.info(" Note content: {content}", { + content: object.content?.toString(), + }); + } + }) + .on(Accept, (_ctx, accept) => { + logger.info( + "Received Accept: actor={actor}, object={object}", + { actor: accept.actorId?.href, object: accept.objectId?.href }, + ); + logActivity("Accept", accept); + }) + .onError((_ctx, error) => { + logger.error("Inbox error: {error}", { error }); + }); + + fedi + .setFollowersDispatcher("/users/{identifier}/followers", (_ctx, _id) => { + const items = Array.from(followersStore.values()).map((f) => ({ + id: f.id, + inboxId: f.inboxId, + endpoints: null, + })); + return { items }; + }); + + return fedi; +} + +function logActivity(type: string, activity: Activity) { + activityLog.push({ + timestamp: new Date().toISOString(), + type, + actorId: activity.actorId?.href ?? null, + id: activity.id?.href ?? null, + raw: {}, + }); +} diff --git a/examples/rfc-9421-test/logging.ts b/examples/rfc-9421-test/logging.ts new file mode 100644 index 000000000..baa379766 --- /dev/null +++ b/examples/rfc-9421-test/logging.ts @@ -0,0 +1,20 @@ +import { configure, getConsoleSink } from "@logtape/logtape"; + +await configure({ + sinks: { console: getConsoleSink() }, + filters: {}, + loggers: [ + { + category: "fedify", + lowestLevel: "debug", + sinks: ["console"], + filters: [], + }, + { + category: ["logtape", "meta"], + lowestLevel: "warning", + sinks: ["console"], + filters: [], + }, + ], +}); diff --git a/examples/rfc-9421-test/main.ts b/examples/rfc-9421-test/main.ts new file mode 100644 index 000000000..42ae567bf --- /dev/null +++ b/examples/rfc-9421-test/main.ts @@ -0,0 +1,95 @@ +/** + * RFC 9421 Interoperability Field Test Server + * + * A Fedify-based server for testing RFC 9421 HTTP Message Signatures + * interoperability with Bonfire, Mastodon, and other fediverse implementations. + * + * Environment variables: + * CHALLENGE_ENABLED=0 Disable Accept-Signature challenge on 401 (enabled by default) + * CHALLENGE_NONCE=1 Enable one-time nonce in challenges + * NONCE_TTL=300 Nonce TTL in seconds (default: 300) + * FIRST_KNOCK=rfc9421 Initial signature spec (rfc9421 | draft-cavage) + * PORT=8000 Server port (default: 8000) + * + * Usage: + * deno run -A main.ts + * CHALLENGE_NONCE=1 deno run -A main.ts + * CHALLENGE_ENABLED=0 deno run -A main.ts + */ + +import { type InboxChallengePolicy } from "@fedify/fedify"; +import type { HttpMessageSignaturesSpec } from "@fedify/fedify/sig"; +import { getLogger } from "@logtape/logtape"; +import createApp from "./app.ts"; +import { ACTOR_ID } from "./const.ts"; +import createFedify from "./federation.ts"; +import "./logging.ts"; +import startTunnel from "./tunnel.ts"; + +const logger = getLogger(["fedify", "examples", "rfc-9421-test", "inbound"]); +const challengeEnabled = Deno.env.get("CHALLENGE_ENABLED") !== "0"; +const challengeNonce = Deno.env.get("CHALLENGE_NONCE") === "1"; +const nonceTtl = parseInt(Deno.env.get("NONCE_TTL") ?? "300", 10); +const firstKnock = + (Deno.env.get("FIRST_KNOCK") ?? "rfc9421") as HttpMessageSignaturesSpec; +const port = parseInt(Deno.env.get("PORT") ?? "8000", 10); + +const inboxChallengePolicy: InboxChallengePolicy | undefined = challengeEnabled + ? { + enabled: true, + requestNonce: challengeNonce, + nonceTtlSeconds: nonceTtl, + } + : undefined; + +logger.info( + "Configuration: firstKnock={firstKnock}, challenge={challenge}, nonce={nonce}, nonceTtl={nonceTtl}", + { + firstKnock, + challenge: challengeEnabled, + nonce: challengeNonce, + nonceTtl, + }, +); + +const fedi = createFedify(firstKnock, inboxChallengePolicy); + +const app = createApp(fedi, { + firstKnock, + challengeEnabled, + challengeNonce, + nonceTtl, +}); + +if (import.meta.main) { + logger.info("Starting RFC 9421 field test server on port {port}", { port }); + Deno.serve({ port }, app.fetch.bind(app)); + + // When ORIGIN is set (e.g. by dev.ts), the tunnel is managed externally. + const origin = Deno.env.get("ORIGIN"); + if (origin) { + logger.info("Public URL (external tunnel): {url}", { url: origin }); + logger.info("Actor: @{id}@{host}", { + id: ACTOR_ID, + host: new URL(origin).hostname, + }); + } else { + const tunnel = await startTunnel(port, 30_000); + if (tunnel) { + logger.info("Public URL: {url}", { url: tunnel.url }); + logger.info("Actor: {actor}", { + actor: `@${ACTOR_ID}@${new URL(tunnel.url).hostname}`, + }); + Deno.addSignalListener("SIGINT", () => { + tunnel.child.kill("SIGTERM"); + Deno.exit(0); + }); + } else { + logger.warn( + "Tunnel failed. Server is running locally on port {port}. " + + "Run `fedify tunnel {port}` manually to expose publicly.", + { port }, + ); + } + } +} diff --git a/examples/rfc-9421-test/tunnel.ts b/examples/rfc-9421-test/tunnel.ts new file mode 100644 index 000000000..ce1b480b5 --- /dev/null +++ b/examples/rfc-9421-test/tunnel.ts @@ -0,0 +1,85 @@ +import $, { type CommandChild } from "@david/dax"; +import { getLogger } from "@logtape/logtape"; + +/** + * Starts `fedify tunnel -s pinggy.io ` and waits up to `timeoutMs` + * for the tunnel URL to appear in its output. The tunnel process is kept + * alive and returned to the caller; it must be killed when no longer needed. + * + * Returns `null` if the URL was not found before the timeout. + */ +export default async function startTunnel( + port: number, + timeoutMs: number, +): Promise<{ child: CommandChild; url: string } | null> { + const tunnelLogger = getLogger(["fedify", "examples", "tunnel"]); + tunnelLogger.info("Opening localhost.run tunnel on port {port}", { port }); + + const child = $`mise cli tunnel -s pinggy.io ${String(port)}` + .stdout("piped") + .stderr("piped") + .noThrow() + .spawn(); + + // Accumulate text from both streams while logging each chunk at DEBUG. + const textChunks: string[] = []; + const decoder = new TextDecoder(); + + const readStream = (stream: ReadableStream) => { + (async () => { + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + textChunks.push(text); + const trimmed = text.trim(); + if (trimmed) tunnelLogger.debug("{output}", { output: trimmed }); + } + } catch { + // Stream may error when the process is killed. + } + })(); + }; + + readStream(child.stdout()); + readStream(child.stderr()); + + // Poll until we find an https URL in the accumulated output. + // The `message` template tag from @optique/run may wrap the URL in double + // quotes in non-TTY output, so we stop matching at whitespace or quotes. + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const match = textChunks.join("").match(/https:\/\/[^\s"']+/); + if (match) { + tunnelLogger.info("Tunnel established at {url}", { url: match[0] }); + return { child, url: match[0] }; + } + await new Promise((r) => setTimeout(r, 200)); + } + + tunnelLogger.error( + "Tunnel did not produce a URL within {timeout} ms", + { timeout: timeoutMs }, + ); + forceKillChild(child); + return null; +} + +/** + * Sends SIGKILL to `child` immediately. A rejection handler is attached to + * the CommandChild promise (which extends Promise) so that the + * eventual rejection from the killed process does not surface as an unhandled + * promise rejection. We intentionally do **not** await the promise because + * dax keeps it pending until all piped streams are fully consumed, which may + * never happen once the process is forcibly killed. + */ +function forceKillChild(child: CommandChild): void { + child.catch(() => {}); + try { + child.kill("SIGKILL"); + } catch { + // Process already exited. + } +} From 87f56034715dcb2f949197904a8ed8f3c15ce833 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 22 Mar 2026 03:15:00 +0000 Subject: [PATCH 36/43] Add page view --- examples/rfc-9421-test/app.ts | 73 ++++-- examples/rfc-9421-test/federation.ts | 19 ++ examples/rfc-9421-test/index.html | 347 +++++++++++++++++++++++++++ 3 files changed, 422 insertions(+), 17 deletions(-) create mode 100644 examples/rfc-9421-test/index.html diff --git a/examples/rfc-9421-test/app.ts b/examples/rfc-9421-test/app.ts index 9041c9f1a..5150ebce4 100644 --- a/examples/rfc-9421-test/app.ts +++ b/examples/rfc-9421-test/app.ts @@ -12,7 +12,17 @@ import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; import { ACTOR_ID } from "./const.ts"; import type createFedify from "./federation.ts"; -import { activityLog, followersStore } from "./federation.ts"; +import { + activityLog, + emitChange, + followersStore, + followingStore, + onStateChange, +} from "./federation.ts"; + +const indexHtml = await Deno.readTextFile( + new URL("index.html", import.meta.url), +); const logger = getLogger(["fedify", "examples", "rfc-9421-test", "send"]); @@ -29,7 +39,8 @@ export default function createApp(fedi: Fedi, config: AppConfig) { const app = new Hono(); app.use(federation(fedi, () => undefined)); - app.get("/", root(config)); + app.get("/", (c) => c.html(indexHtml)); + app.get("/api/info", apiInfo(config)); app.get("/send/follow", sendFollow(fedi)); app.post("/send/follow", sendFollow(fedi)); app.get("/send/note", sendNote(fedi)); @@ -38,29 +49,52 @@ export default function createApp(fedi: Fedi, config: AppConfig) { app.post("/send/unfollow", sendUnfollow(fedi)); app.get("/log", (c) => c.json(activityLog.slice().reverse())); + app.delete("/log", (c) => { + activityLog.length = 0; + emitChange("log"); + return c.json({ ok: true }); + }); app.get("/followers", (c) => c.json(Array.from(followersStore.entries()))); + app.get("/following", (c) => + c.json( + Array.from(followingStore.entries()).map(([k, v]) => ({ + id: k, + handle: v.handle, + })), + )); + + // SSE: push state changes to the browser + app.get("/events", (c) => { + const body = new ReadableStream({ + start(ctrl) { + const encoder = new TextEncoder(); + const send = (event: string) => { + try { + ctrl.enqueue(encoder.encode(`data: ${event}\n\n`)); + } catch { /* client disconnected */ } + }; + const unsubscribe = onStateChange(send); + c.req.raw.signal.addEventListener("abort", () => unsubscribe()); + }, + }); + return new Response(body, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); + }); return app; } -function root(config: AppConfig) { +function apiInfo(config: AppConfig) { return (c: HonoContext) => c.json({ - name: "RFC 9421 Field Test Server", - actor: `${new URL(c.req.url).origin}/users/${ACTOR_ID}`, - webfinger: `${ - new URL(c.req.url).origin - }/.well-known/webfinger?resource=acct:${ACTOR_ID}@${ - new URL(c.req.url).hostname - }`, + handle: `@${ACTOR_ID}@${new URL(c.req.url).hostname}`, + actorUri: `${new URL(c.req.url).origin}/users/${ACTOR_ID}`, config, - endpoints: { - activityLog: "/log", - followers: "/followers", - sendFollow: "/send/follow?handle=@user@example.com", - sendNote: "/send/note?handle=@user@example.com&content=Hello", - sendUnfollow: "/send/unfollow?handle=@user@example.com", - }, }); } @@ -78,6 +112,7 @@ function sendFollow(fedi: Fedi) { object: actor.id, }); + const handle = (await getParam(c, "handle"))!; logger.info("Sending Follow to {target}", { target: actor.id?.href }); try { await ctx.sendActivity({ identifier: ACTOR_ID }, actor, follow); @@ -85,6 +120,8 @@ function sendFollow(fedi: Fedi) { logger.error("Failed: {error}", { error: e }); return c.json({ ok: false, error: String(e) }, 502); } + followingStore.set(actor.id!.href, { id: actor.id!, handle }); + emitChange("following"); return c.json({ ok: true, activityId: followId.href, @@ -165,6 +202,8 @@ function sendUnfollow(fedi: Fedi) { logger.error("Failed: {error}", { error: e }); return c.json({ ok: false, error: String(e) }, 502); } + followingStore.delete(actor.id!.href); + emitChange("following"); return c.json({ ok: true, target: actor.id?.href }); }; } diff --git a/examples/rfc-9421-test/federation.ts b/examples/rfc-9421-test/federation.ts index d4095a921..ce5ec5720 100644 --- a/examples/rfc-9421-test/federation.ts +++ b/examples/rfc-9421-test/federation.ts @@ -28,6 +28,22 @@ export const followersStore = new Map< { id: URL; inboxId: URL | null } >(); +export const followingStore = new Map< + string, + { id: URL; handle: string } +>(); + +/** Simple event bus for SSE push to the frontend. */ +type ChangeListener = (event: string) => void; +const changeListeners = new Set(); +export function onStateChange(cb: ChangeListener): () => void { + changeListeners.add(cb); + return () => changeListeners.delete(cb); +} +export function emitChange(event: string): void { + for (const cb of changeListeners) cb(event); +} + /** Log of received activities for inspection. */ export const activityLog: { timestamp: string; @@ -109,6 +125,7 @@ export default function createFedify( id: follower.id, inboxId: follower.inboxId, }); + emitChange("followers"); logger.info("Accepted follow from {actor}", { actor: follower.id.href, }); @@ -122,6 +139,7 @@ export default function createFedify( const activity = await undo.getObject(ctx); if (activity instanceof Follow && undo.actorId) { followersStore.delete(undo.actorId.href); + emitChange("followers"); logger.info("Removed follower {actor}", { actor: undo.actorId.href }); } }) @@ -170,4 +188,5 @@ function logActivity(type: string, activity: Activity) { id: activity.id?.href ?? null, raw: {}, }); + emitChange("log"); } diff --git a/examples/rfc-9421-test/index.html b/examples/rfc-9421-test/index.html new file mode 100644 index 000000000..3a74bf250 --- /dev/null +++ b/examples/rfc-9421-test/index.html @@ -0,0 +1,347 @@ + + + + + +RFC 9421 Field Test + + + + +
+

RFC 9421 Field Test

+
+ loading… + +
+

Test RFC 9421 HTTP Message Signatures interoperability with fediverse servers.

+
+ + +
+ +
+
+
+ Followers + +
+
none
+
+
+
+ Following + +
+
none
+
+
+ +
+
+ Activity Log + + + + +
+
no activity yet
+
+ + + + + From 5d77178dcdc714894035b313c2882ec8c1861559 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 22 Mar 2026 04:22:33 +0000 Subject: [PATCH 37/43] Lint --- examples/rfc-9421-test/index.html | 746 +++++++++++++++++------------- 1 file changed, 421 insertions(+), 325 deletions(-) diff --git a/examples/rfc-9421-test/index.html b/examples/rfc-9421-test/index.html index 3a74bf250..d92418bfd 100644 --- a/examples/rfc-9421-test/index.html +++ b/examples/rfc-9421-test/index.html @@ -1,347 +1,443 @@ - - - -RFC 9421 Field Test - - - + .panel { + border: 1px solid #ddd; + border-radius: 8px; + background: #fff; + overflow: hidden; + } + .panel-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: #f0f0f0; + font-weight: 600; + font-size: 0.9rem; + } + .panel-head button { + background: none; + border: none; + font-size: 1rem; + cursor: pointer; + padding: 0 0.25rem; + color: #555; + } + .panel-head button:hover { + color: #000; + } + .panel-body { + min-height: 3rem; + } + .panel-body ul { + list-style: none; + } + .panel-body li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.4rem 0.75rem; + border-top: 1px solid #eee; + font-size: 0.9rem; + font-family: monospace; + word-break: break-all; + } + .panel-body li button { + flex-shrink: 0; + margin-left: 0.5rem; + background: none; + border: 1px solid #c33; + color: #c33; + border-radius: 4px; + padding: 0.1rem 0.5rem; + font-size: 0.8rem; + cursor: pointer; + } + .panel-body li button:hover { + background: #c33; + color: #fff; + } + .panel-body li button:disabled { + opacity: 0.5; + cursor: wait; + } + .empty { + padding: 0.75rem; + color: #999; + font-size: 0.85rem; + text-align: center; + } -
-

RFC 9421 Field Test

-
- loading… - -
-

Test RFC 9421 HTTP Message Signatures interoperability with fediverse servers.

-
+ /* Activity log */ + .log-panel { + margin-bottom: 1.5rem; + } + .log-panel .panel-body { + max-height: 300px; + overflow-y: auto; + } + .log-panel li { + font-family: monospace; + font-size: 0.82rem; + gap: 0.5rem; + } + .log-time { + color: #888; + flex-shrink: 0; + } + .log-type { + font-weight: 600; + flex-shrink: 0; + } + .log-actor { + color: #555; + overflow: hidden; + text-overflow: ellipsis; + } + + + +
+

RFC 9421 Field Test

+
+ loading… + +
+

+ Test RFC 9421 HTTP Message Signatures interoperability with fediverse + servers. +

+
- -
+ +
-
-
-
- Followers - -
-
none
-
-
-
- Following - +
+
+
+ Followers + +
+
+
none
+
+
+
+
+ Following + +
+
+
none
+
+
-
none
-
-
-
-
- Activity Log - - - - -
-
no activity yet
-
- - + // ── SSE: server-push updates ────────────────────── + loadFollowers(); + loadFollowing(); + loadLog(); - + const es = new EventSource("/events"); + es.onmessage = (e) => { + if (e.data === "followers") loadFollowers(); + else if (e.data === "following") loadFollowing(); + else if (e.data === "log") loadLog(); + }; + + From 2fd8df158c3b2e7bb225a0265223f2124126ffbf Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 22 Mar 2026 04:24:01 +0000 Subject: [PATCH 38/43] Remove dangling --- examples/rfc-9421-test/README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/examples/rfc-9421-test/README.md b/examples/rfc-9421-test/README.md index 560f0896b..9ab9dcbce 100644 --- a/examples/rfc-9421-test/README.md +++ b/examples/rfc-9421-test/README.md @@ -4,10 +4,6 @@ RFC 9421 Interoperability Field Test A Fedify-based server for testing RFC 9421 HTTP Message Signatures interoperability with Bonfire, Mastodon, and other fediverse implementations. -See [../../plans/field-test.md](../../plans/field-test.md) for the full test -plan. - - Prerequisites ------------- From 36d4ae149b4f9274291a3a5fd30b4ede1c332739 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 22 Mar 2026 04:38:49 +0000 Subject: [PATCH 39/43] Use `@hongminhee/localtunnel` in `startTunnel` --- examples/rfc-9421-test/deno.json | 1 + examples/rfc-9421-test/dev.ts | 12 ++--- examples/rfc-9421-test/main.ts | 15 +++--- examples/rfc-9421-test/tunnel.ts | 90 +++++--------------------------- 4 files changed, 28 insertions(+), 90 deletions(-) diff --git a/examples/rfc-9421-test/deno.json b/examples/rfc-9421-test/deno.json index 94986343e..1df82aad9 100644 --- a/examples/rfc-9421-test/deno.json +++ b/examples/rfc-9421-test/deno.json @@ -1,5 +1,6 @@ { "imports": { + "@hongminhee/localtunnel": "jsr:@hongminhee/localtunnel@^0.3.0", "hono": "jsr:@hono/hono@^4.7.1" }, "tasks": { diff --git a/examples/rfc-9421-test/dev.ts b/examples/rfc-9421-test/dev.ts index 744f3488c..64db3b611 100644 --- a/examples/rfc-9421-test/dev.ts +++ b/examples/rfc-9421-test/dev.ts @@ -14,29 +14,29 @@ const logger = getLogger(["fedify", "examples", "rfc-9421-test", "dev"]); const port = parseInt(Deno.env.get("PORT") ?? "8000", 10); // 1. Start the tunnel (owned by this process, not the watched child). -const tunnel = await startTunnel(port, 30_000); +const tunnel = await startTunnel(port); if (!tunnel) { logger.error("Tunnel failed. Aborting dev mode."); Deno.exit(1); } -logger.info("Tunnel ready: {url}", { url: tunnel.url }); +logger.info("Tunnel ready: {url}", { url: tunnel.url.href }); // 2. Spawn the server in --watch mode, passing the tunnel URL via ORIGIN. const child = new Deno.Command("deno", { args: ["run", "-A", "--watch", "main.ts"], cwd: import.meta.dirname!, - env: { ...Deno.env.toObject(), ORIGIN: tunnel.url }, + env: { ...Deno.env.toObject(), ORIGIN: tunnel.url.href }, stdin: "inherit", stdout: "inherit", stderr: "inherit", }).spawn(); // 3. Clean up on SIGINT: kill server, then tunnel. -Deno.addSignalListener("SIGINT", () => { +Deno.addSignalListener("SIGINT", async () => { child.kill("SIGTERM"); - tunnel.child.kill("SIGTERM"); + await tunnel.close(); Deno.exit(0); }); await child.status; -tunnel.child.kill("SIGTERM"); +await tunnel.close(); diff --git a/examples/rfc-9421-test/main.ts b/examples/rfc-9421-test/main.ts index 42ae567bf..c542ccb73 100644 --- a/examples/rfc-9421-test/main.ts +++ b/examples/rfc-9421-test/main.ts @@ -69,19 +69,18 @@ if (import.meta.main) { const origin = Deno.env.get("ORIGIN"); if (origin) { logger.info("Public URL (external tunnel): {url}", { url: origin }); - logger.info("Actor: @{id}@{host}", { - id: ACTOR_ID, - host: new URL(origin).hostname, + logger.info("Actor: {actor}", { + actor: `@${ACTOR_ID}@${new URL(origin).hostname}`, }); } else { - const tunnel = await startTunnel(port, 30_000); + const tunnel = await startTunnel(port); if (tunnel) { - logger.info("Public URL: {url}", { url: tunnel.url }); + logger.info("Public URL: {url}", { url: tunnel.url.href }); logger.info("Actor: {actor}", { - actor: `@${ACTOR_ID}@${new URL(tunnel.url).hostname}`, + actor: `@${ACTOR_ID}@${tunnel.url.hostname}`, }); - Deno.addSignalListener("SIGINT", () => { - tunnel.child.kill("SIGTERM"); + Deno.addSignalListener("SIGINT", async () => { + await tunnel.close(); Deno.exit(0); }); } else { diff --git a/examples/rfc-9421-test/tunnel.ts b/examples/rfc-9421-test/tunnel.ts index ce1b480b5..bed1304ca 100644 --- a/examples/rfc-9421-test/tunnel.ts +++ b/examples/rfc-9421-test/tunnel.ts @@ -1,85 +1,23 @@ -import $, { type CommandChild } from "@david/dax"; +import { openTunnel, type Tunnel } from "@hongminhee/localtunnel"; import { getLogger } from "@logtape/logtape"; +const logger = getLogger(["fedify", "examples", "tunnel"]); + /** - * Starts `fedify tunnel -s pinggy.io ` and waits up to `timeoutMs` - * for the tunnel URL to appear in its output. The tunnel process is kept - * alive and returned to the caller; it must be killed when no longer needed. - * - * Returns `null` if the URL was not found before the timeout. + * Opens a tunnel to expose a local port using `@hongminhee/localtunnel`. + * Returns the {@link Tunnel} object (with `.url` and `.close()`), or `null` + * if it fails. */ export default async function startTunnel( port: number, - timeoutMs: number, -): Promise<{ child: CommandChild; url: string } | null> { - const tunnelLogger = getLogger(["fedify", "examples", "tunnel"]); - tunnelLogger.info("Opening localhost.run tunnel on port {port}", { port }); - - const child = $`mise cli tunnel -s pinggy.io ${String(port)}` - .stdout("piped") - .stderr("piped") - .noThrow() - .spawn(); - - // Accumulate text from both streams while logging each chunk at DEBUG. - const textChunks: string[] = []; - const decoder = new TextDecoder(); - - const readStream = (stream: ReadableStream) => { - (async () => { - const reader = stream.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const text = decoder.decode(value, { stream: true }); - textChunks.push(text); - const trimmed = text.trim(); - if (trimmed) tunnelLogger.debug("{output}", { output: trimmed }); - } - } catch { - // Stream may error when the process is killed. - } - })(); - }; - - readStream(child.stdout()); - readStream(child.stderr()); - - // Poll until we find an https URL in the accumulated output. - // The `message` template tag from @optique/run may wrap the URL in double - // quotes in non-TTY output, so we stop matching at whitespace or quotes. - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const match = textChunks.join("").match(/https:\/\/[^\s"']+/); - if (match) { - tunnelLogger.info("Tunnel established at {url}", { url: match[0] }); - return { child, url: match[0] }; - } - await new Promise((r) => setTimeout(r, 200)); - } - - tunnelLogger.error( - "Tunnel did not produce a URL within {timeout} ms", - { timeout: timeoutMs }, - ); - forceKillChild(child); - return null; -} - -/** - * Sends SIGKILL to `child` immediately. A rejection handler is attached to - * the CommandChild promise (which extends Promise) so that the - * eventual rejection from the killed process does not surface as an unhandled - * promise rejection. We intentionally do **not** await the promise because - * dax keeps it pending until all piped streams are fully consumed, which may - * never happen once the process is forcibly killed. - */ -function forceKillChild(child: CommandChild): void { - child.catch(() => {}); +): Promise { + logger.info("Opening tunnel on port {port}…", { port }); try { - child.kill("SIGKILL"); - } catch { - // Process already exited. + const tunnel = await openTunnel({ port, service: "pinggy.io" }); + logger.info("Tunnel established at {url}", { url: tunnel.url.href }); + return tunnel; + } catch (error) { + logger.error("Failed to open tunnel: {error}", { error }); + return null; } } From 2ac48a1bfd31809db4d7fe7f14652008ff2cfd14 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 22 Mar 2026 04:41:54 +0000 Subject: [PATCH 40/43] Lint --- deno.lock | 1 + examples/rfc-9421-test/README.md | 37 ++++++++++++++++---------------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/deno.lock b/deno.lock index a440cdda9..e2b520e13 100644 --- a/deno.lock +++ b/deno.lock @@ -7575,6 +7575,7 @@ }, "examples/rfc-9421-test": { "dependencies": [ + "jsr:@hongminhee/localtunnel@0.3", "jsr:@hono/hono@^4.7.1" ] }, diff --git a/examples/rfc-9421-test/README.md b/examples/rfc-9421-test/README.md index 9ab9dcbce..95ad8bb0a 100644 --- a/examples/rfc-9421-test/README.md +++ b/examples/rfc-9421-test/README.md @@ -1,9 +1,10 @@ -RFC 9421 Interoperability Field Test -===================================== +RFC 9421 interoperability field test +==================================== A Fedify-based server for testing RFC 9421 HTTP Message Signatures interoperability with Bonfire, Mastodon, and other fediverse implementations. + Prerequisites ------------- @@ -11,13 +12,15 @@ Prerequisites - Run `mise run install` (or `pnpm install`) from the repo root - A public tunnel for testing (e.g., `fedify tunnel`) +[Deno]: https://deno.com/ + Quick start ----------- -### 1. Start the server +### 1. start the server -~~~~sh +~~~~ sh # Default (RFC 9421 first knock + Accept-Signature challenge): deno run -A main.ts @@ -28,22 +31,22 @@ CHALLENGE_NONCE=1 deno run -A main.ts CHALLENGE_ENABLED=0 deno run -A main.ts ~~~~ -### 2. Expose publicly with `fedify tunnel` +### 2. expose publicly with `fedify tunnel` In a separate terminal, from the repo root: -~~~~sh +~~~~ sh deno task cli tunnel 8000 ~~~~ Note the public URL (e.g., `https://xxxxx.tunnel.example`). -### 3. Send test activities +### 3. send test activities Open your browser or use curl. Both GET (query params) and POST (JSON body) are supported: -~~~~sh +~~~~ sh # Follow a remote actor (GET): curl 'https://xxxxx.tunnel.example/send/follow?handle=@user@bonfire.example' @@ -65,13 +68,13 @@ Configuration All configuration is via environment variables: -| Variable | Default | Description | -|---------------------|-------------|-------------------------------------------| -| `PORT` | `8000` | Server listen port | -| `FIRST_KNOCK` | `rfc9421` | Initial signature spec (`rfc9421` or `draft-cavage-http-signatures-12`) | -| `CHALLENGE_ENABLED` | (enabled) | Set to `0` to disable `Accept-Signature` on `401` | -| `CHALLENGE_NONCE` | (disabled) | Set to `1` to include one-time nonce | -| `NONCE_TTL` | `300` | Nonce time-to-live in seconds | +| Variable | Default | Description | +| ------------------- | ---------- | ----------------------------------------------------------------------- | +| `PORT` | `8000` | Server listen port | +| `FIRST_KNOCK` | `rfc9421` | Initial signature spec (`rfc9421` or `draft-cavage-http-signatures-12`) | +| `CHALLENGE_ENABLED` | (enabled) | Set to `0` to disable `Accept-Signature` on `401` | +| `CHALLENGE_NONCE` | (disabled) | Set to `1` to include one-time nonce | +| `NONCE_TTL` | `300` | Nonce time-to-live in seconds | Endpoints @@ -99,7 +102,7 @@ All send endpoints accept GET (query params) or POST (JSON body). Test scenarios -------------- -### Scenario A: Fedify -> Bonfire (outbound) +### Scenario A: Fedify -> bonfire (outbound) 1. Start the server and expose via tunnel. 2. Use `/send/follow` and `/send/note` to send activities to a Bonfire actor. @@ -124,5 +127,3 @@ Test scenarios 1. Start the server (optionally with challenge enabled). 2. From a Mastodon account, follow `@test@`. 3. Check the `/log` endpoint and server logs. - -[Deno]: https://deno.com/ From a91a3a63cefdee39005c759710ded84d4b81f8ce Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 22 Mar 2026 04:51:37 +0000 Subject: [PATCH 41/43] Show signature spec --- examples/rfc-9421-test/app.ts | 14 ++++++++++++++ examples/rfc-9421-test/federation.ts | 14 ++++++++++++++ examples/rfc-9421-test/index.html | 15 +++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/examples/rfc-9421-test/app.ts b/examples/rfc-9421-test/app.ts index 5150ebce4..9da4dde4a 100644 --- a/examples/rfc-9421-test/app.ts +++ b/examples/rfc-9421-test/app.ts @@ -19,6 +19,7 @@ import { followingStore, onStateChange, } from "./federation.ts"; +import { setInboundSigSpec } from "./federation.ts"; const indexHtml = await Deno.readTextFile( new URL("index.html", import.meta.url), @@ -37,6 +38,19 @@ interface AppConfig { export default function createApp(fedi: Fedi, config: AppConfig) { const app = new Hono(); + + // Detect signature spec on incoming inbox POSTs before federation handles them. + app.use("*", async (c, next) => { + if (c.req.method === "POST") { + setInboundSigSpec(c.req.header("signature-input") != null + ? "rfc9421" + : c.req.header("signature") != null + ? "draft-cavage" + : null); + } + await next(); + }); + app.use(federation(fedi, () => undefined)); app.get("/", (c) => c.html(indexHtml)); diff --git a/examples/rfc-9421-test/federation.ts b/examples/rfc-9421-test/federation.ts index ce5ec5720..16cc01c37 100644 --- a/examples/rfc-9421-test/federation.ts +++ b/examples/rfc-9421-test/federation.ts @@ -44,12 +44,24 @@ export function emitChange(event: string): void { for (const cb of changeListeners) cb(event); } +/** + * Set by Hono middleware before federation handles the request, so that + * `logActivity` can record which signature spec was used. + */ +let lastInboundSigSpec: "rfc9421" | "draft-cavage" | null = null; +export function setInboundSigSpec( + spec: "rfc9421" | "draft-cavage" | null, +): void { + lastInboundSigSpec = spec; +} + /** Log of received activities for inspection. */ export const activityLog: { timestamp: string; type: string; actorId: string | null; id: string | null; + sigSpec: "rfc9421" | "draft-cavage" | null; raw: Record; }[] = []; @@ -186,7 +198,9 @@ function logActivity(type: string, activity: Activity) { type, actorId: activity.actorId?.href ?? null, id: activity.id?.href ?? null, + sigSpec: lastInboundSigSpec, raw: {}, }); + lastInboundSigSpec = null; emitChange("log"); } diff --git a/examples/rfc-9421-test/index.html b/examples/rfc-9421-test/index.html index d92418bfd..5ea070157 100644 --- a/examples/rfc-9421-test/index.html +++ b/examples/rfc-9421-test/index.html @@ -191,6 +191,15 @@ overflow: hidden; text-overflow: ellipsis; } + .log-sig { + flex-shrink: 0; + font-size: 0.7rem; + padding: 0.05rem 0.35rem; + border-radius: 3px; + font-weight: 600; + } + .log-sig.rfc9421 { background: #d4edda; color: #155724; } + .log-sig.draft-cavage { background: #fff3cd; color: #856404; } @@ -421,6 +430,12 @@

RFC 9421 Field Test

actor.textContent = entry.actorId || ""; } li.append(time, " ", type, " ", actor); + if (entry.sigSpec) { + const sig = document.createElement("span"); + sig.className = `log-sig ${entry.sigSpec}`; + sig.textContent = entry.sigSpec === "rfc9421" ? "RFC 9421" : "draft-cavage"; + li.append(" ", sig); + } ul.appendChild(li); } $log.replaceChildren(ul); From 24eddc05485065c6dd5627cf18459d060d0e7edf Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 22 Mar 2026 04:52:06 +0000 Subject: [PATCH 42/43] Lint --- examples/rfc-9421-test/app.ts | 10 ++++++---- examples/rfc-9421-test/index.html | 14 +++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/examples/rfc-9421-test/app.ts b/examples/rfc-9421-test/app.ts index 9da4dde4a..89b64622c 100644 --- a/examples/rfc-9421-test/app.ts +++ b/examples/rfc-9421-test/app.ts @@ -42,11 +42,13 @@ export default function createApp(fedi: Fedi, config: AppConfig) { // Detect signature spec on incoming inbox POSTs before federation handles them. app.use("*", async (c, next) => { if (c.req.method === "POST") { - setInboundSigSpec(c.req.header("signature-input") != null - ? "rfc9421" - : c.req.header("signature") != null + setInboundSigSpec( + c.req.header("signature-input") != null + ? "rfc9421" + : c.req.header("signature") != null ? "draft-cavage" - : null); + : null, + ); } await next(); }); diff --git a/examples/rfc-9421-test/index.html b/examples/rfc-9421-test/index.html index 5ea070157..871e5e7a9 100644 --- a/examples/rfc-9421-test/index.html +++ b/examples/rfc-9421-test/index.html @@ -198,8 +198,14 @@ border-radius: 3px; font-weight: 600; } - .log-sig.rfc9421 { background: #d4edda; color: #155724; } - .log-sig.draft-cavage { background: #fff3cd; color: #856404; } + .log-sig.rfc9421 { + background: #d4edda; + color: #155724; + } + .log-sig.draft-cavage { + background: #fff3cd; + color: #856404; + } @@ -433,7 +439,9 @@

RFC 9421 Field Test

if (entry.sigSpec) { const sig = document.createElement("span"); sig.className = `log-sig ${entry.sigSpec}`; - sig.textContent = entry.sigSpec === "rfc9421" ? "RFC 9421" : "draft-cavage"; + sig.textContent = entry.sigSpec === "rfc9421" + ? "RFC 9421" + : "draft-cavage"; li.append(" ", sig); } ul.appendChild(li); From 836d40abe8c6a1d38a5ce294a0b8f67ffb763a46 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 22 Mar 2026 05:03:44 +0000 Subject: [PATCH 43/43] Skip `rfc-9421-test` while testing examples --- deno.json | 1 + deno.lock | 2 +- examples/rfc-9421-test/deno.json | 1 - examples/test-examples/mod.ts | 100 +++++++++---------------------- 4 files changed, 31 insertions(+), 73 deletions(-) diff --git a/deno.json b/deno.json index 0e9bcc140..172875719 100644 --- a/deno.json +++ b/deno.json @@ -36,6 +36,7 @@ "@cloudflare/workers-types": "npm:@cloudflare/workers-types@^4.20250529.0", "@david/dax": "jsr:@david/dax@^0.43.2", "@fxts/core": "npm:@fxts/core@^1.21.1", + "@hongminhee/localtunnel": "jsr:@hongminhee/localtunnel@^0.3.0", "@js-temporal/polyfill": "npm:@js-temporal/polyfill@^0.5.1", "@logtape/file": "jsr:@logtape/file@^2.0.0", "@logtape/logtape": "jsr:@logtape/logtape@^2.0.0", diff --git a/deno.lock b/deno.lock index e2b520e13..77c81027d 100644 --- a/deno.lock +++ b/deno.lock @@ -7511,6 +7511,7 @@ "workspace": { "dependencies": [ "jsr:@david/dax@~0.43.2", + "jsr:@hongminhee/localtunnel@0.3", "jsr:@hono/hono@^4.8.3", "jsr:@logtape/file@2", "jsr:@logtape/logtape@2", @@ -7575,7 +7576,6 @@ }, "examples/rfc-9421-test": { "dependencies": [ - "jsr:@hongminhee/localtunnel@0.3", "jsr:@hono/hono@^4.7.1" ] }, diff --git a/examples/rfc-9421-test/deno.json b/examples/rfc-9421-test/deno.json index 1df82aad9..94986343e 100644 --- a/examples/rfc-9421-test/deno.json +++ b/examples/rfc-9421-test/deno.json @@ -1,6 +1,5 @@ { "imports": { - "@hongminhee/localtunnel": "jsr:@hongminhee/localtunnel@^0.3.0", "hono": "jsr:@hono/hono@^4.7.1" }, "tasks": { diff --git a/examples/test-examples/mod.ts b/examples/test-examples/mod.ts index ed82106be..0ed78181f 100644 --- a/examples/test-examples/mod.ts +++ b/examples/test-examples/mod.ts @@ -22,6 +22,7 @@ */ import $, { type CommandChild } from "@david/dax"; +import { openTunnel, type Tunnel } from "@hongminhee/localtunnel"; import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; import { fromFileUrl, join } from "@std/path"; @@ -283,6 +284,11 @@ const SKIPPED_EXAMPLES: SkippedExample[] = [ reason: "No actor dispatcher configured; federation lookup cannot be verified", }, + { + name: "rfc-9421-test", + reason: + "Requires live interaction with external fediverse servers (Bonfire, Mastodon)", + }, ]; // ─── ANSI Colors ────────────────────────────────────────────────────────────── @@ -395,70 +401,22 @@ function forceKillChild(child: CommandChild): void { // ─── Tunnel ─────────────────────────────────────────────────────────────────── /** - * Starts `fedify tunnel -s pinggy.io ` and waits up to `timeoutMs` - * for the tunnel URL to appear in its output. The tunnel process is kept - * alive and returned to the caller; it must be killed when no longer needed. - * - * Returns `null` if the URL was not found before the timeout. + * Opens a tunnel via `@hongminhee/localtunnel` (pinggy.io) to expose + * a local port. Returns the {@link Tunnel} object or `null` on failure. */ -async function startTunnel( - port: number, - timeoutMs: number, -): Promise<{ child: CommandChild; url: string } | null> { +async function startTunnel(port: number): Promise { const tunnelLogger = getLogger(["fedify", "examples", "tunnel"]); - tunnelLogger.info("Opening localhost.run tunnel on port {port}", { port }); - - const child = $`deno task cli tunnel -s pinggy.io ${String(port)}` - .cwd(REPO_ROOT) - .stdout("piped") - .stderr("piped") - .noThrow() - .spawn(); - - // Accumulate text from both streams while logging each chunk at DEBUG. - const textChunks: string[] = []; - const decoder = new TextDecoder(); - - const readStream = (stream: ReadableStream) => { - (async () => { - const reader = stream.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const text = decoder.decode(value, { stream: true }); - textChunks.push(text); - const trimmed = text.trim(); - if (trimmed) tunnelLogger.debug("{output}", { output: trimmed }); - } - } catch { - // Stream may error when the process is killed. - } - })(); - }; - - readStream(child.stdout()); - readStream(child.stderr()); - - // Poll until we find an https URL in the accumulated output. - // The `message` template tag from @optique/run may wrap the URL in double - // quotes in non-TTY output, so we stop matching at whitespace or quotes. - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const match = textChunks.join("").match(/https:\/\/[^\s"']+/); - if (match) { - tunnelLogger.info("Tunnel established at {url}", { url: match[0] }); - return { child, url: match[0] }; - } - await new Promise((r) => setTimeout(r, 200)); + tunnelLogger.info("Opening tunnel on port {port}", { port }); + try { + const tunnel = await openTunnel({ port, service: "pinggy.io" }); + tunnelLogger.info("Tunnel established at {url}", { + url: tunnel.url.href, + }); + return tunnel; + } catch (error) { + tunnelLogger.error("Failed to open tunnel: {error}", { error }); + return null; } - - tunnelLogger.error( - "Tunnel did not produce a URL within {timeout} ms", - { timeout: timeoutMs }, - ); - forceKillChild(child); - return null; } // ─── Test Runners ───────────────────────────────────────────────────────────── @@ -549,7 +507,7 @@ async function testServerExample( const collectServerOutput = () => stdoutChunks.join("") + stderrChunks.join(""); - let tunnelChild: CommandChild | null = null; + let activeTunnel: Tunnel | null = null; try { console.log( @@ -572,18 +530,18 @@ async function testServerExample( }); console.log(c.dim(` server ready — opening tunnel on port ${port}…`)); - const tunnel = await startTunnel(port, 30_000); + const tunnel = await startTunnel(port); if (tunnel == null) { - const error = "fedify tunnel did not produce a URL within 30s"; + const error = "Failed to open tunnel"; serverLogger.error("{error}", { error }); return { name, status: "fail", error, output: collectServerOutput() }; } - tunnelChild = tunnel.child; - const tunnelHostname = new URL(tunnel.url).hostname; + activeTunnel = tunnel; + const tunnelHostname = tunnel.url.hostname; const handle = `@${actor}@${tunnelHostname}`; - console.log(c.dim(` tunnel URL : ${tunnel.url}`)); + console.log(c.dim(` tunnel URL : ${tunnel.url.href}`)); console.log(c.dim(` running : fedify lookup ${handle} -d`)); serverLogger.info("Running fedify lookup {handle}", { handle }); @@ -606,10 +564,10 @@ async function testServerExample( serverLogger.error("{error}", { error }); return { name, status: "fail", error, output: lookupOutput }; } finally { - // Force-kill tunnel first (it holds a connection to the server). - if (tunnelChild != null) { - serverLogger.debug("Force-killing tunnel process"); - forceKillChild(tunnelChild); + // Close tunnel first (it holds a connection to the server). + if (activeTunnel != null) { + serverLogger.debug("Closing tunnel"); + await activeTunnel.close(); } serverLogger.debug("Force-killing server process"); forceKillChild(serverChild);