Skip to content

Commit f5543fc

Browse files
authored
Merge pull request #611 from dahlia/on-unverified-activity
Handle unverified inbound activities
2 parents 1172d43 + 12243f4 commit f5543fc

24 files changed

Lines changed: 1786 additions & 157 deletions

CHANGES.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,42 @@ To be released.
1010

1111
### @fedify/fedify
1212

13+
- Added `InboxListenerSetters.onUnverifiedActivity()` so applications can
14+
inspect inbound activities whose signatures could not be verified and
15+
optionally return a custom response instead of the default
16+
`401 Unauthorized`. This is useful for cases like `Delete` deliveries
17+
from actors whose signing keys now return `404 Not Found` or `410 Gone`.
18+
Added the supporting public types `UnverifiedActivityHandler` and
19+
`UnverifiedActivityReason`. [[#472], [#611]]
20+
21+
- Added `verifyRequestDetailed()` plus the public types
22+
`VerifyRequestDetailedResult`, `VerifyRequestFailureReason`, and
23+
`FetchKeyErrorResult` so applications can distinguish unsigned requests,
24+
invalid signatures, and key-fetch failures during HTTP signature
25+
verification. [[#611]]
26+
27+
- OpenTelemetry spans/events and `FedifySpanExporter` signature details now
28+
expose HTTP signature failure reasons and key-fetch failure details for
29+
inbound activities. [[#611]]
30+
1331
- Fixed `RequestContext.getSignedKeyOwner()` to return `null` instead of
1432
throwing an error when the remote server requires authorized fetch and
1533
returns `401 Unauthorized` for the key owner lookup. Previously, this
1634
caused a `500 Internal Server Error` when interoperating with servers like
1735
GoToSocial that have authorized fetch enabled. [[#473], [#589]]
1836

37+
[#472]: https://github.com/fedify-dev/fedify/issues/472
1938
[#473]: https://github.com/fedify-dev/fedify/issues/473
2039
[#589]: https://github.com/fedify-dev/fedify/pull/589
40+
[#611]: https://github.com/fedify-dev/fedify/pull/611
41+
42+
### @fedify/vocab-runtime
43+
44+
- Added optional `FetchError.response` so callers can inspect the original
45+
failed HTTP response when remote document or key fetches return an HTTP
46+
error (such as `404 Not Found` or `410 Gone`). This enables higher-level
47+
APIs to distinguish transport failures from specific HTTP fetch failures.
48+
[[#611]]
2149

2250
### @fedify/cli
2351

docs/manual/inbox.md

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ activities with various specifications, such as:
2626
- [Linked Data Signatures]
2727
- Object Integrity Proofs ([FEP-8b32])
2828

29-
You don't need to worry about the signature verification at all—unsigned
30-
activities and invalid signatures are silently ignored. If you want to see why
31-
some activities are ignored, you can turn on [logging](./log.md) for
29+
You don't need to worry about the signature verification at all. By default,
30+
activities whose signatures/proofs cannot be verified are rejected with
31+
`401 Unauthorized` and are not passed to inbox listeners. If you want to see
32+
why some activities are rejected, you can turn on [logging](./log.md) for
3233
`["fedify", "sig"]` category.
3334

3435
[HTTP Signatures]: https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
@@ -37,6 +38,60 @@ some activities are ignored, you can turn on [logging](./log.md) for
3738
[FEP-8b32]: https://w3id.org/fep/8b32
3839

3940

41+
Handling unverified activities
42+
------------------------------
43+
44+
*This API is available since Fedify 2.1.0.*
45+
46+
Most applications can keep the default behavior and ignore unverified inbound
47+
activities. However, some applications need finer control. Typical examples
48+
include:
49+
50+
- remote actor deletions where the `Delete` activity can still be parsed,
51+
but the signing key now returns `410 Gone`
52+
- noisy redelivery loops from remote servers that keep retrying activities
53+
you have decided not to process
54+
- custom logging, metrics, moderation, or quarantine flows for suspicious
55+
inbound traffic
56+
57+
For these cases, you can register
58+
`~InboxListenerSetters.onUnverifiedActivity()`. The callback receives the
59+
`RequestContext`, the parsed activity, and a reason object whose `type` is one
60+
of `"noSignature"`, `"invalidSignature"`, or `"keyFetchError"`.
61+
62+
If the callback returns a `Response`, Fedify uses it as-is. If it returns
63+
nothing (`void`), Fedify falls back to the default `401 Unauthorized`
64+
response.
65+
66+
~~~~ typescript twoslash
67+
import { type Federation } from "@fedify/fedify";
68+
import { Delete } from "@fedify/vocab";
69+
const federation = null as unknown as Federation<void>;
70+
// ---cut-before---
71+
federation
72+
.setInboxListeners("/users/{identifier}/inbox", "/inbox")
73+
.onUnverifiedActivity((ctx, activity, reason) => {
74+
if (
75+
activity instanceof Delete &&
76+
reason.type === "keyFetchError" &&
77+
"status" in reason.result &&
78+
reason.result.status === 410
79+
) {
80+
// For example, stop redelivery of a Delete from a permanently gone actor.
81+
return new Response(null, { status: 202 });
82+
}
83+
});
84+
~~~~
85+
86+
Returning a custom response does not pass the activity to the inbox listeners
87+
registered through `~InboxListenerSetters.on()`. Verified activities continue
88+
to flow to those listeners as usual; unverified activities remain opt-in.
89+
90+
The request context includes the original `Request` object, so you can inspect
91+
details such as the `Host` header through `RequestContext.request` when making
92+
policy decisions.
93+
94+
4095
Registering an inbox listener
4196
-----------------------------
4297

@@ -374,7 +429,10 @@ its own retry logic and rely on the backend to handle retries. This avoids
374429
duplicate retry mechanisms and leverages the backend's optimized retry features.
375430

376431
> [!NOTE]
377-
> Activities with invalid signatures/proofs are silently ignored and not queued.
432+
> Activities with invalid signatures/proofs are not queued and are not passed
433+
> to inbox listeners. If
434+
> `~InboxListenerSetters.onUnverifiedActivity()` is configured, the hook runs
435+
> before the default `401 Unauthorized` response is returned.
378436
379437
> [!TIP]
380438
> If your inbox listeners are mostly I/O-bound, consider parallelizing
@@ -505,8 +563,9 @@ federation
505563
~~~~
506564

507565
> [!NOTE]
508-
> Activities with invalid signatures/proofs are silently ignored and not passed
509-
> to the error handler.
566+
> Activities with invalid signatures/proofs are not passed to the error
567+
> handler. If you need to inspect them, use
568+
> `~InboxListenerSetters.onUnverifiedActivity()` instead.
510569
511570

512571
Forwarding activities to another server

docs/manual/opentelemetry.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,13 @@ Each span event includes attributes with detailed information:
222222
- `http_signatures.verified`: Whether HTTP Signatures were verified
223223
(`true`/`false`)
224224
- `http_signatures.key_id`: The key ID used for HTTP signature verification
225+
- `http_signatures.failure_reason` (optional): Why HTTP signature
226+
verification failed (`noSignature`, `invalidSignature`, or
227+
`keyFetchError`)
228+
- `http_signatures.key_fetch_status` (optional): The HTTP status code when
229+
fetching the signing key failed with an HTTP response
230+
- `http_signatures.key_fetch_error` (optional): The error type when fetching
231+
the signing key failed without an HTTP response
225232

226233
**`activitypub.activity.sent` event attributes:**
227234

@@ -279,6 +286,10 @@ for ActivityPub:
279286
| `http_signatures.signature` | string | The signature of the HTTP request in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` |
280287
| `http_signatures.algorithm` | string | The algorithm of the HTTP request signature. | `"rsa-sha256"` |
281288
| `http_signatures.key_id` | string | The public key ID of the HTTP request signature. | `"https://example.com/actor/1#main-key"` |
289+
| `http_signatures.verified` | boolean | Whether the HTTP request signature was verified successfully. | `false` |
290+
| `http_signatures.failure_reason` | string | Why HTTP signature verification failed (`noSignature`, `invalidSignature`, or `keyFetchError`). | `"keyFetchError"` |
291+
| `http_signatures.key_fetch_status` | int | The HTTP status code from a failed signing-key fetch, when available. | `410` |
292+
| `http_signatures.key_fetch_error` | string | The error type from a non-HTTP signing-key fetch failure, when available. | `"TypeError"` |
282293
| `http_signatures.digest.{algorithm}` | string | The digest of the HTTP request body in hexadecimal. The `{algorithm}` is the digest algorithm (e.g., `sha`, `sha-256`). | `"d41d8cd98f00b204e9800998ecf8427e"` |
283294
| `ld_signatures.key_id` | string | The public key ID of the Linked Data signature. | `"https://example.com/actor/1#main-key"` |
284295
| `ld_signatures.signature` | string | The signature of the Linked Data in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` |
@@ -534,6 +545,12 @@ Each `TraceActivityRecord` contains:
534545
- `httpSignaturesVerified`: Whether HTTP Signatures were verified
535546
- `httpSignaturesKeyId` (optional): The key ID used for HTTP signature
536547
verification, if available
548+
- `httpSignaturesFailureReason` (optional): Why HTTP signature
549+
verification failed, if available
550+
- `httpSignaturesKeyFetchStatus` (optional): The HTTP status code from a
551+
failed key fetch, if available
552+
- `httpSignaturesKeyFetchError` (optional): The error type from a
553+
non-HTTP key fetch failure, if available
537554
- `ldSignaturesVerified`: Whether Linked Data Signatures were verified
538555
- `timestamp`: ISO 8601 timestamp
539556
- `inboxUrl`: The target inbox URL (for outbound activities)

packages/fedify/src/federation/builder.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
InboxListener,
99
NodeInfoDispatcher,
1010
ObjectDispatcher,
11+
UnverifiedActivityReason,
1112
} from "./callback.ts";
1213
import { MemoryKvStore } from "./kv.ts";
1314
import type { FederationImpl } from "./middleware.ts";
@@ -166,6 +167,29 @@ test("FederationBuilder", async (t) => {
166167
assertEquals(implRfc.firstKnock, "rfc9421");
167168
});
168169

170+
await t.step(
171+
"should copy unverified activity handler into built federation",
172+
async () => {
173+
const builder = createFederationBuilder<void>();
174+
const kv = new MemoryKvStore();
175+
const handler = (
176+
_ctx: unknown,
177+
_activity: Activity,
178+
_reason: UnverifiedActivityReason,
179+
) => {
180+
return;
181+
};
182+
183+
builder
184+
.setInboxListeners("/users/{identifier}/inbox")
185+
.onUnverifiedActivity(handler);
186+
187+
const federation = await builder.build({ kv });
188+
const impl = federation as FederationImpl<void>;
189+
assertEquals(impl.unverifiedActivityHandler, handler);
190+
},
191+
);
192+
169193
await t.step(
170194
"should register multiple object dispatchers and verify them",
171195
async () => {

packages/fedify/src/federation/builder.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type {
3030
ObjectDispatcher,
3131
OutboxPermanentFailureHandler,
3232
SharedInboxKeyDispatcher,
33+
UnverifiedActivityHandler,
3334
WebFingerLinksDispatcher,
3435
} from "./callback.ts";
3536
import type { Context, RequestContext } from "./context.ts";
@@ -108,6 +109,7 @@ export class FederationBuilderImpl<TContextData>
108109
inboxListeners?: InboxListenerSet<TContextData>;
109110
inboxErrorHandler?: InboxErrorHandler<TContextData>;
110111
sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher<TContextData>;
112+
unverifiedActivityHandler?: UnverifiedActivityHandler<TContextData>;
111113
outboxPermanentFailureHandler?: OutboxPermanentFailureHandler<TContextData>;
112114
idempotencyStrategy?:
113115
| IdempotencyStrategy
@@ -188,6 +190,7 @@ export class FederationBuilderImpl<TContextData>
188190
f.inboxListeners = this.inboxListeners?.clone();
189191
f.inboxErrorHandler = this.inboxErrorHandler;
190192
f.sharedInboxKeyDispatcher = this.sharedInboxKeyDispatcher;
193+
f.unverifiedActivityHandler = this.unverifiedActivityHandler;
191194
f.outboxPermanentFailureHandler = this.outboxPermanentFailureHandler;
192195
f.idempotencyStrategy = this.idempotencyStrategy;
193196
return f;
@@ -1150,6 +1153,12 @@ export class FederationBuilderImpl<TContextData>
11501153
this.inboxErrorHandler = handler;
11511154
return setters;
11521155
},
1156+
onUnverifiedActivity: (
1157+
handler: UnverifiedActivityHandler<TContextData>,
1158+
): InboxListenerSetters<TContextData> => {
1159+
this.unverifiedActivityHandler = handler;
1160+
return setters;
1161+
},
11531162
setSharedKeyDispatcher: (
11541163
dispatcher: SharedInboxKeyDispatcher<TContextData>,
11551164
): InboxListenerSetters<TContextData> => {

packages/fedify/src/federation/callback.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Activity, Actor, Object } from "@fedify/vocab";
22
import type { Link } from "@fedify/webfinger";
3+
import type { VerifyRequestFailureReason } from "../sig/http.ts";
34
import type { NodeInfo } from "../nodeinfo/types.ts";
45
import type { PageItems } from "./collection.ts";
56
import type { Context, InboxContext, RequestContext } from "./context.ts";
@@ -180,6 +181,35 @@ export type InboxListener<TContextData, TActivity extends Activity> = (
180181
activity: TActivity,
181182
) => void | Promise<void>;
182183

184+
/**
185+
* The reason why an incoming activity could not be verified.
186+
*
187+
* Unlike inbox listeners registered through {@link InboxListenerSetters.on},
188+
* unverified activity handlers are called only when the activity payload could
189+
* be parsed but its HTTP signatures could not be verified.
190+
*
191+
* @since 2.1.0
192+
*/
193+
export type UnverifiedActivityReason = VerifyRequestFailureReason;
194+
195+
/**
196+
* A callback that handles activities whose signatures could not be verified.
197+
*
198+
* Returning a {@link Response} overrides Fedify's default `401 Unauthorized`
199+
* response. Returning `void` keeps the default behavior.
200+
*
201+
* @template TContextData The context data to pass to the {@link Context}.
202+
* @param context The request context.
203+
* @param activity The incoming activity that could be parsed.
204+
* @param reason The reason why signature verification failed.
205+
* @since 2.1.0
206+
*/
207+
export type UnverifiedActivityHandler<TContextData> = (
208+
context: RequestContext<TContextData>,
209+
activity: Activity,
210+
reason: UnverifiedActivityReason,
211+
) => void | Response | Promise<void | Response>;
212+
183213
/**
184214
* A callback that handles errors in an inbox.
185215
*

packages/fedify/src/federation/federation.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import type {
3333
OutboxErrorHandler,
3434
OutboxPermanentFailureHandler,
3535
SharedInboxKeyDispatcher,
36+
UnverifiedActivityHandler,
3637
WebFingerLinksDispatcher,
3738
} from "./callback.ts";
3839
import type { Context, InboxContext, RequestContext } from "./context.ts";
@@ -1170,6 +1171,38 @@ export interface InboxListenerSetters<TContextData> {
11701171
handler: InboxErrorHandler<TContextData>,
11711172
): InboxListenerSetters<TContextData>;
11721173

1174+
/**
1175+
* Registers a callback for incoming activities whose HTTP signatures could
1176+
* not be verified.
1177+
*
1178+
* The regular inbox listeners registered through {@link on} continue to
1179+
* receive only verified activities. This hook is an opt-in escape hatch for
1180+
* applications that need to inspect unverified deliveries and optionally
1181+
* override the default `401 Unauthorized` response.
1182+
*
1183+
* @example
1184+
* ``` typescript
1185+
* federation
1186+
* .setInboxListeners("/users/{identifier}/inbox", "/inbox")
1187+
* .onUnverifiedActivity((ctx, activity, reason) => {
1188+
* if (
1189+
* reason.type === "keyFetchError" &&
1190+
* "status" in reason.result &&
1191+
* reason.result.status === 410
1192+
* ) {
1193+
* return new Response(null, { status: 202 });
1194+
* }
1195+
* });
1196+
* ```
1197+
*
1198+
* @param handler A callback to handle an unverified activity.
1199+
* @returns The setters object so that settings can be chained.
1200+
* @since 2.1.0
1201+
*/
1202+
onUnverifiedActivity(
1203+
handler: UnverifiedActivityHandler<TContextData>,
1204+
): InboxListenerSetters<TContextData>;
1205+
11731206
/**
11741207
* Configures a callback to dispatch the key pair for the authenticated
11751208
* document loader of the {@link Context} passed to the shared inbox listener.

0 commit comments

Comments
 (0)