diff --git a/CHANGES.md b/CHANGES.md index 4c51ebc93..7083ad98e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -105,6 +105,12 @@ To be released. portable inboxes only through the first gateway, so upgrade them before the servers that enqueue deliveries. + - Added `onRequestFinished()` to observe every inbox delivery, including + rejected requests and preparation errors, with signature/proof checks, + actual verification keys, the final authentication decision, and the + processing outcome. The callback is awaited independently of trace + sampling, and its errors do not change delivery results. [[#1191], [#1201]] + - Added serving of [FEP-ef61] portable collections through the gateway endpoint, e.g., `GET /.well-known/apgateway/did:key:z6Mk.../users/alice/outbox`, by the @@ -873,7 +879,9 @@ To be released. [#1186]: https://github.com/fedify-dev/fedify/pull/1186 [#1188]: https://github.com/fedify-dev/fedify/pull/1188 [#1189]: https://github.com/fedify-dev/fedify/pull/1189 +[#1191]: https://github.com/fedify-dev/fedify/issues/1191 [#1198]: https://github.com/fedify-dev/fedify/pull/1198 +[#1201]: https://github.com/fedify-dev/fedify/pull/1201 ### @fedify/adonisjs @@ -1185,6 +1193,9 @@ To be released. dispatchers, and hashlink media responses from mock federations. Tests can now exercise these FEP-ef61 paths without a live gateway. [[#288], [#1161], [#1196]] + - Added support for registering `onRequestFinished()` on mock federations so + applications can reuse their inbox configuration in tests. + [[#1191], [#1201]] - Added `testKvStore()`, a conformance test suite for `KvStore` implementations, complementing `testMessageQueue()`. [[#1018], [#1020] by ChanHaeng Lee\] diff --git a/changes.d/fedify/on-request-finished.md b/changes.d/fedify/on-request-finished.md new file mode 100644 index 000000000..f486a4e42 --- /dev/null +++ b/changes.d/fedify/on-request-finished.md @@ -0,0 +1,10 @@ +--- +links: + '#1191': https://github.com/fedify-dev/fedify/issues/1191 + '#1201': https://github.com/fedify-dev/fedify/pull/1201 +--- + - Added `onRequestFinished()` to observe every inbox delivery, including + rejected requests and preparation errors, with signature/proof checks, + actual verification keys, the final authentication decision, and the + processing outcome. The callback is awaited independently of trace + sampling, and its errors do not change delivery results. [[#1191], [#1201]] diff --git a/changes.d/testing/on-request-finished.md b/changes.d/testing/on-request-finished.md new file mode 100644 index 000000000..7f658529d --- /dev/null +++ b/changes.d/testing/on-request-finished.md @@ -0,0 +1,8 @@ +--- +links: + '#1191': https://github.com/fedify-dev/fedify/issues/1191 + '#1201': https://github.com/fedify-dev/fedify/pull/1201 +--- + - Added support for registering `onRequestFinished()` on mock federations so + applications can reuse their inbox configuration in tests. + [[#1191], [#1201]] diff --git a/docs/manual/inbox.md b/docs/manual/inbox.md index ab88c0bcf..05e3e9a22 100644 --- a/docs/manual/inbox.md +++ b/docs/manual/inbox.md @@ -187,6 +187,86 @@ authenticated by their [Linked Data Signatures] or Object Integrity Proofs do not rely on the request's signatures, so they are not affected. +Observing inbox requests +------------------------ + +*This API is available since Fedify 2.4.0.* + +Register `onRequestFinished()` to record the result of each inbox delivery, +including rejected requests. The callback receives a `RequestContext` and an +`InboxRequestReport`. It runs once after processing and is awaited before +`Federation.fetch()` returns a response or rethrows an exception: + +~~~~ typescript twoslash +import type { Federation, InboxRequestReport } from "@fedify/fedify"; +declare const federation: Federation; +declare function saveReport(report: InboxRequestReport): Promise; +// ---cut-before--- +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .onRequestFinished(async (ctx, report) => { + await saveReport(report); + }); +~~~~ + +`report.inbox` identifies the personal, shared, or portable inbox and its local +recipient identifier. Shared inboxes have a `null` recipient. The hook also +covers failures while preparing document loaders or resolving a portable +recipient. Inbox collection requests, unmatched routes, programmatic +`routeActivity()` calls, and queue workers do not invoke it. + +`report.payload` is either `unavailable` or `parsed`, whose `value` contains the +original JSON. A parsed JSON `null` is distinct from an unavailable body. +`report.activity` contains the `Activity` obtained by the existing processing +flow, if any. Observation does not parse the body again or fetch more objects. + +`report.attempts` retains each logical evaluation of HTTP Signatures, Linked +Data Signatures, or Object Integrity Proofs. An attempt's `checks` describes +the signatures/proofs evaluated, including the declared key ID and the actual +`CryptoKey` objects used. A stale cached key and its fresh replacement are +both retained in `triedKeys`. Refresh failure retains the key already tried. +A verified check's `key` is the successful entry in `triedKeys`, and a verified +attempt's `signatures` references its successful checks directly. Attempts +can repeat a mechanism when additional portable proof policy is evaluated. +The subject includes its ID and an RFC 6901 JSON Pointer when known; the root +pointer is `""`. + +Cryptographic checks and authentication are separate. For example, valid +proofs can leave an actor attribution uncovered, or a valid HTTP Signature can +fail the actor ownership or nonce check. Such checks remain `verified` in a +rejected attempt or request. Linked Data Signature failure followed by HTTP +success retains both attempts. Consult `report.authentication` for the final +`verified`, `rejected`, `skipped`, or `notDetermined` decision. `skipped` means +processing reached the signature bypass; an earlier parse or preparation +failure leaves authentication `notDetermined`. + +Key snapshots have `URL | null` IDs and either `ownerId` for a +`cryptographicKey` or `controllerId` for a `multikey`. These URL objects are +independent of the vocabulary key objects. The declared key ID is a +`string | null`, preserving its spelling even when invalid. Keys and their +owner/controller claims remain untrusted until the final authentication +checks accept them. A readonly report does not freeze its `Activity`, +`CryptoKey`, or error objects. + +`report.outcome` records a response status and disposition (`processed`, +`enqueued`, `duplicate`, `unhandled`, `rejected`, `customResponse`, or +`failed`), or the original exception and its processing stage. An `enqueued` +result reports producer acceptance, not later worker success. A custom `202` +returned by `onUnverifiedActivity()` remains unauthenticated. The report +contains no live `Response` to consume or alter. + +Errors from the observer are logged and swallowed, preserving the delivery's +original response or exception. The callback does not make database writes +and queue acceptance atomic. Calling `onRequestFinished()` again replaces the +previous callback; a federation built from a builder retains the callback +registered when it was built. Reports and key objects are never serialized +into queue messages. + +The hook runs independently of [OpenTelemetry sampling](./opentelemetry.md). +Use it when your application needs every delivery's result or actual public +keys for later inspection. + + Handling unverified activities ------------------------------ diff --git a/docs/manual/opentelemetry.md b/docs/manual/opentelemetry.md index 44eb80f45..894bc9030 100644 --- a/docs/manual/opentelemetry.md +++ b/docs/manual/opentelemetry.md @@ -233,6 +233,7 @@ spans: | `ld_signatures.verify` | Internal | Verifies the Linked Data signature. | | `object_integrity_proofs.sign` | Internal | Makes the object integrity proof. | | `object_integrity_proofs.verify` | Internal | Verifies the object integrity proof. | +| `object_integrity_proofs.verify_object` | Internal | Verifies an object's proofs and attribution. | | `webfinger.handle` | Server | Handles the WebFinger request. | | `webfinger.lookup` | Client | Looks up the WebFinger resource. | @@ -546,6 +547,10 @@ Fedify records the following OpenTelemetry metrics: with no signature header are reported as `activitypub.signature.result=missing` and do not carry a `http_signatures.failure_reason`. + - `activitypub.verification.failure_reason` (Linked Data and Object + Integrity Proofs, on failure rows) is one of `invalidSignature`, + `keyFetchError`, `noSignature`, `signatureVerificationFailed`, + `uncoveredAttribution`, `missingOwner`, or `proofPolicy`. - `ld_signatures.type` (Linked Data only) is recorded only for the spec-supported `RsaSignature2017` type. - `object_integrity_proofs.cryptosuite` (Object Integrity Proofs @@ -557,6 +562,17 @@ Fedify records the following OpenTelemetry metrics: (`http_signatures.verify`, `ld_signatures.verify`, `object_integrity_proofs.verify`) for trace-level investigation. +The [inbox request observer](./inbox.md#observing-inbox-requests) exposes the +same verification evidence directly to application code, independently of +trace sampling. Actual public keys stay in the report and are not emitted as +telemetry attributes. Inbox spans record `activitypub.authentication.status` +and `activitypub.inbox.disposition`. A rejected final authentication decision +also sets the bounded `activitypub.verification.failure_reason` attribute. +Linked Data Signature and individual proof measurements use this common +failure attribute as well. A cryptographically valid proof still records +`verified` when a later attribution or portable proof policy rejects its +object; the rejection belongs to the object/inbox evaluation. + `activitypub.signature.key_fetch.duration` : `activitypub.signature.kind` is always present (same values as above). `activitypub.signature.key_fetch.result` is always present and is one @@ -964,82 +980,85 @@ for ActivityPub as of November 2024. However, Fedify provides a set of semantic [attributes] for ActivityPub. The following table shows the semantic attributes for ActivityPub: -| Attribute | Type | Description | Example | -| -------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `activitypub.activity.id` | string | The URI of the activity object. | `"https://example.com/activity/1"` | -| `activitypub.activity.type` | string[] | The qualified URI(s) of the activity type(s). | `["https://www.w3.org/ns/activitystreams#Create"]` | -| `activitypub.activity.to` | string[] | The URI(s) of the recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | -| `activitypub.activity.cc` | string[] | The URI(s) of the carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | -| `activitypub.activity.bto` | string[] | The URI(s) of the blind recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | -| `activitypub.activity.bcc` | string[] | The URI(s) of the blind carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | -| `activitypub.activity.retries` | int | The ordinal number of activity resending attempt (if and only if it's retried). | `3` | -| `activitypub.delivery.attempt` | int | The zero-based delivery attempt number for a queued outgoing activity. | `0` | -| `activitypub.delivery.permanent_failure` | boolean | Whether an outgoing delivery failure will be abandoned instead of retried. | `true` | -| `activitypub.circuit_breaker.previous_state` | string | Previous queued outbox circuit breaker state: `closed`, `open`, or `half_open`. | `"closed"` | -| `activitypub.circuit_breaker.state` | string | Current queued outbox circuit breaker state: `closed`, `open`, or `half_open`. | `"open"` | -| `activitypub.processing.result` | string | Lifecycle outcome of an inbox or outbox activity: `queued`, `processed`, `retried`, `rejected`, or `abandoned`. | `"retried"` | -| `activitypub.actor.discovery.result` | string | Terminal outcome of `getActorHandle()`: `resolved`, `not_found`, or `error`. | `"resolved"` | -| `activitypub.actor.id` | string | The URI of the actor object. | `"https://example.com/actor/1"` | -| `activitypub.actor.key.cached` | boolean | Whether the actor's public keys are cached. | `true` | -| `activitypub.actor.type` | string[] | The qualified URI(s) of the actor type(s). | `["https://www.w3.org/ns/activitystreams#Person"]` | -| `activitypub.key.id` | string | The URI of the cryptographic key being verified. | `"https://example.com/actor/1#main-key"` | -| `activitypub.key_ownership.method` | string | The method used to verify key ownership (`key_owner`, `actor_fetch`, or `none`). | `"actor_fetch"` | -| `activitypub.key_ownership.verified` | boolean | Whether the key ownership was successfully verified. | `true` | -| `activitypub.collection.id` | string | The URI of the collection object. | `"https://example.com/collection/1"` | -| `activitypub.collection.kind` | string | The bounded collection kind: `inbox`, `outbox`, `following`, `followers`, `liked`, `featured`, `featured_tags`, or `custom`. | `"followers"` | -| `activitypub.collection.page` | boolean | Whether the collection request targets a cursor page rather than the collection object. | `false` | -| `activitypub.collection.result` | string | Terminal collection request outcome: `served`, `not_found`, `not_acceptable`, `unauthorized`, or `error`. | `"served"` | -| `activitypub.collection.type` | string[] | The qualified URI(s) of the collection type(s). | `["https://www.w3.org/ns/activitystreams#OrderedCollection"]` | -| `activitypub.collection.total_items` | int | The total number of items in the collection. | `42` | -| `activitypub.object.id` | string | The URI of the object or the object enclosed by the activity. | `"https://example.com/object/1"` | -| `activitypub.object.type` | string[] | The qualified URI(s) of the object type(s). | `["https://www.w3.org/ns/activitystreams#Note"]` | -| `activitypub.object.in_reply_to` | string[] | The URI(s) of the original object to which the object reply. | `["https://example.com/object/1"]` | -| `activitypub.inboxes` | int | The number of inboxes the activity is sent to. | `12` | -| `activitypub.remote.host` | string | The host of the remote ActivityPub server, including any non-default port. | `"example.com:8443"` | -| `activitypub.shared_inbox` | boolean | Whether the activity is sent to the shared inbox. | `true` | -| `docloader.context_url` | string | The URL of the JSON-LD context document (if provided via Link header). | `"https://www.w3.org/ns/activitystreams"` | -| `docloader.document_url` | string | The final URL of the fetched document (after following redirects). | `"https://example.com/object/1"` | -| `fedify.actor.identifier` | string | The identifier of the actor. | `"1"` | -| `fedify.endpoint` | string | The bounded endpoint category that classified an inbound HTTP request handled by `Federation.fetch()`. | `"actor"` | -| `fedify.federation.instance_id` | string | Opaque per-Federation instance identifier used to distinguish queue depth series on a shared `MeterProvider`. | `"fedify-1"` | -| `fedify.route.template` | string | The matched URI Template, with parameter names (not values). | `"/users/{identifier}"` | -| `fedify.inbox.recipient` | string | The identifier of the inbox recipient. | `"1"` | -| `fedify.object.type` | string | The URI of the object type. | `"https://www.w3.org/ns/activitystreams#Note"` | -| `fedify.object.values.{parameter}` | string[] | The argument values of the object dispatcher. | `["1", "2"]` | -| `fedify.collection.dispatcher` | string | The collection dispatcher family: `built_in` or `custom`. | `"built_in"` | -| `fedify.collection.cursor` | string | The cursor of the collection. | `"eyJpZCI6IjEiLCJ0eXBlIjoiT3JkZXJlZENvbGxlY3Rpb24ifQ=="` | -| `fedify.collection.items` | number | The number of materialized items in the collection response or page. It can be less than the total items. | `10` | -| `fedify.queue.role` | string | The Fedify queue role: `inbox`, `outbox`, `fanout`, or `shared` for queue depth rows where one queue backs multiple roles. | `"outbox"` | -| `fedify.queue.backend` | string | The queue implementation's constructor name (best-effort backend identifier). | `"RedisMessageQueue"` | -| `fedify.queue.native_retrial` | boolean | Whether the queue backend declares `nativeRetrial`, meaning Fedify defers retry handling to the backend. | `true` | -| `fedify.queue.depth.state` | string | Queue depth count kind: `queued`, `ready`, or `delayed`. | `"queued"` | -| `fedify.queue.roles` | string | Comma-separated queue roles when one queue instance backs multiple roles. | `"fanout,inbox,outbox"` | -| `fedify.queue.task.attempt` | int | The zero-based attempt number recorded on `fedify.queue.task.enqueued`; non-zero for retry re-enqueues. | `1` | -| `fedify.queue.task.result` | string | The terminal outcome of queue task processing: `completed`, `failed`, or `aborted`. | `"failed"` | -| `fedify.task.name` | string | The name of a custom background task: always on the `fedify.task` span; on the task's `fedify.queue.task.*` run metrics only for a registered task (omitted for an `unknown_task` drop, keeping cardinality bounded). | `"sendDigest"` | -| `fedify.task.attempt` | int | The zero-based attempt number of a custom background task, on the `fedify.task` span. | `0` | -| `fedify.task.failure_reason` | string | Why a custom background task failed: `deserialization`, `validation`, `unknown_task`, `handler`, or `retry_enqueue`. Set only on a terminal failure. | `"validation"` | -| `http.redirect.url` | string | The redirect URL when a document fetch results in a redirect. | `"https://example.com/new-location"` | -| `http.response.status_code` | int | The HTTP response status code. | `200` | -| `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"` | -| `ld_signatures.type` | string | The algorithm of the Linked Data signature. | `"RsaSignature2017"` | -| `object_integrity_proofs.cryptosuite` | string | The cryptographic suite of the object integrity proof. | `"eddsa-jcs-2022"` | -| `object_integrity_proofs.key_id` | string | The public key ID of the object integrity proof. | `"https://example.com/actor/1#main-key"` | -| `object_integrity_proofs.signature` | string | The integrity proof of the object in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | -| `url.full` | string | The full URL being fetched by the document loader. | `"https://example.com/actor/1"` | -| `webfinger.handle.result` | string | Terminal outcome of an incoming WebFinger request: `resolved`, `invalid`, `not_found`, `tombstoned`, or `error`. | `"resolved"` | -| `webfinger.lookup.result` | string | Terminal outcome of an outgoing WebFinger lookup: `found`, `not_found`, `invalid`, `network_error`, or `error`. | `"found"` | -| `webfinger.resource` | string | The queried resource URI. | `"acct:fedify@hollo.social"` | -| `webfinger.resource.scheme` | string | The scheme of the queried resource URI. Metric attribute is bucketed to `acct`, `http`, `https`, `mailto`, or `other`. | `"acct"` | +| Attribute | Type | Description | Example | +| -------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `activitypub.activity.id` | string | The URI of the activity object. | `"https://example.com/activity/1"` | +| `activitypub.activity.type` | string[] | The qualified URI(s) of the activity type(s). | `["https://www.w3.org/ns/activitystreams#Create"]` | +| `activitypub.activity.to` | string[] | The URI(s) of the recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | +| `activitypub.activity.cc` | string[] | The URI(s) of the carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | +| `activitypub.activity.bto` | string[] | The URI(s) of the blind recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | +| `activitypub.activity.bcc` | string[] | The URI(s) of the blind carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | +| `activitypub.activity.retries` | int | The ordinal number of activity resending attempt (if and only if it's retried). | `3` | +| `activitypub.delivery.attempt` | int | The zero-based delivery attempt number for a queued outgoing activity. | `0` | +| `activitypub.delivery.permanent_failure` | boolean | Whether an outgoing delivery failure will be abandoned instead of retried. | `true` | +| `activitypub.circuit_breaker.previous_state` | string | Previous queued outbox circuit breaker state: `closed`, `open`, or `half_open`. | `"closed"` | +| `activitypub.circuit_breaker.state` | string | Current queued outbox circuit breaker state: `closed`, `open`, or `half_open`. | `"open"` | +| `activitypub.processing.result` | string | Lifecycle outcome of an inbox or outbox activity: `queued`, `processed`, `retried`, `rejected`, or `abandoned`. | `"retried"` | +| `activitypub.actor.discovery.result` | string | Terminal outcome of `getActorHandle()`: `resolved`, `not_found`, or `error`. | `"resolved"` | +| `activitypub.actor.id` | string | The URI of the actor object. | `"https://example.com/actor/1"` | +| `activitypub.actor.key.cached` | boolean | Whether the actor's public keys are cached. | `true` | +| `activitypub.actor.type` | string[] | The qualified URI(s) of the actor type(s). | `["https://www.w3.org/ns/activitystreams#Person"]` | +| `activitypub.key.id` | string | The URI of the cryptographic key being verified. | `"https://example.com/actor/1#main-key"` | +| `activitypub.key_ownership.method` | string | The method used to verify key ownership (`key_owner`, `actor_fetch`, or `none`). | `"actor_fetch"` | +| `activitypub.key_ownership.verified` | boolean | Whether the key ownership was successfully verified. | `true` | +| `activitypub.collection.id` | string | The URI of the collection object. | `"https://example.com/collection/1"` | +| `activitypub.collection.kind` | string | The bounded collection kind: `inbox`, `outbox`, `following`, `followers`, `liked`, `featured`, `featured_tags`, or `custom`. | `"followers"` | +| `activitypub.collection.page` | boolean | Whether the collection request targets a cursor page rather than the collection object. | `false` | +| `activitypub.collection.result` | string | Terminal collection request outcome: `served`, `not_found`, `not_acceptable`, `unauthorized`, or `error`. | `"served"` | +| `activitypub.collection.type` | string[] | The qualified URI(s) of the collection type(s). | `["https://www.w3.org/ns/activitystreams#OrderedCollection"]` | +| `activitypub.collection.total_items` | int | The total number of items in the collection. | `42` | +| `activitypub.object.id` | string | The URI of the object or the object enclosed by the activity. | `"https://example.com/object/1"` | +| `activitypub.object.type` | string[] | The qualified URI(s) of the object type(s). | `["https://www.w3.org/ns/activitystreams#Note"]` | +| `activitypub.object.in_reply_to` | string[] | The URI(s) of the original object to which the object reply. | `["https://example.com/object/1"]` | +| `activitypub.inboxes` | int | The number of inboxes the activity is sent to. | `12` | +| `activitypub.remote.host` | string | The host of the remote ActivityPub server, including any non-default port. | `"example.com:8443"` | +| `activitypub.shared_inbox` | boolean | Whether the activity is sent to the shared inbox. | `true` | +| `activitypub.authentication.status` | string | The final inbox authentication decision: `verified`, `rejected`, `skipped`, or `notDetermined`. | `"verified"` | +| `activitypub.inbox.disposition` | string | The inbox delivery outcome: `processed`, `enqueued`, `duplicate`, `unhandled`, `customResponse`, `rejected`, `failed`, or `exception`. | `"processed"` | +| `activitypub.verification.failure_reason` | string | The bounded failure reason. Signature/proof verification uses `invalidSignature`, `keyFetchError`, `noSignature`, `signatureVerificationFailed`, `uncoveredAttribution`, `missingOwner`, or `proofPolicy`. Rejected inbox authentication uses `verificationFailed`, `actorKeyMismatch`, `proofPolicy`, or `invalidNonce`. | `"invalidSignature"` | +| `docloader.context_url` | string | The URL of the JSON-LD context document (if provided via Link header). | `"https://www.w3.org/ns/activitystreams"` | +| `docloader.document_url` | string | The final URL of the fetched document (after following redirects). | `"https://example.com/object/1"` | +| `fedify.actor.identifier` | string | The identifier of the actor. | `"1"` | +| `fedify.endpoint` | string | The bounded endpoint category that classified an inbound HTTP request handled by `Federation.fetch()`. | `"actor"` | +| `fedify.federation.instance_id` | string | Opaque per-Federation instance identifier used to distinguish queue depth series on a shared `MeterProvider`. | `"fedify-1"` | +| `fedify.route.template` | string | The matched URI Template, with parameter names (not values). | `"/users/{identifier}"` | +| `fedify.inbox.recipient` | string | The identifier of the inbox recipient. | `"1"` | +| `fedify.object.type` | string | The URI of the object type. | `"https://www.w3.org/ns/activitystreams#Note"` | +| `fedify.object.values.{parameter}` | string[] | The argument values of the object dispatcher. | `["1", "2"]` | +| `fedify.collection.dispatcher` | string | The collection dispatcher family: `built_in` or `custom`. | `"built_in"` | +| `fedify.collection.cursor` | string | The cursor of the collection. | `"eyJpZCI6IjEiLCJ0eXBlIjoiT3JkZXJlZENvbGxlY3Rpb24ifQ=="` | +| `fedify.collection.items` | number | The number of materialized items in the collection response or page. It can be less than the total items. | `10` | +| `fedify.queue.role` | string | The Fedify queue role: `inbox`, `outbox`, `fanout`, or `shared` for queue depth rows where one queue backs multiple roles. | `"outbox"` | +| `fedify.queue.backend` | string | The queue implementation's constructor name (best-effort backend identifier). | `"RedisMessageQueue"` | +| `fedify.queue.native_retrial` | boolean | Whether the queue backend declares `nativeRetrial`, meaning Fedify defers retry handling to the backend. | `true` | +| `fedify.queue.depth.state` | string | Queue depth count kind: `queued`, `ready`, or `delayed`. | `"queued"` | +| `fedify.queue.roles` | string | Comma-separated queue roles when one queue instance backs multiple roles. | `"fanout,inbox,outbox"` | +| `fedify.queue.task.attempt` | int | The zero-based attempt number recorded on `fedify.queue.task.enqueued`; non-zero for retry re-enqueues. | `1` | +| `fedify.queue.task.result` | string | The terminal outcome of queue task processing: `completed`, `failed`, or `aborted`. | `"failed"` | +| `fedify.task.name` | string | The name of a custom background task: always on the `fedify.task` span; on the task's `fedify.queue.task.*` run metrics only for a registered task (omitted for an `unknown_task` drop, keeping cardinality bounded). | `"sendDigest"` | +| `fedify.task.attempt` | int | The zero-based attempt number of a custom background task, on the `fedify.task` span. | `0` | +| `fedify.task.failure_reason` | string | Why a custom background task failed: `deserialization`, `validation`, `unknown_task`, `handler`, or `retry_enqueue`. Set only on a terminal failure. | `"validation"` | +| `http.redirect.url` | string | The redirect URL when a document fetch results in a redirect. | `"https://example.com/new-location"` | +| `http.response.status_code` | int | The HTTP response status code. | `200` | +| `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"` | +| `ld_signatures.type` | string | The algorithm of the Linked Data signature. | `"RsaSignature2017"` | +| `object_integrity_proofs.cryptosuite` | string | The cryptographic suite of the object integrity proof. | `"eddsa-jcs-2022"` | +| `object_integrity_proofs.key_id` | string | The public key ID of the object integrity proof. | `"https://example.com/actor/1#main-key"` | +| `object_integrity_proofs.signature` | string | The integrity proof of the object in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | +| `url.full` | string | The full URL being fetched by the document loader. | `"https://example.com/actor/1"` | +| `webfinger.handle.result` | string | Terminal outcome of an incoming WebFinger request: `resolved`, `invalid`, `not_found`, `tombstoned`, or `error`. | `"resolved"` | +| `webfinger.lookup.result` | string | Terminal outcome of an outgoing WebFinger lookup: `found`, `not_found`, `invalid`, `network_error`, or `error`. | `"found"` | +| `webfinger.resource` | string | The queried resource URI. | `"acct:fedify@hollo.social"` | +| `webfinger.resource.scheme` | string | The scheme of the queried resource URI. Metric attribute is bucketed to `acct`, `http`, `https`, `mailto`, or `other`. | `"acct"` | [attributes]: https://opentelemetry.io/docs/specs/otel/common/#attribute [OpenTelemetry Semantic Conventions]: https://opentelemetry.io/docs/specs/semconv/ diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 0767c5dc8..0ee6670f5 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -1,3 +1,4 @@ +import type { InboxRequestFinishedHandler } from "./inbox-report.ts"; import { assertPath, type Path, @@ -178,6 +179,8 @@ export class FederationBuilderImpl >; inboxListeners?: ActivityListenerSet>; outboxListeners?: ActivityListenerSet>; + inboxRequestFinishedHandler?: InboxRequestFinishedHandler; + inboxErrorHandler?: InboxErrorHandler; outboxListenerErrorHandler?: OutboxListenerErrorHandler; outboxAuthorizePredicate?: AuthorizePredicate; @@ -281,6 +284,7 @@ export class FederationBuilderImpl : { ...this.featuredTagsCallbacks }; f.inboxListeners = this.inboxListeners?.clone(); f.outboxListeners = this.outboxListeners?.clone(); + f.inboxRequestFinishedHandler = this.inboxRequestFinishedHandler; f.inboxErrorHandler = this.inboxErrorHandler; f.outboxListenerErrorHandler = this.outboxListenerErrorHandler; f.outboxAuthorizePredicate = this.outboxAuthorizePredicate; @@ -1341,6 +1345,12 @@ export class FederationBuilderImpl this.inboxErrorHandler = handler; return setters; }, + onRequestFinished: ( + handler: InboxRequestFinishedHandler, + ): InboxListenerSetters => { + this.inboxRequestFinishedHandler = handler; + return setters; + }, onUnverifiedActivity: ( handler: UnverifiedActivityHandler, ): InboxListenerSetters => { diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 9064e6a56..2ce6e3bb3 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -1,3 +1,4 @@ +import type { InboxRequestFinishedHandler } from "./inbox-report.ts"; import type { Activity, Actor, @@ -1713,6 +1714,19 @@ export interface InboxListenerSetters { handler: InboxErrorHandler, ): InboxListenerSetters; + /** + * Observes each configured inbox delivery once, before fetch finishes. + * The callback is awaited and its errors do not alter delivery processing. + * Calling this again replaces the previous callback. Built federations + * retain the callback registered at build time. + * @param handler The request completion observer. + * @returns This setter for chaining. + * @since 2.4.0 + */ + onRequestFinished( + handler: InboxRequestFinishedHandler, + ): InboxListenerSetters; + /** * Registers a callback for incoming activities whose HTTP signatures could * not be verified. diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 47c44df3a..4d33cac6f 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1,3 +1,4 @@ +import type { InboxRequestReport } from "./inbox-report.ts"; import { createTestMeterProvider, createTestTracerProvider, @@ -1579,6 +1580,77 @@ test("handleCollection() records not_found collection metrics", async () => { ); }); +test("handleInbox() reports dispatch context errors before a failing error hook", async () => { + const kv = new MemoryKvStore(); + const federation = createFederation({ kv }); + const request = await signRequest( + new Request("https://example.com/inbox", { + method: "POST", + body: JSON.stringify( + await new Create({ + actor: rsaPublicKey3.ownerId, + }).toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }), + rsaPrivateKey3, + rsaPublicKey3.id!, + ); + const context = createRequestContext({ + federation, + request, + url: new URL(request.url), + data: undefined, + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + const error = new Error("Cannot create dispatch context"); + const reports: InboxRequestReport[] = []; + const listeners = new ActivityListenerSet>(); + let listenerCalled = false; + listeners.add(Create, () => { + listenerCalled = true; + }); + let errorHookCalled = false; + const response = await handleInbox(request, { + context, + recipient: null, + kv, + kvPrefixes: { + activityIdempotence: ["activity"], + publicKey: ["key"], + acceptSignatureNonce: ["nonce"], + }, + actorDispatcher: () => new Person({}), + inboxListeners: listeners, + inboxContextFactory: () => { + throw error; + }, + inboxErrorHandler: (_ctx, value) => { + assertEquals(value, error); + errorHookCalled = true; + throw new Error("Error hook failed"); + }, + inboxRequestFinishedHandler: (_ctx, report) => { + reports.push(report); + }, + onNotFound: () => new Response(null, { status: 404 }), + signatureTimeWindow: false, + skipSignatureVerification: false, + }); + assertEquals(response.status, 500); + assertEquals(listenerCalled, false); + assertEquals(errorHookCalled, true); + assertEquals(reports.length, 1); + assertEquals(reports[0].authentication.status, "verified"); + assertEquals(reports[0].outcome, { + type: "response", + status: 500, + disposition: "failed", + reason: "listenerError", + error, + }); +}); + test("handleInbox()", async () => { const activity = new Create({ id: new URL("https://example.com/activities/1"), @@ -5604,7 +5676,11 @@ test("handleInbox() nonce replay prevention", async () => { if (identifier !== "someone") return null; return new Person({ name: "Someone" }); }; + let report: InboxRequestReport | undefined; const response = await handleInbox(signedRequest, { + inboxRequestFinishedHandler: (_ctx, value) => { + report = value; + }, recipient: "someone", context, inboxContextFactory(_activity) { @@ -5632,6 +5708,12 @@ test("handleInbox() nonce replay prevention", async () => { }, }); assertEquals(response.status, 401); + assert(report != null); + assertEquals(report.authentication, { + status: "rejected", + reason: { type: "invalidNonce" }, + }); + assertEquals(report.attempts.at(-1)?.status, "verified"); // 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"); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index e94d1ee02..fa93aace4 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -1,3 +1,10 @@ +import { InboxObservation } from "./inbox-observation.ts"; +import type { InboxRequestFinishedHandler } from "./inbox-report.ts"; +import { + observeAttempt, + snapshotKey, + verificationObservation, +} from "../sig/verification.ts"; import type { AcceptSignatureParameters } from "@fedify/fedify/sig"; import type { Recipient } from "@fedify/vocab"; import { @@ -1698,6 +1705,10 @@ export async function handleMediaUpload( * @template TContextData The context data to pass to the context. */ export interface InboxHandlerParameters { + /** Internal ingress-owned observation. */ + observation?: InboxObservation; + /** Observer for direct handleInbox callers. */ + inboxRequestFinishedHandler?: InboxRequestFinishedHandler; recipient: string | null; context: RequestContext; inboxContextFactory( @@ -1781,6 +1792,21 @@ export async function handleInbox( request: Request, options: InboxHandlerParameters, ): Promise { + if (options.observation == null) { + const observation = new InboxObservation({ + kind: options.portableInbox != null + ? "portable" + : options.recipient == null + ? "shared" + : "personal", + recipient: options.recipient, + }); + return await observation.runAndFinish( + () => options.context, + options.inboxRequestFinishedHandler, + () => handleInbox(request, { ...options, observation }), + ); + } const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(metadata.name, metadata.version); return await tracer.startActiveSpan( @@ -1796,9 +1822,11 @@ export async function handleInbox( try { return await handleInboxInternal(request, options, span); } catch (e) { + options.observation!.hasException = true; span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { + options.observation!.project(span); span.end(); } }, @@ -1839,6 +1867,10 @@ async function handleInboxInternal( tracerProvider, portableInbox, } = parameters; + const observation = parameters.observation!; + const signatureObservation = { + [verificationObservation]: observation.verification, + }; const logger = getLogger(["fedify", "federation", "inbox"]); if (actorDispatcher == null) { logger.error("Actor dispatcher is not set.", { recipient }); @@ -1860,6 +1892,7 @@ async function handleInboxInternal( } } if (request.bodyUsed) { + observation.result = { disposition: "failed", reason: "bodyUnavailable" }; logger.error("Request body has already been read.", { recipient }); span.setStatus({ code: SpanStatusCode.ERROR, @@ -1870,6 +1903,7 @@ async function handleInboxInternal( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } else if (request.body?.locked) { + observation.result = { disposition: "failed", reason: "bodyUnavailable" }; logger.error("Request body is locked.", { recipient }); span.setStatus({ code: SpanStatusCode.ERROR, @@ -1880,6 +1914,8 @@ async function handleInboxInternal( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } + observation.stage = "parse"; + observation.result = { disposition: "rejected", reason: "invalidJson" }; let json: unknown; try { json = JSON.parse( @@ -1891,6 +1927,7 @@ async function handleInboxInternal( ); } catch (error) { if (error instanceof BodyTooLargeError) { + observation.result = { disposition: "rejected", reason: "bodyTooLarge" }; void request.body?.cancel(error).catch(() => {}); span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); return new Response("Inbox body too large.", { status: 413 }); @@ -1913,6 +1950,12 @@ async function handleInboxInternal( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } + observation.payload = { + status: "parsed", + value: structuredClone(json) as import("./inbox-report.ts").InboxJsonValue, + }; + observation.result = { disposition: "rejected", reason: "invalidActivity" }; + observation.stage = "verify"; const keyCache = new KvKeyCache(kv, kvPrefixes.publicKey, { documentLoader: ctx.documentLoader, contextLoader: ctx.contextLoader, @@ -1944,6 +1987,7 @@ async function handleInboxInternal( code: SpanStatusCode.ERROR, message: `Failed to parse activity:\n${error}`, }); + observation.result = { disposition: "rejected", reason: "invalidActivity" }; return new Response("Invalid activity.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, @@ -1958,11 +2002,22 @@ async function handleInboxInternal( } catch (error) { if (isInvalidJsonLdError(error)) { logger.error("Failed to parse JSON-LD:\n{error}", { recipient, error }); + observation.result = { + disposition: "rejected", + reason: "invalidJsonLd", + }; return new Response("Invalid JSON-LD.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } + observation.verification.attempts.push({ + mechanism: "linkedData", + subject: { id: null, pointer: "" }, + checks: [], + status: "error", + error, + }); if (!canAttemptAlternateAuthAfterLdSignatureFailure) throw error; // The presence of a proof block or HTTP signature headers is not enough // to discard a transient LDS normalization failure. Keep that error @@ -1981,6 +2036,7 @@ async function handleInboxInternal( compactedJsonWithoutSig = detachSignature(compactedJson); try { ldSigVerified = await verifyCompactJsonLd(compactedJson, { + ...signatureObservation, contextLoader: ctx.contextLoader, documentLoader: ctx.documentLoader, keyCache, @@ -2002,6 +2058,10 @@ async function handleInboxInternal( recipient, error, }); + observation.result = { + disposition: "rejected", + reason: "invalidJsonLd", + }; return new Response("Invalid JSON-LD.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, @@ -2030,6 +2090,10 @@ async function handleInboxInternal( recipient, error: parseError, }); + observation.result = { + disposition: "rejected", + reason: "invalidJsonLd", + }; return new Response("Invalid JSON-LD.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, @@ -2056,10 +2120,13 @@ async function handleInboxInternal( if (ldSigVerified) { logger.debug("Linked Data Signatures are verified.", { recipient, json }); try { - activity = await Activity.fromJsonLd(compactedJsonWithoutSig, { - ...ctx, - contextLoader: getNormalizationContextLoader(ctx.contextLoader), - }); + activity = observation.activity = await Activity.fromJsonLd( + compactedJsonWithoutSig, + { + ...ctx, + contextLoader: getNormalizationContextLoader(ctx.contextLoader), + }, + ); } catch (error) { if ( error instanceof RangeError && @@ -2080,6 +2147,7 @@ async function handleInboxInternal( // made by its DID does: try { proofVerified = await verifyObject(Activity, jsonWithoutSig, { + ...signatureObservation, contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), documentLoader: ctx.documentLoader, keyCache, @@ -2102,6 +2170,7 @@ async function handleInboxInternal( ); try { activity = await verifyObject(Activity, jsonWithoutSig, { + ...signatureObservation, contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), documentLoader: ctx.documentLoader, keyCache, @@ -2148,6 +2217,10 @@ async function handleInboxInternal( code: SpanStatusCode.ERROR, message: `Failed to parse activity:\n${error}`, }); + observation.result = { + disposition: "rejected", + reason: "invalidActivity", + }; return new Response("Invalid activity.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, @@ -2174,6 +2247,7 @@ async function handleInboxInternal( if (activity == null) { if (!skipSignatureVerification) { const verification = await verifyRequestDetailed(request, { + ...signatureObservation, contextLoader: ctx.contextLoader, documentLoader: ctx.documentLoader, timeWindow: signatureTimeWindow, @@ -2184,6 +2258,14 @@ async function handleInboxInternal( }); if (verification.verified === false) { if (deferredLdSignatureError != null) throw deferredLdSignatureError; + observation.authentication = { + status: "rejected", + reason: { type: "verificationFailed" }, + }; + observation.result = { + disposition: "rejected", + reason: "authentication", + }; const reason = verification.reason; const remoteHost = "keyId" in reason && reason.keyId != null ? getRemoteHost(reason.keyId) @@ -2210,7 +2292,10 @@ async function handleInboxInternal( ); } try { - activity = await Activity.fromJsonLd(jsonWithoutSig, ctx); + activity = observation.activity = await Activity.fromJsonLd( + jsonWithoutSig, + ctx, + ); } catch (error) { logger.error("Failed to parse activity:\n{error}", { recipient, @@ -2225,6 +2310,10 @@ async function handleInboxInternal( { error, activity: json, recipient }, ); } + observation.result = { + disposition: "rejected", + reason: "invalidActivity", + }; return new Response("Invalid activity.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, @@ -2267,6 +2356,11 @@ async function handleInboxInternal( reason, ); } catch (error) { + observation.result = { + disposition: "failed", + reason: "listenerError", + error, + }; logger.error( "An unexpected error occurred in unverified activity handler:\n" + "{error}", @@ -2286,7 +2380,10 @@ async function handleInboxInternal( kvPrefixes, ); } - if (response instanceof Response) return response; + if (response instanceof Response) { + observation.result = { disposition: "customResponse" }; + return response; + } return await getFailedSignatureResponse( inboxChallengePolicy, kv, @@ -2306,15 +2403,21 @@ async function handleInboxInternal( httpSigKey = verification.key; } try { - activity = await Activity.fromJsonLd(jsonWithoutSig, { - ...ctx, - contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), - }); + activity = observation.activity = await Activity.fromJsonLd( + jsonWithoutSig, + { + ...ctx, + contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), + }, + ); } catch (error) { if (!isPermanentActivityParseError(error)) throw error; return await respondInvalidActivity(error); } } + observation.activity = activity; + observation.stage = "policy"; + observation.result = { disposition: "rejected", reason: "authentication" }; if (activity.id != null) { span.setAttribute("activitypub.activity.id", activity.id.href); } @@ -2340,6 +2443,10 @@ async function handleInboxInternal( // This comes before the key ownership check, which cannot change the // outcome, so that such a request costs no further fetches: if (deferredLdSignatureError != null) throw deferredLdSignatureError; + observation.authentication = { + status: "rejected", + reason: { type: "proofPolicy", policy: "portableActor" }, + }; logger.error( "The activity {activityId} of the portable actor {actorId} is not " + "authenticated by a valid Object Integrity Proof.", @@ -2374,19 +2481,42 @@ async function handleInboxInternal( // ID. The ID of a portable activity, including a compatible identifier, // has to belong to the DID that signed it, which the proof policy checks // on the same document the proofs were verified for: - const rejection = await verifyPortableActivityId( - activity.id, - jsonWithoutSig, - { - contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), - documentLoader: ctx.documentLoader, - keyCache, - meterProvider, - tracerProvider, + const rejection = await observeAttempt( + signatureObservation, + "objectIntegrity", + async (verification) => { + const rejection = await verifyPortableActivityId( + activity.id!, + jsonWithoutSig, + { + [verificationObservation]: verification, + contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), + documentLoader: ctx.documentLoader, + keyCache, + meterProvider, + tracerProvider, + }, + ); + if (rejection != null) { + verification.attempt!.reason = { + type: "proofPolicy", + reason: rejection, + }; + } + return rejection; }, + (value) => value == null, ); if (rejection != null) { if (deferredLdSignatureError != null) throw deferredLdSignatureError; + observation.authentication = { + status: "rejected", + reason: { + type: "proofPolicy", + policy: "portableActivity", + detail: rejection, + }, + }; logger.error( "The portable activity {activityId} is not authenticated by an " + "Object Integrity Proof of its own DID: {reason}", @@ -2394,7 +2524,7 @@ async function handleInboxInternal( activity: json, recipient, activityId: activity.id.href, - reason: rejection, + reason: rejection.type, }, ); span.setStatus({ @@ -2416,6 +2546,14 @@ async function handleInboxInternal( httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx) ) { if (deferredLdSignatureError != null) throw deferredLdSignatureError; + observation.authentication = { + status: "rejected", + reason: { + type: "actorKeyMismatch", + key: snapshotKey(httpSigKey), + actorIds: activity.actorIds.map((id) => new URL(id.href)), + }, + }; getFederationMetrics(parameters.meterProvider) .recordSignatureVerificationFailure( "actorKeyMismatch", @@ -2451,17 +2589,46 @@ async function handleInboxInternal( INBOX_COMPOUND_PROOF_LIMITS, ); if (compoundApplicability !== "absent") { - const compoundProof = await verifyCompoundPortableObjectProofs( - compoundJson, - INBOX_COMPOUND_PROOF_LIMITS, - { - documentLoader: ctx.documentLoader, - keyCache, - meterProvider, - tracerProvider, + const compoundProof = await observeAttempt( + signatureObservation, + "objectIntegrity", + async (verification) => { + const result = await verifyCompoundPortableObjectProofs( + compoundJson, + INBOX_COMPOUND_PROOF_LIMITS, + { + [verificationObservation]: verification, + documentLoader: ctx.documentLoader, + keyCache, + meterProvider, + tracerProvider, + }, + ); + if (result.status === "unsupported") { + verification.attempt!.reason = { + type: "proofPolicy", + reason: result.reason, + }; + } else if (!result.verified) { + const failed = result.portableObjects.find((object) => + !object.verified + ); + verification.attempt!.reason = { + type: "proofPolicy", + reason: failed?.verified === false + ? failed.reason + : { type: "invalidProof" }, + }; + } + return result; }, + (result) => result.status === "ok" && result.verified, ); if (compoundProof.status !== "ok" || !compoundProof.verified) { + observation.authentication = { + status: "rejected", + reason: { type: "proofPolicy", policy: "compound" }, + }; logger.error( "Failed to verify compound portable Object Integrity Proofs.", { @@ -2496,6 +2663,10 @@ async function handleInboxInternal( pendingNonceLabel, ); if (!nonceValid) { + observation.authentication = { + status: "rejected", + reason: { type: "invalidNonce" }, + }; getFederationMetrics(parameters.meterProvider) .recordSignatureVerificationFailure( "invalidNonce", @@ -2512,6 +2683,17 @@ async function handleInboxInternal( ); } } + observation.authentication = skipSignatureVerification + ? { status: "skipped" } + : { + status: "verified", + attempts: observation.verification.attempts.filter(( + attempt, + ): attempt is Extract => + attempt.status === "verified" + ), + }; + observation.stage = "dispatch"; const routeResult = await routeActivity({ context: ctx, // Direct handleInbox() consumers may later forward the payload from the @@ -2544,7 +2726,14 @@ async function handleInboxInternal( [rawInboxContextFactorySymbol]?: typeof inboxContextFactory; })[rawInboxContextFactorySymbol] : undefined, - inboxErrorHandler, + inboxErrorHandler: async (context, error) => { + observation.result = { + disposition: "failed", + reason: "listenerError", + error, + }; + await inboxErrorHandler?.(context, error); + }, kv, kvPrefixes, queue, @@ -2618,6 +2807,7 @@ async function handleInboxInternal( } } if (routeResult === "alreadyProcessed") { + observation.result = { disposition: "duplicate" }; return new Response( `Activity <${activity.id}> has already been processed.`, { @@ -2626,16 +2816,19 @@ async function handleInboxInternal( }, ); } else if (routeResult === "missingActor") { + observation.result = { disposition: "rejected", reason: "missingActor" }; return new Response("Missing actor.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } else if (routeResult === "enqueued") { + observation.result = { disposition: "enqueued" }; return new Response("Activity is enqueued.", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } else if (routeResult === "unsupportedActivity") { + observation.result = { disposition: "unhandled" }; return new Response("", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" }, @@ -2646,6 +2839,7 @@ async function handleInboxInternal( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } else { + observation.result = { disposition: "processed" }; return new Response("", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" }, @@ -3811,9 +4005,11 @@ async function verifyPortableActivityId( activityId: URL, jsonLd: unknown, options: Parameters[1], -): Promise { +): Promise< + import("../sig/verification.ts").InboxProofPolicyFailureReason | null +> { const expectedId = getCanonicalPortableId(activityId); - if (expectedId == null) return "malformed portable ID"; + if (expectedId == null) return { type: "invalidObjectId" }; let verification: Awaited< ReturnType >; @@ -3821,20 +4017,20 @@ async function verifyPortableActivityId( verification = await verifyPortableObjectProofWithRoot(jsonLd, options); } catch (error) { if (error instanceof InvalidPortableObjectIdError) { - return "malformed portable ID"; + return { type: "invalidObjectId" }; } if (!isPermanentActivityParseError(error)) throw error; - return "malformed document"; + return { type: "invalidDocument" }; } const { result, root } = verification; - if (!result.verified) return result.reason.type; + if (!result.verified) return result.reason; // The proof policy has already validated the ID of a verified document: const rootId = root?.["@id"]; if ( typeof rootId !== "string" || getCanonicalPortableId(parseIri(rootId)) !== expectedId ) { - return "the verified document has another ID"; + return { type: "subjectMismatch" }; } return null; } diff --git a/packages/fedify/src/federation/inbox-observation.ts b/packages/fedify/src/federation/inbox-observation.ts new file mode 100644 index 000000000..576c1fe6c --- /dev/null +++ b/packages/fedify/src/federation/inbox-observation.ts @@ -0,0 +1,146 @@ +import { Activity } from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import type { Span } from "@opentelemetry/api"; +import type { RequestContext } from "./context.ts"; +import type { + InboxAuthentication, + InboxRequestFinishedHandler, + InboxRequestOutcome, + InboxRequestReport, +} from "./inbox-report.ts"; +import type { VerificationObservation } from "../sig/verification.ts"; + +/** Internal state owned by the ingress boundary, never serialized into queues. */ +export class InboxObservation { + payload: InboxRequestReport["payload"] = { status: "unavailable" }; + activity: Activity | null = null; + authentication: InboxAuthentication = { status: "notDetermined" }; + hasException = false; + stage: Extract["stage"] = + "prepare"; + result: + | Omit< + Extract, + "type" | "status" + > + | Omit< + Extract, + "type" | "status" + > + | { + disposition: + | "processed" + | "enqueued" + | "duplicate" + | "unhandled" + | "customResponse"; + } = { disposition: "rejected", reason: "recipientNotFound" }; + readonly verification: VerificationObservation = { + attempts: [], + subject: { id: null, pointer: "" }, + parsedObject: (object) => { + if (object instanceof Activity) { + this.activity = object; + this.verification.subject!.id = object.id == null + ? null + : new URL(object.id.href); + } + }, + }; + constructor(readonly inbox: InboxRequestReport["inbox"]) {} + + /** Run an ingress operation and project telemetry; fetch owns completion. */ + async run( + operation: () => Promise, + span?: Span, + ): Promise { + let response: Response | undefined; + let outcome: InboxRequestOutcome; + let threw = false; + let originalError: unknown; + try { + response = await operation(); + outcome = { type: "response", status: response.status, ...this.result }; + } catch (error) { + threw = true; + this.hasException = true; + originalError = error; + outcome = { type: "exception", stage: this.stage, error }; + } + if (span != null) this.project(span, outcome); + if (threw) throw originalError; + return response!; + } + + /** Complete standalone handler calls that have no enclosing fetch boundary. */ + async runAndFinish( + getContext: () => RequestContext, + handler: InboxRequestFinishedHandler | undefined, + operation: () => Promise, + ): Promise { + let response: Response; + try { + response = await this.run(operation); + } catch (error) { + await this.finish(getContext(), handler, { + type: "exception", + stage: this.stage, + error, + }); + throw error; + } + await this.finish(getContext(), handler, { + type: "response", + status: response.status, + ...this.result, + }); + return response; + } + + project(span: Span, outcome?: InboxRequestOutcome): void { + span.setAttribute( + "activitypub.authentication.status", + this.authentication.status, + ); + if (this.authentication.status === "rejected") { + span.setAttribute( + "activitypub.verification.failure_reason", + this.authentication.reason.type, + ); + } + span.setAttribute( + "activitypub.inbox.disposition", + outcome?.type === "exception" || this.hasException + ? "exception" + : this.result.disposition, + ); + } + + async finish( + context: RequestContext, + handler: InboxRequestFinishedHandler | undefined, + outcome: InboxRequestOutcome, + ): Promise { + if (handler == null) return; + const report: InboxRequestReport = { + inbox: this.inbox, + payload: this.payload, + activity: this.activity, + attempts: this.verification.attempts, + authentication: this.authentication, + outcome, + }; + try { + await handler(context, report); + } catch (error) { + try { + getLogger(["fedify", "federation", "inbox"]).error( + "An unexpected error occurred in inbox request finished handler:\n{error}", + { error }, + ); + } catch { + /* A logging sink must not replace the delivery result either. */ + } + } + } +} diff --git a/packages/fedify/src/federation/inbox-report.test.ts b/packages/fedify/src/federation/inbox-report.test.ts new file mode 100644 index 000000000..3dc3ac639 --- /dev/null +++ b/packages/fedify/src/federation/inbox-report.test.ts @@ -0,0 +1,631 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { Create, Person } from "@fedify/vocab"; +import { exportDidKey, parseIri } from "@fedify/vocab-runtime"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; +import { createFederationBuilder } from "./builder.ts"; +import type { InboxRequestReport } from "./inbox-report.ts"; +import { MemoryKvStore } from "./kv.ts"; +import { createFederation } from "./middleware.ts"; +import { signRequest } from "../sig/http.ts"; +import { signJsonLd } from "../sig/ld.ts"; +import { signObject } from "../sig/proof.ts"; +import { + ed25519Multikey, + ed25519PrivateKey, + rsaPrivateKey2, + rsaPrivateKey3, + rsaPublicKey2, + rsaPublicKey3, +} from "../testing/keys.ts"; + +const loaders = { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, +}; +function setup(skipSignatureVerification = false) { + const reports: InboxRequestReport[] = []; + const federation = createFederation({ + kv: new MemoryKvStore(), + skipSignatureVerification, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + }); + federation.setActorDispatcher( + "/users/{identifier}", + (_ctx, id) => id === "missing" ? null : new Person({}), + ).setKeyPairsDispatcher(() => []); + const setters = federation.setInboxListeners( + "/users/{identifier}/inbox", + "/inbox", + ).onRequestFinished((_ctx, report) => { + reports.push(report); + }); + return { federation, setters, reports }; +} +async function payload( + id = "https://example.com/create", + actor = "https://example.com/person2", +) { + return await new Create({ id: new URL(id), actor: new URL(actor) }).toJsonLd( + loaders, + ); +} +function request(body: unknown, path = "/inbox") { + return new Request(`https://example.com${path}`, { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/activity+json" }, + }); +} + +test("onRequestFinished classifies early rejection, JSON null, and excluded routes", async () => { + const { federation, reports } = setup(true); + strictEqual( + (await federation.fetch(request({}, "/users/missing/inbox"), { + contextData: undefined, + })).status, + 404, + ); + deepStrictEqual(reports[0].inbox, { kind: "personal", recipient: "missing" }); + deepStrictEqual(reports[0].payload, { status: "unavailable" }); + deepStrictEqual(reports[0].authentication, { status: "notDetermined" }); + deepStrictEqual(reports[0].outcome, { + type: "response", + status: 404, + disposition: "rejected", + reason: "recipientNotFound", + }); + strictEqual( + (await federation.fetch(request(null), { contextData: undefined })).status, + 400, + ); + deepStrictEqual(reports[1].payload, { status: "parsed", value: null }); + deepStrictEqual(reports[1].authentication, { status: "notDetermined" }); + await federation.fetch(new Request("https://example.com/users/alice/inbox"), { + contextData: undefined, + }); + await federation.fetch(request({}, "/unmatched"), { contextData: undefined }); + strictEqual(reports.length, 2); + await federation.fetch( + new Request("https://example.com/inbox", { method: "POST", body: "{" }), + { contextData: undefined }, + ); + deepStrictEqual(reports[2].outcome, { + type: "response", + status: 400, + disposition: "rejected", + reason: "invalidJson", + }); +}); + +test("onRequestFinished leaves deferred policy errors undetermined", async () => { + const did = await exportDidKey(ed25519Multikey.publicKey!); + const keyId = parseIri(`${did}#${did.substring("did:key:".length)}`); + const otherPair = await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ]) as CryptoKeyPair; + const otherDid = await exportDidKey(otherPair.publicKey); + for (const withProof of [false, true]) { + const contextUrl = `https://example.com/context/deferred-${withProof}`; + const remote = { + contextUrl: null, + documentUrl: contextUrl, + document: { "@context": { ext: "https://example.com/ext" } }, + }; + const contextLoader = (url: string) => + url === contextUrl ? Promise.resolve(remote) : mockDocumentLoader(url); + const activity = new Create({ + id: parseIri( + `ap+ef61://${ + withProof ? otherDid : did + }/activities/deferred-${withProof}`, + ), + actor: parseIri(`ap+ef61://${did}/users/bob`), + }); + const context = [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + contextUrl, + ]; + const body = withProof + ? await (await signObject(activity, ed25519PrivateKey, keyId, { + context, + contextLoader, + })).toJsonLd({ format: "compact", context, contextLoader }) + : await activity.toJsonLd({ format: "compact", context, contextLoader }); + (body as Record).signature = { + type: "RsaSignature2017", + creator: rsaPublicKey3.id!.href, + created: "2024-01-01T00:00:00Z", + signatureValue: "bogus", + }; + let failOnce = true; + const reports: InboxRequestReport[] = []; + const federation = createFederation({ + kv: new MemoryKvStore(), + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => async (url) => { + if (url === contextUrl && failOnce) { + failOnce = false; + throw new Error("Temporary context outage"); + } + return await contextLoader(url); + }, + }); + federation.setActorDispatcher("/users/{identifier}", () => new Person({})) + .setKeyPairsDispatcher(() => []); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Create, () => {}) + .onRequestFinished((_ctx, report) => { + reports.push(report); + }); + const signed = await signRequest( + request(body), + rsaPrivateKey3, + rsaPublicKey3.id!, + ); + await rejects( + federation.fetch(signed, { contextData: undefined }), + (error) => { + strictEqual(reports.length, 1); + deepStrictEqual(reports[0].authentication, { status: "notDetermined" }); + ok(reports[0].outcome.type === "exception"); + strictEqual(reports[0].outcome.stage, "policy"); + strictEqual(reports[0].outcome.error, error); + return true; + }, + ); + strictEqual( + reports[0].attempts.some((attempt) => + attempt.mechanism === "objectIntegrity" && attempt.status === "verified" + ), + withProof, + ); + } +}); + +test("onRequestFinished awaits once and isolates observer failures", async () => { + const { federation, setters } = setup(true); + setters.on(Create, () => {}); + let calls = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + setters.onRequestFinished(async (_ctx, report) => { + calls++; + deepStrictEqual(report.authentication, { status: "skipped" }); + strictEqual( + report.outcome.type === "response" && report.outcome.disposition, + "processed", + ); + entered(); + await gate; + throw new Error("observer failed"); + }); + let finished = false; + const response = federation.fetch(request(await payload()), { + contextData: undefined, + }).then((response) => { + finished = true; + return response; + }); + await started; + strictEqual(finished, false); + release(); + strictEqual((await response).status, 202); + strictEqual(calls, 1); +}); + +test("onRequestFinished preserves preparation exceptions and callback replacement", async () => { + const { federation, setters, reports } = setup(); + const error = new Error("preparation failed"); + setters.setSharedKeyDispatcher(() => { + throw error; + }); + setters.onRequestFinished((_ctx, report) => { + reports.push(report); + throw new Error("secondary failure"); + }); + await rejects( + federation.fetch(request(await payload()), { contextData: undefined }), + (value) => value === error, + ); + strictEqual(reports.length, 1); + deepStrictEqual(reports[0].outcome, { + type: "exception", + stage: "prepare", + error, + }); + deepStrictEqual(reports[0].authentication, { status: "notDetermined" }); +}); + +test("onRequestFinished distinguishes custom responses from authentication", async () => { + const { federation, setters, reports } = setup(); + setters.onUnverifiedActivity(() => new Response(null, { status: 202 })); + const body = await payload(); + strictEqual( + (await federation.fetch(request(body), { contextData: undefined })).status, + 202, + ); + deepStrictEqual(reports[0].authentication, { + status: "rejected", + reason: { type: "verificationFailed" }, + }); + deepStrictEqual(reports[0].outcome, { + type: "response", + status: 202, + disposition: "customResponse", + }); + strictEqual(reports[0].attempts.at(-1)?.mechanism, "http"); + setters.onUnverifiedActivity(() => {}); + strictEqual( + (await federation.fetch(request(body), { contextData: undefined })).status, + 401, + ); + strictEqual( + reports[1].outcome.type === "response" && reports[1].outcome.disposition, + "rejected", + ); +}); + +test("onRequestFinished retains HTTP keys and separates ownership rejection", async () => { + const { federation, setters, reports } = setup(); + setters.on(Create, () => {}); + const body = await payload(); + const signed = await signRequest( + request(body), + rsaPrivateKey3, + new URL("https://example.com/person2#key3"), + ); + strictEqual( + (await federation.fetch(signed, { contextData: undefined })).status, + 202, + ); + const report = reports[0]; + strictEqual(report.authentication.status, "verified"); + const attempt = report.attempts.find((attempt) => + attempt.mechanism === "http" + ); + ok(attempt?.status === "verified"); + strictEqual(attempt.subject.id?.href, "https://example.com/create"); + const check = attempt.checks[0]; + ok(check.status === "verified" && check.key.type === "cryptographicKey"); + strictEqual(check.key.publicKey.algorithm.name, "RSASSA-PKCS1-v1_5"); + strictEqual(check.key.ownerId?.href, "https://example.com/person2"); + strictEqual(check.key, check.triedKeys.at(-1)); + strictEqual(attempt.signatures[0], check); + strictEqual(report.authentication.attempts[0], attempt); + // Same request ID produces a duplicate without losing authentication. + await federation.fetch(signed, { contextData: undefined }); + deepStrictEqual(reports[1].outcome, { + type: "response", + status: 202, + disposition: "duplicate", + }); + const mismatched = await signRequest( + request( + await payload("https://example.com/other", "https://example.com/person"), + ), + rsaPrivateKey3, + new URL("https://example.com/person2#key3"), + ); + strictEqual( + (await federation.fetch(mismatched, { contextData: undefined })).status, + 401, + ); + strictEqual(reports[2].authentication.status, "rejected"); + ok(reports[2].authentication.status === "rejected"); + strictEqual(reports[2].authentication.reason.type, "actorKeyMismatch"); + strictEqual(reports[2].attempts.at(-1)?.status, "verified"); +}); + +test("onRequestFinished retains LDS crypto success before HTTP fallback", async () => { + const { federation, reports } = setup(); + const body = await signJsonLd( + await payload("https://example.com/lds", "https://example.com/person"), + rsaPrivateKey3, + new URL("https://example.com/person2#key3"), + loaders, + ); + const signed = await signRequest( + request(body), + rsaPrivateKey2, + new URL("https://example.com/key2"), + ); + strictEqual( + (await federation.fetch(signed, { contextData: undefined })).status, + 202, + ); + const ld = reports[0].attempts.find((attempt) => + attempt.mechanism === "linkedData" + ); + ok(ld?.status === "rejected"); + strictEqual(ld.reason.type, "uncoveredAttribution"); + strictEqual(ld.checks[0].status, "verified"); + strictEqual(reports[0].authentication.status, "verified"); +}); + +test("onRequestFinished keeps valid OIP checks when attributions are uncovered", async () => { + const { federation, reports } = setup(); + const activity = new Create({ + id: new URL("https://example.com/proof"), + actor: new URL("https://example.com/person"), + }); + const signed = await signObject( + activity, + ed25519PrivateKey, + ed25519Multikey.id!, + loaders, + ); + strictEqual( + (await federation.fetch(request(await signed.toJsonLd(loaders)), { + contextData: undefined, + })).status, + 401, + ); + const proof = reports[0].attempts.find((attempt) => + attempt.mechanism === "objectIntegrity" + ); + ok(proof?.status === "rejected"); + strictEqual(proof.reason.type, "uncoveredAttribution"); + const check = proof.checks[0]; + ok(check.status === "verified" && check.key.type === "multikey"); + strictEqual(check.key.controllerId?.href, "https://example.com/person2"); + strictEqual(reports[0].activity?.id?.href, activity.id?.href); +}); + +test("onRequestFinished reports listener failures with original values", async () => { + const { federation, setters, reports } = setup(true); + const error = new Error("listener failed"); + setters.on(Create, () => { + throw error; + }); + strictEqual( + (await federation.fetch(request(await payload()), { + contextData: undefined, + })).status, + 500, + ); + deepStrictEqual(reports[0].outcome, { + type: "response", + status: 500, + disposition: "failed", + reason: "listenerError", + error, + }); +}); + +test("onRequestFinished builder snapshots callbacks", async () => { + const builder = createFederationBuilder(); + builder.setActorDispatcher("/users/{identifier}", () => new Person({})); + const reports: InboxRequestReport[] = []; + const setters = builder.setInboxListeners( + "/users/{identifier}/inbox", + "/inbox", + ).onRequestFinished((_ctx, report) => { + reports.push(report); + }); + const federation = await builder.build({ + kv: new MemoryKvStore(), + skipSignatureVerification: true, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + }); + setters.onRequestFinished(() => { + throw new Error("new callback must not run"); + }); + await federation.fetch(request(await payload()), { contextData: undefined }); + strictEqual(reports.length, 1); +}); + +// The imported key objects stay intact when observers mutate their URL copies. +test("onRequestFinished key snapshots do not alias vocabulary metadata", async () => { + const { snapshotKey } = await import("../sig/verification.ts"); + const snapshot = snapshotKey(rsaPublicKey2); + snapshot.id!.pathname = "/changed"; + strictEqual(rsaPublicKey2.id!.href, "https://example.com/key2"); + const other = snapshotKey(rsaPublicKey3); + strictEqual(other.publicKey, rsaPublicKey3.publicKey); +}); + +test("onRequestFinished reports enqueue acceptance without adding queue fields", async () => { + const messages: unknown[] = []; + const reports: InboxRequestReport[] = []; + const federation = createFederation({ + kv: new MemoryKvStore(), + skipSignatureVerification: true, + manuallyStartQueue: true, + queue: { + inbox: { + enqueue: (message) => { + messages.push(message); + return Promise.resolve(); + }, + listen: () => Promise.resolve(), + }, + }, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + }); + federation.setActorDispatcher("/users/{identifier}", () => new Person({})); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .onRequestFinished((_ctx, report) => { + reports.push(report); + }); + strictEqual( + (await federation.fetch(request(await payload()), { + contextData: undefined, + })).status, + 202, + ); + deepStrictEqual(reports[0].outcome, { + type: "response", + status: 202, + disposition: "enqueued", + }); + strictEqual(messages.length, 1); + ok(!("report" in (messages[0] as Record))); + ok(!("attempts" in (messages[0] as Record))); +}); + +test("onRequestFinished preserves a non-Error value thrown during enqueue", async () => { + const reports: InboxRequestReport[] = []; + const federation = createFederation({ + kv: new MemoryKvStore(), + skipSignatureVerification: true, + manuallyStartQueue: true, + queue: { + inbox: { + enqueue: () => Promise.reject(undefined), + listen: () => Promise.resolve(), + }, + }, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + }); + federation.setActorDispatcher("/users/{identifier}", () => new Person({})); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .onRequestFinished((_ctx, report) => { + reports.push(report); + throw new Error("observer failure"); + }); + let threw = false; + try { + await federation.fetch(request(await payload()), { + contextData: undefined, + }); + } catch (error) { + threw = true; + strictEqual(error, undefined); + } + strictEqual(threw, true); + strictEqual(reports.length, 1); + deepStrictEqual(reports[0].outcome, { + type: "exception", + stage: "dispatch", + error: undefined, + }); +}); + +test("onRequestFinished runs with tracing disabled", async () => { + const { AlwaysOffSampler, BasicTracerProvider } = await import( + "@opentelemetry/sdk-trace-base" + ); + const provider = new BasicTracerProvider({ sampler: new AlwaysOffSampler() }); + const reports: InboxRequestReport[] = []; + const federation = createFederation({ + kv: new MemoryKvStore(), + tracerProvider: provider, + skipSignatureVerification: true, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + }); + federation.setActorDispatcher("/users/{identifier}", () => new Person({})); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .onRequestFinished((_ctx, report) => { + reports.push(report); + }); + await federation.fetch(request(await payload()), { contextData: undefined }); + strictEqual(reports.length, 1); + await provider.shutdown(); +}); + +test("onRequestFinished preserves non-POST shared inbox behavior without reports", async () => { + const { federation, reports } = setup(true); + strictEqual( + (await federation.fetch( + new Request("https://example.com/inbox", { + headers: { Accept: "application/activity+json" }, + }), + { contextData: undefined }, + )).status, + 400, + ); + strictEqual(reports.length, 0); + strictEqual( + (await federation.fetch( + new Request("https://example.com/inbox", { + method: "PUT", + body: JSON.stringify(await payload()), + headers: { Accept: "application/activity+json" }, + }), + { contextData: undefined }, + )).status, + 202, + ); + strictEqual(reports.length, 0); +}); + +test("onRequestFinished covers errors constructing the initial request context", async () => { + const reports: InboxRequestReport[] = []; + const error = new Error("loader factory failed"); + const federation = createFederation({ + kv: new MemoryKvStore(), + documentLoaderFactory: () => { + throw error; + }, + contextLoaderFactory: () => mockDocumentLoader, + }); + federation.setActorDispatcher("/users/{identifier}", () => new Person({})); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .onRequestFinished((ctx, report) => { + strictEqual(ctx.data, "data"); + strictEqual(ctx.request.url, "https://example.com/inbox"); + reports.push(report); + }); + await rejects( + federation.fetch(request({}), { contextData: "data" }), + (value) => value === error, + ); + strictEqual(reports.length, 1); + deepStrictEqual(reports[0].outcome, { + type: "exception", + stage: "prepare", + error, + }); +}); + +test("onRequestFinished observes the final response decoration result", async () => { + const { federation, setters, reports } = setup(); + const redirect = Response.redirect("https://example.com/redirect", 302); + // Bun permits mutations here; Deno and Node.js use immutable headers. + let immutable = false; + try { + redirect.headers.set("X-Header-Probe", "probe"); + } catch { + immutable = true; + } + setters.onUnverifiedActivity(() => redirect); + const delivery = request(await payload()); + delivery.headers.set("Accept", "application/activity+json"); + let thrown: unknown; + let response: Response | undefined; + try { + response = await federation.fetch(delivery, { contextData: undefined }); + } catch (error) { + thrown = error; + } + strictEqual(reports.length, 1); + if (immutable) { + ok(thrown instanceof TypeError); + deepStrictEqual(reports[0].outcome, { + type: "exception", + stage: "respond", + error: thrown, + }); + } else { + strictEqual(thrown, undefined); + strictEqual(response?.status, 302); + deepStrictEqual(reports[0].outcome, { + type: "response", + status: 302, + disposition: "customResponse", + }); + } +}); diff --git a/packages/fedify/src/federation/inbox-report.ts b/packages/fedify/src/federation/inbox-report.ts new file mode 100644 index 000000000..7f1865f38 --- /dev/null +++ b/packages/fedify/src/federation/inbox-report.ts @@ -0,0 +1,148 @@ +import type { Activity } from "@fedify/vocab"; +import type { RequestContext } from "./context.ts"; +import type { + InboxVerificationAttempt, + InboxVerificationKey, +} from "../sig/verification.ts"; +export type { + InboxProofPolicyFailureReason, + InboxSignatureCheck, + InboxSignatureFailureReason, + InboxSignatureIdentity, + InboxVerificationAttempt, + InboxVerificationFailureReason, + InboxVerificationKey, +} from "../sig/verification.ts"; + +/** + * A JSON value retained from the incoming request. + * @since 2.4.0 + */ +export type InboxJsonValue = + | null + | boolean + | number + | string + | readonly InboxJsonValue[] + | { readonly [key: string]: InboxJsonValue }; + +/** + * The final inbox authentication decision. + * @since 2.4.0 + */ +export type InboxAuthentication = + | { + readonly status: "verified"; + /** Direct references to the attempts that authenticated this delivery. */ + readonly attempts: readonly Extract< + InboxVerificationAttempt, + { status: "verified" } + >[]; + } + | { + readonly status: "rejected"; + readonly reason: + | { readonly type: "verificationFailed" } + | { + readonly type: "actorKeyMismatch"; + readonly key: InboxVerificationKey; + readonly actorIds: readonly URL[]; + } + | { + readonly type: "proofPolicy"; + readonly policy: "portableActor" | "portableActivity" | "compound"; + readonly detail?: + import("../sig/verification.ts").InboxProofPolicyFailureReason; + } + | { readonly type: "invalidNonce" }; + } + | { readonly status: "skipped" } + | { readonly status: "notDetermined" }; + +/** + * How an inbox request finished. + * @since 2.4.0 + */ +export type InboxRequestOutcome = + | { + readonly type: "response"; + readonly status: number; + readonly disposition: + | "processed" + | "enqueued" + | "duplicate" + | "unhandled" + | "customResponse"; + } + | { + readonly type: "response"; + readonly status: number; + readonly disposition: "rejected"; + readonly reason: + | "recipientNotFound" + | "invalidJson" + | "invalidJsonLd" + | "invalidActivity" + | "bodyTooLarge" + | "missingActor" + | "authentication"; + } + | { + readonly type: "response"; + readonly status: number; + readonly disposition: "failed"; + readonly reason: "bodyUnavailable" | "enqueueError" | "listenerError"; + readonly error?: unknown; + } + | { + readonly type: "exception"; + readonly stage: + | "prepare" + | "parse" + | "verify" + | "policy" + | "dispatch" + | "respond"; + /** The original thrown value, including null/undefined. */ + readonly error: unknown; + }; + +/** + * Observations of a single inbox delivery request. + * @since 2.4.0 + */ +export interface InboxRequestReport { + /** The delivery route, including a portable gateway route. */ + readonly inbox: { + readonly kind: "personal" | "shared" | "portable"; + readonly recipient: string | null; + }; + /** Unavailable differs from a successfully parsed JSON null. */ + readonly payload: { readonly status: "unavailable" } | { + readonly status: "parsed"; + readonly value: InboxJsonValue; + }; + /** The Activity obtained by the normal processing flow, if any. */ + readonly activity: Activity | null; + /** Logical evaluations, including unsuccessful fallback paths. */ + readonly attempts: readonly InboxVerificationAttempt[]; + /** Cryptographic success alone does not imply this decision is verified. */ + readonly authentication: InboxAuthentication; + /** Response status snapshot or the original exception. */ + readonly outcome: InboxRequestOutcome; +} + +/** + * Observes an inbox delivery after processing, before fetch resolves/rethrows. + * The callback is awaited. Its errors are logged and swallowed, preserving + * the original response/exception. The last registered callback replaces the + * previous one. It excludes inbox collection GETs, unmatched routes, + * programmatic routing, and queue workers. This is not an atomic persistence + * guarantee. Keys/owner/controller declarations remain untrusted unless the + * final authentication decision verifies them. + * @since 2.4.0 + */ +export type InboxRequestFinishedHandler = ( + context: RequestContext, + report: InboxRequestReport, +) => void | Promise; diff --git a/packages/fedify/src/federation/metrics.ts b/packages/fedify/src/federation/metrics.ts index 248e1977b..794c8e5b0 100644 --- a/packages/fedify/src/federation/metrics.ts +++ b/packages/fedify/src/federation/metrics.ts @@ -11,6 +11,10 @@ import { } from "@opentelemetry/api"; import metadata from "../../deno.json" with { type: "json" }; import type { MessageQueue } from "./mq.ts"; +import type { + InboxSignatureFailureReason, + InboxVerificationFailureReason, +} from "../sig/verification.ts"; /** * The role of a queued task, derived from the queued message's `type` field. @@ -411,6 +415,10 @@ export interface SignatureVerificationExtraAttributes { * cardinality on success rows. */ failureReason?: HttpSignatureMetricFailureReason; + /** Bounded diagnostic shared by signature mechanisms. */ + verificationFailureReason?: + | InboxSignatureFailureReason["type"] + | InboxVerificationFailureReason["type"]; } /** @@ -963,6 +971,10 @@ class FederationMetrics { if (extra.failureReason != null) { attributes["http_signatures.failure_reason"] = extra.failureReason; } + if (extra.verificationFailureReason != null) { + attributes["activitypub.verification.failure_reason"] = + extra.verificationFailureReason; + } if (extra.ldType != null) { attributes["ld_signatures.type"] = extra.ldType; } diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 74a916afd..414c0c3a0 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -1,3 +1,4 @@ +import { InboxObservation } from "./inbox-observation.ts"; import { type Path, RouterError } from "@fedify/uri-template"; import type { Actor, @@ -3215,88 +3216,97 @@ export class FederationImpl const spanCtx = span.spanContext(); return await withContext( { traceId: spanCtx.traceId, spanId: spanCtx.spanId }, - async () => { - const logger = getLogger(["fedify", "federation", "http"]); - if (span.isRecording()) { - for (const [k, v] of request.headers) { - span.setAttribute(ATTR_HTTP_REQUEST_HEADER(k), [v]); + () => + this.#observeInboxFetch(metricState, async () => { + const logger = getLogger(["fedify", "federation", "http"]); + if (span.isRecording()) { + for (const [k, v] of request.headers) { + span.setAttribute(ATTR_HTTP_REQUEST_HEADER(k), [v]); + } } - } - let response: Response; - try { - response = await this.#fetch(request, { - ...options, - span, - tracer, - metricState, - }); - // Hashlink media responses are not negotiated, and they are - // sent as the application returns them, whose headers may - // even be immutable: - if ( - metricState.endpoint !== "hashlink_media" && - acceptsJsonLd(request) - ) { - response.headers.set("Vary", "Accept"); - } - } catch (error) { - this.metrics - .recordHttpServerRequest( - request.method, - metricState.endpoint ?? "error", - getDurationMs(metricStart), - { routeTemplate: metricState.routeTemplate }, + let response: Response; + try { + response = await this.#fetch(request, { + ...options, + span, + tracer, + metricState, + }); + if (metricState.inboxCompletion != null) { + metricState.inboxCompletion.observation.stage = "respond"; + } + // Hashlink media responses are not negotiated, and they are + // sent as the application returns them, whose headers may + // even be immutable: + if ( + metricState.endpoint !== "hashlink_media" && + acceptsJsonLd(request) + ) { + response.headers.set("Vary", "Accept"); + } + } catch (error) { + metricState.inboxCompletion?.observation.project(span, { + type: "exception", + stage: metricState.inboxCompletion.observation.stage, + error, + }); + this.metrics + .recordHttpServerRequest( + request.method, + metricState.endpoint ?? "error", + getDurationMs(metricStart), + { routeTemplate: metricState.routeTemplate }, + ); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: `${error}`, + }); + span.end(); + logger.error( + "An error occurred while serving request " + + "{method} {url}: {error}", + { method: request.method, url: request.url, error }, ); - span.setStatus({ - code: SpanStatusCode.ERROR, - message: `${error}`, - }); - span.end(); - logger.error( - "An error occurred while serving request " + - "{method} {url}: {error}", - { method: request.method, url: request.url, error }, - ); - throw error; - } - this.metrics.recordHttpServerRequest( - request.method, - metricState.endpoint ?? "error", - getDurationMs(metricStart), - { - statusCode: response.status, - routeTemplate: metricState.routeTemplate, - }, - ); - if (span.isRecording()) { - span.setAttribute( - ATTR_HTTP_RESPONSE_STATUS_CODE, - response.status, + throw error; + } + this.metrics.recordHttpServerRequest( + request.method, + metricState.endpoint ?? "error", + getDurationMs(metricStart), + { + statusCode: response.status, + routeTemplate: metricState.routeTemplate, + }, ); - for (const [k, v] of response.headers) { - span.setAttribute(ATTR_HTTP_RESPONSE_HEADER(k), [v]); + if (span.isRecording()) { + span.setAttribute( + ATTR_HTTP_RESPONSE_STATUS_CODE, + response.status, + ); + for (const [k, v] of response.headers) { + span.setAttribute(ATTR_HTTP_RESPONSE_HEADER(k), [v]); + } + span.setStatus({ + code: response.status >= 500 + ? SpanStatusCode.ERROR + : SpanStatusCode.UNSET, + message: response.statusText, + }); } - span.setStatus({ - code: response.status >= 500 - ? SpanStatusCode.ERROR - : SpanStatusCode.UNSET, - message: response.statusText, - }); - } - span.end(); - const url = new URL(request.url); - const logTpl = "{method} {path}: {status}"; - const values = { - method: request.method, - path: `${url.pathname}${url.search}`, - url: request.url, - status: response.status, - }; - if (response.status >= 500) logger.error(logTpl, values); - else if (response.status >= 400) logger.warn(logTpl, values); - else logger.info(logTpl, values); - return response; - }, + span.end(); + const url = new URL(request.url); + const logTpl = "{method} {path}: {status}"; + const values = { + method: request.method, + path: `${url.pathname}${url.search}`, + url: request.url, + status: response.status, + }; + if (response.status >= 500) logger.error(logTpl, values); + else if (response.status >= 400) logger.warn(logTpl, values); + else logger.info(logTpl, values); + return response; + }), ); }, ); @@ -3370,8 +3380,27 @@ export class FederationImpl metricState.routeTemplate = route.template; metricState.endpoint = getEndpointCategory(route.name); span.updateName(`${request.method} ${route.template}`); - let context = this.#createContext(request, contextData); const routeName = route.name.replace(/:.*$/, ""); + let context: RequestContextImpl; + try { + context = this.#createContext(request, contextData); + } catch (error) { + if ( + request.method !== "POST" || + routeName !== "inbox" && routeName !== "sharedInbox" + ) throw error; + return await this.#reportInboxPreparationError( + request, + contextData, + { + kind: routeName === "inbox" ? "personal" : "shared", + recipient: route.values.identifier ?? null, + }, + error, + span, + metricState, + ); + } // Routes that aren't JSON-LD based: switch (routeName) { @@ -3464,6 +3493,46 @@ export class FederationImpl } return response; } + if ( + request.method === "POST" && + (routeName === "inbox" || routeName === "sharedInbox") + ) { + const recipient = route.values.identifier ?? null; + const observation = new InboxObservation({ + kind: routeName === "inbox" ? "personal" : "shared", + recipient, + }); + this.#setInboxCompletion(metricState, observation, () => context); + return await observation.run( + async () => { + if (routeName === "inbox") { + context = this.#createContext(request, contextData, { + documentLoader: await context.getDocumentLoader({ + identifier: recipient!, + }), + }); + } else if (this.sharedInboxKeyDispatcher != null) { + const identity = await this.sharedInboxKeyDispatcher(context); + if (identity != null) { + context = this.#createContext(request, contextData, { + documentLoader: + "identifier" in identity || "username" in identity + ? await context.getDocumentLoader(identity) + : context.getDocumentLoader(identity), + }); + } + } + return await this.#handleInbox(request, { + recipient, + context, + contextData, + onNotFound, + observation, + }); + }, + span, + ); + } switch (routeName) { case "actor": case "actorAlias": { @@ -3547,14 +3616,11 @@ export class FederationImpl onNotFound, }); } - context = this.#createContext(request, contextData, { - documentLoader: await context.getDocumentLoader({ - identifier: route.values.identifier, - }), - }); - // falls through + // POST deliveries are handled by the observed ingress above. + throw new Error("Unreachable inbox delivery."); case "sharedInbox": { - if (routeName !== "inbox" && this.sharedInboxKeyDispatcher != null) { + // Preserve the existing non-POST handling without reporting a delivery. + if (this.sharedInboxKeyDispatcher != null) { const identity = await this.sharedInboxKeyDispatcher(context); if (identity != null) { context = this.#createContext(request, contextData, { @@ -3980,6 +4046,72 @@ export class FederationImpl return await onNotFound(request); } + #setInboxCompletion( + metricState: HttpMetricState, + observation: InboxObservation, + getContext: () => RequestContext, + ): void { + const handler = this.inboxRequestFinishedHandler; + metricState.inboxCompletion = { + observation, + finish: (outcome) => observation.finish(getContext(), handler, outcome), + }; + } + + async #observeInboxFetch( + metricState: HttpMetricState, + operation: () => Promise, + ): Promise { + let response: Response; + try { + response = await operation(); + } catch (error) { + const completion = metricState.inboxCompletion; + if (completion != null) { + await completion.finish({ + type: "exception", + stage: completion.observation.stage, + error, + }); + } + throw error; + } + const completion = metricState.inboxCompletion; + if (completion != null) { + await completion.finish({ + type: "response", + status: response.status, + ...completion.observation.result, + }); + } + return response; + } + + async #reportInboxPreparationError( + request: Request, + contextData: TContextData, + inbox: import("./inbox-report.ts").InboxRequestReport["inbox"], + error: unknown, + span: Span, + metricState: HttpMetricState, + ): Promise { + const unavailableLoader: DocumentLoader = () => Promise.reject(error); + const context = new RequestContextImpl({ + url: new URL(request.url), + request, + federation: this, + data: contextData, + documentLoader: unavailableLoader, + contextLoader: unavailableLoader, + }); + const observation = new InboxObservation(inbox); + this.#setInboxCompletion(metricState, observation, () => context); + return await observation.run( + () => Promise.reject(error), + span, + ); + } + async #handleInbox( request: Request, { @@ -3988,12 +4120,14 @@ export class FederationImpl contextData, onNotFound, portableInbox, + observation, }: { recipient: string | null; context: RequestContextImpl; contextData: TContextData; onNotFound: (request: Request) => Response | Promise; portableInbox?: PortableInboxDelivery; + observation?: InboxObservation; }, ): Promise { if (!this.manuallyStartQueue) this._startQueueInternal(contextData); @@ -4025,6 +4159,7 @@ export class FederationImpl tracerProvider: this.tracerProvider, idempotencyStrategy: this.idempotencyStrategy, portableInbox, + observation, }); } @@ -4066,94 +4201,121 @@ export class FederationImpl metricState.endpoint = "inbox"; span.updateName(`${request.method} ${metricState.routeTemplate}`); const identifier = route.values.identifier; - let context = this.#createContext(request, contextData); - // The actor is looked up only by its identifier, as for ordinary inbox - // deliveries, so that the identifier alone determines the recipient, - // also for inbox listeners and queued deliveries: - const actor = this.actorCallbacks?.dispatcher == null - ? null - : await this.actorCallbacks.dispatcher(context, identifier); - const localOrigin = context.canonicalOrigin; - const resolution = resolvePortableInboxRecipient( - actor == null || actor instanceof Tombstone ? null : actor, - { - authority: portable.portableRequest.authority, - canonicalInboxId: portable.canonicalId, - localOrigin, - }, - ); - if (resolution.status === "rejected") { - logger.debug( - "Not accepting a delivery to the portable inbox {inbox} on behalf " + - "of the actor {identifier}: {reason}.", - { - inbox: portable.canonicalId, - identifier, - reason: resolution.reason, - }, + let context: RequestContextImpl; + try { + context = this.#createContext(request, contextData); + } catch (error) { + return await this.#reportInboxPreparationError( + request, + contextData, + { kind: "portable", recipient: identifier }, + error, + span, + metricState, ); - return await onNotFound(request); } - const { recipient } = resolution; - // A portable actor does not necessarily have key pairs for authorized - // fetch, so fall back to the default document loader without them: - if (this.actorCallbacks?.keyPairsDispatcher != null) { - context = this.#createContext(request, contextData, { - documentLoader: await context.getDocumentLoader({ identifier }), - }); - } - const excludedOrigins = [localOrigin, new URL(request.url).origin]; - return await this.#handleInbox(request, { + const observation = new InboxObservation({ + kind: "portable", recipient: identifier, - context, - contextData, - onNotFound, - portableInbox: { - recipient, - forward: async (activity, activityId, activityType, signatures) => { - if (this.portableInboxForwarding.maxTargets < 1) return; - if (this.kv.cas == null) { - if (!this.#portableInboxForwardingWarned) { - this.#portableInboxForwardingWarned = true; - logger.warn( - "Activities delivered to FEP-ef61 portable inboxes are not " + - "forwarded to the other gateways, as the key–value store " + - "does not support compare-and-swap (KvStore.cas()), which " + - "is needed to forward each activity at most once.", - ); - } - return; - } - await forwardPortableInboxActivity({ - recipient, - activity, - activityId, - activityType, - excludedOrigins, - baseUrl: context.origin, - kv: this.kv, - kvPrefix: this.kvPrefixes.portableInboxForwarding, - outboxQueue: this.outboxQueue, - startQueue: this.manuallyStartQueue - ? undefined - : () => this._startQueueInternal(contextData), - allowPrivateAddress: this.allowPrivateAddress, - getKeys: () => - this.#getPortableGatewayKeyPairs(context, identifier, recipient), - specDeterminer: new KvSpecDeterminer( - this.kv, - this.kvPrefixes.httpMessageSignaturesSpec, - this.firstKnock, - { specTtl: this.httpMessageSignaturesSpecTtl }, - ), - signatures, - options: this.portableInboxForwarding, - meterProvider: this.meterProvider, - tracerProvider: this.tracerProvider, + }); + this.#setInboxCompletion(metricState, observation, () => context); + return await observation.run( + async () => { + // The actor is looked up only by its identifier, as for ordinary inbox + // deliveries, so that the identifier alone determines the recipient, + // also for inbox listeners and queued deliveries: + const actor = this.actorCallbacks?.dispatcher == null + ? null + : await this.actorCallbacks.dispatcher(context, identifier); + const localOrigin = context.canonicalOrigin; + const resolution = resolvePortableInboxRecipient( + actor == null || actor instanceof Tombstone ? null : actor, + { + authority: portable.portableRequest.authority, + canonicalInboxId: portable.canonicalId, + localOrigin, + }, + ); + if (resolution.status === "rejected") { + logger.debug( + "Not accepting a delivery to the portable inbox {inbox} on behalf " + + "of the actor {identifier}: {reason}.", + { + inbox: portable.canonicalId, + identifier, + reason: resolution.reason, + }, + ); + return await onNotFound(request); + } + const { recipient } = resolution; + // A portable actor does not necessarily have key pairs for authorized + // fetch, so fall back to the default document loader without them: + if (this.actorCallbacks?.keyPairsDispatcher != null) { + context = this.#createContext(request, contextData, { + documentLoader: await context.getDocumentLoader({ identifier }), }); - }, + } + const excludedOrigins = [localOrigin, new URL(request.url).origin]; + return await this.#handleInbox(request, { + recipient: identifier, + observation, + context, + contextData, + onNotFound, + portableInbox: { + recipient, + forward: async (activity, activityId, activityType, signatures) => { + if (this.portableInboxForwarding.maxTargets < 1) return; + if (this.kv.cas == null) { + if (!this.#portableInboxForwardingWarned) { + this.#portableInboxForwardingWarned = true; + logger.warn( + "Activities delivered to FEP-ef61 portable inboxes are not " + + "forwarded to the other gateways, as the key–value store " + + "does not support compare-and-swap (KvStore.cas()), which " + + "is needed to forward each activity at most once.", + ); + } + return; + } + await forwardPortableInboxActivity({ + recipient, + activity, + activityId, + activityType, + excludedOrigins, + baseUrl: context.origin, + kv: this.kv, + kvPrefix: this.kvPrefixes.portableInboxForwarding, + outboxQueue: this.outboxQueue, + startQueue: this.manuallyStartQueue + ? undefined + : () => this._startQueueInternal(contextData), + allowPrivateAddress: this.allowPrivateAddress, + getKeys: () => + this.#getPortableGatewayKeyPairs( + context, + identifier, + recipient, + ), + specDeterminer: new KvSpecDeterminer( + this.kv, + this.kvPrefixes.httpMessageSignaturesSpec, + this.firstKnock, + { specTtl: this.httpMessageSignaturesSpecTtl }, + ), + signatures, + options: this.portableInboxForwarding, + meterProvider: this.meterProvider, + tracerProvider: this.tracerProvider, + }); + }, + }, + }); }, - }); + span, + ); } /** @@ -4377,6 +4539,12 @@ type FedifyEndpoint = | "error"; interface HttpMetricState { + inboxCompletion?: { + observation: InboxObservation; + finish( + outcome: import("./inbox-report.ts").InboxRequestOutcome, + ): Promise; + }; endpoint?: FedifyEndpoint; routeTemplate?: string; } diff --git a/packages/fedify/src/federation/mod.ts b/packages/fedify/src/federation/mod.ts index 6a3bb1f6e..4bdb6369d 100644 --- a/packages/fedify/src/federation/mod.ts +++ b/packages/fedify/src/federation/mod.ts @@ -14,6 +14,7 @@ export { respondWithObjectIfAcceptable, type RespondWithObjectOptions, } from "./handler.ts"; +export * from "./inbox-report.ts"; export * from "./kv.ts"; export { createFederation, diff --git a/packages/fedify/src/federation/portable-inbox.test.ts b/packages/fedify/src/federation/portable-inbox.test.ts index a027c80cd..a22ef54b6 100644 --- a/packages/fedify/src/federation/portable-inbox.test.ts +++ b/packages/fedify/src/federation/portable-inbox.test.ts @@ -1,3 +1,4 @@ +import type { InboxRequestReport } from "./inbox-report.ts"; import { mockDocumentLoader, test } from "@fedify/fixture"; import { type Activity, @@ -175,8 +176,13 @@ function setup( mapPortableActorId(identifier) ); } + const reports: InboxRequestReport[] = []; const inboxListeners = federation - .setInboxListeners("/users/{identifier}/inbox", "/inbox"); + .setInboxListeners("/users/{identifier}/inbox", "/inbox").onRequestFinished( + (_ctx, report) => { + reports.push(report); + }, + ); if (onSharedInboxKey != null) { inboxListeners.setSharedKeyDispatcher(() => { onSharedInboxKey(); @@ -197,7 +203,7 @@ function setup( recipient: ctx.recipient, }); }); - return { federation, received, kv, queue }; + return { federation, received, kv, queue, reports }; } let activityCounter = 0; @@ -350,6 +356,59 @@ test("Federation.fetch() refuses portable inbox deliveries it does not accept", }); }); +test("onRequestFinished retains crypto success when a portable activity's DID mismatches", async () => { + const { federation, reports, received } = setup(); + const signingKeyId = new URL( + `${otherDid}#${otherDid.substring("did:key:".length)}`, + ); + const activity = await signObject( + new Follow({ + id: parseIri(`ap+ef61://${did}/follows/mismatched-report`), + actor: parseIri(`ap+ef61://${otherDid}/users/bob`), + object: parseIri(`ap+ef61://${did}/users/alice`), + }), + otherKeyPair.privateKey, + signingKeyId, + { contextLoader: mockDocumentLoader }, + ); + const response = await federation.fetch( + post( + inboxUrl(), + await activity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + { contextData: undefined }, + ); + assertEquals(response.status, 401); + assertEquals(received.length, 0); + assertEquals(reports.length, 1); + const report = reports[0]; + assert(report.authentication.status === "rejected"); + assert(report.authentication.reason.type === "proofPolicy"); + assertEquals(report.authentication.reason.policy, "portableActivity"); + assertEquals( + report.authentication.reason.detail?.type, + "verificationMethodMismatch", + ); + assert( + report.attempts.some((attempt) => + attempt.mechanism === "objectIntegrity" && + attempt.checks.some((check) => check.status === "verified") + ), + ); + assert( + report.attempts.some((attempt) => + attempt.status === "rejected" && attempt.reason.type === "proofPolicy" && + attempt.reason.reason.type === "verificationMethodMismatch" + ), + ); + assertEquals(report.outcome, { + type: "response", + status: 401, + disposition: "rejected", + reason: "authentication", + }); +}); + test("Federation.fetch() applies the proof policy to portable inbox deliveries", async (t) => { const queue = new RecordingQueue(); const { federation, received } = setup({ queue }); @@ -2172,3 +2231,52 @@ test("Federation.fetch() keeps ordinary inbox deliveries unchanged", async () => assertEquals(received.length, 1); assertEquals(queue.outbox.length, 0); }); + +test("onRequestFinished covers portable ingress and recipient lookup failures", async () => { + const { federation, reports } = setup({ + options: { portableInboxForwarding: { maxTargets: 0 } }, + }); + const response = await federation.fetch( + post(inboxUrl(), await signedFollow()), + { contextData: undefined }, + ); + assertEquals(response.status, 202); + assertEquals(reports.length, 1); + assertEquals(reports[0].inbox, { kind: "portable", recipient: "alice" }); + assertEquals(reports[0].authentication.status, "verified"); + assert( + reports[0].attempts.some((attempt) => + attempt.subject.pointer === "" && attempt.status === "verified" + ), + ); + const absent = setup({ actor: () => null }); + assertEquals( + (await absent.federation.fetch(post(inboxUrl(), {}), { + contextData: undefined, + })).status, + 404, + ); + assertEquals(absent.reports[0].payload, { status: "unavailable" }); + assertEquals(absent.reports[0].authentication, { status: "notDetermined" }); + const failure = new Error("recipient lookup failed"); + const broken = setup({ + actor: () => { + throw failure; + }, + }); + let thrown: unknown; + try { + await broken.federation.fetch(post(inboxUrl(), {}), { + contextData: undefined, + }); + } catch (error) { + thrown = error; + } + assertEquals(thrown, failure); + assertEquals(broken.reports.length, 1); + assertEquals(broken.reports[0].outcome, { + type: "exception", + stage: "prepare", + error: failure, + }); +}); diff --git a/packages/fedify/src/sig/compound-proof-verification.test.ts b/packages/fedify/src/sig/compound-proof-verification.test.ts index f7c5cdd52..eebd65d28 100644 --- a/packages/fedify/src/sig/compound-proof-verification.test.ts +++ b/packages/fedify/src/sig/compound-proof-verification.test.ts @@ -1155,3 +1155,27 @@ test("verifyCompoundPortableObjectProofs() leaves keys at DID URLs alone", async assert(result.verified); assertEquals(result.portableObjects.map((o) => o.path), [""]); }); + +test("compound proof observation does not throw for malformed subject IDs", async () => { + const { verificationObservation } = await import("./verification.ts"); + const evidence = { + attempts: [] as import("./verification.ts").InboxVerificationAttempt[], + }; + const result = await verifyCompoundPortableObjectProofs( + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + ], + id: "ap://did%ZZkey/objects/1", + type: "Note", + proof: {}, + }, + limits, + { ...options, [verificationObservation]: evidence }, + ); + assertEquals(result.status, "ok"); + assert(result.status === "ok" && !result.verified); + assertEquals(evidence.attempts[0].subject.id, null); + assertEquals(evidence.attempts[0].subject.pointer, ""); +}); diff --git a/packages/fedify/src/sig/compound-proof.ts b/packages/fedify/src/sig/compound-proof.ts index 892aca337..98de3146a 100644 --- a/packages/fedify/src/sig/compound-proof.ts +++ b/packages/fedify/src/sig/compound-proof.ts @@ -1,3 +1,4 @@ +import { observeAttempt, verificationObservation } from "./verification.ts"; import type { Multikey } from "@fedify/vocab"; import { parseIri, preloadedContexts } from "@fedify/vocab-runtime"; import jsonld from "@fedify/vocab-runtime/jsonld"; @@ -913,9 +914,37 @@ async function verifyDiscoveredProofDocuments( reason: { type: "missingContext" as const }, }); } - const key = await verifyMapLocalProof( - document.securedDocument, - options, + const inherited = options[verificationObservation]; + let subjectId: URL | null = null; + if (inherited != null && document.id != null) { + try { + subjectId = parseIri(document.id); + } catch { /* Diagnostic metadata must not change policy handling. */ } + } + const key = await observeAttempt( + { + [verificationObservation]: inherited == null ? undefined : { + ...inherited, + attempt: undefined, + check: undefined, + subject: { + id: subjectId, + pointer: document.path, + }, + }, + }, + "objectIntegrity", + (observation) => + verifyMapLocalProof( + document.securedDocument, + { + ...options, + [verificationObservation]: inherited == null + ? undefined + : observation, + }, + ), + (key) => key != null, ); return key == null ? Object.freeze({ diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 3ca167fcc..dbf4efc03 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -1,3 +1,10 @@ +import { + observeAttempt, + observeCheck, + triedKey, + verificationObservation, + type VerificationObservationOptions, +} from "./verification.ts"; import { CryptographicKey } from "@fedify/vocab"; import { type DocumentLoader, FetchError } from "@fedify/vocab-runtime"; import { getLogger } from "@logtape/logtape"; @@ -756,7 +763,7 @@ const supportedHashAlgorithms: Record = { /** * Options for {@link verifyRequest}. */ -export interface VerifyRequestOptions { +export interface VerifyRequestOptions extends VerificationObservationOptions { /** * The document loader to use for fetching the public key. */ @@ -1015,6 +1022,23 @@ export async function verifyRequestDetailed( request: Request, options: VerifyRequestOptions = {}, ): Promise { + if ( + options[verificationObservation] != null && + options[verificationObservation]?.attempt == null + ) { + return await observeAttempt(options, "http", async (observation) => { + const result = await verifyRequestDetailed(request, { + ...options, + [verificationObservation]: observation, + }); + if (!result.verified) { + observation.attempt!.reason = result.reason.type === "noSignature" + ? { type: "noSignature" } + : { type: "signatureVerificationFailed" }; + } + return result; + }, (result) => result.verified); + } if (options.maxSignatures !== undefined) { validateMaxSignatures(options.maxSignatures, "maxSignatures"); } @@ -1109,6 +1133,43 @@ export async function verifyRequestDetailed( } async function verifyRequestDraft( + request: Request, + span: Span, + metricsContext: HttpSignatureMetricsContext, + options: VerifyRequestOptions = {}, +): Promise { + if (options[verificationObservation]?.check != null) { + return await verifyRequestDraftInternal( + request, + span, + metricsContext, + options, + ); + } + let result: VerifyRequestDetailedResult = noSignatureResult(); + const raw = request.headers.get("Signature"); + const declared = raw == null ? null : parseDraftSignature(raw)?.keyId ?? null; + if (raw == null) return result; + await observeCheck( + options, + { mechanism: "http", spec: "draft-cavage-http-signatures-12", label: null }, + declared, + async (observation) => { + result = await verifyRequestDraftInternal(request, span, metricsContext, { + ...options, + [verificationObservation]: observation, + }); + if ( + !result.verified && result.reason.type === "keyFetchError" && + observation.check != null + ) observation.check.reason = result.reason; + return result.verified ? result.key : null; + }, + ); + return result; +} + +async function verifyRequestDraftInternal( request: Request, span: Span, metricsContext: HttpSignatureMetricsContext, @@ -1120,6 +1181,7 @@ async function verifyRequestDraft( keyCache, meterProvider, tracerProvider, + [verificationObservation]: observation, }: VerifyRequestOptions = {}, ): Promise { const logger = getLogger(["fedify", "sig", "http"]); @@ -1384,6 +1446,7 @@ async function verifyRequestDraft( const sig = decodeBase64(signature); span?.setAttribute("http_signatures.signature", encodeHex(sig)); // TODO: support other than RSASSA-PKCS1-v1_5: + triedKey({ [verificationObservation]: observation }, key); const verified = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", key.publicKey, @@ -1413,6 +1476,7 @@ async function verifyRequestDraft( keyCache: bypassKeyCacheReads(keyCache), meterProvider, tracerProvider, + [verificationObservation]: observation, }, ); } @@ -1529,6 +1593,7 @@ async function verifyRfc9421SignatureWithKey( sigBytes: Uint8Array, key: CryptographicKey & { publicKey: CryptoKey }, span: Span, + observationOptions: VerificationObservationOptions = {}, ): Promise { const logger = getLogger(["fedify", "sig", "http"]); // Map algorithm name to WebCrypto algorithm @@ -1607,6 +1672,7 @@ async function verifyRfc9421SignatureWithKey( } let verified: boolean; try { + triedKey(observationOptions, key); verified = await crypto.subtle.verify( algorithm, key.publicKey, @@ -1639,6 +1705,7 @@ async function verifyRequestRfc9421( currentTime, keyCache, maxSignatures = DEFAULT_MAX_RFC9421_SIGNATURES, + [verificationObservation]: observation, meterProvider, tracerProvider, }: VerifyRequestOptions = {}, @@ -1761,152 +1828,171 @@ async function verifyRequestRfc9421( let digestValid: boolean | null = null; for (const sigName of signatureNames) { - // Skip if we don't have the signature bytes - if (!signatures[sigName]) { - setFailure( - invalidSignatureResult(parseKeyId(signatureInputs[sigName]?.keyId)), - ); - continue; - } - - const sigInput = signatureInputs[sigName]; - const sigBytes = signatures[sigName]; - const keyId = parseKeyId(sigInput.keyId); - - // Validate signature input parameters - if (!sigInput.keyId) { - logger.debug( - "Failed to verify; missing keyId in signature {signatureName}.", - { signatureName: sigName, signatureInput: signatureInputHeader }, - ); - setFailure(invalidSignatureResult(null)); - continue; - } + let winning: VerifyRequestDetailedResult | undefined; + await observeCheck( + { [verificationObservation]: observation }, + { mechanism: "http", spec: "rfc9421", label: sigName }, + signatureInputs[sigName]?.keyId ?? null, + async (candidateObservation) => { + // Skip if we don't have the signature bytes + if (!signatures[sigName]) { + setFailure( + invalidSignatureResult(parseKeyId(signatureInputs[sigName]?.keyId)), + ); + return null; + } - if (!sigInput.created) { - logger.debug( - "Failed to verify; missing created timestamp in signature {signatureName}.", - { signatureName: sigName, signatureInput: signatureInputHeader }, - ); - setFailure(invalidSignatureResult(keyId)); - continue; - } + const sigInput = signatureInputs[sigName]; + const sigBytes = signatures[sigName]; + const keyId = parseKeyId(sigInput.keyId); - // Check timestamp validity - const signatureCreated = Temporal.Instant.fromEpochMilliseconds( - sigInput.created * 1000, - ); - const now = currentTime ?? Temporal.Now.instant(); + // Validate signature input parameters + if (!sigInput.keyId) { + logger.debug( + "Failed to verify; missing keyId in signature {signatureName}.", + { signatureName: sigName, signatureInput: signatureInputHeader }, + ); + setFailure(invalidSignatureResult(null)); + return null; + } - if (timeWindow !== false) { - const tw: Temporal.Duration | Temporal.DurationLike = timeWindow ?? - { hours: 1 }; - if (Temporal.Instant.compare(signatureCreated, now.add(tw)) > 0) { - logger.debug( - "Failed to verify; signature created time is too far in the future.", - { created: signatureCreated.toString(), now: now.toString() }, - ); - setFailure(invalidSignatureResult(keyId)); - continue; - } else if ( - Temporal.Instant.compare(signatureCreated, now.subtract(tw)) < 0 - ) { - logger.debug( - "Failed to verify; signature created time is too far in the past.", - { created: signatureCreated.toString(), now: now.toString() }, - ); - setFailure(invalidSignatureResult(keyId)); - continue; - } - } + if (!sigInput.created) { + logger.debug( + "Failed to verify; missing created timestamp in signature {signatureName}.", + { signatureName: sigName, signatureInput: signatureInputHeader }, + ); + setFailure(invalidSignatureResult(keyId)); + return null; + } - // Verify Content-Digest if present and required - if ( - request.method !== "GET" && - request.method !== "HEAD" && - sigInput.components.some((c) => c.value === "content-digest") - ) { - const contentDigestHeader = request.headers.get("Content-Digest"); - if (!contentDigestHeader) { - logger.debug( - "Failed to verify; Content-Digest header required but not found.", - { components: sigInput.components }, + // Check timestamp validity + const signatureCreated = Temporal.Instant.fromEpochMilliseconds( + sigInput.created * 1000, ); - setFailure(invalidSignatureResult(keyId)); - continue; - } + const now = currentTime ?? Temporal.Now.instant(); + + if (timeWindow !== false) { + const tw: Temporal.Duration | Temporal.DurationLike = timeWindow ?? + { hours: 1 }; + if (Temporal.Instant.compare(signatureCreated, now.add(tw)) > 0) { + logger.debug( + "Failed to verify; signature created time is too far in the future.", + { created: signatureCreated.toString(), now: now.toString() }, + ); + setFailure(invalidSignatureResult(keyId)); + return null; + } else if ( + Temporal.Instant.compare(signatureCreated, now.subtract(tw)) < 0 + ) { + logger.debug( + "Failed to verify; signature created time is too far in the past.", + { created: signatureCreated.toString(), now: now.toString() }, + ); + setFailure(invalidSignatureResult(keyId)); + return null; + } + } - // Every signature covers the same body and Content-Digest header. - body ??= await request.arrayBuffer(); - digestValid ??= await verifyRfc9421ContentDigest( - contentDigestHeader, - body, - ); + // Verify Content-Digest if present and required + if ( + request.method !== "GET" && + request.method !== "HEAD" && + sigInput.components.some((c) => c.value === "content-digest") + ) { + const contentDigestHeader = request.headers.get("Content-Digest"); + if (!contentDigestHeader) { + logger.debug( + "Failed to verify; Content-Digest header required but not found.", + { components: sigInput.components }, + ); + setFailure(invalidSignatureResult(keyId)); + return null; + } - if (!digestValid) { - logger.debug( - "Failed to verify; Content-Digest verification failed.", - { contentDigest: contentDigestHeader }, - ); - setFailure(invalidSignatureResult(keyId)); - continue; - } - } + // Every signature covers the same body and Content-Digest header. + body ??= await request.arrayBuffer(); + digestValid ??= await verifyRfc9421ContentDigest( + contentDigestHeader, + body, + ); - // Fetch the public key - span?.setAttribute("http_signatures.key_id", sigInput.keyId); - span?.setAttribute("http_signatures.created", sigInput.created.toString()); - if (keyId == null) { - setFailure(invalidSignatureResult(null)); - continue; - } + if (!digestValid) { + logger.debug( + "Failed to verify; Content-Digest verification failed.", + { contentDigest: contentDigestHeader }, + ); + setFailure(invalidSignatureResult(keyId)); + return null; + } + } - let lookup = keyLookups.get(keyId.href); - if (lookup == null) { - lookup = await lookUpKey(keyId, keyCache); - keyLookups.set(keyId.href, lookup); - } - while (true) { - const { key, cached, fetchError } = lookup; - if (fetchError != null) { - setFailure(keyFetchErrorResult(keyId, fetchError)); - break; - } - if (!key) { - logger.debug("Failed to fetch key: {keyId}", { - keyId: sigInput.keyId, - }); - setFailure(invalidSignatureResult(keyId)); - break; - } - const result = await verifyRfc9421SignatureWithKey( - request, - sigInput, - sigBytes, - key, - span, - ); - if (result.verified) { - metricsContext.algorithm = result.algorithm; - return { verified: true, key, signatureLabel: sigName }; - } - if (result.mismatched && cached && !refreshedKeyIds.has(keyId.href)) { - // The cached key may be stale, so look it up again without the cache, - // but only once for each key ID, and only for this signature; the - // other signatures naming the same key use the fresh one: - logger.debug( - "Failed to verify with cached key {keyId}; retrying with fresh " + - "key...", - { keyId: sigInput.keyId }, + // Fetch the public key + span?.setAttribute("http_signatures.key_id", sigInput.keyId); + span?.setAttribute( + "http_signatures.created", + sigInput.created.toString(), ); - refreshedKeyIds.add(keyId.href); - lookup = await lookUpKey(keyId, bypassKeyCacheReads(keyCache)); - keyLookups.set(keyId.href, lookup); - continue; - } - setFailure(invalidSignatureResult(keyId), result.algorithm); - break; - } + if (keyId == null) { + setFailure(invalidSignatureResult(null)); + return null; + } + + let lookup = keyLookups.get(keyId.href); + if (lookup == null) { + lookup = await lookUpKey(keyId, keyCache); + keyLookups.set(keyId.href, lookup); + } + while (true) { + const { key, cached, fetchError } = lookup; + if (fetchError != null) { + setFailure(keyFetchErrorResult(keyId, fetchError)); + break; + } + if (!key) { + logger.debug("Failed to fetch key: {keyId}", { + keyId: sigInput.keyId, + }); + setFailure(invalidSignatureResult(keyId)); + break; + } + const result = await verifyRfc9421SignatureWithKey( + request, + sigInput, + sigBytes, + key, + span, + { [verificationObservation]: candidateObservation }, + ); + if (result.verified) { + metricsContext.algorithm = result.algorithm; + winning = { verified: true, key, signatureLabel: sigName }; + return key; + } + if (result.mismatched && cached && !refreshedKeyIds.has(keyId.href)) { + // The cached key may be stale, so look it up again without the cache, + // but only once for each key ID, and only for this signature; the + // other signatures naming the same key use the fresh one: + logger.debug( + "Failed to verify with cached key {keyId}; retrying with fresh " + + "key...", + { keyId: sigInput.keyId }, + ); + refreshedKeyIds.add(keyId.href); + lookup = await lookUpKey(keyId, bypassKeyCacheReads(keyCache)); + keyLookups.set(keyId.href, lookup); + continue; + } + setFailure(invalidSignatureResult(keyId), result.algorithm); + break; + } + if ( + !failure.verified && failure.reason.type === "keyFetchError" && + candidateObservation.check != null + ) candidateObservation.check.reason = failure.reason; + return null; + }, + ); + if (winning != null) return winning; } metricsContext.algorithm = failureAlgorithm; diff --git a/packages/fedify/src/sig/key.ts b/packages/fedify/src/sig/key.ts index 5865cabca..b882fec65 100644 --- a/packages/fedify/src/sig/key.ts +++ b/packages/fedify/src/sig/key.ts @@ -1,3 +1,7 @@ +import { + verificationObservation, + type VerificationObservationOptions, +} from "./verification.ts"; import { type Actor, CryptographicKey, @@ -178,7 +182,7 @@ export async function importJwk( * Options for {@link fetchKey}. * @since 1.3.0 */ -export interface FetchKeyOptions { +export interface FetchKeyOptions extends VerificationObservationOptions { /** * The document loader for loading remote JSON-LD documents. */ @@ -1694,10 +1698,48 @@ async function fetchKeyInternal( cacheKey, cls, options, - (_cacheKey, _keyId, _keyCache, _logger) => { + async (_cacheKey, _keyId, _keyCache, _logger) => { + const check = options[verificationObservation]?.check; + if (check != null) { + try { + const result = await _keyCache?.getFetchError?.(_cacheKey); + if (result != null) { + check.reason = { + type: "keyFetchError", + keyId: new URL(_cacheKey.href), + result, + }; + } + } catch { + // Optional diagnostic metadata must not change a cached miss. + } + } return { key: null, cached: true }; }, async (error, cacheKey, keyId, keyCache, logger) => { + const check = options[verificationObservation]?.check; + if (check != null) { + let result: FetchKeyErrorResult; + try { + result = error instanceof FetchError && error.response != null + ? { + status: error.response.status, + response: error.response.clone(), + } + : { + error: error instanceof Error ? error : new Error(String(error)), + }; + } catch { + // A custom loader can provide a response whose body is already used. + // Collecting evidence must preserve the original verification result. + result = { error: error instanceof Error ? error : new Error() }; + } + check.reason = { + type: "keyFetchError", + keyId: new URL(cacheKey.href), + result, + }; + } logger.debug("Failed to fetch key {keyId}.", { keyId, error }); await keyCache?.set(cacheKey, null); if (error instanceof FetchError && error.response != null) { diff --git a/packages/fedify/src/sig/ld.ts b/packages/fedify/src/sig/ld.ts index e588bbd9f..6d009ca62 100644 --- a/packages/fedify/src/sig/ld.ts +++ b/packages/fedify/src/sig/ld.ts @@ -1,3 +1,10 @@ +import { + observeAttempt, + observeCheck, + triedKey, + verificationObservation, + type VerificationObservationOptions, +} from "./verification.ts"; import { Activity, CryptographicKey, getTypeId, Object } from "@fedify/vocab"; import { type DocumentLoader, @@ -881,7 +888,7 @@ export function detachSignature(jsonLd: unknown): unknown { * Options for verifying Linked Data Signatures. * @since 1.0.0 */ -export interface VerifySignatureOptions { +export interface VerifySignatureOptions extends VerificationObservationOptions { /** * The document loader to use for fetching the public key. */ @@ -936,6 +943,22 @@ export async function verifySignature( options: VerifySignatureOptions = {}, ): Promise { if (!hasSignature(jsonLd)) return null; + if ( + options[verificationObservation]?.attempt != null && + options[verificationObservation]?.check == null + ) { + const declared = getLdSignatureObject(jsonLd)?.creator; + return await observeCheck( + options, + { mechanism: "linkedData" }, + typeof declared === "string" ? declared : null, + (observation) => + verifySignature(jsonLd, { + ...options, + [verificationObservation]: observation, + }), + ); + } const sig = jsonLd.signature; let signature: Uint8Array; try { @@ -990,6 +1013,7 @@ export async function verifySignature( const encoder = new TextEncoder(); const message = sigOptsHash + docHash; const messageBytes = encoder.encode(message); + triedKey(options, key); const verified = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", key.publicKey, @@ -1014,6 +1038,7 @@ export async function verifySignature( }), ); if (key == null) return null; + triedKey(options, key); const verified = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", key.publicKey, @@ -1108,6 +1133,25 @@ async function verifyJsonLdInternal( options: VerifyJsonLdOptions, compact: boolean, ): Promise { + if ( + options[verificationObservation]?.attempt == null + ) { + return await observeAttempt( + { + ...options, + [verificationObservation]: options[verificationObservation] ?? + { attempts: [] }, + }, + "linkedData", + (observation) => + verifyJsonLdInternal(jsonLd, { + ...options, + [verificationObservation]: observation, + }, compact), + (value) => value, + ); + } + const observation = options[verificationObservation]; const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(metadata.name, metadata.version); return await tracer.startActiveSpan( @@ -1130,6 +1174,12 @@ async function verifyJsonLdInternal( : jsonLd : jsonLd; const object = await Object.fromJsonLd(compacted, verificationOptions); + observation?.parsedObject?.(object); + if (observation?.subject != null) { + observation.subject.id = object.id == null + ? null + : new URL(object.id.href); + } if (object.id != null) { span.setAttribute("activitypub.object.id", object.id.href); } @@ -1159,13 +1209,27 @@ async function verifyJsonLdInternal( for (const uri of object.actorIds) attributions.add(uri.href); } const key = await verifySignature(compacted, verificationOptions); - if (key == null) return false; + if (key == null) { + if (observation?.attempt != null && !hasLdSignatureProperty(jsonLd)) { + observation.attempt.reason = { type: "noSignature" }; + } + return false; + } if (key.ownerId == null) { + if (observation?.attempt != null) { + observation.attempt.reason = { type: "missingOwner" }; + } logger.debug("Key {keyId} has no owner.", { keyId: key.id?.href }); return false; } attributions.delete(key.ownerId.href); if (attributions.size > 0) { + if (observation?.attempt != null) { + observation.attempt.reason = { + type: "uncoveredAttribution", + attributionIds: [...attributions].map((id) => new URL(id)), + }; + } logger.debug( "Some attributions are not authenticated by the Linked Data " + "Signatures: {attributions}.", @@ -1187,6 +1251,13 @@ async function verifyJsonLdInternal( // or a primitive) counts as "had signature" for classification, so // malformed signatures are reported as `rejected` rather than as // `missing`. + if (observation?.attempt?.reason != null) { + span.setAttribute( + "activitypub.verification.failure_reason", + observation.attempt.reason.type, + ); + span.setStatus({ code: SpanStatusCode.ERROR }); + } const classified: SignatureVerificationResult = threw ? "error" : verified @@ -1199,7 +1270,15 @@ async function verifyJsonLdInternal( getDurationMs(start), "linked_data", classified, - { ldType: signatureType }, + { + ldType: signatureType, + verificationFailureReason: verified || threw + ? undefined + : observation?.attempt?.reason?.type ?? + (hasLdSignatureProperty(jsonLd) + ? "signatureVerificationFailed" + : "noSignature"), + }, ); span.end(); } diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index 422e8309a..cf5a629c1 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -24,6 +24,7 @@ import { importMultibaseKey, parseIri, } from "@fedify/vocab-runtime"; +import jsonld from "@fedify/vocab-runtime/jsonld"; import { assert, assertEquals, @@ -49,12 +50,14 @@ import { createProof, hasProofLike, signObject, + verifyMapLocalProof, verifyObject, type VerifyObjectOptions, verifyPortableObjectProof, verifyProof, type VerifyProofOptions, } from "./proof.ts"; +import { verificationObservation } from "./verification.ts"; // Test vector from : const fep8b32TestVectorPrivateKey = await crypto.subtle.importKey( @@ -1363,6 +1366,97 @@ test("verifyProof() records verification duration metric", async (t) => { ); }); +test("proof verification processes raw declaration contexts only for observers", async (t) => { + const options = { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }; + const proofUrl = `ap://did:key:${portableDidMethod}/proofs/diagnostics`; + const signed = await signPortableJsonLd({ + "@context": portableContext, + id: `ap://did:key:${portableDidMethod}/objects/diagnostics`, + type: "Note", + attributedTo: `ap://did:key:${portableDidMethod}/actor`, + content: "Observed proof", + }, { proofOptions: { id: proofUrl } }); + const rawProof = signed.proof; + const proof = await DataIntegrityProof.fromJsonLd(rawProof, options); + const referenced = { ...signed }; + delete referenced.proof; + referenced["https://w3id.org/security#proof"] = { + "@graph": [{ "@id": proofUrl }], + }; + const cases: { + name: string; + verify: (options: VerifyProofOptions) => Promise; + }[] = [ + { + name: "object", + verify: async (options) => + await verifyObject(Note, structuredClone(signed), options) != null, + }, + { + name: "portable", + verify: async (options) => + (await verifyPortableObjectProof(structuredClone(signed), options)) + .verified, + }, + { + name: "map-local", + verify: async (options) => + await verifyMapLocalProof(structuredClone(signed), options) != null, + }, + { + name: "proof", + verify: async (options) => + await verifyProof(structuredClone(signed), proof, options) != null, + }, + { + name: "remote proof", + verify: async (options) => + await verifyObject(Note, structuredClone(referenced), { + ...options, + documentLoader: (url) => + Promise.resolve({ + contextUrl: null, + documentUrl: url, + document: structuredClone(rawProof), + }), + }) != null, + }, + ]; + for (const { name, verify } of cases) { + await t.step(name, async () => { + const original = jsonld.processContext; + let calls = 0; + jsonld.processContext = (...args: Parameters) => { + calls++; + return original(...args); + }; + try { + assert(await verify(options)); + const unobservedCalls = calls; + calls = 0; + assert( + await verify({ + ...options, + [verificationObservation]: { + attempts: [], + attempt: { checks: [] }, + }, + }), + ); + assert( + calls > unobservedCalls, + "Raw declaration processing should be absent from unobserved verification", + ); + } finally { + jsonld.processContext = original; + } + }); + } +}); + test("verifyPortableObjectProof()", async (t) => { const options: VerifyProofOptions = { documentLoader() { diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 2f4b66a45..29162260b 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -1,3 +1,10 @@ +import { + observeAttempt, + observeCheck, + triedKey, + verificationObservation, + type VerificationObservationOptions, +} from "./verification.ts"; import { Activity, DataIntegrityProof, @@ -449,7 +456,7 @@ export async function signObject( * Options for {@link verifyProof}. * @since 0.10.0 */ -export interface VerifyProofOptions { +export interface VerifyProofOptions extends VerificationObservationOptions { /** * The security domain expected by the verifier. When specified, it must * contain the same strings as the proof's `domain` option. @@ -625,6 +632,24 @@ export async function verifyMapLocalProof( ) { return null; } + const rawProof = jsonLd.proof; + const previousChecks = options[verificationObservation]?.attempt?.checks + .length; + const malformed = () => + observeCheck( + options, + { + mechanism: "objectIntegrity", + proofId: typeof rawProof.id === "string" + ? rawProof.id + : typeof rawProof["@id"] === "string" + ? rawProof["@id"] + : null, + proofIndex: previousChecks ?? null, + }, + getDeclaredProofKeyId(rawProof), + () => Promise.resolve(null), + ); const proofContextLoader = getNormalizationContextLoader( preloadedOnlyDocumentLoader, ); @@ -635,7 +660,7 @@ export async function verifyMapLocalProof( options, proofContextLoader, ); - if (candidate.proof == null) return null; + if (candidate.proof == null) return await malformed(); return await verifyProofWithMessageDigestCache( jsonLd, candidate.proof, @@ -644,10 +669,33 @@ export async function verifyMapLocalProof( candidate, ); } catch { + if ( + previousChecks != null && + options[verificationObservation]?.attempt?.checks.length === + previousChecks + ) return await malformed(); return null; } } +function getDeclaredProofKeyId( + value: unknown, + propertyNames: Iterable = [ + "verificationMethod", + "https://w3id.org/security#verificationMethod", + ], +): string | null { + if (!isJsonLdNode(value)) return null; + const method = Array.from(propertyNames, (name) => value[name]).find((v) => + v != null + ); + const candidate = Array.isArray(method) ? method[0] : method; + if (typeof candidate === "string") return candidate; + return isJsonLdNode(candidate) && typeof candidate["@id"] === "string" + ? candidate["@id"] + : null; +} + async function verifyProofWithMessageDigestCache( jsonLd: unknown, proof: DataIntegrityProof, @@ -657,6 +705,48 @@ async function verifyProofWithMessageDigestCache( // See the call in verifyPortableObjectProof() for why this exists. keyIdBoundByCaller = false, ): Promise { + if (options[verificationObservation] == null) { + return await verifyProofWithMessageDigestCache( + jsonLd, + proof, + { + ...options, + [verificationObservation]: { + attempts: [], + attempt: { checks: [] }, + captureRawKeyIds: false, + }, + }, + messageDigestCache, + rawProofCandidate, + keyIdBoundByCaller, + ); + } + if ( + options[verificationObservation]?.attempt != null && + options[verificationObservation]?.check == null + ) { + return await observeCheck( + options, + { + mechanism: "objectIntegrity", + proofId: proof.id?.href ?? null, + proofIndex: options[verificationObservation]!.attempt!.checks.length, + }, + rawProofCandidate?.declaredKeyId ?? + getDeclaredProofKeyId(rawProofCandidate?.value) ?? + proof.verificationMethodId?.href ?? null, + (observation) => + verifyProofWithMessageDigestCache( + jsonLd, + proof, + { ...options, [verificationObservation]: observation }, + messageDigestCache, + rawProofCandidate, + keyIdBoundByCaller, + ), + ); + } const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(metadata.name, metadata.version); return await tracer.startActiveSpan( @@ -699,8 +789,14 @@ async function verifyProofWithMessageDigestCache( rawProofCandidate, keyIdBoundByCaller, ); - if (key == null) span.setStatus({ code: SpanStatusCode.ERROR }); - else verified = true; + if (key == null) { + span.setStatus({ code: SpanStatusCode.ERROR }); + span.setAttribute( + "activitypub.verification.failure_reason", + options[verificationObservation]?.check?.reason?.type ?? + "invalidSignature", + ); + } else verified = true; return key; } catch (error) { threw = true; @@ -720,7 +816,13 @@ async function verifyProofWithMessageDigestCache( getDurationMs(start), "object_integrity", classified, - { cryptosuite }, + { + cryptosuite, + verificationFailureReason: verified || threw + ? undefined + : options[verificationObservation]?.check?.reason?.type ?? + "invalidSignature", + }, ); span.end(); } @@ -774,18 +876,29 @@ async function getJsonLdPropertyNames( documentLoader: DocumentLoader = preloadedOnlyDocumentLoader, inheritedContext?: unknown, rejectOnContextError = false, + contextState?: { initial?: unknown; scoped?: unknown; active?: unknown }, ): Promise | null> { const names = new Set(defaults); const context = jsonLd["@context"] ?? inheritedContext; - if (context == null) return names; + if (context == null && contextState?.initial == null) return names; try { const options = { documentLoader }; - let activeContext = await jsonld.processContext(null, null, options); - activeContext = await jsonld.processContext( - activeContext, - context, - options, - ); + let activeContext = contextState?.initial ?? + await jsonld.processContext(null, null, options); + if (contextState?.scoped != null) { + activeContext = await jsonld.processContext( + activeContext, + contextState.scoped, + options, + ); + } + if (context != null) { + activeContext = await jsonld.processContext( + activeContext, + context, + options, + ); + } const typeScopedContext = activeContext; for (const key of globalThis.Object.keys(jsonLd).sort()) { if ( @@ -812,6 +925,7 @@ async function getJsonLdPropertyNames( } } } + if (contextState != null) contextState.active = activeContext; for (const key of globalThis.Object.keys(jsonLd)) { if (expandContextPropertyIri(activeContext, key) === propertyIri) { names.add(key); @@ -962,6 +1076,72 @@ interface RawProofCandidate { readonly value: unknown; readonly proof: DataIntegrityProof | null; readonly reference?: string; + readonly declaredKeyId?: string | null; +} + +// Replay contexts already read by the vocabulary parser when resolving raw +// property aliases. Diagnostic extraction never dereferences another context. +function recordProofContexts(documentLoader: DocumentLoader): { + loader: DocumentLoader; + replay: DocumentLoader; +} { + const documents = new Map(); + return { + loader: async (url, options) => { + const document = await documentLoader(url, options); + documents.set(url, document); + return document; + }, + replay: (url, options) => { + const document = documents.get(url); + return document == null + ? preloadedOnlyDocumentLoader(url, options) + : Promise.resolve(document); + }, + }; +} + +async function getAliasedDeclaredProofKeyId( + value: unknown, + inheritedContext: unknown, + contextLoader: DocumentLoader, +): Promise { + if (!isJsonLdNode(value)) return null; + const contextState: { active?: unknown } = {}; + const names = await getJsonLdPropertyNames( + value, + "https://w3id.org/security#verificationMethod", + ["verificationMethod", "https://w3id.org/security#verificationMethod"], + contextLoader, + inheritedContext, + false, + contextState, + ); + const methodName = Array.from(names ?? []).find((name) => + value[name] != null + ); + const method = methodName == null ? undefined : value[methodName]; + const candidate = Array.isArray(method) ? method[0] : method; + if (typeof candidate === "string") return candidate; + if (!isJsonLdNode(candidate)) return null; + const idNames = await getJsonLdPropertyNames( + candidate, + "@id", + ["@id"], + contextLoader, + undefined, + false, + { + initial: contextState.active, + scoped: contextState.active == null || methodName == null + ? undefined + : jsonld.getContextValue(contextState.active, methodName, "@context"), + }, + ); + for (const name of idNames ?? []) { + if (typeof candidate[name] === "string") return candidate[name]; + } + return null; } function normalizeDocumentUrl(url: string): string { @@ -989,6 +1169,10 @@ async function parseRawProofCandidates( const candidates: RawProofCandidate[] = []; for (const value of values) { let parsed: DataIntegrityProof | null = null; + const contexts = options[verificationObservation] != null && + options[verificationObservation]?.captureRawKeyIds !== false + ? recordProofContexts(documentLoader) + : undefined; if (isJsonLdNode(value)) { const proofJsonLd = value["@context"] == null && jsonLd["@context"] != null @@ -997,7 +1181,7 @@ async function parseRawProofCandidates( try { parsed = await DataIntegrityProof.fromJsonLd( proofJsonLd, - { ...options, contextLoader: documentLoader }, + { ...options, contextLoader: contexts?.loader ?? documentLoader }, ); } catch { // Malformed sibling proofs cannot match a typed proof. @@ -1007,6 +1191,13 @@ async function parseRawProofCandidates( value, proof: parsed, reference: getRawProofReference(value) ?? parsed?.id?.href, + declaredKeyId: contexts == null + ? undefined + : await getAliasedDeclaredProofKeyId( + value, + jsonLd["@context"], + contexts.replay, + ), }); } return candidates; @@ -1505,8 +1696,13 @@ async function verifyProofInternal( const digest = new Uint8Array(proofDigest.byteLength + SHA256_LENGTH); digest.set(new Uint8Array(proofDigest), 0); const proofValue = proof.proofValue; + let keyTried = false; const verifyCandidate = async (msgDigest: ArrayBuffer): Promise => { digest.set(new Uint8Array(msgDigest), proofDigest.byteLength); + if (!keyTried) { + triedKey(options, publicKey); + keyTried = true; + } return await crypto.subtle.verify( "Ed25519", publicKey.publicKey, @@ -2231,8 +2427,57 @@ export async function verifyObject( jsonLd: unknown, options: VerifyObjectOptions = {}, ): Promise { + if (options[verificationObservation]?.attempt == null) { + const observedOptions = { + ...options, + [verificationObservation]: options[verificationObservation] ?? + { attempts: [], captureRawKeyIds: false }, + }; + const tracer = (options.tracerProvider ?? trace.getTracerProvider()) + .getTracer(metadata.name, metadata.version); + return await tracer.startActiveSpan( + "object_integrity_proofs.verify_object", + async (span) => { + try { + return await observeAttempt( + observedOptions, + "objectIntegrity", + async (observation) => { + const object = await verifyObject(cls, jsonLd, { + ...options, + [verificationObservation]: observation, + }); + if (object == null) { + span.setStatus({ code: SpanStatusCode.ERROR }); + span.setAttribute( + "activitypub.verification.failure_reason", + observation.attempt?.reason?.type ?? + "signatureVerificationFailed", + ); + } + return object; + }, + (value) => value != null, + ); + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: String(error), + }); + throw error; + } finally { + span.end(); + } + }, + ); + } + const observation = options[verificationObservation]; const logger = getLogger(["fedify", "sig", "proof"]); const object = await cls.fromJsonLd(jsonLd, options); + observation?.parsedObject?.(object); + if (observation?.subject != null) { + observation.subject.id = object.id == null ? null : new URL(object.id.href); + } const defaultDocumentLoader = getDocumentLoader(); const proofContextLoader = options.contextLoader ?? defaultDocumentLoader; const attributions = new Set(object.attributionIds.map((uri) => uri.href)); @@ -2288,12 +2533,16 @@ export async function verifyObject( if (candidateIndex >= 0) { hydratedCandidates.add(candidateIndex); let parsed: DataIntegrityProof | null = null; + const contexts = options[verificationObservation] != null && + options[verificationObservation]?.captureRawKeyIds !== false + ? recordProofContexts(proofContextLoader) + : undefined; try { parsed = await DataIntegrityProof.fromJsonLd( remoteDocument.document, { documentLoader: baseDocumentLoader, - contextLoader: proofContextLoader, + contextLoader: contexts?.loader ?? proofContextLoader, tracerProvider: options.tracerProvider, baseUrl: parseIri(remoteDocument.documentUrl), }, @@ -2305,6 +2554,13 @@ export async function verifyObject( value: structuredClone(remoteDocument.document), proof: parsed, reference, + declaredKeyId: contexts == null + ? undefined + : await getAliasedDeclaredProofKeyId( + remoteDocument.document, + undefined, + contexts.replay, + ), }; } return remoteDocument; @@ -2357,6 +2613,15 @@ export async function verifyObject( return null; } if (attributions.size > 0) { + if (observation?.attempt != null) { + observation.attempt.reason = observation.attempt.checks.length === 0 + ? { type: "noSignature" } + : { + type: "uncoveredAttribution", + attributionIds: [...attributions].map((id) => new URL(id)), + }; + } + logger.debug( "Some attributions are not authenticated by the proofs: {attributions}.", { attributions: [...attributions] }, diff --git a/packages/fedify/src/sig/verification.test.ts b/packages/fedify/src/sig/verification.test.ts new file mode 100644 index 000000000..9d223e9a2 --- /dev/null +++ b/packages/fedify/src/sig/verification.test.ts @@ -0,0 +1,428 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { Activity, Create, CryptographicKey, Multikey } from "@fedify/vocab"; +import { FetchError } from "@fedify/vocab-runtime"; +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import { signRequest, verifyRequestDetailed } from "./http.ts"; +import { signJsonLd, verifyJsonLd } from "./ld.ts"; +import { signObject, verifyObject } from "./proof.ts"; +import { + type VerificationObservation, + verificationObservation, +} from "./verification.ts"; +import { + ed25519Multikey, + ed25519PrivateKey, + rsaPrivateKey2, + rsaPublicKey1, + rsaPublicKey2, +} from "../testing/keys.ts"; + +const loaders = { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, +}; +function observation(): VerificationObservation { + return { attempts: [] }; +} + +test("LDS evidence has no checks for absent or malformed signatures", async () => { + const json = await new Create({ actor: rsaPublicKey2.ownerId }).toJsonLd( + loaders, + ); + for (const signature of [undefined, null, "invalid", [], {}]) { + const evidence = observation(); + const value = signature === undefined + ? json + : { ...json as Record, signature }; + strictEqual( + await verifyJsonLd(value, { + ...loaders, + [verificationObservation]: evidence, + }), + false, + ); + const attempt = evidence.attempts[0]; + ok(attempt.status === "rejected"); + strictEqual( + attempt.reason.type, + signature === undefined ? "noSignature" : "signatureVerificationFailed", + ); + deepStrictEqual(attempt.checks, []); + } +}); + +test("signature evidence preserves stale and fresh HTTP keys for both specs", async () => { + for (const spec of ["draft-cavage-http-signatures-12", "rfc9421"] as const) { + const evidence = observation(); + const signed = await signRequest( + new Request("https://example.com/inbox", { method: "POST", body: "{}" }), + rsaPrivateKey2, + rsaPublicKey2.id!, + { spec }, + ); + const stale = rsaPublicKey1.clone({ id: rsaPublicKey2.id }); + const result = await verifyRequestDetailed(signed, { + ...loaders, + [verificationObservation]: evidence, + keyCache: { + get: () => Promise.resolve(stale), + set: () => Promise.resolve(), + }, + }); + strictEqual(result.verified, true); + const attempt = evidence.attempts[0]; + ok(attempt.status === "verified"); + strictEqual(attempt.checks.length, 1); + const check = attempt.checks[0]; + ok(check.status === "verified"); + strictEqual(check.triedKeys.length, 2); + strictEqual(check.triedKeys[0].publicKey, rsaPublicKey1.publicKey); + // The freshly loaded key has the same material as key2. + deepStrictEqual( + await crypto.subtle.exportKey("jwk", check.triedKeys[1].publicKey), + await crypto.subtle.exportKey("jwk", rsaPublicKey2.publicKey), + ); + strictEqual(check.key, check.triedKeys[1]); + strictEqual(attempt.signatures[0], check); + } +}); + +test("signature evidence retains the failed key when HTTP refresh cannot load", async () => { + for (const spec of ["draft-cavage-http-signatures-12", "rfc9421"] as const) { + const evidence = observation(); + const signed = await signRequest( + new Request("https://example.com/inbox", { method: "POST", body: "{}" }), + rsaPrivateKey2, + rsaPublicKey2.id!, + { spec }, + ); + const response = new Response(null, { status: 410 }); + const result = await verifyRequestDetailed(signed, { + ...loaders, + [verificationObservation]: evidence, + documentLoader: () => + Promise.reject(new FetchError(rsaPublicKey2.id!, "gone", response)), + keyCache: { + get: () => + Promise.resolve(rsaPublicKey1.clone({ id: rsaPublicKey2.id })), + set: () => Promise.resolve(), + }, + }); + strictEqual(result.verified, false); + const check = evidence.attempts[0].checks[0]; + ok(check.status === "rejected"); + strictEqual(check.reason.type, "keyFetchError"); + strictEqual(check.triedKeys[0].publicKey, rsaPublicKey1.publicKey); + } +}); + +test("signature evidence preserves failed LDS keys and successful stale-key refresh", async () => { + const activity = await new Create({ + actor: new URL("https://example.com/person"), + }).toJsonLd(loaders); + const signed = await signJsonLd( + activity, + rsaPrivateKey2, + rsaPublicKey2.id!, + loaders, + ); + const evidence = observation(); + // key2 has no owner, so crypto verifies but full LDS authentication rejects. + strictEqual( + await verifyJsonLd(signed, { + ...loaders, + [verificationObservation]: evidence, + keyCache: { + get: () => + Promise.resolve(rsaPublicKey1.clone({ id: rsaPublicKey2.id })), + set: () => Promise.resolve(), + }, + }), + false, + ); + const attempt = evidence.attempts[0]; + ok(attempt.status === "rejected"); + strictEqual(attempt.reason.type, "missingOwner"); + strictEqual(attempt.checks[0].status, "verified"); + strictEqual(attempt.checks[0].triedKeys.length, 2); + const bad = observation(); + strictEqual( + await verifyJsonLd(signed, { + ...loaders, + [verificationObservation]: bad, + documentLoader: async () => ({ + contextUrl: null, + documentUrl: rsaPublicKey2.id!.href, + document: await new CryptographicKey({ + id: rsaPublicKey2.id, + publicKey: rsaPublicKey1.publicKey, + }).toJsonLd(loaders), + }), + }), + false, + ); + strictEqual(bad.attempts[0].checks[0].status, "rejected"); + strictEqual(bad.attempts[0].checks[0].triedKeys.length, 1); +}); + +test("signature evidence preserves multiple valid proofs despite uncovered attribution", async () => { + const activity = new Create({ actor: new URL("https://example.com/person") }); + const first = await signObject( + activity, + ed25519PrivateKey, + ed25519Multikey.id!, + loaders, + ); + const second = await signObject( + first, + ed25519PrivateKey, + ed25519Multikey.id!, + loaders, + ); + const evidence = observation(); + strictEqual( + await verifyObject(Activity, await second.toJsonLd(loaders), { + ...loaders, + [verificationObservation]: evidence, + }), + null, + ); + const attempt = evidence.attempts[0]; + ok(attempt.status === "rejected"); + strictEqual(attempt.reason.type, "uncoveredAttribution"); + strictEqual(attempt.checks.length, 2); + ok(attempt.checks.every((check) => check.status === "verified")); +}); + +test("signature evidence preserves OIP stale keys when fresh verification fails", async () => { + const signed = await signObject( + new Create({ actor: ed25519Multikey.controllerId }), + ed25519PrivateKey, + ed25519Multikey.id!, + loaders, + ); + const pair = await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ]) as CryptoKeyPair; + const stale = new Multikey({ + id: ed25519Multikey.id, + controller: null, + publicKey: pair.publicKey, + }); + const evidence = observation(); + strictEqual( + await verifyObject(Activity, await signed.toJsonLd(loaders), { + ...loaders, + [verificationObservation]: evidence, + keyCache: { + get: () => Promise.resolve(stale), + set: () => Promise.resolve(), + }, + documentLoader: async () => ({ + contextUrl: null, + documentUrl: ed25519Multikey.id!.href, + document: await stale.toJsonLd(loaders), + }), + }), + null, + ); + const check = evidence.attempts[0].checks[0]; + strictEqual(check.status, "rejected"); + strictEqual(check.triedKeys.length, 2); + strictEqual(check.triedKeys[0].publicKey, pair.publicKey); +}); + +test("OIP check preserves the literal declared key ID", async () => { + const signed = await signObject( + new Create({ actor: ed25519Multikey.controllerId }), + ed25519PrivateKey, + ed25519Multikey.id!, + loaders, + ); + const json = await signed.toJsonLd(loaders) as Record; + const proof = json.proof as Record; + const declaredKeyId = "https://EXAMPLE.com/person2#key4"; + proof.verificationMethod = declaredKeyId; + const evidence = observation(); + await verifyObject(Activity, json, { + ...loaders, + [verificationObservation]: evidence, + }); + strictEqual(evidence.attempts[0].checks[0].declaredKeyId, declaredKeyId); +}); + +test("signature observation cannot turn a cached miss into a metadata exception", async () => { + const json = await signJsonLd( + await new Create({ actor: new URL("https://example.com/person") }).toJsonLd( + loaders, + ), + rsaPrivateKey2, + rsaPublicKey2.id!, + loaders, + ); + const evidence = observation(); + const cache = { + get: () => Promise.resolve(null), + set: () => Promise.resolve(), + getFetchError: () => Promise.reject(new Error("diagnostic unavailable")), + }; + strictEqual( + await verifyJsonLd(json, { + ...loaders, + keyCache: cache, + [verificationObservation]: evidence, + }), + false, + ); + strictEqual(evidence.attempts[0].checks[0].status, "rejected"); +}); + +test("signature observation preserves rejection for consumed fetch-error responses", async () => { + const json = await signJsonLd( + await new Create({ actor: new URL("https://example.com/person") }).toJsonLd( + loaders, + ), + rsaPrivateKey2, + rsaPublicKey2.id!, + loaders, + ); + const response = new Response("gone", { status: 410 }); + await response.text(); + const error = new FetchError(rsaPublicKey2.id!, "gone", response); + const options = { + ...loaders, + documentLoader: () => Promise.reject(error), + }; + strictEqual(await verifyJsonLd(json, options), false); + const evidence = observation(); + strictEqual( + await verifyJsonLd(json, { + ...options, + [verificationObservation]: evidence, + }), + false, + ); + const check = evidence.attempts[0].checks[0]; + ok(check.status === "rejected" && check.reason.type === "keyFetchError"); + let cloneThrows = false; + try { + response.clone(); + } catch { + cloneThrows = true; + } + if (cloneThrows) { + ok("error" in check.reason.result); + strictEqual(check.reason.result.error, error); + } else { + ok("status" in check.reason.result); + strictEqual(check.reason.result.status, 410); + } +}); + +test("OIP check retains aliased declarations without extra context fetches", async () => { + const signed = await signObject( + new Create({ actor: ed25519Multikey.controllerId }), + ed25519PrivateKey, + ed25519Multikey.id!, + loaders, + ); + const json = await signed.toJsonLd(loaders) as Record; + const proof = json.proof as Record; + const context = { + vm: { + "@id": "https://w3id.org/security#verificationMethod", + "@type": "@id", + }, + }; + const contextUrl = "https://example.com/custom-proof-context"; + json["@context"] = [...json["@context"] as unknown[], contextUrl]; + proof["@context"] = [...proof["@context"] as unknown[], contextUrl]; + const declaredKeyId = "https://EXAMPLE.com/person2#key4"; + proof.vm = declaredKeyId; + delete proof.verificationMethod; + const run = async (evidence?: VerificationObservation) => { + let fetches = 0; + await verifyObject(Activity, structuredClone(json), { + ...loaders, + contextLoader: async (url, options) => { + fetches++; + if (url === contextUrl) { + return { + contextUrl: null, + documentUrl: url, + document: { "@context": context }, + }; + } + return await mockDocumentLoader(url, options); + }, + ...(evidence == null ? {} : { [verificationObservation]: evidence }), + }); + return fetches; + }; + const baselineFetches = await run(); + const evidence = observation(); + strictEqual(await run(evidence), baselineFetches); + strictEqual(evidence.attempts[0].checks[0].declaredKeyId, declaredKeyId); +}); + +test("OIP check preserves compact node identifiers and their aliases", async () => { + for (const idProperty of ["id", "rawId", "scopedId"]) { + const signed = await signObject( + new Create({ actor: ed25519Multikey.controllerId }), + ed25519PrivateKey, + ed25519Multikey.id!, + loaders, + ); + const json = await signed.toJsonLd(loaders) as Record; + const proof = json.proof as Record; + const context = { + rawId: "@id", + vm: { + "@id": "https://w3id.org/security#verificationMethod", + "@type": "@id", + "@context": { scopedId: "@id" }, + }, + }; + json["@context"] = [...json["@context"] as unknown[], context]; + proof["@context"] = [...proof["@context"] as unknown[], context]; + const declaredKeyId = "https://EXAMPLE.com/person2#key4"; + if (idProperty === "scopedId") { + proof.vm = { [idProperty]: declaredKeyId }; + delete proof.verificationMethod; + } else { + proof.verificationMethod = { [idProperty]: declaredKeyId }; + } + const evidence = observation(); + await verifyObject(Activity, json, { + ...loaders, + [verificationObservation]: evidence, + }); + strictEqual(evidence.attempts[0].checks[0].declaredKeyId, declaredKeyId); + } +}); + +test("signature evidence excludes keys loaded before canonicalization errors", async () => { + const signed = await signObject( + new Create({ actor: ed25519Multikey.controllerId }), + ed25519PrivateKey, + ed25519Multikey.id!, + loaders, + ); + const json = await signed.toJsonLd(loaders) as Record; + json.extra = JSON.parse("1e400"); + const evidence = observation(); + let thrown = false; + try { + await verifyObject(Activity, json, { + ...loaders, + [verificationObservation]: evidence, + }); + } catch { + thrown = true; + } + strictEqual(thrown, true); + const check = evidence.attempts[0].checks[0]; + strictEqual(check.status, "error"); + deepStrictEqual(check.triedKeys, []); +}); diff --git a/packages/fedify/src/sig/verification.ts b/packages/fedify/src/sig/verification.ts new file mode 100644 index 000000000..5008f23a7 --- /dev/null +++ b/packages/fedify/src/sig/verification.ts @@ -0,0 +1,280 @@ +import type { + CompoundPortableObjectFailureReason, + CompoundProofDiscoveryFailureReason, +} from "./compound-proof.ts"; +import { CryptographicKey, type Multikey } from "@fedify/vocab"; +import type { HttpMessageSignaturesSpec } from "./http.ts"; +import type { FetchKeyErrorResult } from "./key.ts"; +import type { VerifyPortableObjectProofFailureReason } from "./proof.ts"; + +/** + * A snapshot of a key actually used to check a signature. + * @since 2.4.0 + */ +export type InboxVerificationKey = + | { + readonly type: "cryptographicKey"; + readonly id: URL | null; + readonly ownerId: URL | null; + readonly publicKey: CryptoKey; + } + | { + readonly type: "multikey"; + readonly id: URL | null; + readonly controllerId: URL | null; + readonly publicKey: CryptoKey; + }; + +/** + * Why a cryptographic check failed. + * @since 2.4.0 + */ +export type InboxSignatureFailureReason = + | { readonly type: "invalidSignature" } + | { + readonly type: "keyFetchError"; + readonly keyId: URL; + readonly result: FetchKeyErrorResult; + }; + +/** + * The signature or proof being checked. + * @since 2.4.0 + */ +export type InboxSignatureIdentity = + | { + readonly mechanism: "http"; + readonly spec: HttpMessageSignaturesSpec; + readonly label: string | null; + } + | { readonly mechanism: "linkedData" } + | { + readonly mechanism: "objectIntegrity"; + readonly proofId: string | null; + /** Index within this evaluation, not a global proof identifier. */ + readonly proofIndex: number | null; + }; + +/** + * Evidence from one signature/proof, including cached-key retries. + * @since 2.4.0 + */ +export type InboxSignatureCheck = + & InboxSignatureIdentity + & { + /** The declaration as received, including invalid IRIs. */ + readonly declaredKeyId: string | null; + /** Keys actually tried, in use order; refresh details are not a retry API. */ + readonly triedKeys: readonly InboxVerificationKey[]; + } + & ( + | { readonly status: "verified"; readonly key: InboxVerificationKey } + | { + readonly status: "rejected"; + readonly reason: InboxSignatureFailureReason; + } + | { readonly status: "error"; readonly error: unknown } + ); + +/** + * A bounded proof-policy diagnostic. + * @since 2.4.0 + */ +export type InboxProofPolicyFailureReason = + | VerifyPortableObjectProofFailureReason + | CompoundPortableObjectFailureReason + | CompoundProofDiscoveryFailureReason + | { + readonly type: "invalidObjectId" | "invalidDocument" | "subjectMismatch"; + }; + +/** + * Why a complete verification evaluation was rejected. + * @since 2.4.0 + */ +export type InboxVerificationFailureReason = + | { readonly type: "noSignature" } + | { readonly type: "signatureVerificationFailed" } + | { + readonly type: "uncoveredAttribution"; + readonly attributionIds: readonly URL[]; + } + | { readonly type: "missingOwner" } + | { + readonly type: "proofPolicy"; + readonly reason: InboxProofPolicyFailureReason; + }; + +/** + * A logical verification evaluation, separate from inbox authentication. + * @since 2.4.0 + */ +export type InboxVerificationAttempt = + & { + readonly mechanism: "http" | "linkedData" | "objectIntegrity"; + readonly subject: { + readonly id: URL | null; + /** RFC 6901 pointer; empty for the root, null when unavailable. */ + readonly pointer: string | null; + }; + readonly checks: readonly InboxSignatureCheck[]; + } + & ( + | { + readonly status: "verified"; + /** Direct references to successful members of checks. */ + readonly signatures: readonly Extract< + InboxSignatureCheck, + { status: "verified" } + >[]; + } + | { + readonly status: "rejected"; + readonly reason: InboxVerificationFailureReason; + } + | { readonly status: "error"; readonly error: unknown } + ); + +// Internal observation follows options, including recursive retries. It never +// runs application code inside a verifier or uses ambient/global request state. +export const verificationObservation: unique symbol = Symbol( + "verificationObservation", +); +export interface VerificationObservationOptions { + [verificationObservation]?: VerificationObservation; +} +export interface VerificationObservation { + readonly attempts: InboxVerificationAttempt[]; + /** False for internal telemetry collectors; observers capture raw IDs by default. */ + readonly captureRawKeyIds?: boolean; + readonly parsedObject?: (object: unknown) => void; + readonly subject?: { id: URL | null; pointer: string | null }; + readonly attempt?: AttemptBuilder; + readonly check?: CheckBuilder; +} +export interface AttemptBuilder { + checks: InboxSignatureCheck[]; + reason?: InboxVerificationFailureReason; +} +export interface CheckBuilder { + identity: InboxSignatureIdentity; + declaredKeyId: string | null; + triedKeys: InboxVerificationKey[]; + reason?: InboxSignatureFailureReason; +} + +export function snapshotKey( + key: CryptographicKey | Multikey, +): InboxVerificationKey { + if (key.publicKey == null) { + throw new TypeError("Verification key has no public key."); + } + const id = key.id == null ? null : new URL(key.id.href); + return key instanceof CryptographicKey + ? { + type: "cryptographicKey", + id, + ownerId: key.ownerId == null ? null : new URL(key.ownerId.href), + publicKey: key.publicKey, + } + : { + type: "multikey", + id, + controllerId: key.controllerId == null + ? null + : new URL(key.controllerId.href), + publicKey: key.publicKey, + }; +} + +export function triedKey( + options: VerificationObservationOptions, + key: CryptographicKey | Multikey, +): void { + const check = options[verificationObservation]?.check; + if (check != null) check.triedKeys.push(snapshotKey(key)); +} + +export async function observeCheck( + options: VerificationObservationOptions, + identity: InboxSignatureIdentity, + declaredKeyId: string | null, + operation: (observation: VerificationObservation) => Promise, +): Promise { + const observation = options[verificationObservation]; + if (observation?.attempt == null) { + return await operation(observation ?? { attempts: [] }); + } + const check: CheckBuilder = { identity, declaredKeyId, triedKeys: [] }; + const base = { ...identity, declaredKeyId, triedKeys: check.triedKeys }; + try { + const key = await operation({ ...observation, check }); + observation.attempt.checks.push( + key == null + ? { + ...base, + status: "rejected", + reason: check.reason ?? { type: "invalidSignature" }, + } + : { + ...base, + status: "verified", + key: check.triedKeys.at(-1) ?? snapshotKey(key), + }, + ); + return key; + } catch (error) { + observation.attempt.checks.push({ ...base, status: "error", error }); + throw error; + } +} + +export async function observeAttempt( + options: VerificationObservationOptions, + mechanism: InboxVerificationAttempt["mechanism"], + operation: (observation: VerificationObservation) => Promise, + isVerified: (value: T) => boolean, +): Promise { + const observation = options[verificationObservation]; + if (observation == null) return await operation({ attempts: [] }); + const attempt: AttemptBuilder = { checks: [] }; + const base = { + mechanism, + subject: observation.subject == null ? { id: null, pointer: "" } : { + id: observation.subject.id == null + ? null + : new URL(observation.subject.id.href), + pointer: observation.subject.pointer, + }, + checks: attempt.checks, + }; + try { + const value = await operation({ + ...observation, + subject: base.subject, + attempt, + check: undefined, + }); + observation.attempts.push( + isVerified(value) + ? { + ...base, + status: "verified", + signatures: attempt.checks.filter(( + check, + ): check is Extract => + check.status === "verified" + ), + } + : { + ...base, + status: "rejected", + reason: attempt.reason ?? { type: "signatureVerificationFailed" }, + }, + ); + return value; + } catch (error) { + observation.attempts.push({ ...base, status: "error", error }); + throw error; + } +} diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index cc3b453af..3f0b6ed9a 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -183,6 +183,13 @@ test("reset clears sent activities", async () => { assertEquals(mockFederation.sentActivities.length, 0); }); +test("MockFederation accepts inbox request observation registration", () => { + const federation = createFederation(); + const setters = federation.setInboxListeners("/users/{identifier}/inbox"); + assertStrictEquals(setters.onRequestFinished(() => {}), setters); + assertStrictEquals(setters.on(Create, () => {}), setters); +}); + test("receiveActivity triggers inbox listeners", async () => { // Provide contextData through constructor const mockFederation = createFederation<{ test: string }>({ diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 560f5511e..958e93ff6 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -602,6 +602,9 @@ class MockFederation implements Federation { onUnverifiedActivity(): any { return this; }, + onRequestFinished(): any { + return this; + }, setSharedKeyDispatcher(): any { return this; },