diff --git a/CHANGES.md b/CHANGES.md index f78c4fabb..753907ad0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -34,10 +34,22 @@ 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], [#626] 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 +[#626]: https://github.com/fedify-dev/fedify/pull/626 ### @fedify/vocab-runtime diff --git a/deno.json b/deno.json index f51641b02..172875719 100644 --- a/deno.json +++ b/deno.json @@ -29,12 +29,14 @@ "./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", "@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 9608d537f..77c81027d 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 }, @@ -7517,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", @@ -7579,6 +7574,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/docs/manual/inbox.md b/docs/manual/inbox.md index 37201c7c5..b1a258d28 100644 --- a/docs/manual/inbox.md +++ b/docs/manual/inbox.md @@ -37,6 +37,48 @@ why some activities are rejected, you can turn on [logging](./log.md) for [Linked Data Signatures]: https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/ [FEP-8b32]: https://w3id.org/fep/8b32 +### `Accept-Signature` challenges + +*This API is available since Fedify 2.1.0.* + +You can optionally enable [`Accept-Signature`] challenge emission on inbox +`401` responses by setting the `inboxChallengePolicy` option when creating +a `Federation`: + +~~~~ typescript +import { createFederation } from "@fedify/fedify"; + +const federation = createFederation({ + // ... other options ... + inboxChallengePolicy: { + enabled: true, + // Optional: customize covered components (defaults shown below) + // components: ["@method", "@target-uri", "@authority", "content-digest"], + // Optional: require a one-time nonce for replay protection + // requestNonce: false, + // Optional: nonce TTL in seconds (default: 300) + // nonceTtlSeconds: 300, + }, +}); +~~~~ + +When enabled, if HTTP Signature verification fails, the `401` response will +include an `Accept-Signature` header telling the sender which components and +parameters to include in a new signature. Senders that support [RFC 9421 §5] +(including Fedify 2.1.0+) will automatically retry with the requested +parameters. + +Note that actor/key mismatch `401` responses are *not* challenged, since +re-signing with different parameters does not resolve an impersonation issue. + +When `requestNonce` is enabled, a cryptographically random nonce is included +in each challenge and must be echoed back in the retry signature. The nonce +is stored in the key-value store and consumed on use, providing replay +protection. Nonces expire after `nonceTtlSeconds` (default: 5 minutes). + +[`Accept-Signature`]: https://www.rfc-editor.org/rfc/rfc9421#section-5.1 +[RFC 9421 §5]: https://www.rfc-editor.org/rfc/rfc9421#section-5 + Handling unverified activities ------------------------------ diff --git a/docs/manual/send.md b/docs/manual/send.md index 2d58b4360..51b4805cf 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -984,6 +984,39 @@ to the draft cavage version and remembers it for the next time. [double-knocking]: https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions +### `Accept-Signature` negotiation + +*This API is available since Fedify 2.1.0.* + +In addition to double-knocking, Fedify supports the [`Accept-Signature`] +challenge-response negotiation defined in [RFC 9421 §5]. When a recipient +server responds with a `401` status and includes an `Accept-Signature` header, +Fedify automatically parses the challenge, validates it, and retries the +request with the requested signature parameters (e.g., specific covered +components, a nonce, or a tag). + +Safety constraints prevent abuse: + + - The requested algorithm (`alg`) must match the local private key's + algorithm; otherwise the challenge entry is skipped. + - The requested key identifier (`keyid`) must match the local key; otherwise + the challenge entry is skipped. + - Fedify's minimum covered component set (`@method`, `@target-uri`, + `@authority`) is always included, even if the challenge does not request + them. + +If the challenge cannot be fulfilled (e.g., incompatible algorithm), +Fedify falls through to the existing double-knocking spec-swap fallback. +At most three signed request attempts are made to the final URL per delivery +attempt (redirects may add extra HTTP requests): + +1. Initial signed request +2. Challenge-driven retry (if `Accept-Signature` is present) +3. Legacy spec-swap retry (if the challenge retry also fails) + +[`Accept-Signature`]: https://www.rfc-editor.org/rfc/rfc9421#section-5.1 +[RFC 9421 §5]: https://www.rfc-editor.org/rfc/rfc9421#section-5 + Linked Data Signatures ---------------------- diff --git a/examples/astro/deno.json b/examples/astro/deno.json index 052c758ba..030a9ebea 100644 --- a/examples/astro/deno.json +++ b/examples/astro/deno.json @@ -1,4 +1,7 @@ { + "compilerOptions": { + "moduleResolution": "nodenext" + }, "imports": { "@deno/astro-adapter": "npm:@deno/astro-adapter@^0.3.2" }, diff --git a/examples/rfc-9421-test/README.md b/examples/rfc-9421-test/README.md new file mode 100644 index 000000000..95ad8bb0a --- /dev/null +++ b/examples/rfc-9421-test/README.md @@ -0,0 +1,129 @@ +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 +------------- + + - [Deno] installed + - 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 + +~~~~ 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. diff --git a/examples/rfc-9421-test/app.ts b/examples/rfc-9421-test/app.ts new file mode 100644 index 000000000..89b64622c --- /dev/null +++ b/examples/rfc-9421-test/app.ts @@ -0,0 +1,267 @@ +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, + emitChange, + followersStore, + followingStore, + onStateChange, +} from "./federation.ts"; +import { setInboundSigSpec } from "./federation.ts"; + +const indexHtml = await Deno.readTextFile( + new URL("index.html", import.meta.url), +); + +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(); + + // 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)); + app.get("/api/info", apiInfo(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.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 apiInfo(config: AppConfig) { + return (c: HonoContext) => + c.json({ + handle: `@${ACTOR_ID}@${new URL(c.req.url).hostname}`, + actorUri: `${new URL(c.req.url).origin}/users/${ACTOR_ID}`, + config, + }); +} + +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, + }); + + 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); + } catch (e) { + 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, + 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); + } + followingStore.delete(actor.id!.href); + emitChange("following"); + 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..64db3b611 --- /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); +if (!tunnel) { + logger.error("Tunnel failed. Aborting dev mode."); + Deno.exit(1); +} +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.href }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", +}).spawn(); + +// 3. Clean up on SIGINT: kill server, then tunnel. +Deno.addSignalListener("SIGINT", async () => { + child.kill("SIGTERM"); + await tunnel.close(); + Deno.exit(0); +}); + +await child.status; +await tunnel.close(); diff --git a/examples/rfc-9421-test/federation.ts b/examples/rfc-9421-test/federation.ts new file mode 100644 index 000000000..16cc01c37 --- /dev/null +++ b/examples/rfc-9421-test/federation.ts @@ -0,0 +1,206 @@ +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 } +>(); + +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); +} + +/** + * 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; +}[] = []; + +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, + }); + emitChange("followers"); + 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); + emitChange("followers"); + 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, + sigSpec: lastInboundSigSpec, + raw: {}, + }); + lastInboundSigSpec = null; + emitChange("log"); +} diff --git a/examples/rfc-9421-test/index.html b/examples/rfc-9421-test/index.html new file mode 100644 index 000000000..871e5e7a9 --- /dev/null +++ b/examples/rfc-9421-test/index.html @@ -0,0 +1,466 @@ + + + + + + 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
+
+
+ + + + 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..c542ccb73 --- /dev/null +++ b/examples/rfc-9421-test/main.ts @@ -0,0 +1,94 @@ +/** + * 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: {actor}", { + actor: `@${ACTOR_ID}@${new URL(origin).hostname}`, + }); + } else { + const tunnel = await startTunnel(port); + if (tunnel) { + logger.info("Public URL: {url}", { url: tunnel.url.href }); + logger.info("Actor: {actor}", { + actor: `@${ACTOR_ID}@${tunnel.url.hostname}`, + }); + Deno.addSignalListener("SIGINT", async () => { + await tunnel.close(); + 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..bed1304ca --- /dev/null +++ b/examples/rfc-9421-test/tunnel.ts @@ -0,0 +1,23 @@ +import { openTunnel, type Tunnel } from "@hongminhee/localtunnel"; +import { getLogger } from "@logtape/logtape"; + +const logger = getLogger(["fedify", "examples", "tunnel"]); + +/** + * 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, +): Promise { + logger.info("Opening tunnel on port {port}…", { port }); + try { + 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; + } +} 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); diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index d45678fa7..2623d8e55 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -775,6 +775,43 @@ export interface FederationBuilder ): Promise>; } +/** + * Policy for emitting `Accept-Signature` challenges on inbox `401` + * responses, as defined in + * [RFC 9421 §5](https://www.rfc-editor.org/rfc/rfc9421#section-5). + * @since 2.1.0 + */ +export interface InboxChallengePolicy { + /** + * Whether to emit `Accept-Signature` headers on `401` responses + * caused by HTTP Signature verification failures. + */ + enabled: boolean; + + /** + * The covered component identifiers to request. Only request-applicable + * identifiers should be used (`@status` is automatically excluded). + * @default `["@method", "@target-uri", "@authority", "content-digest"]` + */ + components?: string[]; + + /** + * Whether to generate and require a one-time nonce for replay protection. + * When enabled, a cryptographically random nonce is included in each + * challenge and verified on subsequent requests. Requires a + * {@link KvStore}. + * @default `false` + */ + requestNonce?: boolean; + + /** + * The time-to-live (in seconds) for stored nonces. After this period, + * nonces expire and are no longer accepted. + * @default `300` (5 minutes) + */ + nonceTtlSeconds?: number; +} + /** * Options for creating a {@link Federation} object. * @template TContextData The context data to pass to the {@link Context}. @@ -931,6 +968,17 @@ export interface FederationOptions { */ firstKnock?: HttpMessageSignaturesSpec; + /** + * The policy for emitting `Accept-Signature` challenges on inbox `401` + * responses (RFC 9421 §5). When enabled, failed HTTP Signature + * verification responses will include an `Accept-Signature` header + * telling the sender which components and parameters to include. + * + * Disabled by default (no `Accept-Signature` header is emitted). + * @since 2.1.0 + */ + inboxChallengePolicy?: InboxChallengePolicy; + /** * The retry policy for sending activities to recipients' inboxes. * By default, this uses an exponential backoff strategy with a maximum of diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 41c4e55f6..b7658ca2f 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -12,6 +12,7 @@ import { } from "@fedify/vocab"; import { FetchError } from "@fedify/vocab-runtime"; import { assert, assertEquals } from "@std/assert"; +import { parseAcceptSignature } from "../sig/accept.ts"; import { signRequest } from "../sig/http.ts"; import { createInboxContext, @@ -1082,6 +1083,7 @@ test("handleInbox()", async () => { kvPrefixes: { activityIdempotence: ["_fedify", "activityIdempotence"], publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], }, actorDispatcher, onNotFound, @@ -1350,6 +1352,7 @@ test("handleInbox() - authentication bypass vulnerability", async () => { kvPrefixes: { activityIdempotence: ["_fedify", "activityIdempotence"], publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], }, actorDispatcher, inboxListeners, @@ -1894,6 +1897,7 @@ test("handleInbox() records OpenTelemetry span events", async () => { kvPrefixes: { activityIdempotence: ["activityIdempotence"], publicKey: ["publicKey"], + acceptSignatureNonce: ["acceptSignatureNonce"], }, actorDispatcher, inboxListeners: listeners, @@ -2008,6 +2012,7 @@ test("handleInbox() records unverified HTTP signature details", async () => { kvPrefixes: { activityIdempotence: ["activityIdempotence"], publicKey: ["publicKey"], + acceptSignatureNonce: ["acceptSignatureNonce"], }, actorDispatcher, inboxListeners: new InboxListenerSet(), @@ -2047,3 +2052,972 @@ 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.some((c) => c.value === "@method"), + "Must include @method component", + ); + assertEquals( + response.headers.get("Cache-Control"), + "no-store", + ); + assertEquals( + response.headers.get("Vary"), + "Accept, Signature", + ); +}); + +test("handleInbox() challenge policy enabled + invalid signature", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/challenge-2"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/challenge-2"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + // Sign with a key, then tamper with the body to invalidate the signature + const originalRequest = new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }); + const signedRequest = await signRequest( + originalRequest, + rsaPrivateKey3, + rsaPublicKey3.id!, + ); + // Reconstruct with a different body but same signature headers + const jsonLd = await activity.toJsonLd() as Record; + const tamperedBody = JSON.stringify({ + ...jsonLd, + "https://example.com/tampered": true, + }); + const tamperedRequest = new Request(signedRequest.url, { + method: signedRequest.method, + headers: signedRequest.headers, + body: tamperedBody, + }); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: tamperedRequest, + url: new URL(tamperedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(tamperedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { enabled: true }, + }); + assertEquals(response.status, 401); + const acceptSig = response.headers.get("Accept-Signature"); + assert(acceptSig != null, "Accept-Signature header must be present"); + assertEquals(response.headers.get("Cache-Control"), "no-store"); +}); + +test("handleInbox() challenge policy enabled + valid signature", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/challenge-3"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/challenge-3"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const federation = createFederation({ kv: new MemoryKvStore() }); + const signedRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + ); + const context = createRequestContext({ + federation, + request: signedRequest, + url: new URL(signedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(signedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { enabled: true }, + }); + assertEquals(response.status, 202); + assertEquals( + response.headers.get("Accept-Signature"), + null, + "No Accept-Signature header on successful request", + ); +}); + +test("handleInbox() challenge policy disabled + unsigned request", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/challenge-4"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/challenge-4"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const unsignedRequest = new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: unsignedRequest, + url: new URL(unsignedRequest.url), + data: undefined, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(unsignedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + // No inboxChallengePolicy — disabled by default + }); + assertEquals(response.status, 401); + assertEquals( + response.headers.get("Accept-Signature"), + null, + "No Accept-Signature header when challenge policy is disabled", + ); +}); + +test("handleInbox() actor/key mismatch → plain 401 (no challenge)", async () => { + // Sign with attacker's key but claim to be a different actor + const maliciousActivity = new Create({ + id: new URL("https://attacker.example.com/activities/challenge-5"), + actor: new URL("https://victim.example.com/users/alice"), + object: new Note({ + id: new URL("https://attacker.example.com/notes/challenge-5"), + attribution: new URL("https://victim.example.com/users/alice"), + content: "Forged message!", + }), + }); + const maliciousRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await maliciousActivity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + ); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: maliciousRequest, + url: new URL(maliciousRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(maliciousRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { enabled: true }, + }); + assertEquals(response.status, 401); + assertEquals( + response.headers.get("Accept-Signature"), + null, + "Actor/key mismatch should not emit Accept-Signature challenge", + ); + assertEquals( + await response.text(), + "The signer and the actor do not match.", + ); +}); + +test("handleInbox() nonce issuance in challenge", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/nonce-1"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/nonce-1"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const unsignedRequest = new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: unsignedRequest, + url: new URL(unsignedRequest.url), + data: undefined, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const kv = new MemoryKvStore(); + const response = await handleInbox(unsignedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { + enabled: true, + requestNonce: true, + nonceTtlSeconds: 300, + }, + }); + assertEquals(response.status, 401); + const acceptSig = response.headers.get("Accept-Signature"); + assert(acceptSig != null, "Accept-Signature header must be present"); + const parsed = parseAcceptSignature(acceptSig); + assert(parsed.length > 0); + assert( + parsed[0].parameters.nonce != null, + "Nonce must be present in Accept-Signature parameters", + ); + assertEquals(response.headers.get("Cache-Control"), "no-store"); + // Verify the nonce was stored in KV + const nonceKey = [ + "_fedify", + "acceptSignatureNonce", + parsed[0].parameters.nonce!, + ] as const; + const stored = await kv.get(nonceKey); + assertEquals(stored, true, "Nonce must be stored in KV store"); +}); + +test("handleInbox() nonce consumption on valid signed request", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/nonce-2"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/nonce-2"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const kv = new MemoryKvStore(); + const noncePrefix = ["_fedify", "acceptSignatureNonce"] as const; + // Pre-store a nonce in KV + const nonce = "test-nonce-abc123"; + await kv.set( + ["_fedify", "acceptSignatureNonce", nonce] as const, + true, + { ttl: Temporal.Duration.from({ seconds: 300 }) }, + ); + // Sign request with the nonce included via rfc9421 + const signedRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + { spec: "rfc9421", rfc9421: { nonce } }, + ); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: signedRequest, + url: new URL(signedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const response = await handleInbox(signedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: noncePrefix, + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { + enabled: true, + requestNonce: true, + nonceTtlSeconds: 300, + }, + }); + assertEquals(response.status, 202); + // Nonce must have been consumed (deleted from KV) + const stored = await kv.get( + ["_fedify", "acceptSignatureNonce", nonce] as const, + ); + assertEquals(stored, undefined, "Nonce must be consumed after use"); +}); + +test("handleInbox() nonce replay prevention", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/nonce-3"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/nonce-3"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + const kv = new MemoryKvStore(); + const noncePrefix = ["_fedify", "acceptSignatureNonce"] as const; + const nonce = "replay-nonce-xyz"; + // Do NOT store the nonce — simulate it was already consumed or never issued + const signedRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + { spec: "rfc9421", rfc9421: { nonce } }, + ); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: signedRequest, + url: new URL(signedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const response = await handleInbox(signedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: noncePrefix, + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { + enabled: true, + requestNonce: true, + nonceTtlSeconds: 300, + }, + }); + assertEquals(response.status, 401); + // Should return a fresh challenge with a new nonce + const acceptSig = response.headers.get("Accept-Signature"); + assert(acceptSig != null, "Must emit fresh Accept-Signature challenge"); + const parsed = parseAcceptSignature(acceptSig); + assert(parsed.length > 0); + assert( + parsed[0].parameters.nonce != null, + "Fresh challenge must include a new nonce", + ); + assert( + parsed[0].parameters.nonce !== nonce, + "Fresh nonce must differ from the replayed one", + ); + assertEquals( + response.headers.get("Cache-Control"), + "no-store", + "Challenge response must have Cache-Control: no-store", + ); +}); + +test( + "handleInbox() nonce bypass: valid sig without nonce + invalid sig with nonce", + async () => { + // This test demonstrates a vulnerability where verifySignatureNonce() scans + // ALL Signature-Input entries for a nonce, but verifyRequestDetailed() does + // not report which signature label was verified. An attacker can bypass + // nonce enforcement by submitting: + // 1. A valid signature (sig1) WITHOUT a nonce + // 2. A bogus signature (sig2) that carries a stored nonce + // verifyRequestDetailed() succeeds on sig1, then verifySignatureNonce() + // finds and consumes the nonce from sig2, so the request is accepted even + // though the *verified* signature never carried a nonce. + + const activity = new Create({ + id: new URL("https://example.com/activities/nonce-bypass-1"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/nonce-bypass-1"), + attribution: new URL("https://example.com/person2"), + content: "Hello!", + }), + }); + + const kv = new MemoryKvStore(); + const noncePrefix = ["_fedify", "acceptSignatureNonce"] as const; + + // Pre-store a nonce that the attacker knows (e.g., from a prior challenge) + const storedNonce = "bypass-nonce-abc123"; + await kv.set( + ["_fedify", "acceptSignatureNonce", storedNonce] as const, + true, + { ttl: Temporal.Duration.from({ seconds: 300 }) }, + ); + + // Step 1: Create a legitimately signed request (sig1) WITHOUT a nonce + const signedRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + { spec: "rfc9421" }, // no nonce + ); + + // Step 2: Manually inject a second bogus signature entry (sig2) that carries + // the stored nonce. The signature bytes are garbage — it will never verify — + // but verifySignatureNonce() doesn't check validity, only presence. + const existingSignatureInput = signedRequest.headers.get( + "Signature-Input", + )!; + const existingSignature = signedRequest.headers.get("Signature")!; + const bogusSigInput = `sig2=("@method" "@target-uri");` + + `alg="rsa-v1_5-sha256";keyid="${rsaPublicKey3.id!.href}";` + + `created=${Math.floor(Date.now() / 1000)};` + + `nonce="${storedNonce}"`; + const bogusSigValue = `sig2=:AAAA:`; // garbage base64 + + const tamperedHeaders = new Headers(signedRequest.headers); + tamperedHeaders.set( + "Signature-Input", + `${existingSignatureInput}, ${bogusSigInput}`, + ); + tamperedHeaders.set( + "Signature", + `${existingSignature}, ${bogusSigValue}`, + ); + + const tamperedRequest = new Request(signedRequest.url, { + method: signedRequest.method, + headers: tamperedHeaders, + body: await signedRequest.clone().arrayBuffer(), + }); + + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: tamperedRequest, + url: new URL(tamperedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + + const response = await handleInbox(tamperedRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: noncePrefix, + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { + enabled: true, + requestNonce: true, + nonceTtlSeconds: 300, + }, + }); + + // The verified signature (sig1) has no nonce. The nonce was only in the + // bogus sig2. A correct implementation MUST reject this request because + // the *verified* signature did not carry a valid nonce. + assertEquals( + response.status, + 401, + "Request with nonce only in a non-verified signature must be rejected " + + "(nonce verification must be bound to the verified signature label)", + ); + + // The stored nonce should NOT have been consumed by a bogus signature + const stored = await kv.get( + ["_fedify", "acceptSignatureNonce", storedNonce] as const, + ); + assertEquals( + stored, + true, + "Nonce must not be consumed when it comes from a non-verified signature", + ); + }, +); + +test( + "handleInbox() actor/key mismatch does not consume nonce", + async () => { + // A request that has a valid RFC 9421 signature with a nonce, but the + // signing key does not belong to the claimed actor. The nonce must NOT be + // consumed so the legitimate sender can still use it. + const maliciousActivity = new Create({ + id: new URL("https://attacker.example.com/activities/mismatch-nonce-1"), + actor: new URL("https://victim.example.com/users/alice"), + object: new Note({ + id: new URL("https://attacker.example.com/notes/mismatch-nonce-1"), + attribution: new URL("https://victim.example.com/users/alice"), + content: "Forged message with nonce!", + }), + }); + const kv = new MemoryKvStore(); + const noncePrefix = ["_fedify", "acceptSignatureNonce"] as const; + const nonce = "mismatch-nonce-xyz"; + await kv.set( + ["_fedify", "acceptSignatureNonce", nonce] as const, + true, + { ttl: Temporal.Duration.from({ seconds: 300 }) }, + ); + // Sign with rsaPrivateKey3 (associated with example.com/person2, not + // victim.example.com/users/alice), and include the stored nonce. + const maliciousRequest = await signRequest( + new Request("https://example.com/", { + method: "POST", + body: JSON.stringify(await maliciousActivity.toJsonLd()), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + { spec: "rfc9421", rfc9421: { nonce } }, + ); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request: maliciousRequest, + url: new URL(maliciousRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const response = await handleInbox(maliciousRequest, { + recipient: "someone", + context, + inboxContextFactory(_activity) { + return createInboxContext({ + ...context, + clone: undefined, + recipient: "someone", + }); + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: noncePrefix, + }, + actorDispatcher, + onNotFound: () => new Response("Not found", { status: 404 }), + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: false, + inboxChallengePolicy: { + enabled: true, + requestNonce: true, + nonceTtlSeconds: 300, + }, + }); + assertEquals(response.status, 401); + assertEquals( + await response.text(), + "The signer and the actor do not match.", + ); + // The nonce must NOT have been consumed — the actor/key mismatch should + // reject before nonce consumption so the nonce remains usable. + const stored = await kv.get( + ["_fedify", "acceptSignatureNonce", nonce] as const, + ); + assertEquals( + stored, + true, + "Nonce must not be consumed when actor/key ownership check fails", + ); + }, +); + +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 18703eaba..3bbe5b0d7 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -1,3 +1,4 @@ +import type { AcceptSignatureParameters } from "@fedify/fedify/sig"; import type { Recipient } from "@fedify/vocab"; import { Activity, @@ -19,8 +20,13 @@ import type { TracerProvider, } from "@opentelemetry/api"; import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; +import { uniq } from "es-toolkit"; import metadata from "../../deno.json" with { type: "json" }; -import { verifyRequestDetailed } from "../sig/http.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 +50,7 @@ import type { ConstructorWithTypeId, IdempotencyKeyCallback, IdempotencyStrategy, + InboxChallengePolicy, } from "./federation.ts"; import { type InboxListenerSet, routeActivity } from "./inbox.ts"; import { KvKeyCache } from "./keycache.ts"; @@ -461,6 +468,7 @@ export interface InboxHandlerParameters { kvPrefixes: { activityIdempotence: KvKey; publicKey: KvKey; + acceptSignatureNonce: KvKey; }; queue?: MessageQueue; actorDispatcher?: ActorDispatcher; @@ -470,6 +478,7 @@ export interface InboxHandlerParameters { onNotFound(request: Request): Response | Promise; signatureTimeWindow: Temporal.Duration | Temporal.DurationLike | false; skipSignatureVerification: boolean; + inboxChallengePolicy?: InboxChallengePolicy; idempotencyStrategy?: | IdempotencyStrategy | IdempotencyKeyCallback; @@ -538,6 +547,7 @@ async function handleInboxInternal( onNotFound, signatureTimeWindow, skipSignatureVerification, + inboxChallengePolicy, tracerProvider, } = parameters; const logger = getLogger(["fedify", "federation", "inbox"]); @@ -677,6 +687,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; if (activity == null) { if (!skipSignatureVerification) { const verification = await verifyRequestDetailed(request, { @@ -701,12 +714,10 @@ async function handleInboxInternal( message: `Failed to verify the request's HTTP Signatures.`, }); if (unverifiedActivityHandler == null) { - 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, ); } try { @@ -780,23 +791,26 @@ 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 ( + inboxChallengePolicy?.enabled && inboxChallengePolicy.requestNonce + ) { + // Defer nonce consumption until after actor/key ownership check to + // avoid burning nonces on requests that will be rejected anyway. + pendingNonceLabel = verification.signatureLabel; + } logger.debug("HTTP Signatures are verified.", { recipient }); activityVerified = true; } @@ -840,6 +854,26 @@ 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 }, + ); + return await getFailedSignatureResponse( + inboxChallengePolicy, + kv, + kvPrefixes, + ); + } + } const routeResult = await routeActivity({ context: ctx, json, @@ -1630,3 +1664,120 @@ export async function respondWithObjectIfAcceptable( response.headers.set("Vary", "Accept"); return response; } + +function generateNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + // Base64url encoding without padding + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function verifySignatureNonce( + request: Request, + kv: KvStore, + noncePrefix: KvKey, + verifiedLabel?: string, +): Promise { + const signatureInput = request.headers.get("Signature-Input"); + if (signatureInput == null) return false; + const parsed = parseRfc9421SignatureInput(signatureInput); + // Only check the nonce from the verified signature label to prevent bypass + // attacks where a bogus signature carries a valid nonce while a different + // signature (without a nonce) is the one that actually verified. + // Nonces are only supported for RFC 9421 signatures. If no verified label + // is available (e.g., draft-cavage), skip nonce verification entirely to + // prevent a decoupled-check bypass via a non-RFC-9421 path. + if (verifiedLabel == null) return false; + const sig = parsed[verifiedLabel]; + if (sig == null) return false; + const nonce = sig.nonce; + if (nonce == null) return false; + const key = [...noncePrefix, nonce] as unknown as KvKey; + if (kv.cas != null) { + return await kv.cas(key, true, undefined); + } + const stored = await kv.get(key); + if (stored != null) { + await kv.delete(key); + return true; + } + 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, + noncePrefix: KvKey, +): Promise { + const parameters: AcceptSignatureParameters = { created: true }; + if (policy.requestNonce) { + const nonce = generateNonce(); + const key: KvKey = [...noncePrefix, nonce]; + await setKey(kv, key, policy); + parameters.nonce = nonce; + } + 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 = 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/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 7fe1b91ab..fb901ec26 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -83,6 +83,7 @@ import type { FederationFetchOptions, FederationOptions, FederationStartQueueOptions, + InboxChallengePolicy, } from "./federation.ts"; import { handleActor, @@ -172,6 +173,14 @@ export interface FederationKvPrefixes { * @since 1.6.0 */ readonly httpMessageSignaturesSpec: KvKey; + + /** + * The key prefix used for storing `Accept-Signature` challenge nonces. + * Only used when {@link InboxChallengePolicy.requestNonce} is `true`. + * @default `["_fedify", "acceptSignatureNonce"]` + * @since 2.1.0 + */ + readonly acceptSignatureNonce: KvKey; } /** @@ -233,6 +242,7 @@ export class FederationImpl activityTransformers: readonly ActivityTransformer[]; _tracerProvider: TracerProvider | undefined; firstKnock?: HttpMessageSignaturesSpec; + inboxChallengePolicy?: InboxChallengePolicy; constructor(options: FederationOptions) { super(); @@ -243,6 +253,7 @@ export class FederationImpl remoteDocument: ["_fedify", "remoteDocument"], publicKey: ["_fedify", "publicKey"], httpMessageSignaturesSpec: ["_fedify", "httpMessageSignaturesSpec"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], } satisfies FederationKvPrefixes), ...(options.kvPrefixes ?? {}), }; @@ -369,6 +380,7 @@ export class FederationImpl [404, 410]; this.signatureTimeWindow = options.signatureTimeWindow ?? { hours: 1 }; this.skipSignatureVerification = options.skipSignatureVerification ?? false; + this.inboxChallengePolicy = options.inboxChallengePolicy; this.outboxRetryPolicy = options.outboxRetryPolicy ?? createExponentialBackoffPolicy(); this.inboxRetryPolicy = options.inboxRetryPolicy ?? @@ -1485,6 +1497,7 @@ export class FederationImpl onNotFound, signatureTimeWindow: this.signatureTimeWindow, skipSignatureVerification: this.skipSignatureVerification, + inboxChallengePolicy: this.inboxChallengePolicy, tracerProvider: this.tracerProvider, idempotencyStrategy: this.idempotencyStrategy, }); diff --git a/packages/fedify/src/sig/accept.test.ts b/packages/fedify/src/sig/accept.test.ts new file mode 100644 index 000000000..5e9c9e0fc --- /dev/null +++ b/packages/fedify/src/sig/accept.test.ts @@ -0,0 +1,421 @@ +import { test } from "@fedify/fixture"; +import { deepStrictEqual, strictEqual } from "node:assert/strict"; +import { + type AcceptSignatureMember, + formatAcceptSignature, + fulfillAcceptSignature, + parseAcceptSignature, + validateAcceptSignature, +} from "./accept.ts"; + +// --------------------------------------------------------------------------- +// parseAcceptSignature() +// --------------------------------------------------------------------------- + +test("parseAcceptSignature(): single entry", () => { + const result = parseAcceptSignature( + 'sig1=("@method" "@target-uri")', + ); + + deepStrictEqual(result, [{ + label: "sig1", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + ], + parameters: {}, + }]); +}); + +test("parseAcceptSignature(): multiple entries", () => { + const result = parseAcceptSignature( + 'sig1=("@method"), sig2=("@authority")', + ); + + deepStrictEqual(result, [ + { + label: "sig1", + components: [{ value: "@method", params: {} }], + parameters: {}, + }, + { + label: "sig2", + components: [{ value: "@authority", params: {} }], + parameters: {}, + }, + ]); +}); + +test("parseAcceptSignature(): all six parameters", () => { + const result = parseAcceptSignature( + 'sig1=("@method");keyid="k1";alg="rsa-v1_5-sha256"' + + ';created;expires;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(): preserves string component parameters", () => { + const result = parseAcceptSignature( + 'sig1=("@query-param";name="foo" "@method")', + ); + + deepStrictEqual(result, [{ + label: "sig1", + components: [ + { value: "@query-param", params: { name: "foo" } }, + { value: "@method", params: {} }, + ], + parameters: {}, + }]); +}); + +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(): 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(""), []); +}); + +// --------------------------------------------------------------------------- +// formatAcceptSignature() +// --------------------------------------------------------------------------- + +test("formatAcceptSignature(): single entry with created", () => { + const members: AcceptSignatureMember[] = [{ + label: "sig1", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + ], + parameters: { created: true }, + }]; + const header = formatAcceptSignature(members); + const parsed = parseAcceptSignature(header); + + deepStrictEqual(parsed, members); +}); + +test("formatAcceptSignature(): created + nonce", () => { + const members: AcceptSignatureMember[] = [{ + label: "sig1", + components: [{ value: "@method", params: {} }], + parameters: { + created: true, + nonce: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + }, + }]; + const header = formatAcceptSignature(members); + const parsed = parseAcceptSignature(header); + + deepStrictEqual(parsed, members); +}); + +test("formatAcceptSignature(): multiple entries", () => { + const members: AcceptSignatureMember[] = [ + { + label: "sig1", + components: [{ value: "@method", params: {} }], + parameters: {}, + }, + { + label: "sig2", + components: [ + { value: "@authority", params: {} }, + { value: "content-digest", params: {} }, + ], + parameters: { tag: "app-123" }, + }, + ]; + const header = formatAcceptSignature(members); + const parsed = parseAcceptSignature(header); + + deepStrictEqual(parsed, members); +}); + +test("formatAcceptSignature(): round-trip with all parameters", () => { + const input: AcceptSignatureMember[] = [{ + label: "sig1", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + { value: "content-digest", params: {} }, + ], + parameters: { + keyid: "test-key-rsa-pss", + alg: "rsa-pss-sha512", + created: true, + expires: true, + nonce: "abc123", + tag: "app-123", + }, + }]; + const header = formatAcceptSignature(input); + const members = parseAcceptSignature(header); + + deepStrictEqual(members, input); +}); + +test("formatAcceptSignature(): round-trip with parameterized components", () => { + const input: AcceptSignatureMember[] = [{ + label: "sig1", + components: [ + { value: "@query-param", params: { name: "foo" } }, + { value: "content-type", params: { sf: true } }, + { value: "@method", params: {} }, + ], + parameters: { created: true }, + }]; + const header = formatAcceptSignature(input); + const members = parseAcceptSignature(header); + deepStrictEqual(members, input); +}); + +// --------------------------------------------------------------------------- +// validateAcceptSignature() +// --------------------------------------------------------------------------- + +test("validateAcceptSignature(): filters out @status", () => { + const valid: AcceptSignatureMember = { + label: "sig1", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + ], + parameters: {}, + }; + 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( + "validateAcceptSignature(): passes entries with parameterized components", + () => { + const members: AcceptSignatureMember[] = [{ + label: "sig1", + components: [ + { value: "@query-param", params: { name: "foo" } }, + { value: "@method", params: {} }, + ], + parameters: {}, + }]; + deepStrictEqual(validateAcceptSignature(members), members); + }, +); + +// --------------------------------------------------------------------------- +// fulfillAcceptSignature() +// --------------------------------------------------------------------------- + +test("fulfillAcceptSignature(): compatible alg and keyid", () => { + const entry: AcceptSignatureMember = { + label: "sig1", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "content-digest", params: {} }, + ], + 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", + ); + + // Components must be exactly what the challenger requested — no additions. + deepStrictEqual(result, { + label: "sig1", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "content-digest", params: {} }, + ], + nonce: "abc", + tag: "t1", + expires: undefined, + }); +}); + +test("fulfillAcceptSignature(): incompatible alg", () => { + const entry: AcceptSignatureMember = { + label: "sig1", + components: [{ value: "@method", params: {} }], + 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: [{ value: "@method", params: {} }], + parameters: { keyid: "https://other.example/key" }, + }; + const result = fulfillAcceptSignature( + entry, + "https://example.com/key", + "rsa-v1_5-sha256", + ); + + strictEqual(result, null); +}); + +test("fulfillAcceptSignature(): components returned exactly as requested", () => { + const entry: AcceptSignatureMember = { + label: "sig1", + components: [{ value: "content-digest", params: {} }], + parameters: {}, + }; + const result = fulfillAcceptSignature( + entry, + "https://example.com/key", + "rsa-v1_5-sha256", + ); + + // Challenger only requested content-digest; no minimum-set components added. + deepStrictEqual(result!.components, [ + { value: "content-digest", params: {} }, + ]); +}); + +test("fulfillAcceptSignature(): no alg/keyid constraints", () => { + const entry: AcceptSignatureMember = { + label: "custom", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + ], + parameters: {}, + }; + const result = fulfillAcceptSignature( + entry, + "https://example.com/key", + "rsa-v1_5-sha256", + ); + + deepStrictEqual(result, { + label: "custom", + components: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + ], + 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", + () => { + 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 new file mode 100644 index 000000000..202f913d4 --- /dev/null +++ b/packages/fedify/src/sig/accept.ts @@ -0,0 +1,329 @@ +/** + * `Accept-Signature` header parsing, serialization, and validation utilities + * for RFC 9421 §5 challenge-response negotiation. + * + * @module + */ +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; +} + +/** + * 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 + * [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 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: AcceptSignatureComponent[]; + + /** + * 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 parseEachSignature(decodeDict(header)); + } catch { + getLogger(["fedify", "sig", "http"]).warn( + "Failed to parse Accept-Signature header: {header}", + { header }, + ); + return []; + } +} + +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 compactParams = ( + item: { params: 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; +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 { + const items = members.map((member) => + [ + member.label, + new Item( + compToItems(member), + compactParameters(member), + ), + ] as const + ); + return encodeDict(Object.fromEntries(items)); +} + +const compToItems = (member: AcceptSignatureMember): Item[] => + member.components.map((c) => new Item(c.value, c.params)); +const compactParameters = ( + member: AcceptSignatureMember, +): 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 + * 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 validateAcceptSignature( + members: AcceptSignatureMember[], +): AcceptSignatureMember[] { + const logger = getLogger(["fedify", "sig", "http"]); + 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 => + logger.warn( + "Discarding Accept-Signature member {label}: " + + "covered components include response-only identifier @status.", + { label }, + ) as undefined; + +/** + * The result of {@link fulfillAcceptSignature}. This can be used directly + * as the `rfc9421` option of {@link SignRequestOptions}. + * @since 2.1.0 + */ +export interface FulfillAcceptSignatureResult { + /** The label for the signature. */ + label: string; + /** + * The merged set of covered component identifiers, 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. */ + tag?: string; + /** + * If `true`, the challenger requested that the signer generate and include + * an expiration timestamp in the signature parameters. + */ + expires?: true; +} + +/** + * 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`: 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). + * @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: entry.components, + nonce: entry.parameters.nonce, + tag: entry.parameters.tag, + expires: entry.parameters.expires, + }; +} + +// cspell: ignore keyid diff --git a/packages/fedify/src/sig/http.test.ts b/packages/fedify/src/sig/http.test.ts index 2c95b18b2..22c746e25 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,12 @@ 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.includes( - 'value with "quotes" and spaces', + complexParsedInput.sig1.components.some((c) => c.value === "content-type"), + ); + assert( + complexParsedInput.sig1.components.some( + (c) => c.value === 'value with "quotes" and spaces', ), ); @@ -1962,7 +1973,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', ); }, @@ -2178,3 +2189,643 @@ 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: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + ], + }, + }, + ); + 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"'); +}); + +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 () => { + // 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: [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + ], + }, + }, + ); + + // 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: [ + { value: "@authority", params: {} }, + ], + }, + }, + ); + + 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 +// --------------------------------------------------------------------------- + +test( + "doubleKnock(): Accept-Signature challenge retry succeeds", + async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-challenge-ok", (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // First attempt fails with Accept-Signature challenge + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri" "@authority" "content-digest")' + + ';created;nonce="challenge-nonce-1"', + }, + }); + } + // Second attempt (challenge retry) succeeds + const sigInput = req.headers.get("Signature-Input") ?? ""; + if (sigInput.includes("challenge-nonce-1")) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }); + + const request = new Request("https://example.com/inbox-challenge-ok", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): unfulfillable Accept-Signature falls to legacy fallback", + async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-unfulfillable", (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // Challenge with incompatible algorithm + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": 'sig1=("@method");alg="ecdsa-p256-sha256"', + }, + }); + } + // Legacy fallback (draft-cavage) succeeds + if (req.headers.has("Signature") && !req.headers.has("Signature-Input")) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }); + + const request = new Request("https://example.com/inbox-unfulfillable", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): no Accept-Signature falls to legacy fallback", + async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-no-challenge", (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + return new Response("Not Authorized", { status: 401 }); + } + if (req.headers.has("Signature")) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }); + + const request = new Request("https://example.com/inbox-no-challenge", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): challenge retry also fails → legacy fallback attempted", + async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post("https://example.com/inbox-challenge-fails", (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": 'sig1=("@method" "@target-uri");created', + }, + }); + } + if (requestCount === 2) { + // Challenge retry also fails + return new Response("Still Not Authorized", { status: 401 }); + } + // Legacy fallback (3rd attempt) + if (req.headers.has("Signature") && !req.headers.has("Signature-Input")) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }); + + const request = new Request( + "https://example.com/inbox-challenge-fails", + { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }, + ); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 3); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): challenge retry returns another challenge → not followed", + async () => { + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post( + "https://example.com/inbox-challenge-loop", + (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // First attempt: returns Accept-Signature challenge + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri");created;nonce="nonce-1"', + }, + }); + } + if (requestCount === 2) { + // Challenge retry: returns ANOTHER Accept-Signature challenge + // (should NOT be followed — loop prevention) + return new Response("Still Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri");created;nonce="nonce-2"', + }, + }); + } + // Legacy fallback (3rd attempt, spec-swap to draft-cavage) + if ( + req.headers.has("Signature") && + !req.headers.has("Signature-Input") + ) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }, + ); + + const request = new Request( + "https://example.com/inbox-challenge-loop", + { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }, + ); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + // Should have made exactly 3 requests: + // 1. Initial → 401 + Accept-Signature + // 2. Challenge retry → 401 + Accept-Signature (NOT followed again) + // 3. Legacy fallback (draft-cavage) → 202 + assertEquals(response.status, 202); + assertEquals(requestCount, 3); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): Accept-Signature with unsupported component falls to legacy fallback", + async () => { + // Regression test for missing error guard in doubleKnock() challenge retry. + // When a server sends an Accept-Signature challenge containing a component + // that causes signRequest() to throw (e.g., a header not present on the + // request), the error should be caught so that doubleKnock() falls through + // to the legacy spec-swap fallback instead of propagating the TypeError. + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post( + "https://example.com/inbox-bad-challenge", + (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // Challenge with a header component ("x-custom-required") that is + // absent from the request — createRfc9421SignatureBase() will throw + // "Missing header: x-custom-required". + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": + 'sig1=("@method" "@target-uri" "x-custom-required");created', + }, + }); + } + // Legacy fallback (draft-cavage) should still be reached + if ( + req.headers.has("Signature") && !req.headers.has("Signature-Input") + ) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }, + ); + + const request = new Request("https://example.com/inbox-bad-challenge", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + // The challenge retry should fail gracefully and fall through to legacy + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): Accept-Signature with unsupported derived component falls to legacy fallback", + async () => { + // Similar to the above test, but with an unsupported derived component + // (e.g., "@query-param") instead of a missing header. + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post( + "https://example.com/inbox-bad-derived", + (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // Challenge with "@query-param" — a derived component that throws + // in createRfc9421SignatureBase() because it requires special params. + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": 'sig1=("@method" "@query-param");created', + }, + }); + } + // Legacy fallback should be reached + if ( + req.headers.has("Signature") && !req.headers.has("Signature-Input") + ) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }, + ); + + const request = new Request("https://example.com/inbox-bad-derived", { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +test( + "doubleKnock(): Accept-Signature with multiple entries where first throws falls to next entry", + async () => { + // When Accept-Signature contains multiple entries, if the first entry + // causes signRequest() to throw, the loop should catch the error and + // try the next entry (or fall through to legacy fallback). + fetchMock.spyGlobal(); + let requestCount = 0; + + fetchMock.post( + "https://example.com/inbox-multi-challenge", + (cl) => { + const req = cl.request!; + requestCount++; + if (requestCount === 1) { + // First entry has a missing header; second entry is valid + return new Response("Not Authorized", { + status: 401, + headers: { + "Accept-Signature": 'sig1=("@method" "x-nonexistent");created,' + + 'sig2=("@method" "@target-uri" "@authority");created', + }, + }); + } + // Challenge retry with valid sig2 should succeed + if (req.headers.has("Signature-Input")) { + return new Response("", { status: 202 }); + } + return new Response("Bad", { status: 400 }); + }, + ); + + const request = new Request( + "https://example.com/inbox-multi-challenge", + { + method: "POST", + body: "Test message", + headers: { "Content-Type": "text/plain" }, + }, + ); + + const response = await doubleKnock(request, { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }); + + assertEquals(response.status, 202); + assertEquals(requestCount, 2); + + fetchMock.hardReset(); + }, +); + +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 0c7c7129f..7599d0834 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -21,6 +21,12 @@ import { Item, } from "structured-field-values"; import metadata from "../../deno.json" with { type: "json" }; +import { + type AcceptSignatureComponent, + fulfillAcceptSignature, + parseAcceptSignature, + validateAcceptSignature, +} from "./accept.ts"; import { fetchKeyDetailed, type FetchKeyErrorResult, @@ -74,6 +80,51 @@ 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?: AcceptSignatureComponent[]; + + /** + * A nonce value to include in the signature parameters. + */ + nonce?: string; + + /** + * 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; } /** @@ -114,6 +165,7 @@ export async function signRequest( span, options.currentTime, options.body, + options.rfc9421, ); } else { // Default to draft-cavage @@ -217,12 +269,31 @@ export interface Rfc9421SignatureParameters { algorithm: string; keyId: URL; created: number; + expires?: 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.expires != null) yield `expires=${params.expires}`; + 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)); } /** @@ -234,58 +305,52 @@ export function formatRfc9421SignatureParameters( */ export function createRfc9421SignatureBase( request: Request, - components: string[], + components: AcceptSignatureComponent[], 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("@")) { - 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; + return components.map((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.value); + if (header == null) { + throw new Error(`Missing header: ${component.value}`); } - // 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 `${id}: ${header}`; + }).concat([ + `"@signature-params": (${ + components.map((c) => formatComponentId(c)).join(" ") + });${parameters}`, + ]).join("\n"); } +const derivedComponents: Record< + string, + (request: Request, url: URL) => string +> = { + "@method": (request) => request.method.toUpperCase(), + "@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"); + }, + "@status": () => { + throw new Error("@status is only valid for responses"); + }, +}; + /** * Formats a signature using rfc9421 format. * @param signature The raw signature bytes. @@ -295,13 +360,14 @@ export function createRfc9421SignatureBase( */ export function formatRfc9421Signature( signature: ArrayBuffer | Uint8Array, - components: string[], + components: AcceptSignatureComponent[], parameters: string, + label = "sig1", ): [string, string] { - const signatureInputValue = `sig1=("${ - components.join('" "') - }");${parameters}`; - const signatureValue = `sig1=:${encodeBase64(signature)}:`; + const signatureInputValue = `${label}=(${ + components.map((c) => formatComponentId(c)).join(" ") + });${parameters}`; + const signatureValue = `${label}=:${encodeBase64(signature)}:`; return [signatureInputValue, signatureValue]; } @@ -318,7 +384,9 @@ export function parseRfc9421SignatureInput( keyId: string; alg?: string; created: number; - components: string[]; + nonce?: string; + tag?: string; + components: AcceptSignatureComponent[]; parameters: string; } > { @@ -338,7 +406,9 @@ export function parseRfc9421SignatureInput( keyId: string; alg?: string; created: number; - components: string[]; + nonce?: string; + tag?: string; + components: AcceptSignatureComponent[]; parameters: string; } > = {}; @@ -348,14 +418,21 @@ 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, 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 +475,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 +511,29 @@ 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: AcceptSignatureComponent[] = [ + ...(rfc9421Options?.components ?? [ + { value: "@method", params: {} }, + { value: "@target-uri", params: {} }, + { value: "@authority", params: {} }, + { value: "host", params: {} }, + { value: "date", params: {} }, + ]), + ...(body != null ? [{ value: "content-digest", params: {} }] : []), ]; - if (body != null) { - components.push("content-digest"); - } - // 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, }); let signatureBase: string; try { @@ -480,11 +564,28 @@ async function signRequestRfc9421( signatureBytes, components, signatureParams, + 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"); @@ -577,6 +678,7 @@ export type VerifyRequestDetailedResult = | { readonly verified: true; readonly key: CryptographicKey; + readonly signatureLabel?: string; } | { readonly verified: false; @@ -1235,7 +1337,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) { @@ -1356,7 +1458,7 @@ async function verifyRequestRfc9421( ); if (verified) { - return { verified: true, key }; + return { verified: true, key, signatureLabel: sigName }; } else if (cached) { // If we used a cached key and verification failed, try fetching fresh key logger.debug( @@ -1551,12 +1653,90 @@ 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 = validateAcceptSignature( + parseAcceptSignature(acceptSigHeader), + ); + const localKeyId = identity.keyId.href; + const localAlg = "rsa-v1_5-sha256"; + // RFC 9421 §5: "The target message of an Accept-Signature field MUST + // include all labeled signatures indicated in the Accept-Signature + // field." We therefore accumulate every compatible entry's signature + // 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; accumulating " + + "label {label} and components {components}.", + { label: rfc9421.label, components: rfc9421.components }, + ); + try { + // 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, + { + spec: "rfc9421", + tracerProvider, + body, + rfc9421, + }, + ); + fulfilled = true; + } catch (error) { + logger.debug( + "Failed to fulfill Accept-Signature challenge entry " + + "{label}: {error}", + { label: entry.label, error }, + ); + } + } + 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 && + response.headers.has("Location") + ) { + const location = response.headers.get("Location")!; + return doubleKnock( + createRedirectRequest(request, location, body), + identity, + { ...options, body }, + ); + } + } + // 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 !== 400 && response.status !== 401 + ) { + return response; + } + // Otherwise fall through to legacy spec-swap fallback + } + + // Legacy double-knocking: swap between RFC 9421 and draft-cavage const spec = firstTrySpec === "draft-cavage-http-signatures-12" ? "rfc9421" : "draft-cavage-http-signatures-12"; - getLogger(["fedify", "sig", "http"]).debug( + logger.debug( "Failed to verify with the spec {spec} ({status} {statusText}); retrying with spec {secondSpec}... (double-knocking)", { spec: firstTrySpec, diff --git a/packages/fedify/src/sig/mod.ts b/packages/fedify/src/sig/mod.ts index 8f7342f9c..50d653886 100644 --- a/packages/fedify/src/sig/mod.ts +++ b/packages/fedify/src/sig/mod.ts @@ -3,9 +3,19 @@ * * @module */ +export { + type AcceptSignatureMember, + type AcceptSignatureParameters, + formatAcceptSignature, + fulfillAcceptSignature, + type FulfillAcceptSignatureResult, + parseAcceptSignature, + validateAcceptSignature, +} from "./accept.ts"; export { type HttpMessageSignaturesSpec, type HttpMessageSignaturesSpecDeterminer, + type Rfc9421SignRequestOptions, signRequest, type SignRequestOptions, verifyRequest,