Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,42 @@ To be released.

### @fedify/fedify

- Added `InboxListenerSetters.onUnverifiedActivity()` so applications can
inspect inbound activities whose signatures could not be verified and
optionally return a custom response instead of the default
`401 Unauthorized`. This is useful for cases like `Delete` deliveries
from actors whose signing keys now return `404 Not Found` or `410 Gone`.
Added the supporting public types `UnverifiedActivityHandler` and
`UnverifiedActivityReason`. [[#472], [#611]]

- Added `verifyRequestDetailed()` plus the public types
`VerifyRequestDetailedResult`, `VerifyRequestFailureReason`, and
`FetchKeyErrorResult` so applications can distinguish unsigned requests,
invalid signatures, and key-fetch failures during HTTP signature
verification. [[#611]]

- OpenTelemetry spans/events and `FedifySpanExporter` signature details now
expose HTTP signature failure reasons and key-fetch failure details for
inbound activities. [[#611]]

- Fixed `RequestContext.getSignedKeyOwner()` to return `null` instead of
throwing an error when the remote server requires authorized fetch and
returns `401 Unauthorized` for the key owner lookup. Previously, this
caused a `500 Internal Server Error` when interoperating with servers like
GoToSocial that have authorized fetch enabled. [[#473], [#589]]

[#472]: https://github.com/fedify-dev/fedify/issues/472
[#473]: https://github.com/fedify-dev/fedify/issues/473
[#589]: https://github.com/fedify-dev/fedify/pull/589
[#611]: https://github.com/fedify-dev/fedify/pull/611

### @fedify/vocab-runtime

- Added optional `FetchError.response` so callers can inspect the original
failed HTTP response when remote document or key fetches return an HTTP
error (such as `404 Not Found` or `410 Gone`). This enables higher-level
APIs to distinguish transport failures from specific HTTP fetch failures.
[[#611]]

### @fedify/cli

Expand Down
71 changes: 65 additions & 6 deletions docs/manual/inbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ activities with various specifications, such as:
- [Linked Data Signatures]
- Object Integrity Proofs ([FEP-8b32])

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

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


Handling unverified activities
------------------------------

*This API is available since Fedify 2.1.0.*

Most applications can keep the default behavior and ignore unverified inbound
activities. However, some applications need finer control. Typical examples
include:

- remote actor deletions where the `Delete` activity can still be parsed,
but the signing key now returns `410 Gone`
- noisy redelivery loops from remote servers that keep retrying activities
you have decided not to process
- custom logging, metrics, moderation, or quarantine flows for suspicious
inbound traffic

For these cases, you can register
`~InboxListenerSetters.onUnverifiedActivity()`. The callback receives the
`RequestContext`, the parsed activity, and a reason object whose `type` is one
of `"noSignature"`, `"invalidSignature"`, or `"keyFetchError"`.

If the callback returns a `Response`, Fedify uses it as-is. If it returns
nothing (`void`), Fedify falls back to the default `401 Unauthorized`
response.

~~~~ typescript twoslash
import { type Federation } from "@fedify/fedify";
import { Delete } from "@fedify/vocab";
const federation = null as unknown as Federation<void>;
// ---cut-before---
federation
.setInboxListeners("/users/{identifier}/inbox", "/inbox")
.onUnverifiedActivity((ctx, activity, reason) => {
if (
activity instanceof Delete &&
reason.type === "keyFetchError" &&
"status" in reason.result &&
reason.result.status === 410
) {
// For example, stop redelivery of a Delete from a permanently gone actor.
return new Response(null, { status: 202 });
}
});
~~~~

Returning a custom response does not pass the activity to the inbox listeners
registered through `~InboxListenerSetters.on()`. Verified activities continue
to flow to those listeners as usual; unverified activities remain opt-in.

The request context includes the original `Request` object, so you can inspect
details such as the `Host` header through `RequestContext.request` when making
policy decisions.


Registering an inbox listener
-----------------------------

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

> [!NOTE]
> Activities with invalid signatures/proofs are silently ignored and not queued.
> Activities with invalid signatures/proofs are not queued and are not passed
> to inbox listeners. If
> `~InboxListenerSetters.onUnverifiedActivity()` is configured, the hook runs
> before the default `401 Unauthorized` response is returned.

> [!TIP]
> If your inbox listeners are mostly I/O-bound, consider parallelizing
Expand Down Expand Up @@ -505,8 +563,9 @@ federation
~~~~

> [!NOTE]
> Activities with invalid signatures/proofs are silently ignored and not passed
> to the error handler.
> Activities with invalid signatures/proofs are not passed to the error
> handler. If you need to inspect them, use
> `~InboxListenerSetters.onUnverifiedActivity()` instead.


Forwarding activities to another server
Expand Down
17 changes: 17 additions & 0 deletions docs/manual/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@ Each span event includes attributes with detailed information:
- `http_signatures.verified`: Whether HTTP Signatures were verified
(`true`/`false`)
- `http_signatures.key_id`: The key ID used for HTTP signature verification
- `http_signatures.failure_reason` (optional): Why HTTP signature
verification failed (`noSignature`, `invalidSignature`, or
`keyFetchError`)
- `http_signatures.key_fetch_status` (optional): The HTTP status code when
fetching the signing key failed with an HTTP response
- `http_signatures.key_fetch_error` (optional): The error type when fetching
the signing key failed without an HTTP response

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

Expand Down Expand Up @@ -279,6 +286,10 @@ for ActivityPub:
| `http_signatures.signature` | string | The signature of the HTTP request in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` |
| `http_signatures.algorithm` | string | The algorithm of the HTTP request signature. | `"rsa-sha256"` |
| `http_signatures.key_id` | string | The public key ID of the HTTP request signature. | `"https://example.com/actor/1#main-key"` |
| `http_signatures.verified` | boolean | Whether the HTTP request signature was verified successfully. | `false` |
| `http_signatures.failure_reason` | string | Why HTTP signature verification failed (`noSignature`, `invalidSignature`, or `keyFetchError`). | `"keyFetchError"` |
| `http_signatures.key_fetch_status` | int | The HTTP status code from a failed signing-key fetch, when available. | `410` |
| `http_signatures.key_fetch_error` | string | The error type from a non-HTTP signing-key fetch failure, when available. | `"TypeError"` |
| `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"` |
| `ld_signatures.key_id` | string | The public key ID of the Linked Data signature. | `"https://example.com/actor/1#main-key"` |
| `ld_signatures.signature` | string | The signature of the Linked Data in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` |
Expand Down Expand Up @@ -534,6 +545,12 @@ Each `TraceActivityRecord` contains:
- `httpSignaturesVerified`: Whether HTTP Signatures were verified
- `httpSignaturesKeyId` (optional): The key ID used for HTTP signature
verification, if available
- `httpSignaturesFailureReason` (optional): Why HTTP signature
verification failed, if available
- `httpSignaturesKeyFetchStatus` (optional): The HTTP status code from a
failed key fetch, if available
- `httpSignaturesKeyFetchError` (optional): The error type from a
non-HTTP key fetch failure, if available
- `ldSignaturesVerified`: Whether Linked Data Signatures were verified
- `timestamp`: ISO 8601 timestamp
- `inboxUrl`: The target inbox URL (for outbound activities)
Expand Down
24 changes: 24 additions & 0 deletions packages/fedify/src/federation/builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
InboxListener,
NodeInfoDispatcher,
ObjectDispatcher,
UnverifiedActivityReason,
} from "./callback.ts";
import { MemoryKvStore } from "./kv.ts";
import type { FederationImpl } from "./middleware.ts";
Expand Down Expand Up @@ -166,6 +167,29 @@ test("FederationBuilder", async (t) => {
assertEquals(implRfc.firstKnock, "rfc9421");
});

await t.step(
"should copy unverified activity handler into built federation",
async () => {
const builder = createFederationBuilder<void>();
const kv = new MemoryKvStore();
const handler = (
_ctx: unknown,
_activity: Activity,
_reason: UnverifiedActivityReason,
) => {
return;
};

builder
.setInboxListeners("/users/{identifier}/inbox")
.onUnverifiedActivity(handler);

const federation = await builder.build({ kv });
const impl = federation as FederationImpl<void>;
assertEquals(impl.unverifiedActivityHandler, handler);
},
);

await t.step(
"should register multiple object dispatchers and verify them",
async () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/fedify/src/federation/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
ObjectDispatcher,
OutboxPermanentFailureHandler,
SharedInboxKeyDispatcher,
UnverifiedActivityHandler,
WebFingerLinksDispatcher,
} from "./callback.ts";
import type { Context, RequestContext } from "./context.ts";
Expand Down Expand Up @@ -108,6 +109,7 @@ export class FederationBuilderImpl<TContextData>
inboxListeners?: InboxListenerSet<TContextData>;
inboxErrorHandler?: InboxErrorHandler<TContextData>;
sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher<TContextData>;
unverifiedActivityHandler?: UnverifiedActivityHandler<TContextData>;
outboxPermanentFailureHandler?: OutboxPermanentFailureHandler<TContextData>;
idempotencyStrategy?:
| IdempotencyStrategy
Expand Down Expand Up @@ -188,6 +190,7 @@ export class FederationBuilderImpl<TContextData>
f.inboxListeners = this.inboxListeners?.clone();
f.inboxErrorHandler = this.inboxErrorHandler;
f.sharedInboxKeyDispatcher = this.sharedInboxKeyDispatcher;
f.unverifiedActivityHandler = this.unverifiedActivityHandler;
f.outboxPermanentFailureHandler = this.outboxPermanentFailureHandler;
f.idempotencyStrategy = this.idempotencyStrategy;
return f;
Expand Down Expand Up @@ -1150,6 +1153,12 @@ export class FederationBuilderImpl<TContextData>
this.inboxErrorHandler = handler;
return setters;
},
onUnverifiedActivity: (
handler: UnverifiedActivityHandler<TContextData>,
): InboxListenerSetters<TContextData> => {
this.unverifiedActivityHandler = handler;
return setters;
},
setSharedKeyDispatcher: (
dispatcher: SharedInboxKeyDispatcher<TContextData>,
): InboxListenerSetters<TContextData> => {
Expand Down
30 changes: 30 additions & 0 deletions packages/fedify/src/federation/callback.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Activity, Actor, Object } from "@fedify/vocab";
import type { Link } from "@fedify/webfinger";
import type { VerifyRequestFailureReason } from "../sig/http.ts";
import type { NodeInfo } from "../nodeinfo/types.ts";
import type { PageItems } from "./collection.ts";
import type { Context, InboxContext, RequestContext } from "./context.ts";
Expand Down Expand Up @@ -180,6 +181,35 @@ export type InboxListener<TContextData, TActivity extends Activity> = (
activity: TActivity,
) => void | Promise<void>;

/**
* The reason why an incoming activity could not be verified.
*
* Unlike inbox listeners registered through {@link InboxListenerSetters.on},
* unverified activity handlers are called only when the activity payload could
* be parsed but its HTTP signatures could not be verified.
*
* @since 2.1.0
*/
export type UnverifiedActivityReason = VerifyRequestFailureReason;

/**
* A callback that handles activities whose signatures could not be verified.
*
* Returning a {@link Response} overrides Fedify's default `401 Unauthorized`
* response. Returning `void` keeps the default behavior.
*
* @template TContextData The context data to pass to the {@link Context}.
* @param context The request context.
* @param activity The incoming activity that could be parsed.
* @param reason The reason why signature verification failed.
* @since 2.1.0
*/
export type UnverifiedActivityHandler<TContextData> = (
context: RequestContext<TContextData>,
activity: Activity,
reason: UnverifiedActivityReason,
) => void | Response | Promise<void | Response>;

/**
* A callback that handles errors in an inbox.
*
Expand Down
33 changes: 33 additions & 0 deletions packages/fedify/src/federation/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
OutboxErrorHandler,
OutboxPermanentFailureHandler,
SharedInboxKeyDispatcher,
UnverifiedActivityHandler,
WebFingerLinksDispatcher,
} from "./callback.ts";
import type { Context, InboxContext, RequestContext } from "./context.ts";
Expand Down Expand Up @@ -1170,6 +1171,38 @@ export interface InboxListenerSetters<TContextData> {
handler: InboxErrorHandler<TContextData>,
): InboxListenerSetters<TContextData>;

/**
* Registers a callback for incoming activities whose HTTP signatures could
* not be verified.
*
* The regular inbox listeners registered through {@link on} continue to
* receive only verified activities. This hook is an opt-in escape hatch for
* applications that need to inspect unverified deliveries and optionally
* override the default `401 Unauthorized` response.
*
* @example
* ``` typescript
* federation
* .setInboxListeners("/users/{identifier}/inbox", "/inbox")
* .onUnverifiedActivity((ctx, activity, reason) => {
* if (
* reason.type === "keyFetchError" &&
* "status" in reason.result &&
* reason.result.status === 410
* ) {
* return new Response(null, { status: 202 });
* }
* });
* ```
*
* @param handler A callback to handle an unverified activity.
* @returns The setters object so that settings can be chained.
* @since 2.1.0
*/
onUnverifiedActivity(
handler: UnverifiedActivityHandler<TContextData>,
): InboxListenerSetters<TContextData>;

/**
* Configures a callback to dispatch the key pair for the authenticated
* document loader of the {@link Context} passed to the shared inbox listener.
Expand Down
Loading
Loading