From e9d72cf728e4d2bcfc0664ced3f01733467a5ac9 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 12 Oct 2026 21:52:13 +0900 Subject: [PATCH 1/5] Separate inbox authentication from the actor Mastodon sends FEP-7aa9 FeatureRequest activities without an actor, so Fedify rejected them with 400 Missing actor before any listener ran. Rather than adding another activity-specific exception, the inbox now records who it authenticated independently of the declared actor, and binds that evidence to the exact JSON-LD interpretation it verified. Inbox and queue: - Every JSON-LD processing step of a delivery goes through a request-scoped context snapshot, so a mutable remote context cannot give verification and dispatch different meanings. The snapshot is stored in queued inbox messages, and workers and retries replay it without loading contexts again; an expanded-view digest guards it. - InboxContext.authentication exposes the HTTP request signers, the LDS/OIP document signers (all proofs, DID authorities for portable actors), and the expanded activity. isActorAuthenticated() compares actors, canonicalizing FEP-ef61 portable IDs. - InboxListenerSetters.on() takes an allowActorless option. An activity that declares no actor at all reaches such a listener only if a principal was authenticated and its ID shares that principal's origin. Anonymous or blank-node actors still count as declared. A successful verification of zero proofs is not authentication, and an owner's proof is preferred to a relay's Linked Data Signature. - Context.routeActivity() provides evidence too, and dispatches the object it verified or fetched. - MessageQueue.maxMessageSize lets the inbox process an activity in-request when its message would not fit the queue; Deno KV declares it. Queue workers now honor the idempotency strategy. - A key whose owner document cannot be fetched for now is reported as a retryable key fetch failure, so that an outage does not let a relay's signature supersede an author's proof. featureInteraction.verifyRequest() always fetches the featured collection from its ID (web origin or FEP-ef61 proof policy, exactly one attributedTo counted on the expanded document), never trusting an embedded collection, and binds the request to the authenticated interpretation. A request without an actor is accepted only when the collection owner was authenticated. lookupObject() gains an internal observer that reports the candidate actually returned. Fixes https://github.com/fedify-dev/fedify/issues/1289 Fixes https://github.com/fedify-dev/fedify/issues/1290 Assisted-by: OpenCode:deepseek-flash Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-opus-5-5 --- CHANGES.md | 70 ++ changes.d/denokv/max-message-size.md | 8 + changes.d/fedify/inbox-authentication.md | 46 + .../actorless-feature-requests.md | 24 + changes.d/testing/inbox-authentication.md | 8 + docs/manual/inbox.md | 115 +++ docs/manual/interaction-controls.md | 62 +- docs/manual/mq.md | 18 +- packages/denokv/src/mod.ts | 7 + .../src/federation/activity-listener.ts | 46 +- packages/fedify/src/federation/builder.ts | 12 +- packages/fedify/src/federation/context.ts | 13 + packages/fedify/src/federation/federation.ts | 18 + .../fedify/src/federation/handler.test.ts | 22 + packages/fedify/src/federation/handler.ts | 362 ++++++- .../src/federation/inbox-actorless.test.ts | 734 +++++++++++++ .../federation/inbox-authentication.test.ts | 146 +++ .../src/federation/inbox-authentication.ts | 375 +++++++ .../fedify/src/federation/inbox-report.ts | 8 + .../fedify/src/federation/inbox-view.test.ts | 320 ++++++ packages/fedify/src/federation/inbox-view.ts | 597 +++++++++++ packages/fedify/src/federation/inbox.test.ts | 20 +- packages/fedify/src/federation/inbox.ts | 398 +++++-- .../fedify/src/federation/middleware.test.ts | 4 +- packages/fedify/src/federation/middleware.ts | 455 ++++++-- packages/fedify/src/federation/mod.ts | 7 + packages/fedify/src/federation/mq.test.ts | 50 + packages/fedify/src/federation/mq.ts | 19 + packages/fedify/src/federation/queue.ts | 18 + packages/fedify/src/sig/key.ts | 4 + packages/fedify/src/sig/ld.ts | 82 +- packages/fedify/src/sig/proof.test.ts | 71 ++ packages/fedify/src/sig/proof.ts | 182 +++- packages/fedify/src/testing/context.ts | 2 + .../interaction-controls/src/collection.ts | 246 +++++ packages/interaction-controls/src/control.ts | 458 ++++++--- packages/interaction-controls/src/evidence.ts | 133 +++ .../interaction-controls/src/feature.test.ts | 967 +++++++++++++++++- packages/interaction-controls/src/feature.ts | 11 +- packages/interaction-controls/src/loaders.ts | 159 +++ packages/interaction-controls/src/types.ts | 71 +- packages/testing/src/context.ts | 16 + .../src/internal/portable-dereference.ts | 122 ++- packages/vocab/src/lookup.test.ts | 125 ++- packages/vocab/src/lookup.ts | 74 +- 45 files changed, 6257 insertions(+), 448 deletions(-) create mode 100644 changes.d/denokv/max-message-size.md create mode 100644 changes.d/fedify/inbox-authentication.md create mode 100644 changes.d/interaction-controls/actorless-feature-requests.md create mode 100644 changes.d/testing/inbox-authentication.md create mode 100644 packages/fedify/src/federation/inbox-actorless.test.ts create mode 100644 packages/fedify/src/federation/inbox-authentication.test.ts create mode 100644 packages/fedify/src/federation/inbox-authentication.ts create mode 100644 packages/fedify/src/federation/inbox-view.test.ts create mode 100644 packages/fedify/src/federation/inbox-view.ts create mode 100644 packages/interaction-controls/src/collection.ts create mode 100644 packages/interaction-controls/src/evidence.ts create mode 100644 packages/interaction-controls/src/loaders.ts diff --git a/CHANGES.md b/CHANGES.md index 343bbda66..6ca5bcd04 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,42 @@ To be released. ### @fedify/fedify + - Added `InboxContext.authentication`, which tells inbox listeners who Fedify + authenticated for an incoming activity independently of its declared + `actor`: the signers of the HTTP request, the signers of the activity's + Linked Data Signature or Object Integrity Proofs, and the activity as Fedify + interpreted it while authenticating it. A valid signature does not + authorize an activity by itself; use the new `isActorAuthenticated()` + function to check whether a specific actor was authenticated, e.g., the + owner of an object the activity acts on. The new `InboxPrincipal` and + `InboxAuthenticationEvidence` types describe the evidence. + [[#1290], [#1299]] + - Added the `allowActorless` option to `InboxListenerSetters.on()`, so that + activities declaring no `actor` at all, such as the \[FEP-7aa9\] + `FeatureRequest` activities Mastodon sends, can reach a listener. Such an + activity is dispatched only if Fedify authenticated a principal for it and + its `id` has the same origin as that principal; the listener must authorize + it with `InboxContext.authentication`. Without the option, these + activities are still rejected with `400 Bad Request`. An activity whose + `actor` cannot be read, such as an anonymous actor, is never treated as + having no actor. Rejected deliveries are reported with the new + `noAuthenticatedPrincipal` reason in `onRequestFinished` reports. + [[#1289], [#1290], [#1299]] + - Changed the inbox to interpret each incoming activity with one set of + JSON-LD context documents throughout signature verification and dispatch, + and to keep that interpretation, along with the authentication evidence, + in queued inbox messages. Queue workers and their retries no longer load + remote JSON-LD contexts again for such messages, so a remote context that + changes in the meantime cannot make a listener see an activity other than + the one that was authenticated. Messages queued by older versions are + processed as before. [[#1290], [#1299]] + - Added the `MessageQueue.maxMessageSize` property. When an incoming + activity would make an inbox message larger than the queue can hold, it is + now processed right away instead of being enqueued. [[#1290], [#1299]] + - Changed inbox queue workers to deduplicate activities with the configured + `idempotency` strategy, as direct processing already did, instead of always + deduplicating them per origin. Activities recorded by workers of older + versions are still recognized. [[#1290], [#1299]] - Added the `activityOriginMismatch` reason to rejected `InboxAuthentication` decisions in `onRequestFinished` reports. Inbox deliveries rejected because the activity's ID does not have the same origin as its actors now @@ -36,6 +72,9 @@ To be released. [#1177]: https://github.com/fedify-dev/fedify/issues/1177 [#1205]: https://github.com/fedify-dev/fedify/pull/1205 [#1264]: https://github.com/fedify-dev/fedify/pull/1264 +[#1289]: https://github.com/fedify-dev/fedify/issues/1289 +[#1290]: https://github.com/fedify-dev/fedify/issues/1290 +[#1299]: https://github.com/fedify-dev/fedify/pull/1299 ### @fedify/cli @@ -65,6 +104,12 @@ To be released. [#896]: https://github.com/fedify-dev/fedify/issues/896 [#1204]: https://github.com/fedify-dev/fedify/pull/1204 +### @fedify/denokv + + - `DenoKvMessageQueue` now declares the `maxMessageSize` of Deno KV, so that + an incoming activity too large for Deno KV is processed immediately + instead of failing to be enqueued. [[#1290], [#1299]] + ### @fedify/interaction-controls - Changed the interaction control helpers to tell transient verification @@ -119,9 +164,34 @@ To be released. embeds the authorization in the `Delete` activity with only the IDs of its interacting object and interaction target. + - `featureInteraction.verifyRequest()` can now verify the \[FEP-7aa9\] + `FeatureRequest` activities Mastodon sends without an `actor`. Pass the + inbox listener's `InboxContext.authentication` as the new `authentication` + option; the requester is then the owner of the featured collection, who + must have been authenticated. [[#1289], [#1290], [#1299]] + + - Changed `featureInteraction.verifyRequest()` to always fetch the featured + collection from its ID and to take its owner from that document only: an + ordinary collection must be served from the origin of its ID, a portable + one must satisfy the FEP-ef61 policy, and it must have exactly one + `attributedTo` value. A collection embedded in the request, or given as + `resolvedInteractingObject`, is no longer trusted, because anyone could + attribute someone else's collection to themselves that way. A request + without an actor is no longer accepted on the strength of the collection's + `attributedTo` alone; without `authentication`, it fails with + `missingActor`. New failure types `credentialsInUrl`, + `invalidInteractingObject`, `ambiguousRequest`, and + `unauthenticatedRequester` describe the new checks. [[#1290], [#1299]] + [#1206]: https://github.com/fedify-dev/fedify/issues/1206 [#1245]: https://github.com/fedify-dev/fedify/pull/1245 +### @fedify/testing + + - Mock inbox contexts made by `createInboxContext()` now have the + `authentication` property, which can be given as an option and defaults + to evidence that nothing was authenticated. [[#1290], [#1299]] + ### @fedify/vocab - Added vocabulary support for the [FEP-6757] draft, which marks diff --git a/changes.d/denokv/max-message-size.md b/changes.d/denokv/max-message-size.md new file mode 100644 index 000000000..f6edb2cbd --- /dev/null +++ b/changes.d/denokv/max-message-size.md @@ -0,0 +1,8 @@ +--- +links: + '#1290': https://github.com/fedify-dev/fedify/issues/1290 + '#1299': https://github.com/fedify-dev/fedify/pull/1299 +--- + - `DenoKvMessageQueue` now declares the `maxMessageSize` of Deno KV, so that + an incoming activity too large for Deno KV is processed immediately + instead of failing to be enqueued. [[#1290], [#1299]] diff --git a/changes.d/fedify/inbox-authentication.md b/changes.d/fedify/inbox-authentication.md new file mode 100644 index 000000000..b69fc85f5 --- /dev/null +++ b/changes.d/fedify/inbox-authentication.md @@ -0,0 +1,46 @@ +--- +links: + '#1289': https://github.com/fedify-dev/fedify/issues/1289 + '#1290': https://github.com/fedify-dev/fedify/issues/1290 + '#1299': https://github.com/fedify-dev/fedify/pull/1299 +--- + - Added `InboxContext.authentication`, which tells inbox listeners who Fedify + authenticated for an incoming activity independently of its declared + `actor`: the signers of the HTTP request, the signers of the activity's + Linked Data Signature or Object Integrity Proofs, and the activity as Fedify + interpreted it while authenticating it. A valid signature does not + authorize an activity by itself; use the new `isActorAuthenticated()` + function to check whether a specific actor was authenticated, e.g., the + owner of an object the activity acts on. The new `InboxPrincipal` and + `InboxAuthenticationEvidence` types describe the evidence. + [[#1290], [#1299]] + + - Added the `allowActorless` option to `InboxListenerSetters.on()`, so that + activities declaring no `actor` at all, such as the [FEP-7aa9] + `FeatureRequest` activities Mastodon sends, can reach a listener. Such an + activity is dispatched only if Fedify authenticated a principal for it and + its `id` has the same origin as that principal; the listener must authorize + it with `InboxContext.authentication`. Without the option, these + activities are still rejected with `400 Bad Request`. An activity whose + `actor` cannot be read, such as an anonymous actor, is never treated as + having no actor. Rejected deliveries are reported with the new + `noAuthenticatedPrincipal` reason in `onRequestFinished` reports. + [[#1289], [#1290], [#1299]] + + - Changed the inbox to interpret each incoming activity with one set of + JSON-LD context documents throughout signature verification and dispatch, + and to keep that interpretation, along with the authentication evidence, + in queued inbox messages. Queue workers and their retries no longer load + remote JSON-LD contexts again for such messages, so a remote context that + changes in the meantime cannot make a listener see an activity other than + the one that was authenticated. Messages queued by older versions are + processed as before. [[#1290], [#1299]] + + - Added the `MessageQueue.maxMessageSize` property. When an incoming + activity would make an inbox message larger than the queue can hold, it is + now processed right away instead of being enqueued. [[#1290], [#1299]] + + - Changed inbox queue workers to deduplicate activities with the configured + `idempotency` strategy, as direct processing already did, instead of always + deduplicating them per origin. Activities recorded by workers of older + versions are still recognized. [[#1290], [#1299]] diff --git a/changes.d/interaction-controls/actorless-feature-requests.md b/changes.d/interaction-controls/actorless-feature-requests.md new file mode 100644 index 000000000..eb39c7b56 --- /dev/null +++ b/changes.d/interaction-controls/actorless-feature-requests.md @@ -0,0 +1,24 @@ +--- +links: + '#1289': https://github.com/fedify-dev/fedify/issues/1289 + '#1290': https://github.com/fedify-dev/fedify/issues/1290 + '#1299': https://github.com/fedify-dev/fedify/pull/1299 +--- + - `featureInteraction.verifyRequest()` can now verify the [FEP-7aa9] + `FeatureRequest` activities Mastodon sends without an `actor`. Pass the + inbox listener's `InboxContext.authentication` as the new `authentication` + option; the requester is then the owner of the featured collection, who + must have been authenticated. [[#1289], [#1290], [#1299]] + + - Changed `featureInteraction.verifyRequest()` to always fetch the featured + collection from its ID and to take its owner from that document only: an + ordinary collection must be served from the origin of its ID, a portable + one must satisfy the FEP-ef61 policy, and it must have exactly one + `attributedTo` value. A collection embedded in the request, or given as + `resolvedInteractingObject`, is no longer trusted, because anyone could + attribute someone else's collection to themselves that way. A request + without an actor is no longer accepted on the strength of the collection's + `attributedTo` alone; without `authentication`, it fails with + `missingActor`. New failure types `credentialsInUrl`, + `invalidInteractingObject`, `ambiguousRequest`, and + `unauthenticatedRequester` describe the new checks. [[#1290], [#1299]] diff --git a/changes.d/testing/inbox-authentication.md b/changes.d/testing/inbox-authentication.md new file mode 100644 index 000000000..044456fce --- /dev/null +++ b/changes.d/testing/inbox-authentication.md @@ -0,0 +1,8 @@ +--- +links: + '#1290': https://github.com/fedify-dev/fedify/issues/1290 + '#1299': https://github.com/fedify-dev/fedify/pull/1299 +--- + - Mock inbox contexts made by `createInboxContext()` now have the + `authentication` property, which can be given as an option and defaults + to evidence that nothing was authenticated. [[#1290], [#1299]] diff --git a/docs/manual/inbox.md b/docs/manual/inbox.md index 75205fb8d..4a3df6004 100644 --- a/docs/manual/inbox.md +++ b/docs/manual/inbox.md @@ -402,6 +402,121 @@ multiple inbox listeners for different activity types. [shared inbox]: https://www.w3.org/TR/activitypub/#shared-inbox-delivery +Authenticated principals +------------------------ + +*This API is available since Fedify 2.5.0.* + +Before an activity reaches an inbox listener, Fedify authenticates it with HTTP +Signatures, Linked Data Signatures, or Object Integrity Proofs. The parties it +authenticated, called *principals*, are available to the listener as +`InboxContext.authentication`, independently of the activity's declared +`actor`: + +`requestSigners` +: The actors whose keys signed the HTTP request, i.e., the server that + delivered the activity, which may have forwarded it on behalf of + another actor. It is empty when the activity document was authenticated + by its own signatures, so that the HTTP Signatures did not need to be + verified. + +`documentSigners` +: The actors whose keys made the verified Linked Data Signature or Object + Integrity Proofs of the activity document. When there are several + proofs, all of their signers are listed. A proof by the DID of an + [FEP-ef61] portable actor is listed as a `portableAuthority` principal, + which authenticates the portable actors of that DID. + +`originAuthenticated` +: Only for activities routed by `Context.routeActivity()`: the actors + authenticated because the activity was fetched again from their own web + origin. + +`expandedActivity` +: The activity as Fedify interpreted it while authenticating it, in expanded + JSON-LD. Typed vocabulary objects omit some values, such as blank node + references and values they cannot decode, so check this when your + authorization has to count every value of a property. + +Use `isActorAuthenticated()` to check whether a specific actor was +authenticated. It considers the document signers if there are any, otherwise +the request signers, and compares [FEP-ef61] portable actor IDs canonically: + +~~~~ typescript twoslash +import { type Federation, isActorAuthenticated } from "@fedify/fedify"; +import { Undo } from "@fedify/vocab"; +declare const federation: Federation; +declare function undoFollow(follower: URL, follow: URL): Promise; +// ---cut-before--- +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Undo, async (ctx, undo) => { + if (undo.actorId == null || undo.objectId == null) return; + // Always true for an activity that declares an actor, which Fedify + // authenticates before calling the listener: + if (!isActorAuthenticated(ctx.authentication, undo.actorId)) return; + await undoFollow(undo.actorId, undo.objectId); + }); +~~~~ + +A valid signature alone does not authorize anything: whoever signed an activity +can name anyone else's objects in it, so compare the authenticated principal +with the owner of what the activity acts on. The evidence, including the +interpretation of the activity, is kept as it was when the activity was +received, even when the activity is processed later by a [message +queue](./mq.md) and its retries. + +### Activities without an actor + +Some protocols identify the requester without an `actor`. For example, +Mastodon sends [FEP-7aa9] `FeatureRequest` activities, which ask an actor for +consent to be featured in a collection, without one. By default, Fedify +responds to such an activity with `400 Bad Request` before any listener runs. + +To receive activities without an actor, register the listener with the +`allowActorless` option. Such an activity then reaches the listener only if +Fedify authenticated at least one principal for it, and its `id`, if any, has +the same origin as that principal. The listener must authorize the request +itself. For `FeatureRequest`, `featureInteraction.verifyRequest()` from +[`@fedify/interaction-controls`](./interaction-controls.md) does that: it +fetches the featured collection from its ID and checks that its owner is the +authenticated requester: + +~~~~ typescript twoslash +import type { Federation } from "@fedify/fedify"; +import { featureInteraction } from "@fedify/interaction-controls"; +import { FeatureRequest } from "@fedify/vocab"; +declare const federation: Federation; +// ---cut-before--- +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(FeatureRequest, async (ctx, request) => { + const verification = await featureInteraction.verifyRequest(ctx, { + request, + authentication: ctx.authentication, + }); + if (!verification.verified) return; + // verification.requester is the owner of the collection, who signed + // the request: + console.log(verification.requester.href); + }, { allowActorless: true }); +~~~~ + +An activity that declares an `actor` is never affected by this option: its +actor must be authenticated as usual. An `actor` that ID accessors cannot +read, such as an anonymous or blank node actor, still counts as a declared +actor, so such an activity is rejected rather than treated as having no actor. + +> [!WARNING] +> Do not set `allowActorless` for a listener that relies on the activity's +> actor, such as one for `Undo` or `Delete`, unless the listener checks +> `InboxContext.authentication` itself. Otherwise, any authenticated server +> could act on objects that the listener only matches by the activity's +> `object`. + +[FEP-7aa9]: https://w3id.org/fep/7aa9 + + Determining the recipient of an activity ---------------------------------------- diff --git a/docs/manual/interaction-controls.md b/docs/manual/interaction-controls.md index 4a05c4fc3..2bc910508 100644 --- a/docs/manual/interaction-controls.md +++ b/docs/manual/interaction-controls.md @@ -273,6 +273,65 @@ verification and pass the clone to `createAccept()` or `createReject()`. The clone keeps the request's vocabulary values and references, though not necessarily its exact original JSON-LD representation. +### Feature requests + +*This behavior is available since Fedify 2.5.0.* + +`featureInteraction.verifyRequest()` never trusts the `FeaturedCollection` that +a request embeds, nor one passed as `resolvedInteractingObject`. It always +fetches the collection from its ID: an ordinary collection has to be served from +the origin of its ID, and an [FEP-ef61] portable collection has to satisfy the +portable object policy. The collection's owner is the only `attributedTo` value +of that document; a collection with several attribution values, including +anonymous ones, is rejected. The owner has to be the requester. + +Mastodon sends `FeatureRequest` activities without an `actor`. The requester +of such a request is the owner of the collection, who must have been +authenticated by Fedify, so pass the [authentication evidence] of the inbox +listener as the `authentication` option. The listener has to be registered +with the `allowActorless` option to receive such requests at all: + +~~~~ typescript twoslash +import type { Federation } from "@fedify/fedify"; +import { featureInteraction } from "@fedify/interaction-controls"; +import { FeatureRequest } from "@fedify/vocab"; +declare const federation: Federation; +// ---cut-before--- +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(FeatureRequest, async (ctx, request) => { + const verification = await featureInteraction.verifyRequest(ctx, { + request, + authentication: ctx.authentication, + }); + if (!verification.verified) { + if ( + verification.failure.category === "unverifiable" && + verification.failure.transient + ) { + // Throw so that the inbox queue retries the request later: + throw new Error("The collection cannot be fetched for now."); + } + return; + } + // ... + }, { allowActorless: true }); +~~~~ + +With `authentication`, the helper also checks that the request it verifies is +the one Fedify authenticated, counting every `actor`, `object`, and +`instrument` value, including those typed parsing drops: a request with more +than one `object` or `instrument` is rejected as `ambiguousRequest`. A request +that declares an actor is accepted only if that actor was authenticated. +Without `authentication`, a request without an actor is rejected as +`missingActor`, and the value counts are checked on the parsed request only. +When the request is given as a URL, it is fetched, and its actors are +authenticated by the web origin it is served from, as long as neither the +request nor its actors are portable. + +[FEP-ef61]: https://w3id.org/fep/ef61 +[authentication evidence]: ./inbox.md#authenticated-principals + ### Already resolved objects *This API is available since Fedify 2.5.0.* @@ -306,7 +365,8 @@ where it came from, so make sure it is trustworthy, e.g., that the instrument was fetched from the requester's origin. The helper still checks its type, its requester, and that it refers to the target. Resolved objects are used only when the request has the corresponding reference; they never fill in -a missing `object` or `instrument`. +a missing `object` or `instrument`. `featureInteraction` ignores +`resolvedInteractingObject`; see [*Feature requests*](#feature-requests). ### Relaxing quote request validation diff --git a/docs/manual/mq.md b/docs/manual/mq.md index 7917f2c44..e5480f1c6 100644 --- a/docs/manual/mq.md +++ b/docs/manual/mq.md @@ -83,7 +83,9 @@ Pros : Persistent, scalable, easy to set up, native retry with exponential backoff. Cons -: Only available in Deno runtime. +: Only available in Deno runtime. Deno KV limits a message to 64 KiB, so an + incoming activity too large for it is processed immediately instead of + being enqueued (since Fedify 2.5.0). ~~~~ typescript import { createFederation } from "@fedify/fedify"; @@ -921,6 +923,20 @@ you can set the `nativeRetrial` property to `true` to indicate this. When this property is `true`, Fedify will skip its own retry logic and rely on your backend to handle retries, avoiding duplicate retry mechanisms. +### Declare `~MessageQueue.maxMessageSize` (optional) + +*This API is available since Fedify 2.5.0.* + +If your backend cannot hold messages larger than a certain size, set the +`maxMessageSize` property to that size in bytes. Fedify compares it with +a conservative estimate of a message's serialized size: the length of its JSON +serialization encoded in UTF-8, plus 8 bytes for each JSON value in it, plus +what encoding strings beyond Latin-1 as UTF-16 adds, so that it also bounds +binary serialization formats. When an incoming activity would +make a larger inbox message, Fedify processes it right away instead of +enqueueing it, as if there were no inbox queue, so that the activity is not +lost. Leave it unset if your backend has no practical limit. + ### Implement `~MessageQueue.getDepth()` method (optional) *This API is available since Fedify 2.3.0.* diff --git a/packages/denokv/src/mod.ts b/packages/denokv/src/mod.ts index c1d5965f8..ead35a13c 100644 --- a/packages/denokv/src/mod.ts +++ b/packages/denokv/src/mod.ts @@ -162,6 +162,13 @@ export class DenoKvMessageQueue implements MessageQueue, Disposable { */ readonly nativeRetrial = true; + /** + * Deno KV limits a serialized value to 64 KiB. This leaves room for the + * envelope this adapter wraps around each message. + * @since 2.5.0 + */ + readonly maxMessageSize = 63 * 1024; + /** * Constructs a new {@link DenoKvMessageQueue} adapter with the given Deno KV * store. diff --git a/packages/fedify/src/federation/activity-listener.ts b/packages/fedify/src/federation/activity-listener.ts index ced22b4d6..36341935e 100644 --- a/packages/fedify/src/federation/activity-listener.ts +++ b/packages/fedify/src/federation/activity-listener.ts @@ -9,8 +9,41 @@ type ActivityListener = ( activity: TActivity, ) => void | Promise; +/** + * Options for an inbox listener. + * @since 2.5.0 + */ +export interface InboxListenerOptions { + /** + * Whether activities that declare no `actor` at all may reach the listener. + * + * Some protocols identify the requester without an `actor`; for example, + * Mastodon sends [FEP-7aa9] `FeatureRequest` activities without one. When + * this is `true`, such an activity is dispatched to the listener if Fedify + * authenticated at least one principal for it, i.e., the signer of its + * Object Integrity Proofs or Linked Data Signature, or otherwise the signer + * of the HTTP request, and its `id`, if any, has the same origin as that + * principal. The listener is then responsible for authorizing the request + * with {@link InboxContext.authentication}, e.g., with + * `featureInteraction.verifyRequest()` from + * `@fedify/interaction-controls`. + * + * An activity that declares an `actor` is never affected by this option; + * its actor must be authenticated as usual. + * + * [FEP-7aa9]: https://w3id.org/fep/7aa9 + * @default `false` + */ + readonly allowActorless?: boolean; +} + +interface ListenerEntry { + readonly listener: ActivityListener; + readonly options: InboxListenerOptions; +} + export class ActivityListenerSet { - #listeners: Map>; + #listeners: Map>; constructor() { this.#listeners = new Map(); @@ -26,11 +59,15 @@ export class ActivityListenerSet { add( type: ActivityConstructor, listener: ActivityListener, + options: InboxListenerOptions = {}, ): void { if (this.#listeners.has(type)) { throw new TypeError("Listener already set for this type."); } - this.#listeners.set(type, listener as ActivityListener); + this.#listeners.set(type, { + listener: listener as ActivityListener, + options: { ...options }, + }); } dispatchWithClass( @@ -38,6 +75,7 @@ export class ActivityListenerSet { ): { class: ActivityConstructor; listener: ActivityListener; + options: InboxListenerOptions; } | null { let cls: ActivityConstructor = activity.constructor as ActivityConstructor; while (cls != null) { @@ -46,8 +84,8 @@ export class ActivityListenerSet { cls = globalThis.Object.getPrototypeOf(cls); } if (cls == null) return null; - const listener = this.#listeners.get(cls)!; - return { class: cls, listener }; + const { listener, options } = this.#listeners.get(cls)!; + return { class: cls, listener, options }; } dispatch( diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 0ee6670f5..4b660ba0d 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -25,7 +25,10 @@ import { getPortableDid, isPortableId, } from "../sig/portable-key-id.ts"; -import { ActivityListenerSet } from "./activity-listener.ts"; +import { + ActivityListenerSet, + type InboxListenerOptions, +} from "./activity-listener.ts"; import type { ActorAliasMapper, ActorDispatcher, @@ -1335,8 +1338,13 @@ export class FederationBuilderImpl // deno-lint-ignore no-explicit-any type: new (...args: any[]) => TActivity, listener: InboxListener, + options?: InboxListenerOptions, ): InboxListenerSetters { - listeners.add(type, listener as InboxListener); + listeners.add( + type, + listener as InboxListener, + options, + ); return setters; }, onError: ( diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index 04a1670a8..9aae47c09 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -24,6 +24,7 @@ import type { GetNodeInfoOptions } from "../nodeinfo/client.ts"; import type { JsonValue, NodeInfo } from "../nodeinfo/types.ts"; import type { GetKeyOwnerOptions } from "../sig/owner.ts"; import type { ConstructorWithTypeId, Federation } from "./federation.ts"; +import type { InboxAuthenticationEvidence } from "./inbox-authentication.ts"; import type { SenderKeyPair } from "./send.ts"; import type { TaskDefinition, TaskEnqueueOptions } from "./tasks/mod.ts"; @@ -1492,6 +1493,18 @@ export interface InboxContext extends Context { */ readonly recipient: string | null; + /** + * Who Fedify authenticated for the incoming activity, independently of its + * declared `actor`, and the interpretation of the activity that was + * authenticated. It is preserved through the inbox queue and its retries. + * + * Use it with {@link isActorAuthenticated} to authorize requests that do + * not declare an `actor`, which reach a listener only if it is registered + * with the `allowActorless` option. + * @since 2.5.0 + */ + readonly authentication: InboxAuthenticationEvidence; + /** * Creates a new context with the same properties as this one, * but with the given data. diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 2ce6e3bb3..471f4011a 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -1,3 +1,4 @@ +import type { InboxListenerOptions } from "./activity-listener.ts"; import type { InboxRequestFinishedHandler } from "./inbox-report.ts"; import type { Activity, @@ -1693,14 +1694,31 @@ export interface InboxListenerSetters { /** * Registers a listener for a specific incoming activity type. * + * @example + * ``` typescript + * federation + * .setInboxListeners("/users/{identifier}/inbox", "/inbox") + * // Mastodon sends FeatureRequest activities without an actor: + * .on(FeatureRequest, async (ctx, request) => { + * const verification = await featureInteraction.verifyRequest(ctx, { + * request, + * authentication: ctx.authentication, + * }); + * // ... + * }, { allowActorless: true }); + * ``` + * * @param type A subclass of {@link Activity} to listen to. * @param listener A callback to handle an incoming activity. + * @param options Options for the listener. See + * {@link InboxListenerOptions}. (Since 2.5.0.) * @returns The setters object so that settings can be chained. */ on( // deno-lint-ignore no-explicit-any type: new (...args: any[]) => TActivity, listener: InboxListener, + options?: InboxListenerOptions, ): InboxListenerSetters; /** diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index e7e54c47f..868a60514 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -4021,11 +4021,33 @@ test( sourceContextLoader, ), ldSignatureVerified: false, + verifiedView: inboxMessage.verifiedView, + authentication: { + verified: true, + requestSigners: [{ + kind: "actor", + id: "https://example.com/person2", + mechanism: "httpSignatures", + keyId: "https://example.com/person2#key3", + }], + documentSigners: [], + originAuthenticated: [], + }, started: inboxMessage.started, attempt: 0, identifier: "someone", traceContext: inboxMessage.traceContext, }); + // The HTTP-authenticated interpretation is the raw payload's, replayed + // with the remote context recorded while the request was handled: + assertEquals(inboxMessage.verifiedView?.policy, "standard"); + assertEquals(inboxMessage.verifiedView?.source, "activity"); + assert( + inboxMessage.verifiedView?.contexts.some((entry) => + entry.url === "https://remote.example/contexts/ext" && + "response" in entry + ), + ); }, ); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index fb7e35150..77f315d39 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -63,9 +63,9 @@ import { InvalidContextReferenceError, isClearlyMalformedContextReference, isInvalidUrlTypeError, - verifyCompactJsonLd, - wrapContextLoaderForJsonLd, + verifyCompactJsonLdDetailed, } from "../sig/ld.ts"; +import { getVerifiedKeyOwnerEvidence } from "../sig/key-owner-evidence.ts"; import { doesActorOwnKey } from "../sig/owner.ts"; import { getCanonicalPortableId, @@ -82,7 +82,9 @@ import { } from "./compatible-id-warning.ts"; import { InvalidPortableObjectIdError, + type ProofSigner, verifyObject, + verifyObjectDetailed, verifyPortableObjectProof, type VerifyPortableObjectProofResult, verifyPortableObjectProofWithRoot, @@ -121,9 +123,24 @@ import type { } from "./federation.ts"; import { actorsHaveSameOrigin, + checkActorlessActivity, + type InboxContextFactory, routeActivity, type RouteActivityResult, } from "./inbox.ts"; +import { + createAuthenticationEvidence, + type InboxPrincipal, +} from "./inbox-authentication.ts"; +import { + buildVerifiedActivityView, + type ContextSnapshot, + createContextSnapshot, + deepFreeze, + expandDocument, + getActorPresence, + type VerifiedActivityViewSource, +} from "./inbox-view.ts"; import { KvKeyCache } from "./keycache.ts"; import type { KvKey, KvStore } from "./kv.ts"; import { @@ -1721,12 +1738,7 @@ export interface InboxHandlerParameters { inboxRequestFinishedHandler?: InboxRequestFinishedHandler; recipient: string | null; context: RequestContext; - inboxContextFactory( - recipient: string | null, - activity: unknown, - activityId: string | undefined, - activityType: string, - ): InboxContext; + inboxContextFactory: InboxContextFactory; kv: KvStore; kvPrefixes: { activityIdempotence: KvKey; @@ -1830,16 +1842,21 @@ export async function handleInbox( span.setAttribute("fedify.inbox.recipient", options.recipient); } const spanContext = span.spanContext(); + // Every JSON-LD processing step of this delivery sees the same remote + // context responses, so that verification and dispatch cannot observe + // different interpretations of the activity: + const snapshot = createContextSnapshot(options.context.contextLoader); try { return await withContext( { traceId: spanContext.traceId, spanId: spanContext.spanId }, - () => handleInboxInternal(request, options, span), + () => handleInboxInternal(request, options, span, snapshot), ); } catch (e) { options.observation!.hasException = true; span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { + snapshot.release(); options.observation!.project(span); span.end(); } @@ -1853,12 +1870,14 @@ export async function handleInbox( * @param request The HTTP request. * @param options The parameters for handling the inbox. * @param span The OpenTelemetry span for tracing. + * @param snapshot The context snapshot of the delivery. * @returns A promise that resolves to an HTTP response. */ async function handleInboxInternal( request: Request, parameters: InboxHandlerParameters, span: Span, + snapshot: ContextSnapshot, ): Promise { const { recipient, @@ -1976,6 +1995,8 @@ async function handleInboxInternal( tracerProvider, keyTtl: publicKeyTtl, }); + const standardLoader = snapshot.loader("standard"); + const normalizationLoader = snapshot.loader("normalization"); const jsonWithoutSig = detachSignature(json); const hasLdSignature = hasSignature(json); const canAttemptAlternateAuthAfterLdSignatureFailure = @@ -2010,9 +2031,10 @@ async function handleInboxInternal( let compactedJson = json; let compactedJsonWithoutSig = jsonWithoutSig; let ldSigVerified = false; + let ldSigKey: CryptographicKey | null = null; if (hasLdSignature) { try { - compactedJson = await compactJsonLd(json, ctx.contextLoader); + compactedJson = await compactJsonLd(json, normalizationLoader); } catch (error) { if (isInvalidJsonLdError(error)) { logger.error("Failed to parse JSON-LD:\n{error}", { recipient, error }); @@ -2049,20 +2071,22 @@ async function handleInboxInternal( if (compactedJson !== json) { compactedJsonWithoutSig = detachSignature(compactedJson); try { - ldSigVerified = await verifyCompactJsonLd(compactedJson, { + const ldResult = await verifyCompactJsonLdDetailed(compactedJson, { ...signatureObservation, - contextLoader: ctx.contextLoader, + contextLoader: normalizationLoader, documentLoader: ctx.documentLoader, keyCache, meterProvider, tracerProvider, }); + ldSigVerified = ldResult.verified; + ldSigKey = ldResult.key; } catch (error) { if ( error instanceof RangeError && await hasMalformedKnownTemporalLiteral( compactedJsonWithoutSig, - ctx.contextLoader, + normalizationLoader, ) ) { return await respondInvalidActivity(error); @@ -2085,7 +2109,7 @@ async function handleInboxInternal( if (!skipSignatureVerification) { try { await Object.fromJsonLd(compactedJson, { - contextLoader: getNormalizationContextLoader(ctx.contextLoader), + contextLoader: normalizationLoader, documentLoader: ctx.documentLoader, tracerProvider, }); @@ -2094,7 +2118,7 @@ async function handleInboxInternal( parseError instanceof RangeError && await hasMalformedKnownTemporalLiteral( compactedJsonWithoutSig, - ctx.contextLoader, + normalizationLoader, ) ) { return await respondInvalidActivity(parseError); @@ -2131,22 +2155,63 @@ async function handleInboxInternal( // Whether the activity is authenticated by its Object Integrity Proofs, // as opposed to Linked Data Signatures or HTTP Signatures: let proofVerified = false; + // The principals authenticated for the activity, and which document the + // dispatched activity was parsed from: + let documentSigners: InboxPrincipal[] = []; + let requestSigners: InboxPrincipal[] = []; + let viewSource: VerifiedActivityViewSource | undefined; + const rawViewSource: VerifiedActivityViewSource = { + policy: "standard", + source: "activity", + document: jsonWithoutSig, + baseUrl: null, + }; + // Whether an activity, parsed from the given document, declares no actor + // at all and would be dispatched to a listener that allows that: + const isActorlessCandidate = async ( + candidate: Activity, + source: VerifiedActivityViewSource, + ): Promise => { + if (skipSignatureVerification) return false; + if ( + inboxListeners?.dispatchWithClass(candidate)?.options.allowActorless !== + true + ) { + return false; + } + try { + const expanded = await expandDocument( + source.document, + snapshot.loader(source.policy), + ); + return getActorPresence(expanded) === "absent"; + } catch { + return false; + } + }; + const verifyProofs = (contextLoader = standardLoader) => + verifyObjectDetailed(Activity, jsonWithoutSig, { + ...signatureObservation, + contextLoader, + documentLoader: ctx.documentLoader, + keyCache, + meterProvider, + tracerProvider, + verifyPortableObject: ctx.verifyPortableObject, + }); if (ldSigVerified) { logger.debug("Linked Data Signatures are verified.", { recipient, json }); try { activity = observation.activity = await Activity.fromJsonLd( compactedJsonWithoutSig, - { - ...ctx, - contextLoader: getNormalizationContextLoader(ctx.contextLoader), - }, + { ...ctx, contextLoader: normalizationLoader }, ); } catch (error) { if ( error instanceof RangeError && await hasMalformedKnownTemporalLiteral( compactedJsonWithoutSig, - ctx.contextLoader, + normalizationLoader, ) ) { return await respondInvalidActivity(error); @@ -2155,6 +2220,46 @@ async function handleInboxInternal( return await respondInvalidActivity(error); } activityVerified = true; + viewSource = { + policy: "normalization", + source: "normalizedActivity", + document: compactedJsonWithoutSig, + baseUrl: null, + }; + if (ldSigKey?.ownerId != null) { + documentSigners = [{ + kind: "actor", + id: ldSigKey.ownerId, + mechanism: "linkedDataSignatures", + keyId: ldSigKey.id, + }]; + } + // The proof may be under any JSON-LD alias of the `proof` property, so + // the proofs are verified whenever the activity has no actor: + if (await isActorlessCandidate(activity, viewSource)) { + // An activity without an actor may be relayed with a Linked Data + // Signature of the relay, while its Object Integrity Proof is made by + // its author, so the proof is preferred. A proof that cannot be + // verified for now must not be superseded by the relay's signature: + let result: Awaited> | null = null; + try { + result = await verifyProofs(); + } catch (error) { + if (!isPermanentActivityParseError(error)) throw error; + logger.debug( + "Failed to verify the Object Integrity Proofs of the activity " + + "without an actor:\n{error}", + { recipient, error }, + ); + } + if (result?.status === "unavailable") throw result.error; + if (result?.status === "verified" && result.signers.length > 0) { + activity = observation.activity = result.object; + documentSigners = toProofPrincipals(result.signers); + viewSource = rawViewSource; + proofVerified = true; + } + } if (!skipSignatureVerification && isPortableActivity(activity)) { // A Linked Data Signature never authenticates a portable actor's // activity, nor a portable activity; only an Object Integrity Proof @@ -2162,7 +2267,7 @@ async function handleInboxInternal( try { proofVerified = await verifyObject(Activity, jsonWithoutSig, { ...signatureObservation, - contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), + contextLoader: standardLoader, documentLoader: ctx.documentLoader, keyCache, meterProvider, @@ -2182,23 +2287,14 @@ async function handleInboxInternal( "Linked Data Signatures are not verified.", { recipient, json }, ); + let proofResult: Awaited>; try { - activity = await verifyObject(Activity, jsonWithoutSig, { - ...signatureObservation, - contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), - documentLoader: ctx.documentLoader, - keyCache, - meterProvider, - tracerProvider, - verifyPortableObject: ctx.verifyPortableObject, - }); + proofResult = await verifyProofs(); } catch (error) { if ( error instanceof RangeError && - await hasMalformedKnownTemporalLiteral( - jsonWithoutSig, - ctx.contextLoader, - ) + // The diagnosis inspects the document the way the failed parse did: + await hasMalformedKnownTemporalLiteral(jsonWithoutSig, standardLoader) ) { // A deferred LDS loader failure is still retriable, but it must not // hide a payload that this boundary can already prove is permanently @@ -2240,6 +2336,36 @@ async function handleInboxInternal( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } + if (proofResult.status === "verified") { + if ( + proofResult.signers.length < 1 && + await isActorlessCandidate(proofResult.object, rawViewSource) + ) { + // Without an actor, there is nothing for the proofs to cover, so + // a successful verification of zero proofs authenticates nobody: + activity = null; + } else { + activity = proofResult.object; + documentSigners = toProofPrincipals(proofResult.signers); + viewSource = rawViewSource; + } + } else if (proofResult.status === "unavailable") { + let parsed: Activity | null = null; + try { + parsed = await Activity.fromJsonLd(jsonWithoutSig, { + ...ctx, + contextLoader: standardLoader, + }); + } catch { + // The activity will be parsed again below. + } + if (parsed != null && await isActorlessCandidate(parsed, rawViewSource)) { + // The proof of an activity without an actor may be its only + // authentication, so a key that cannot be fetched for now must not + // be superseded by another mechanism: + throw proofResult.error; + } + } if (activity == null) { logger.debug( "Object Integrity Proofs are not verified.", @@ -2419,17 +2545,40 @@ async function handleInboxInternal( try { activity = observation.activity = await Activity.fromJsonLd( jsonWithoutSig, - { - ...ctx, - contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), - }, + { ...ctx, contextLoader: standardLoader }, ); } catch (error) { if (!isPermanentActivityParseError(error)) throw error; return await respondInvalidActivity(error); } + viewSource = rawViewSource; } observation.activity = activity; + let view: Awaited>; + try { + view = await buildVerifiedActivityView( + snapshot, + viewSource ?? rawViewSource, + ); + } catch (error) { + if (!isPermanentActivityParseError(error)) throw error; + return await respondInvalidActivity(error); + } + // An activity that declares no actor at all reaches only a listener that + // allows that, and only if a principal is authenticated for it. Values + // that ID accessors or typed parsing drop, such as anonymous actors, still + // count as a declared actor: + const actorless = !skipSignatureVerification && + getActorPresence(view.expanded) === "absent" && + inboxListeners?.dispatchWithClass(activity)?.options.allowActorless === + true; + span.setAttribute("activitypub.activity.actorless", actorless); + if (actorless && httpSigKey != null) { + // The key's owner was confirmed by its own document when the key was + // fetched, so no other actor document needs to be fetched: + const principal = getHttpKeyPrincipal(httpSigKey); + if (principal != null) requestSigners = [principal]; + } observation.stage = "policy"; observation.result = { disposition: "rejected", reason: "authentication" }; if (activity.id != null) { @@ -2504,7 +2653,7 @@ async function handleInboxInternal( jsonWithoutSig, { [verificationObservation]: verification, - contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), + contextLoader: standardLoader, documentLoader: ctx.documentLoader, keyCache, meterProvider, @@ -2557,7 +2706,8 @@ async function handleInboxInternal( } } if ( - httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx) + httpSigKey != null && !actorless && + !await doesActorOwnKey(activity, httpSigKey, ctx) ) { if (deferredLdSignatureError != null) throw deferredLdSignatureError; observation.authentication = { @@ -2592,6 +2742,82 @@ async function handleInboxInternal( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } + if (httpSigKey != null && !actorless && activity.actorId != null) { + requestSigners = [{ + kind: "actor", + id: activity.actorId, + mechanism: "httpSignatures", + keyId: httpSigKey.id, + }]; + } + if (actorless) { + const check = checkActorlessActivity(activity, { + verified: true, + requestSigners, + documentSigners, + originAuthenticated: [], + expandedActivity: [], + baseUrl: null, + }); + if (check.status === "noAuthenticatedPrincipal") { + if (deferredLdSignatureError != null) throw deferredLdSignatureError; + observation.authentication = { + status: "rejected", + reason: { type: "noAuthenticatedPrincipal" }, + }; + logger.error( + "The activity {activityId} declares no actor, and no principal is " + + "authenticated for it.", + { activity: json, recipient, activityId: activity.id?.href }, + ); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: "No principal is authenticated for the activity.", + }); + return new Response( + "No principal is authenticated for the activity.", + { + status: 401, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }, + ); + } else if (check.status === "activityOriginMismatch") { + // Without an actor, the activity's ID has to have the same FEP-fe34 + // origin as a principal that authenticated it; otherwise a signer + // could deliver an activity under another server's ID: + if (deferredLdSignatureError != null) throw deferredLdSignatureError; + observation.authentication = { + status: "rejected", + reason: { + type: "activityOriginMismatch", + activityId: new URL(activity.id!.href), + actorIds: check.principals.map((principal) => principal.id), + }, + }; + logger.error( + "The activity ({activityId}) does not have the same origin as the " + + "principals that authenticated it.", + { + activity: json, + recipient, + activityId: activity.id?.href, + principals: check.principals.map((principal) => principal.id.href), + }, + ); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: `The activity (${activity.id?.href}) does not have the ` + + "same origin as the principals that authenticated it.", + }); + return new Response( + "The activity and its signers do not have the same origin.", + { + status: 401, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }, + ); + } + } if (!skipSignatureVerification) { // A Linked Data Signature is made after the Object Integrity Proofs, so // the top-level signature is excluded from their verification, as it is @@ -2698,7 +2924,7 @@ async function handleInboxInternal( } } if ( - !skipSignatureVerification && activity.id != null && + !skipSignatureVerification && !actorless && activity.id != null && !actorsHaveSameOrigin(activity) ) { // The activity's ID has to have the same origin as its actors, as @@ -2745,7 +2971,7 @@ async function handleInboxInternal( ), }; observation.stage = "dispatch"; - if (activity.actorId == null) { + if (!actorless && activity.actorId == null) { observation.result = { disposition: "rejected", reason: "missingActor" }; logger.error("Missing actor.", { activity: json, recipient }); span.setStatus({ code: SpanStatusCode.ERROR, message: "Missing actor." }); @@ -2777,6 +3003,16 @@ async function handleInboxInternal( ? compactedJson : undefined, ldSignatureVerified: hasLdSignature ? ldSigVerified : undefined, + authentication: createAuthenticationEvidence({ + verified: !skipSignatureVerification, + requestSigners, + documentSigners, + originAuthenticated: [], + expandedActivity: deepFreeze(view.expanded), + baseUrl: null, + }), + verifiedView: view.view, + actorless, activity, recipient, inboxListeners, @@ -4031,6 +4267,48 @@ const MIN_COMPONENTS = [ "@authority", ]; +/** + * Turns the signers of verified Object Integrity Proofs into principals. + */ +function toProofPrincipals( + signers: readonly ProofSigner[], +): InboxPrincipal[] { + return signers.map((signer) => ({ + kind: signer.kind, + id: signer.id, + mechanism: "objectIntegrityProofs", + keyId: signer.keyId, + })); +} + +/** + * Gets the principal of an HTTP Signature key, whose owner was confirmed by + * the owner's own document, or by the signed document of a portable actor, + * when the key was fetched. + */ +function getHttpKeyPrincipal(key: CryptographicKey): InboxPrincipal | null { + const evidence = getVerifiedKeyOwnerEvidence(key); + if (evidence != null) { + try { + return { + kind: "actor", + id: parseIri(evidence.ownerId), + mechanism: "httpSignatures", + keyId: key.id, + }; + } catch { + return null; + } + } + if (key.ownerId == null || isPortableId(key.ownerId)) return null; + return { + kind: "actor", + id: key.ownerId, + mechanism: "httpSignatures", + keyId: key.id, + }; +} + /** * Checks whether an HTTP Signature key is a key of an FEP-ef61 portable * actor, i.e., its ID is a compatible identifier or an `ap:` or `ap+ef61:` diff --git a/packages/fedify/src/federation/inbox-actorless.test.ts b/packages/fedify/src/federation/inbox-actorless.test.ts new file mode 100644 index 000000000..cc947f535 --- /dev/null +++ b/packages/fedify/src/federation/inbox-actorless.test.ts @@ -0,0 +1,734 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { Create, FeatureRequest, Person } from "@fedify/vocab"; +import { + type DocumentLoader, + encodeMultibase, + FetchError, + preloadedContexts, + type RemoteDocument, +} from "@fedify/vocab-runtime"; +import serialize from "json-canon"; +import { assert, assertEquals, assertFalse } from "@std/assert"; +import { signRequest } from "../sig/http.ts"; +import { signJsonLd } from "../sig/ld.ts"; +import { signObject } from "../sig/proof.ts"; +import { + ed25519PrivateKey, + rsaPrivateKey2, + rsaPrivateKey3, + rsaPublicKey2, +} from "../testing/keys.ts"; +import type { InboxContext } from "./context.ts"; +import { + type InboxAuthenticationEvidence, + isActorAuthenticated, +} from "./inbox-authentication.ts"; +import { MemoryKvStore } from "./kv.ts"; +import { createFederation, type InboxContextImpl } from "./middleware.ts"; +import type { MessageQueue } from "./mq.ts"; +import type { InboxMessage, Message } from "./queue.ts"; + +const SIGNER = new URL("https://example.com/person2"); +const RSA_KEY = new URL("https://example.com/person2#key3"); +const ED25519_KEY = new URL("https://example.com/person2#key4"); +const TARGET = new URL("https://example.com/users/alice"); +const COLLECTION = new URL("https://example.com/person2/featured/1"); +const MUTABLE_CONTEXT = "https://remote.example/contexts/mutable"; +const RELAY = new URL("https://example.com/relay"); +const RELAY_KEY = new URL("https://example.com/relay#main-key"); +const { publicKeyPem: RELAY_PEM } = await rsaPublicKey2.toJsonLd({ + contextLoader: mockDocumentLoader, +}) as { publicKeyPem: string }; + +/** The document Mastodon sends, which declares no actor. */ +function featureRequestDocument( + extra: Record = {}, +): Record { + return { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/fep/7aa9", + ], + id: "https://example.com/person2/featured/1/requests/1", + type: "FeatureRequest", + object: TARGET.href, + instrument: COLLECTION.href, + ...extra, + }; +} + +interface Received { + readonly activity: FeatureRequest; + readonly authentication: InboxAuthenticationEvidence; + readonly rawActivity: unknown; +} + +class CapturingQueue implements MessageQueue { + readonly messages: Message[] = []; + readonly maxMessageSize?: number; + + constructor(maxMessageSize?: number) { + this.maxMessageSize = maxMessageSize; + } + + enqueue(message: Message): Promise { + this.messages.push(structuredClone(message)); + return Promise.resolve(); + } + + listen(): Promise { + return new Promise(() => {}); + } +} + +interface SetupOptions { + readonly unavailable?: readonly string[]; + readonly allowActorless?: boolean; + readonly queue?: CapturingQueue; + readonly contextLoads?: string[]; + readonly failOnce?: { failed: boolean }; +} + +function setup(options: SetupOptions = {}) { + const received: Received[] = []; + let mutableLoads = 0; + // A remote context whose meaning changes every time it is loaded: + const loader: DocumentLoader = async (url, loaderOptions) => { + options.contextLoads?.push(url); + if (options.unavailable?.includes(url)) { + throw new FetchError( + url, + "Service unavailable", + new Response(null, { status: 503 }), + ); + } + if (url === RELAY.href || url === RELAY_KEY.href) { + // A relay on the same origin, with a key of its own: + return { + contextUrl: null, + documentUrl: RELAY.href, + document: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: RELAY.href, + type: "Service", + publicKey: { + id: RELAY_KEY.href, + type: "CryptographicKey", + owner: RELAY.href, + publicKeyPem: RELAY_PEM, + }, + }, + } satisfies RemoteDocument; + } + if (url === MUTABLE_CONTEXT) { + mutableLoads++; + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": { + tgt: mutableLoads % 2 === 1 + ? { + "@id": "https://www.w3.org/ns/activitystreams#object", + "@type": "@id", + } + : { + "@id": "https://www.w3.org/ns/activitystreams#target", + "@type": "@id", + }, + }, + }, + } satisfies RemoteDocument; + } + return await mockDocumentLoader(url, loaderOptions); + }; + const federation = createFederation({ + kv: new MemoryKvStore(), + documentLoaderFactory: () => loader, + contextLoaderFactory: () => loader, + authenticatedDocumentLoaderFactory: () => loader, + queue: options.queue, + manuallyStartQueue: true, + }); + federation.setActorDispatcher( + "/users/{identifier}", + (ctx, identifier) => + new Person({ + id: ctx.getActorUri(identifier), + inbox: ctx.getInboxUri(identifier), + }), + ).setKeyPairsDispatcher(() => [ + { privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey! }, + ]); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(FeatureRequest, (ctx: InboxContext, activity) => { + if (options.failOnce != null && !options.failOnce.failed) { + options.failOnce.failed = true; + throw new Error("Temporary failure."); + } + received.push({ + activity, + authentication: ctx.authentication, + rawActivity: (ctx as InboxContextImpl).activity, + }); + }, { allowActorless: options.allowActorless ?? true }) + .on(Create, () => {}); + return { federation, received }; +} + +function post(body: unknown): Request { + return new Request("https://example.com/users/alice/inbox", { + method: "POST", + headers: { "Content-Type": "application/activity+json" }, + body: JSON.stringify(body), + }); +} + +async function postSigned(body: unknown): Promise { + return await signRequest(post(body), rsaPrivateKey3, RSA_KEY); +} + +async function withLds( + document: Record, +): Promise> { + const signed = await signJsonLd(document, rsaPrivateKey3, RSA_KEY, { + contextLoader: mockDocumentLoader, + }); + return signed as unknown as Record; +} + +async function withProof( + document: Record, +): Promise> { + const activity = await FeatureRequest.fromJsonLd(document, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + const signed = await signObject(activity, ed25519PrivateKey, ED25519_KEY, { + contextLoader: mockDocumentLoader, + }); + return await signed.toJsonLd({ + format: "compact", + contextLoader: mockDocumentLoader, + }) as Record; +} + +/** + * Signs a JSON document as is with an `eddsa-jcs-2022` proof, so that values + * typed parsing would drop are covered by the proof as well. + */ +async function withRawProof( + document: Record, + proofProperty = "proof", +): Promise> { + const contexts = Array.isArray(document["@context"]) + ? document["@context"] + : [document["@context"]]; + const unsecured = { + ...document, + "@context": [ + ...contexts, + "https://w3id.org/security/data-integrity/v1", + ...(proofProperty === "proof" ? [] : [{ + [proofProperty]: { + "@id": "https://w3id.org/security#proof", + "@type": "@id", + "@container": "@graph", + }, + }]), + ], + }; + const created = "2026-10-01T00:00:00Z"; + const proofConfig = { + "@context": unsecured["@context"], + type: "DataIntegrityProof", + cryptosuite: "eddsa-jcs-2022", + verificationMethod: ED25519_KEY.href, + proofPurpose: "assertionMethod", + created, + }; + const digest = async (value: unknown) => + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(serialize(value)), + ), + ); + const signed = new Uint8Array( + await crypto.subtle.sign( + "Ed25519", + ed25519PrivateKey, + new Uint8Array([ + ...await digest(proofConfig), + ...await digest(unsecured), + ]), + ), + ); + const { "@context": _, ...proof } = proofConfig; + return { + ...unsecured, + [proofProperty]: { + ...proof, + proofValue: new TextDecoder().decode( + encodeMultibase("base58btc", signed), + ), + }, + }; +} + +async function processQueue( + federation: ReturnType["federation"], + queue: CapturingQueue, +): Promise { + while (queue.messages.length > 0) { + const message = queue.messages.shift()!; + await federation.processQueuedTask(undefined, message); + } +} + +test("Actorless FeatureRequest authenticated by HTTP Signatures", async () => { + const { federation, received } = setup(); + const response = await federation.fetch( + await postSigned(featureRequestDocument()), + { contextData: undefined }, + ); + assertEquals(response.status, 202); + assertEquals(received.length, 1); + const [{ activity, authentication, rawActivity }] = received; + assertEquals(activity.actorId, null); + assertEquals(activity.instrumentId, COLLECTION); + assertEquals(authentication.verified, true); + assertEquals(authentication.documentSigners, []); + assertEquals( + authentication.requestSigners.map((p) => [p.kind, p.id.href, p.mechanism]), + [["actor", SIGNER.href, "httpSignatures"]], + ); + assert(isActorAuthenticated(authentication, SIGNER)); + assertFalse( + isActorAuthenticated(authentication, SIGNER, { scope: "document" }), + ); + // No actor is synthesized anywhere: + assertEquals(rawActivity, featureRequestDocument()); +}); + +test("Actorless FeatureRequest authenticated by Linked Data Signatures", async () => { + const { federation, received } = setup(); + const document = await withLds(featureRequestDocument()); + const response = await federation.fetch(post(document), { + contextData: undefined, + }); + assertEquals(response.status, 202); + assertEquals(received.length, 1); + assertEquals( + received[0].authentication.documentSigners.map((p) => [ + p.id.href, + p.mechanism, + ]), + [[SIGNER.href, "linkedDataSignatures"]], + ); + // The original signed payload is kept for forwarding: + assertEquals(received[0].rawActivity, document); +}); + +test("Actorless FeatureRequest authenticated by Object Integrity Proofs", async () => { + const { federation, received } = setup(); + const document = await withProof(featureRequestDocument()); + const response = await federation.fetch(post(document), { + contextData: undefined, + }); + assertEquals(response.status, 202); + assertEquals(received.length, 1); + assertEquals( + received[0].authentication.documentSigners.map((p) => [ + p.kind, + p.id.href, + p.mechanism, + p.keyId?.href, + ]), + [["actor", SIGNER.href, "objectIntegrityProofs", ED25519_KEY.href]], + ); +}); + +test("Actorless activities need a listener that allows them", async () => { + const { federation, received } = setup({ allowActorless: false }); + const response = await federation.fetch( + await postSigned(featureRequestDocument()), + { contextData: undefined }, + ); + assertEquals(response.status, 400); + assertEquals(await response.text(), "Missing actor."); + assertEquals(received, []); +}); + +test("Actorless activities need an authenticated principal", async () => { + const { federation, received } = setup(); + // No signature at all, and a successful verification of zero proofs is not + // an authentication either: + const response = await federation.fetch(post(featureRequestDocument()), { + contextData: undefined, + }); + assertEquals(response.status, 401); + assertEquals(received, []); +}); + +test("Actorless activities need the same origin as their signer", async () => { + const { federation, received } = setup(); + const response = await federation.fetch( + await postSigned( + featureRequestDocument({ id: "https://other.example/requests/1" }), + ), + { contextData: undefined }, + ); + assertEquals(response.status, 401); + assertEquals(received, []); +}); + +for ( + const [label, actor] of [ + ["an anonymous actor", { type: "Person", name: "Anonymous" }], + ["an untyped actor", { name: "Anonymous" }], + ["a blank node actor", { id: "_:b0" }], + ] as const +) { + for (const mechanism of ["http", "lds", "proof"] as const) { + test(`Activities with ${label} are not actorless (${mechanism})`, async () => { + const queue = new CapturingQueue(); + const { federation, received } = setup({ queue }); + const document = featureRequestDocument({ actor }); + const response = await federation.fetch( + mechanism === "http" ? await postSigned(document) : post( + mechanism === "lds" + ? await withLds(document) + : await withRawProof(document), + ), + { contextData: undefined }, + ); + assert(response.status >= 400, `${response.status}`); + await processQueue(federation, queue); + assertEquals(received, []); + }); + } +} + +test("Queued actorless activities keep their interpretation and evidence", async () => { + const queue = new CapturingQueue(); + const contextLoads: string[] = []; + const failOnce = { failed: false }; + const { federation, received } = setup({ queue, contextLoads, failOnce }); + const document = featureRequestDocument({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/fep/7aa9", + MUTABLE_CONTEXT, + ], + object: undefined, + tgt: TARGET.href, + }); + delete document.object; + const response = await federation.fetch(await postSigned(document), { + contextData: undefined, + }); + assertEquals(response.status, 202); + assertEquals(queue.messages.length, 1); + const message = queue.messages[0] as InboxMessage; + assertEquals(message.activity, document); + assertEquals(message.verifiedView?.source, "activity"); + // The worker, and the retry after the listener fails once, replay the + // interpretation without loading the context again: + const loadsBefore = contextLoads.filter((url) => url === MUTABLE_CONTEXT) + .length; + await processQueue(federation, queue); + assertEquals( + contextLoads.filter((url) => url === MUTABLE_CONTEXT).length, + loadsBefore, + ); + assert(failOnce.failed); + assertEquals(received.length, 1); + assertEquals(received[0].activity.objectId, TARGET); + assertEquals(received[0].activity.targetId, null); + assertEquals( + received[0].authentication.requestSigners.map((p) => p.id.href), + [SIGNER.href], + ); + assertEquals(received[0].authentication.expandedActivity.length, 1); +}); + +test("Queued actorless activities are dropped without evidence", async () => { + const queue = new CapturingQueue(); + const { federation, received } = setup({ queue }); + const response = await federation.fetch( + await postSigned(featureRequestDocument()), + { contextData: undefined }, + ); + assertEquals(response.status, 202); + const message = queue.messages.shift() as InboxMessage; + // A message queued by an older version of Fedify has no evidence: + const { authentication: _, verifiedView: __, ...legacy } = message; + await federation.processQueuedTask(undefined, legacy); + assertEquals(received, []); + // A message whose evidence names nobody is not processed either: + await federation.processQueuedTask(undefined, { + ...message, + authentication: { + verified: true, + requestSigners: [], + documentSigners: [], + originAuthenticated: [], + }, + }); + assertEquals(received, []); + // A message whose view is malformed is never replayed any other way: + await federation.processQueuedTask(undefined, { + ...message, + verifiedView: { version: 2 } as unknown as InboxMessage["verifiedView"], + }); + assertEquals(received, []); + await federation.processQueuedTask(undefined, message); + assertEquals(received.length, 1); +}); + +test("Actorless activities too large to enqueue are processed immediately", async () => { + const queue = new CapturingQueue(512); + const { federation, received } = setup({ queue }); + const response = await federation.fetch( + await postSigned(featureRequestDocument()), + { contextData: undefined }, + ); + assertEquals(response.status, 202); + assertEquals(queue.messages, []); + assertEquals(received.length, 1); +}); + +test("Activities processed by older queue workers are not processed again", async () => { + const kv = new MemoryKvStore(); + const document = featureRequestDocument({ actor: SIGNER.href }); + await kv.set( + [ + "_fedify", + "activityIdempotence", + "https://example.com", + document.id as string, + ], + true, + ); + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + federation.setActorDispatcher( + "/users/{identifier}", + (ctx, identifier) => new Person({ id: ctx.getActorUri(identifier) }), + ).setKeyPairsDispatcher(() => [ + { privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey! }, + ]); + let calls = 0; + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(FeatureRequest, () => { + calls++; + }); + const response = await federation.fetch(await postSigned(document), { + contextData: undefined, + }); + assertEquals(response.status, 202); + assertEquals(calls, 0); +}); + +test("Activities with an actor expose their evidence", async () => { + const { federation, received } = setup(); + const response = await federation.fetch( + await postSigned(featureRequestDocument({ actor: SIGNER.href })), + { contextData: undefined }, + ); + assertEquals(response.status, 202); + assertEquals(received.length, 1); + assertEquals( + received[0].authentication.requestSigners.map((p) => [ + p.id.href, + p.mechanism, + p.keyId?.href, + ]), + [[SIGNER.href, "httpSignatures", RSA_KEY.href]], + ); +}); + +test("An author's proof is preferred to a relay's Linked Data Signature", async () => { + const { federation, received } = setup(); + // The author signs the document with an Object Integrity Proof, and a relay + // on the same origin adds its Linked Data Signature: + const relayKey = RELAY_KEY; + const document = await signJsonLd( + await withRawProof(featureRequestDocument()), + rsaPrivateKey2, + relayKey, + { contextLoader: mockDocumentLoader }, + ) as unknown as Record; + const response = await federation.fetch(post(document), { + contextData: undefined, + }); + assertEquals(response.status, 202); + assertEquals(received.length, 1); + assertEquals( + received[0].authentication.documentSigners.map((p) => [ + p.id.href, + p.mechanism, + ]), + [[SIGNER.href, "objectIntegrityProofs"]], + ); +}); + +test("A proof that cannot be verified for now is not superseded", async () => { + const { federation, received } = setup({ + unavailable: [ + "https://example.com/person2", + "https://example.com/person2#key4", + ], + }); + const relayKey = RELAY_KEY; + const document = await signJsonLd( + await withRawProof(featureRequestDocument()), + rsaPrivateKey2, + relayKey, + { contextLoader: mockDocumentLoader }, + ) as unknown as Record; + let status: number | undefined; + try { + const response = await federation.fetch(post(document), { + contextData: undefined, + }); + status = response.status; + } catch { + // An error thrown out of the inbox is retryable as well. + } + assert(status == null || status >= 500, `${status}`); + assertEquals(received, []); +}); + +test("Context.routeActivity() exposes the evidence of verified proofs", async () => { + const { federation, received } = setup(); + const document = await withRawProof( + featureRequestDocument({ actor: SIGNER.href }), + ); + const activity = await FeatureRequest.fromJsonLd(document, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + const context = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + assert(await context.routeActivity("alice", activity)); + assertEquals(received.length, 1); + assertEquals( + received[0].authentication.documentSigners.map((p) => [ + p.id.href, + p.mechanism, + ]), + [[SIGNER.href, "objectIntegrityProofs"]], + ); + assertEquals(received[0].authentication.originAuthenticated, []); +}); + +test("An author's aliased proof is preferred to a relay's signature", async () => { + const { federation, received } = setup(); + const document = await signJsonLd( + await withRawProof(featureRequestDocument(), "integrity"), + rsaPrivateKey2, + RELAY_KEY, + { contextLoader: mockDocumentLoader }, + ) as unknown as Record; + const response = await federation.fetch(post(document), { + contextData: undefined, + }); + assertEquals(response.status, 202); + assertEquals(received.length, 1); + assertEquals( + received[0].authentication.documentSigners.map((p) => [ + p.id.href, + p.mechanism, + ]), + [[SIGNER.href, "objectIntegrityProofs"]], + ); +}); + +for (const queued of [false, true]) { + test(`Context.routeActivity() keeps the fetched interpretation (queued: ${queued})`, async () => { + const queue = queued ? new CapturingQueue() : undefined; + const { federation, received } = setup({ queue }); + const activityId = "https://example.com/person2/activities/fetched"; + const fetchedDocument = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/fep/7aa9", + ], + id: activityId, + type: "FeatureRequest", + actor: SIGNER.href, + object: TARGET.href, + instrument: COLLECTION.href, + }; + // The caller's context loader overrides the ActivityStreams context so + // that `object` means something else; the fetched activity is parsed + // with the bundled one regardless: + const overriding: DocumentLoader = async (url, options) => { + if (url === "https://www.w3.org/ns/activitystreams") { + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": { + ...(preloadedContexts[url] as { + "@context": Record; + })["@context"], + object: { + "@id": "https://www.w3.org/ns/activitystreams#target", + "@type": "@id", + }, + }, + }, + }; + } + return await mockDocumentLoader(url, options); + }; + const context = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + const activity = await FeatureRequest.fromJsonLd(fetchedDocument, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + assert( + await context.routeActivity("alice", activity, { + contextLoader: overriding, + documentLoader: async (url, options) => + url === activityId + ? { + contextUrl: null, + documentUrl: url, + document: structuredClone(fetchedDocument), + } + : await mockDocumentLoader(url, options), + }), + ); + if (queue != null) await processQueue(federation, queue); + assertEquals(received.length, 1); + const [{ activity: dispatched, authentication }] = received; + const root = authentication.expandedActivity[0]; + assertEquals( + (root["https://www.w3.org/ns/activitystreams#object"] as + | { "@id": string }[] + | undefined)?.[0]?.["@id"], + dispatched.objectId?.href, + ); + assertEquals(dispatched.objectId, TARGET); + assertEquals( + authentication.originAuthenticated.map((p) => p.id.href), + [SIGNER.href], + ); + }); +} diff --git a/packages/fedify/src/federation/inbox-authentication.test.ts b/packages/fedify/src/federation/inbox-authentication.test.ts new file mode 100644 index 000000000..90fde876a --- /dev/null +++ b/packages/fedify/src/federation/inbox-authentication.test.ts @@ -0,0 +1,146 @@ +import { test } from "@fedify/fixture"; +import { parseIri } from "@fedify/vocab-runtime"; +import { assert, assertEquals, assertFalse } from "@std/assert"; +import { + createAuthenticationEvidence, + deserializeAuthentication, + getEmptyAuthenticationEvidence, + type InboxAuthenticationEvidence, + type InboxPrincipal, + isActorAuthenticated, + serializeAuthentication, +} from "./inbox-authentication.ts"; + +const DID = "did:key:z6MkhVPuyvgG1RkMv67azDqDCDERPXVrUg1i3qchXY5EACE3"; +const OTHER_DID = "did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2"; + +function evidence( + overrides: Partial, +): InboxAuthenticationEvidence { + return createAuthenticationEvidence({ + ...getEmptyAuthenticationEvidence(), + verified: true, + ...overrides, + }); +} + +function actor( + id: string, + mechanism: InboxPrincipal["mechanism"] = "httpSignatures", +): InboxPrincipal { + return { kind: "actor", id: parseIri(id), mechanism, keyId: null }; +} + +test("isActorAuthenticated() uses the effective principals", () => { + const alice = new URL("https://example.com/users/alice"); + const relay = new URL("https://relay.example/actor"); + const forwarded = evidence({ + requestSigners: [actor(relay.href)], + documentSigners: [actor(alice.href, "linkedDataSignatures")], + }); + assert(isActorAuthenticated(forwarded, alice)); + // When the document is signed, the deliverer does not speak for it: + assertFalse(isActorAuthenticated(forwarded, relay)); + const delivered = evidence({ requestSigners: [actor(alice.href)] }); + assert(isActorAuthenticated(delivered, alice)); + assertFalse(isActorAuthenticated(delivered, alice, { scope: "document" })); + const fetched = evidence({ + originAuthenticated: [actor(alice.href, "origin")], + }); + assert(isActorAuthenticated(fetched, alice)); + assertFalse(isActorAuthenticated(getEmptyAuthenticationEvidence(), alice)); + // IDs are compared exactly: + assertFalse( + isActorAuthenticated( + delivered, + new URL("https://example.com/users/alice/"), + ), + ); +}); + +test("isActorAuthenticated() compares portable actors canonically", () => { + const portable = parseIri(`ap://${DID}/actor`); + const compatible = new URL( + `https://gateway.example/.well-known/apgateway/${DID}/actor`, + ); + const otherGateway = new URL( + `https://other.example/.well-known/apgateway/${DID}/actor`, + ); + const byActor = evidence({ requestSigners: [actor(portable.href)] }); + assert(isActorAuthenticated(byActor, compatible)); + assert(isActorAuthenticated(byActor, otherGateway)); + assertFalse( + isActorAuthenticated(byActor, parseIri(`ap://${DID}/other`)), + ); + const byAuthority = evidence({ + documentSigners: [{ + kind: "portableAuthority", + id: new URL(DID), + mechanism: "objectIntegrityProofs", + keyId: new URL(`${DID}#${DID.slice(8)}`), + }], + }); + assert(isActorAuthenticated(byAuthority, portable)); + assert(isActorAuthenticated(byAuthority, compatible)); + assertFalse( + isActorAuthenticated(byAuthority, parseIri(`ap://${OTHER_DID}/actor`)), + ); + // A DID authenticates no ordinary web actor: + assertFalse( + isActorAuthenticated(byAuthority, new URL("https://example.com/users/a")), + ); + // Malformed compatible identifiers never match: + assertFalse( + isActorAuthenticated( + byActor, + new URL("https://gateway.example/.well-known/apgateway/did:bogus"), + ), + ); +}); + +test("serializeAuthentication() round-trips through JSON", () => { + const original = evidence({ + requestSigners: [{ + kind: "actor", + id: new URL("https://example.com/users/alice"), + mechanism: "httpSignatures", + keyId: new URL("https://example.com/users/alice#main-key"), + }], + documentSigners: [{ + kind: "portableAuthority", + id: new URL(DID), + mechanism: "objectIntegrityProofs", + keyId: null, + }], + }); + const expanded = [{ "@id": "https://example.com/activities/1" }]; + const restored = deserializeAuthentication( + JSON.parse(JSON.stringify(serializeAuthentication(original))), + expanded, + null, + ); + assertEquals(restored, { ...original, expandedActivity: expanded }); + for ( + const malformed of [ + null, + {}, + { ...serializeAuthentication(original), verified: "yes" }, + { ...serializeAuthentication(original), documentSigners: null }, + { + ...serializeAuthentication(original), + requestSigners: [{ kind: "server", id: "x", mechanism: "origin" }], + }, + { + ...serializeAuthentication(original), + requestSigners: [{ + kind: "actor", + id: "not a url", + mechanism: "httpSignatures", + keyId: null, + }], + }, + ] + ) { + assertEquals(deserializeAuthentication(malformed, [], null), null); + } +}); diff --git a/packages/fedify/src/federation/inbox-authentication.ts b/packages/fedify/src/federation/inbox-authentication.ts new file mode 100644 index 000000000..7262a2bc3 --- /dev/null +++ b/packages/fedify/src/federation/inbox-authentication.ts @@ -0,0 +1,375 @@ +import { + getCanonicalPortableId, + getPortableDid, + isPortableId, +} from "../sig/portable-key-id.ts"; + +/** + * A party that Fedify authenticated for an incoming activity, independently + * of the activity's declared `actor`. + * @since 2.5.0 + */ +export interface InboxPrincipal { + /** + * What {@link InboxPrincipal.id} is: + * + * - `"actor"`: an actor whose ownership of the verifying key Fedify + * confirmed with the actor's own document. + * - `"portableAuthority"`: a DID whose verification method made a valid + * Object Integrity Proof. It authenticates the [FEP-ef61] portable + * actors of that DID, and nothing else. + * + * [FEP-ef61]: https://w3id.org/fep/ef61 + */ + readonly kind: "actor" | "portableAuthority"; + + /** The actor ID, or the DID if {@link kind} is `"portableAuthority"`. */ + readonly id: URL; + + /** + * How the principal was authenticated: + * + * - `"httpSignatures"`: it signed the HTTP request. + * - `"linkedDataSignatures"`: it made the Linked Data Signature of the + * activity. + * - `"objectIntegrityProofs"`: it made an Object Integrity Proof of the + * activity. + * - `"origin"`: the activity was fetched again from the actor's own web + * origin, which happens only for `Context.routeActivity()`. + */ + readonly mechanism: + | "httpSignatures" + | "linkedDataSignatures" + | "objectIntegrityProofs" + | "origin"; + + /** The key or verification method that authenticated the principal. */ + readonly keyId: URL | null; +} + +/** + * Who Fedify authenticated for an incoming activity, and the interpretation of + * the activity it authenticated. Inbox listeners receive it as + * {@link InboxContext.authentication}. + * + * Authenticated principals are independent of the activity's declared + * `actor`: an activity forwarded by a relay is signed by the relay in its + * HTTP request, but its document may be signed by its author, and some + * activities, such as [FEP-7aa9] `FeatureRequest`s sent by Mastodon, declare + * no `actor` at all. A valid signature alone does not authorize anything; + * use {@link isActorAuthenticated} to check whether a specific actor was + * authenticated. + * + * [FEP-7aa9]: https://w3id.org/fep/7aa9 + * @since 2.5.0 + */ +export interface InboxAuthenticationEvidence { + /** + * Whether signatures were verified at all. It is `false` only when + * signature verification is skipped, or when there is no evidence, e.g., + * for an activity queued by an older version of Fedify. In that case all + * the principal lists are empty. + */ + readonly verified: boolean; + + /** + * The principals that signed the HTTP request, i.e., the server that + * delivered the activity, which may have forwarded it. It is empty when + * the activity document was authenticated by its own signatures, so that + * HTTP Signatures did not need to be verified. + */ + readonly requestSigners: readonly InboxPrincipal[]; + + /** + * The principals that made the verified Linked Data Signature or Object + * Integrity Proofs of the activity document. All of them are listed when + * there are several proofs. + */ + readonly documentSigners: readonly InboxPrincipal[]; + + /** + * The actors authenticated because `Context.routeActivity()` fetched the + * activity again from their own web origin. It is always empty for + * activities delivered to an inbox. + */ + readonly originAuthenticated: readonly InboxPrincipal[]; + + /** + * The activity as Fedify interpreted it while authenticating it, in + * expanded JSON-LD. Typed vocabulary objects omit some values, such as + * blank node references and values they cannot decode, so authorization + * that has to count every value of a property, e.g., that a property has + * exactly one value, should inspect this instead. It is deeply frozen. + * It is empty when there is no evidence. + */ + readonly expandedActivity: readonly Record[]; + + /** + * The base URL the activity was decoded with, e.g., the URL an activity + * fetched by `Context.routeActivity()` was finally retrieved from, or + * `null` if none was given. Relative IRIs left in + * {@link expandedActivity} resolve against it, or against the activity's + * own `@id` if it is `null`, as the decoder resolves them. + */ + readonly baseUrl: URL | null; +} + +/** + * The evidence of an activity that has not been authenticated by Fedify. + */ +const EMPTY_EVIDENCE: InboxAuthenticationEvidence = Object.freeze({ + verified: false, + requestSigners: Object.freeze([]), + documentSigners: Object.freeze([]), + originAuthenticated: Object.freeze([]), + expandedActivity: Object.freeze([]), + baseUrl: null, +}); + +/** + * Gets the evidence of an activity that has not been authenticated. + * @internal + */ +export function getEmptyAuthenticationEvidence(): InboxAuthenticationEvidence { + return EMPTY_EVIDENCE; +} + +/** + * Options for {@link isActorAuthenticated}. + * @since 2.5.0 + */ +export interface IsActorAuthenticatedOptions { + /** + * Which principals to consider: + * + * - `"effective"` (default): the document signers if there are any, + * otherwise the request signers if there are any, otherwise the actors + * authenticated by their origin. + * - `"document"`: the document signers only. + */ + readonly scope?: "effective" | "document"; +} + +/** + * Gets the principals that an authorization decision should rely on. + * @param evidence The evidence. + * @param scope See {@link IsActorAuthenticatedOptions.scope}. + * @returns The principals. + * @internal + */ +export function getEffectivePrincipals( + evidence: InboxAuthenticationEvidence, + scope: "effective" | "document" = "effective", +): readonly InboxPrincipal[] { + if (scope === "document" || evidence.documentSigners.length > 0) { + return evidence.documentSigners; + } + if (evidence.requestSigners.length > 0) return evidence.requestSigners; + return evidence.originAuthenticated; +} + +/** + * Normalizes an actor ID for comparison: portable IDs and compatible + * identifiers to their canonical portable ID, other IDs to their `href`. + * @returns The normalized ID, or `null` for a malformed portable ID. + */ +function normalizeActorId(id: URL): string | null { + return isPortableId(id) ? getCanonicalPortableId(id) : id.href; +} + +/** + * Checks whether the given principal authenticates the given actor. + * @param principal The principal. + * @param actorId The actor ID. + * @returns `true` if the principal authenticates the actor. + * @internal + */ +export function principalAuthenticates( + principal: InboxPrincipal, + actorId: URL, +): boolean { + if (principal.kind === "portableAuthority") { + if (!isPortableId(actorId)) return false; + const did = getPortableDid(actorId); + return did != null && did === getPortableDid(principal.id); + } + const expected = normalizeActorId(actorId); + return expected != null && normalizeActorId(principal.id) === expected; +} + +/** + * Checks whether the given evidence authenticates the given actor. + * + * Actor IDs are compared exactly, except that [FEP-ef61] portable IDs and + * compatible identifiers are compared by their canonical portable IDs, and + * a portable authority (a DID) authenticates the portable actors of that DID. + * + * @example + * ``` typescript + * federation + * .setInboxListeners("/users/{identifier}/inbox", "/inbox") + * .on(Follow, async (ctx, follow) => { + * if (follow.actorId == null) return; + * // The actor is always authenticated for activities that declare one, + * // but the same check works for any actor an activity names: + * isActorAuthenticated(ctx.authentication, follow.actorId); + * }); + * ``` + * + * [FEP-ef61]: https://w3id.org/fep/ef61 + * @param evidence The authentication evidence, usually + * {@link InboxContext.authentication}. + * @param actorId The actor ID to check. + * @param options Options. + * @returns `true` if the actor is authenticated. + * @since 2.5.0 + */ +export function isActorAuthenticated( + evidence: InboxAuthenticationEvidence, + actorId: URL, + options: IsActorAuthenticatedOptions = {}, +): boolean { + return getEffectivePrincipals(evidence, options.scope).some((principal) => + principalAuthenticates(principal, actorId) + ); +} + +/** + * A {@link InboxPrincipal} as stored in a queued message. + * @internal + */ +export interface SerializedInboxPrincipal { + readonly kind: InboxPrincipal["kind"]; + readonly id: string; + readonly mechanism: InboxPrincipal["mechanism"]; + readonly keyId: string | null; +} + +/** + * The principals of an {@link InboxAuthenticationEvidence} as stored in + * a queued message. The expanded activity and the base URL are restored + * from the queued verified view instead. + * @internal + */ +export interface SerializedInboxAuthentication { + readonly verified: boolean; + readonly requestSigners: readonly SerializedInboxPrincipal[]; + readonly documentSigners: readonly SerializedInboxPrincipal[]; + readonly originAuthenticated: readonly SerializedInboxPrincipal[]; +} + +function serializePrincipal( + principal: InboxPrincipal, +): SerializedInboxPrincipal { + return { + kind: principal.kind, + id: principal.id.href, + mechanism: principal.mechanism, + keyId: principal.keyId?.href ?? null, + }; +} + +/** + * Serializes the principals of the given evidence for a queued message. + * @internal + */ +export function serializeAuthentication( + evidence: InboxAuthenticationEvidence, +): SerializedInboxAuthentication { + return { + verified: evidence.verified, + requestSigners: evidence.requestSigners.map(serializePrincipal), + documentSigners: evidence.documentSigners.map(serializePrincipal), + originAuthenticated: evidence.originAuthenticated.map(serializePrincipal), + }; +} + +const KINDS = new Set(["actor", "portableAuthority"]); +const MECHANISMS = new Set([ + "httpSignatures", + "linkedDataSignatures", + "objectIntegrityProofs", + "origin", +]); + +function deserializePrincipals(value: unknown): InboxPrincipal[] | null { + if (!Array.isArray(value)) return null; + const principals: InboxPrincipal[] = []; + for (const item of value) { + if ( + item == null || typeof item !== "object" || !KINDS.has(item.kind) || + !MECHANISMS.has(item.mechanism) || typeof item.id !== "string" || + !URL.canParse(item.id) || + (item.keyId != null && + (typeof item.keyId !== "string" || !URL.canParse(item.keyId))) + ) { + return null; + } + principals.push(Object.freeze({ + kind: item.kind, + id: new URL(item.id), + mechanism: item.mechanism, + keyId: item.keyId == null ? null : new URL(item.keyId), + })); + } + return principals; +} + +/** + * Restores the evidence of a queued message. + * @param value The serialized principals. + * @param expandedActivity The expanded activity, restored from the queued + * verified view. + * @param baseUrl The base URL, restored from the queued verified view. + * @returns The evidence, or `null` if the serialized value is malformed. + * @internal + */ +export function deserializeAuthentication( + value: unknown, + expandedActivity: readonly Record[], + baseUrl: URL | null, +): InboxAuthenticationEvidence | null { + if ( + value == null || typeof value !== "object" || + typeof (value as { verified?: unknown }).verified !== "boolean" + ) { + return null; + } + const record = value as Record; + const requestSigners = deserializePrincipals(record.requestSigners); + const documentSigners = deserializePrincipals(record.documentSigners); + const originAuthenticated = deserializePrincipals( + record.originAuthenticated, + ); + if ( + requestSigners == null || documentSigners == null || + originAuthenticated == null + ) { + return null; + } + return createAuthenticationEvidence({ + verified: record.verified as boolean, + requestSigners, + documentSigners, + originAuthenticated, + expandedActivity, + baseUrl, + }); +} + +/** + * Creates frozen evidence. + * @internal + */ +export function createAuthenticationEvidence( + evidence: InboxAuthenticationEvidence, +): InboxAuthenticationEvidence { + return Object.freeze({ + verified: evidence.verified, + requestSigners: Object.freeze([...evidence.requestSigners]), + documentSigners: Object.freeze([...evidence.documentSigners]), + originAuthenticated: Object.freeze([...evidence.originAuthenticated]), + expandedActivity: evidence.expandedActivity, + baseUrl: evidence.baseUrl, + }); +} diff --git a/packages/fedify/src/federation/inbox-report.ts b/packages/fedify/src/federation/inbox-report.ts index edf34bd19..68e7445ea 100644 --- a/packages/fedify/src/federation/inbox-report.ts +++ b/packages/fedify/src/federation/inbox-report.ts @@ -66,6 +66,14 @@ export type InboxAuthentication = readonly type: "activityOriginMismatch"; readonly activityId: URL; readonly actorIds: readonly URL[]; + } + | { + /** + * The activity declares no actor, and no principal was authenticated + * for it, although its listener allows activities without an actor. + * @since 2.5.0 + */ + readonly type: "noAuthenticatedPrincipal"; }; } | { readonly status: "skipped" } diff --git a/packages/fedify/src/federation/inbox-view.test.ts b/packages/fedify/src/federation/inbox-view.test.ts new file mode 100644 index 000000000..bb25a4dbf --- /dev/null +++ b/packages/fedify/src/federation/inbox-view.test.ts @@ -0,0 +1,320 @@ +import { test } from "@fedify/fixture"; +import { + type DocumentLoader, + getDocumentLoader, + preloadedContexts, +} from "@fedify/vocab-runtime"; +import { assert, assertEquals, assertRejects } from "@std/assert"; +import { + buildVerifiedActivityView, + ContextSnapshotMissError, + createContextSnapshot, + createReleasableLoader, + createSnapshotOnlyLoader, + deserializeContextSnapshot, + expandDocument, + getActorPresence, + getPropertyValues, + restoreVerifiedActivityView, + serializeContextSnapshot, +} from "./inbox-view.ts"; + +const AS = "https://www.w3.org/ns/activitystreams"; +const CUSTOM = "https://remote.example/contexts/custom"; + +function createMutableLoader(): { loader: DocumentLoader; loads: string[] } { + const loads: string[] = []; + const preloaded = getDocumentLoader(); + return { + loads, + loader: (url, options) => { + loads.push(url); + if (url === CUSTOM) { + const n = loads.filter((u) => u === CUSTOM).length; + return Promise.resolve({ + contextUrl: null, + documentUrl: url, + document: { + "@context": { + tgt: { + "@id": n % 2 === 1 ? `${AS}#object` : `${AS}#target`, + "@type": "@id", + }, + }, + }, + }); + } + return preloaded(url, options); + }, + }; +} + +test("createContextSnapshot() memoizes responses for a delivery", async () => { + const { loader, loads } = createMutableLoader(); + const snapshot = createContextSnapshot(loader); + const standard = snapshot.loader("standard"); + const first = await standard(CUSTOM); + const second = await standard(CUSTOM); + assertEquals(first, second); + assertEquals(loads.filter((url) => url === CUSTOM).length, 1); + // Responses are cloned, so a caller cannot change what others see: + (first.document as Record)["@context"] = null; + assertEquals((await standard(CUSTOM)).document, second.document); + assertEquals( + snapshot.entries().map((entry) => [entry.policy, entry.url]), + [["standard", CUSTOM]], + ); + // After the delivery, the loader passes through: + snapshot.release(); + await standard(CUSTOM); + assertEquals(loads.filter((url) => url === CUSTOM).length, 2); +}); + +test("createContextSnapshot() memoizes failures for a delivery", async () => { + let calls = 0; + const snapshot = createContextSnapshot((url) => { + calls++; + return calls === 1 + ? Promise.reject(new TypeError("fetch failed")) + : Promise.resolve({ contextUrl: null, documentUrl: url, document: {} }); + }); + const loader = snapshot.loader("standard"); + await assertRejects(() => loader(CUSTOM), TypeError); + await assertRejects(() => loader(CUSTOM), TypeError); + assertEquals(calls, 1); + assertEquals(snapshot.entries(), []); +}); + +test("createContextSnapshot() records the normalization overrides", async () => { + const snapshot = createContextSnapshot(() => + Promise.reject(new Error("The caller's loader must not be asked.")) + ); + const loader = snapshot.loader("normalization"); + await loader("https://w3id.org/security/v1"); + assertEquals( + snapshot.entries().map((entry) => [entry.policy, entry.url]), + [["normalization", "https://w3id.org/security/v1"]], + ); + const [entry] = await serializeContextSnapshot(snapshot.entries()); + assert("bundled" in entry && entry.bundled); +}); + +test("serializeContextSnapshot() stores only exactly bundled responses by hash", async () => { + const bundled = { + contextUrl: null, + documentUrl: AS, + document: preloadedContexts[AS], + }; + const serialized = await serializeContextSnapshot([ + { policy: "standard", url: AS, response: bundled }, + // The same document served from another location is not the bundled + // response: + { + policy: "standard", + url: "https://www.w3.org/ns/activitystreams", + response: { ...bundled, documentUrl: "https://mirror.example/as" }, + }, + // A caller's own version of a known context is kept as is: + { + policy: "standard", + url: AS, + response: { ...bundled, document: { "@context": {} } }, + }, + ]); + assert("bundled" in serialized[0]); + assert("response" in serialized[1]); + assert("response" in serialized[2]); + const restored = await deserializeContextSnapshot( + JSON.parse(JSON.stringify(serialized)), + ); + assertEquals(restored[0].response.document, preloadedContexts[AS]); + assertEquals(restored[1].response.documentUrl, "https://mirror.example/as"); + assertEquals(restored[2].response.document, { "@context": {} }); +}); + +test("deserializeContextSnapshot() rejects tampered or unknown entries", async () => { + const [entry] = await serializeContextSnapshot([{ + policy: "standard", + url: CUSTOM, + response: { contextUrl: null, documentUrl: CUSTOM, document: { a: 1 } }, + }]); + await assertRejects( + () => + deserializeContextSnapshot([{ + ...entry, + response: { + contextUrl: null, + documentUrl: CUSTOM, + document: { a: 2 }, + }, + }]), + ContextSnapshotMissError, + ); + await assertRejects( + () => + deserializeContextSnapshot([{ + policy: "standard", + url: CUSTOM, + sha256: entry.sha256, + bundled: true, + }]), + ContextSnapshotMissError, + ); + await assertRejects( + () => deserializeContextSnapshot([{ url: CUSTOM }]), + ContextSnapshotMissError, + ); + await assertRejects( + () => deserializeContextSnapshot("nope"), + ContextSnapshotMissError, + ); +}); + +test("createSnapshotOnlyLoader() serves only the snapshot", async () => { + const loader = createSnapshotOnlyLoader([{ + policy: "standard", + url: CUSTOM, + response: { contextUrl: null, documentUrl: CUSTOM, document: { a: 1 } }, + }], "standard"); + assertEquals((await loader(CUSTOM)).document, { a: 1 }); + await assertRejects(() => loader(AS), ContextSnapshotMissError); + const normalization = createSnapshotOnlyLoader([{ + policy: "standard", + url: CUSTOM, + response: { contextUrl: null, documentUrl: CUSTOM, document: { a: 1 } }, + }], "normalization"); + await assertRejects(() => normalization(CUSTOM), ContextSnapshotMissError); +}); + +test("createReleasableLoader() is strict until released", async () => { + const strict: DocumentLoader = () => Promise.reject(new Error("strict")); + const fallback: DocumentLoader = (url) => + Promise.resolve({ contextUrl: null, documentUrl: url, document: {} }); + const releasable = createReleasableLoader(strict, fallback, "standard"); + await assertRejects(() => releasable.loader(CUSTOM), Error, "strict"); + releasable.release(); + assertEquals((await releasable.loader(CUSTOM)).document, {}); +}); + +test("getActorPresence() counts every declared actor value", async () => { + const expand = (actor?: unknown) => + expandDocument( + { + "@context": AS, + id: "https://example.com/activities/1", + type: "Create", + ...(actor === undefined ? {} : { actor }), + }, + getDocumentLoader(), + ); + assertEquals(getActorPresence(await expand()), "absent"); + assertEquals(getActorPresence(await expand([])), "absent"); + assertEquals(getActorPresence(await expand({ "@list": [] })), "absent"); + for ( + const actor of [ + "https://example.com/users/alice", + { type: "Person", name: "Anonymous" }, + { name: "Anonymous" }, + { id: "_:b0" }, + { "@list": [{ name: "Anonymous" }] }, + ] + ) { + assertEquals(getActorPresence(await expand(actor)), "present"); + } + assertEquals(getActorPresence([]), "present"); +}); + +test("getPropertyValues() unwraps a single list like the decoders", () => { + const node = { + a: [{ "@list": [{ "@id": "x" }, { "@id": "y" }] }], + b: [{ "@id": "x" }, { "@list": [] }], + }; + assertEquals(getPropertyValues(node, "a").length, 2); + assertEquals(getPropertyValues(node, "b").length, 2); + assertEquals(getPropertyValues(node, "c"), []); +}); + +test("restoreVerifiedActivityView() replays the authenticated interpretation", async () => { + const { loader, loads } = createMutableLoader(); + const document = { + "@context": [AS, CUSTOM], + id: "https://example.com/activities/1", + type: "Create", + tgt: "https://example.com/notes/1", + }; + const snapshot = createContextSnapshot(loader); + const { view, expanded } = await buildVerifiedActivityView(snapshot, { + policy: "standard", + source: "activity", + document, + baseUrl: null, + }); + const queued = JSON.parse(JSON.stringify(view)); + const loadsBefore = loads.length; + const restored = await restoreVerifiedActivityView(queued, { + activity: document, + }); + assertEquals(loads.length, loadsBefore); + assertEquals(restored.expanded, expanded); + assertEquals( + getPropertyValues(restored.expanded[0], `${AS}#object`).length, + 1, + ); + // A view whose digest does not match is never replayed: + await assertRejects( + () => + restoreVerifiedActivityView({ ...queued, expandedDigest: "0" }, { + activity: document, + }), + ContextSnapshotMissError, + ); + // Nor one whose document changed: + await assertRejects( + () => + restoreVerifiedActivityView(queued, { + activity: { ...document, tgt: "https://example.com/notes/2" }, + }), + ContextSnapshotMissError, + ); + // Nor one whose normalized document is missing: + await assertRejects( + () => + restoreVerifiedActivityView( + { ...queued, source: "normalizedActivity" }, + { activity: document }, + ), + ContextSnapshotMissError, + ); +}); + +test("restoreVerifiedActivityView() survives changes to unused bundled terms", async () => { + const document = { + "@context": [AS, "http://joinmastodon.org/ns"], + id: "https://example.com/likes/1", + type: "Like", + object: "https://example.com/notes/1", + }; + const snapshot = createContextSnapshot(getDocumentLoader()); + const { view, expanded } = await buildVerifiedActivityView(snapshot, { + policy: "standard", + source: "activity", + document, + baseUrl: null, + }); + const mastodon = view.contexts.find((entry) => + entry.url === "http://joinmastodon.org/ns" + ); + assert(mastodon != null && "bundled" in mastodon); + // A newer version of Fedify bundles a context that differs from the one + // the message was queued with, as far as the activity does not use it: + const queued = { + ...view, + contexts: view.contexts.map((entry) => + entry === mastodon ? { ...entry, sha256: "0".repeat(64) } : entry + ), + }; + const restored = await restoreVerifiedActivityView(queued, { + activity: document, + }); + assertEquals(restored.expanded, expanded); +}); diff --git a/packages/fedify/src/federation/inbox-view.ts b/packages/fedify/src/federation/inbox-view.ts new file mode 100644 index 000000000..23e6486fc --- /dev/null +++ b/packages/fedify/src/federation/inbox-view.ts @@ -0,0 +1,597 @@ +/** + * The interpretation of an incoming activity that Fedify authenticated, kept + * so that listeners, authorization helpers, and queue workers observe the + * same JSON-LD meaning that signature verification observed. + * + * Every JSON-LD processing step of one delivery goes through a request-scoped + * *context snapshot*, which memoizes the remote context responses so that + * a nondeterministic or mutable remote context cannot give verification and + * dispatch different meanings. The snapshot is serialized into queued + * messages, so that workers and retries replay the very same responses + * without loading contexts again. + * @module + */ +import { + type DocumentLoader, + preloadedContexts, + type RemoteDocument, +} from "@fedify/vocab-runtime"; +import jsonld from "@fedify/vocab-runtime/jsonld"; +import { encodeHex } from "byte-encodings/hex"; +import serialize from "json-canon"; +import { + detachSignature, + type EffectiveContextLoaderPolicy, + getNormalizationContextLoader, + markEffectiveContextLoader, + wrapContextLoaderForJsonLd, +} from "../sig/ld.ts"; + +/** + * A context response that a delivery's interpretation used. + * @internal + */ +export interface ContextSnapshotEntry { + readonly policy: EffectiveContextLoaderPolicy; + /** The normalized URL the response was requested for. */ + readonly url: string; + readonly response: RemoteDocument; +} + +/** + * A {@link ContextSnapshotEntry} as stored in a queued message. A response + * that equals the one Fedify bundles for the URL is stored as a reference to + * the bundled context, along with its hash for diagnostics. + * @internal + */ +export type SerializedContextSnapshotEntry = + & { + readonly policy: EffectiveContextLoaderPolicy; + readonly url: string; + /** The SHA-256 of the canonical JSON of the whole response. */ + readonly sha256: string; + } + & ( + | { readonly bundled: true } + | { readonly response: RemoteDocument } + ); + +/** + * Thrown when replaying an interpretation needs a context response that its + * snapshot does not have, or has with a different content. + * @internal + */ +export class ContextSnapshotMissError extends Error { + readonly url: string; + + constructor(url: string, reason: string) { + super(`Context ${url} cannot be replayed: ${reason}.`); + this.name = "ContextSnapshotMissError"; + this.url = url; + } +} + +/** + * The context snapshot of one delivery. + * @internal + */ +export interface ContextSnapshot { + /** + * Gets the effective context loader of a policy, going through the + * snapshot. The loader is marked so that Fedify's JSON-LD helpers use it + * as is instead of wrapping it again. + * @param policy The loader policy. + */ + loader(policy: EffectiveContextLoaderPolicy): DocumentLoader; + + /** The successful responses recorded so far. */ + entries(): readonly ContextSnapshotEntry[]; + + /** + * Turns the snapshot's loaders into pass-throughs to the underlying + * effective loaders. Objects parsed during the delivery keep their + * loaders for later dereferences, which then load contexts normally. + */ + release(): void; +} + +function normalizeUrl(url: string): string { + return URL.canParse(url) ? new URL(url).href : url; +} + +/** + * Creates the context snapshot of a delivery. + * @param base The caller's context loader. + * @returns The snapshot. + * @internal + */ +export function createContextSnapshot( + base: DocumentLoader | undefined, +): ContextSnapshot { + const underlying: Record = { + standard: wrapContextLoaderForJsonLd(base), + normalization: getNormalizationContextLoader(base), + }; + const cache = new Map>(); + const recorded = new Map(); + const loaders = new Map(); + let released = false; + return { + loader(policy) { + let loader = loaders.get(policy); + if (loader != null) return loader; + const effective = underlying[policy]; + loader = markEffectiveContextLoader(async (url, options) => { + if (released) return await effective(url, options); + const normalizedUrl = normalizeUrl(url); + const key = `${policy}\n${normalizedUrl}`; + let promise = cache.get(key); + if (promise == null) { + // Rejections are memoized too: a context that failed once in this + // delivery must not succeed later with another document. + promise = Promise.resolve(effective(url, options)).then( + (response) => { + const cloned = structuredClone(response); + recorded.set(key, { + policy, + url: normalizedUrl, + response: structuredClone(cloned), + }); + return cloned; + }, + ); + promise.catch(() => {}); + cache.set(key, promise); + } + return structuredClone(await promise); + }, policy); + loaders.set(policy, loader); + return loader; + }, + entries() { + return [...recorded.values()]; + }, + release() { + released = true; + }, + }; +} + +function getBundledResponse(url: string): RemoteDocument | null { + if (!(url in preloadedContexts)) return null; + return { + contextUrl: null, + document: preloadedContexts[url], + documentUrl: url, + }; +} + +async function sha256(value: unknown): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(serialize(value)), + ); + return encodeHex(digest); +} + +function normalizeResponse(response: RemoteDocument): RemoteDocument { + return { + contextUrl: response.contextUrl ?? null, + document: response.document, + documentUrl: response.documentUrl, + }; +} + +/** + * Serializes the entries of a context snapshot for a queued message. + * @param entries The entries. + * @returns The serialized entries, which are plain JSON values. + * @internal + */ +export async function serializeContextSnapshot( + entries: readonly ContextSnapshotEntry[], +): Promise { + const result: SerializedContextSnapshotEntry[] = []; + for (const entry of entries) { + const response = normalizeResponse(entry.response); + const hash = await sha256(response); + const bundled = getBundledResponse(entry.url); + if (bundled != null && await sha256(bundled) === hash) { + result.push({ + policy: entry.policy, + url: entry.url, + sha256: hash, + bundled: true, + }); + } else { + result.push({ + policy: entry.policy, + url: entry.url, + sha256: hash, + response: structuredClone(response), + }); + } + } + return result; +} + +function isRemoteDocument(value: unknown): value is RemoteDocument { + if (value == null || typeof value !== "object") return false; + const record = value as Record; + return typeof record.documentUrl === "string" && "document" in record && + (record.contextUrl == null || typeof record.contextUrl === "string"); +} + +/** + * Restores the entries of a serialized context snapshot, verifying the + * hashes of stored responses. Bundled entries are reconstructed from the + * contexts this version of Fedify bundles. + * @param entries The serialized entries. + * @returns The entries. + * @throws {ContextSnapshotMissError} If an entry is malformed, or its + * content does not match its hash. + * @internal + */ +export async function deserializeContextSnapshot( + entries: unknown, +): Promise { + if (!Array.isArray(entries)) { + throw new ContextSnapshotMissError("(snapshot)", "malformed snapshot"); + } + const result: ContextSnapshotEntry[] = []; + for (const entry of entries) { + if ( + entry == null || typeof entry !== "object" || + (entry.policy !== "standard" && entry.policy !== "normalization") || + typeof entry.url !== "string" || typeof entry.sha256 !== "string" + ) { + throw new ContextSnapshotMissError("(snapshot)", "malformed entry"); + } + let response: RemoteDocument | null; + if (entry.bundled === true) { + response = getBundledResponse(entry.url); + if (response == null) { + throw new ContextSnapshotMissError(entry.url, "not bundled"); + } + } else if (isRemoteDocument(entry.response)) { + response = normalizeResponse(entry.response); + } else { + throw new ContextSnapshotMissError(entry.url, "malformed entry"); + } + // A bundled context may change between Fedify versions, e.g., with + // a term the activity does not use; the digest of the expanded activity, + // which restoreVerifiedActivityView() checks, tells whether the meaning of + // the activity changed. Other responses are stored as they were: + if (entry.bundled !== true && await sha256(response) !== entry.sha256) { + throw new ContextSnapshotMissError(entry.url, "content mismatch"); + } + result.push({ + policy: entry.policy, + url: entry.url, + response: structuredClone(response), + }); + } + return result; +} + +/** + * Creates a context loader of a policy that serves only the responses of + * a snapshot. It is marked as the effective loader of the policy. + * @param entries The snapshot's entries. + * @param policy The policy. + * @returns The loader, which throws {@link ContextSnapshotMissError} for + * anything else. + * @internal + */ +export function createSnapshotOnlyLoader( + entries: readonly ContextSnapshotEntry[], + policy: EffectiveContextLoaderPolicy, +): DocumentLoader { + const responses = new Map(); + for (const entry of entries) { + if (entry.policy === policy) responses.set(entry.url, entry.response); + } + // deno-lint-ignore require-await + return markEffectiveContextLoader(async (url) => { + const response = responses.get(normalizeUrl(url)); + if (response == null) { + throw new ContextSnapshotMissError(url, "not in the snapshot"); + } + return structuredClone(response); + }, policy); +} + +/** + * A context loader that is strict until it is released, and a pass-through to + * another loader afterwards. + * @internal + */ +export interface ReleasableLoader { + readonly loader: DocumentLoader; + release(): void; +} + +/** + * Creates a {@link ReleasableLoader}. + * @param strict The loader to use until release. + * @param fallback The loader to use after release. + * @param policy The policy both loaders apply. + * @returns The releasable loader. + * @internal + */ +export function createReleasableLoader( + strict: DocumentLoader, + fallback: DocumentLoader, + policy: EffectiveContextLoaderPolicy, +): ReleasableLoader { + let released = false; + return { + loader: markEffectiveContextLoader( + (url, options) => + released ? fallback(url, options) : strict(url, options), + policy, + ), + release() { + released = true; + }, + }; +} + +/** + * Expands a JSON-LD document with the given context loader. + * @param document The document. + * @param contextLoader The context loader. + * @returns The expanded document. + * @internal + */ +export async function expandDocument( + document: unknown, + contextLoader: DocumentLoader, +): Promise[]> { + // deno-lint-ignore no-explicit-any + return await jsonld.expand(document as any, { + documentLoader: contextLoader, + // deno-lint-ignore no-explicit-any + } as any) as Record[]; +} + +/** + * Computes the digest of an expanded document. + * @param expanded The expanded document. + * @returns The hex-encoded SHA-256 of its canonical JSON. + * @internal + */ +export function digestExpandedDocument(expanded: unknown): Promise { + return sha256(expanded); +} + +/** + * Deeply freezes a JSON value. + * @param value The value. + * @returns The same value, frozen. + * @internal + */ +export function deepFreeze(value: T): T { + if (value != null && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + } + return value; +} + +const ACTOR_IRI = "https://www.w3.org/ns/activitystreams#actor"; + +/** + * Gets the single root node of an expanded document. + * @param expanded The expanded document. + * @returns The root node, or `null` if there is not exactly one. + * @internal + */ +export function getRootNode( + expanded: readonly unknown[], +): Record | null { + if (expanded.length !== 1) return null; + const node = expanded[0]; + if (node == null || typeof node !== "object" || Array.isArray(node)) { + return null; + } + return node as Record; +} + +/** + * Gets the values of a property of an expanded node, unwrapping a single + * `@list` container the way generated vocabulary decoders do. + * @param node The expanded node. + * @param iri The property IRI. + * @returns The values, or an empty array if the property is absent. + * @internal + */ +export function getPropertyValues( + node: Record, + iri: string, +): readonly unknown[] { + const values = node[iri]; + if (values == null) return []; + if (!Array.isArray(values)) return [values]; + if ( + values.length === 1 && values[0] != null && + typeof values[0] === "object" && "@list" in values[0] + ) { + const list = (values[0] as { "@list": unknown })["@list"]; + return Array.isArray(list) ? list : [list]; + } + return values; +} + +/** + * Tells whether the root node of an expanded activity declares any `actor` + * value at all, including blank nodes, anonymous or untyped nodes, and + * literals, which ID accessors and typed parsing may drop. + * @param expanded The expanded activity. + * @returns `"absent"` if the root node declares no actor; `"present"` + * otherwise, including when there is no single root node. + * @internal + */ +export function getActorPresence( + expanded: readonly unknown[], +): "absent" | "present" { + const root = getRootNode(expanded); + if (root == null) return "present"; + return getPropertyValues(root, ACTOR_IRI).length < 1 ? "absent" : "present"; +} + +/** + * The interpretation of an incoming activity that Fedify authenticated, as + * stored in a queued message. The document itself is not copied; it is + * a field of the message. + * @internal + */ +export interface SerializedVerifiedActivityView { + /** The format version. */ + readonly version: 1; + /** The context loader policy the activity was parsed with. */ + readonly policy: EffectiveContextLoaderPolicy; + /** + * Which document the activity was parsed from, with its Linked Data + * Signature detached: the `activity` of the message, i.e., the payload as + * received, or its `normalizedActivity`, i.e., the payload compacted for + * Linked Data Signatures. + */ + readonly source: "activity" | "normalizedActivity"; + /** The `baseUrl` option the activity was parsed with. */ + readonly baseUrl: string | null; + /** The context responses the interpretation used. */ + readonly contexts: readonly SerializedContextSnapshotEntry[]; + /** The digest of the expanded activity. */ + readonly expandedDigest: string; +} + +/** + * Describes which document an activity was parsed from, and how. + * @internal + */ +export interface VerifiedActivityViewSource { + readonly policy: EffectiveContextLoaderPolicy; + readonly source: "activity" | "normalizedActivity"; + /** The document the activity was parsed from. */ + readonly document: unknown; + readonly baseUrl: URL | null; +} + +/** + * Builds the serialized view of an authenticated activity. The document is + * expanded through the snapshot, so that the snapshot holds every context + * response the interpretation needs. + * @param snapshot The delivery's context snapshot. + * @param source Which document the activity was parsed from. + * @returns The serialized view and the expanded activity. + * @internal + */ +export async function buildVerifiedActivityView( + snapshot: ContextSnapshot, + source: VerifiedActivityViewSource, +): Promise<{ + view: SerializedVerifiedActivityView; + expanded: Record[]; +}> { + const expanded = await expandDocument( + source.document, + snapshot.loader(source.policy), + ); + const contexts = await serializeContextSnapshot( + snapshot.entries().filter((entry) => entry.policy === source.policy), + ); + return { + view: { + version: 1, + policy: source.policy, + source: source.source, + baseUrl: source.baseUrl?.href ?? null, + contexts, + expandedDigest: await digestExpandedDocument(expanded), + }, + expanded, + }; +} + +/** + * Validates the shape of a serialized view taken from a queued message. + * @param value The value of the message's `verifiedView` field. + * @returns The view, or `null` if it is malformed or of an unsupported + * version. + * @internal + */ +export function parseSerializedVerifiedActivityView( + value: unknown, +): SerializedVerifiedActivityView | null { + if (value == null || typeof value !== "object") return null; + const view = value as Record; + if ( + view.version !== 1 || + (view.policy !== "standard" && view.policy !== "normalization") || + (view.source !== "activity" && view.source !== "normalizedActivity") || + (view.baseUrl !== null && + (typeof view.baseUrl !== "string" || !URL.canParse(view.baseUrl))) || + !Array.isArray(view.contexts) || typeof view.expandedDigest !== "string" + ) { + return null; + } + return view as unknown as SerializedVerifiedActivityView; +} + +/** + * A view restored from a queued message. + * @internal + */ +export interface RestoredVerifiedActivityView { + /** The document to parse the activity from. */ + readonly document: unknown; + /** A loader that serves only the snapshot's responses of the policy. */ + readonly loader: DocumentLoader; + /** The expanded activity, whose digest has been checked. */ + readonly expanded: Record[]; + readonly baseUrl: URL | null; +} + +/** + * Restores a view from a queued message without loading any context. + * @param view The serialized view. + * @param message The fields of the message the view refers to. + * @returns The restored view. + * @throws {ContextSnapshotMissError} If the view cannot be replayed exactly. + * @internal + */ +export async function restoreVerifiedActivityView( + view: SerializedVerifiedActivityView, + message: { + readonly activity: unknown; + readonly normalizedActivity?: unknown; + }, +): Promise { + const entries = await deserializeContextSnapshot(view.contexts); + const loader = createSnapshotOnlyLoader(entries, view.policy); + if ( + view.source === "normalizedActivity" && message.normalizedActivity == null + ) { + throw new ContextSnapshotMissError( + "(normalizedActivity)", + "the normalized activity is missing", + ); + } + const document = detachSignature( + view.source === "activity" ? message.activity : message.normalizedActivity, + ); + const expanded = await expandDocument(document, loader); + if (await digestExpandedDocument(expanded) !== view.expandedDigest) { + throw new ContextSnapshotMissError( + "(activity)", + "the interpretation does not match the authenticated one", + ); + } + return { + document, + loader, + expanded, + baseUrl: view.baseUrl == null ? null : new URL(view.baseUrl), + }; +} diff --git a/packages/fedify/src/federation/inbox.test.ts b/packages/fedify/src/federation/inbox.test.ts index 3f46caf2d..be701abd5 100644 --- a/packages/fedify/src/federation/inbox.test.ts +++ b/packages/fedify/src/federation/inbox.test.ts @@ -116,12 +116,12 @@ test("ActivityListenerSet", () => { assertEquals(listeners.dispatch(activity), null); assertEquals( listeners.dispatchWithClass(offer), - { class: Offer, listener: listenOffer }, + { class: Offer, listener: listenOffer, options: {} }, ); assertEquals(listeners.dispatch(offer), listenOffer); assertEquals( listeners.dispatchWithClass(invite), - { class: Offer, listener: listenOffer }, + { class: Offer, listener: listenOffer, options: {} }, ); assertEquals(listeners.dispatch(invite), listenOffer); assertEquals(listeners.dispatch(create), null); @@ -133,17 +133,17 @@ test("ActivityListenerSet", () => { assertEquals(listeners.dispatch(offer), listenOffer); assertEquals( listeners.dispatchWithClass(offer), - { class: Offer, listener: listenOffer }, + { class: Offer, listener: listenOffer, options: {} }, ); assertEquals(listeners.dispatch(invite), listenOffer); assertEquals( listeners.dispatchWithClass(invite), - { class: Offer, listener: listenOffer }, + { class: Offer, listener: listenOffer, options: {} }, ); assertEquals(listeners.dispatch(create), listenCreate); assertEquals( listeners.dispatchWithClass(create), - { class: Create, listener: listenCreate }, + { class: Create, listener: listenCreate, options: {} }, ); assertEquals(listeners.dispatch(update), null); @@ -152,27 +152,27 @@ test("ActivityListenerSet", () => { assertEquals(listeners.dispatch(activity), listenActivity); assertEquals( listeners.dispatchWithClass(activity), - { class: Activity, listener: listenActivity }, + { class: Activity, listener: listenActivity, options: {} }, ); assertEquals(listeners.dispatch(offer), listenOffer); assertEquals( listeners.dispatchWithClass(offer), - { class: Offer, listener: listenOffer }, + { class: Offer, listener: listenOffer, options: {} }, ); assertEquals(listeners.dispatch(invite), listenOffer); assertEquals( listeners.dispatchWithClass(invite), - { class: Offer, listener: listenOffer }, + { class: Offer, listener: listenOffer, options: {} }, ); assertEquals(listeners.dispatch(create), listenCreate); assertEquals( listeners.dispatchWithClass(create), - { class: Create, listener: listenCreate }, + { class: Create, listener: listenCreate, options: {} }, ); assertEquals(listeners.dispatch(update), listenActivity); assertEquals( listeners.dispatchWithClass(update), - { class: Activity, listener: listenActivity }, + { class: Activity, listener: listenActivity, options: {} }, ); assertThrows( diff --git a/packages/fedify/src/federation/inbox.ts b/packages/fedify/src/federation/inbox.ts index a952fe76d..12cb4c515 100644 --- a/packages/fedify/src/federation/inbox.ts +++ b/packages/fedify/src/federation/inbox.ts @@ -22,6 +22,13 @@ import type { import type { KvKey, KvStore } from "./kv.ts"; import type { MessageQueue } from "./mq.ts"; import type { InboxMessage } from "./queue.ts"; +import { + getEffectivePrincipals, + type InboxAuthenticationEvidence, + type InboxPrincipal, + serializeAuthentication, +} from "./inbox-authentication.ts"; +import type { SerializedVerifiedActivityView } from "./inbox-view.ts"; import type { ActivityListenerSet } from "./activity-listener.ts"; import { getDurationMs, @@ -53,6 +60,197 @@ export function actorsHaveSameOrigin(activity: Activity): boolean { ); } +/** + * Metadata that Fedify passes to an inbox context factory along with the + * activity. + * @internal + */ +export interface InboxContextMetadata { + readonly authentication?: InboxAuthenticationEvidence; +} + +/** + * A factory of inbox contexts. + * @internal + */ +export type InboxContextFactory = ( + recipient: string | null, + activity: unknown, + activityId: string | undefined, + activityType: string, + metadata?: InboxContextMetadata, +) => InboxContext; + +/** + * The headroom that the size check of a queued inbox message leaves for the + * fields that grow when Fedify retries the message, e.g., its attempt count. + */ +const QUEUED_MESSAGE_HEADROOM = 64; + +/** + * Computes the idempotency key of an incoming activity. + * @returns The key, or `null` if the activity is not deduplicated. + * @internal + */ +export async function getInboxIdempotencyKey( + strategy: + | IdempotencyStrategy + | IdempotencyKeyCallback + | undefined, + inboxContext: () => InboxContext, + activity: Activity, + origin: string, + recipient: string | null, + prefix: KvKey, +): Promise { + if (activity.id == null) return null; + // Default to "per-inbox" strategy since Fedify 2.0.0. + // (It had been "per-origin" in Fedify 1.x for backward compatibility.) + strategy ??= "per-inbox"; + let keyString: string | null; + if (typeof strategy === "function") { + // Custom callback strategy + keyString = await strategy(inboxContext(), activity); + } else { + // Preset strategies + switch (strategy) { + case "global": + // Global deduplication across all inboxes and inbox origins + keyString = activity.id.href; + break; + case "per-origin": + // Current behavior: deduplicate per inbox origin + keyString = `${origin}\n${activity.id.href}`; + break; + case "per-inbox": + // Standard ActivityPub behavior: deduplicate per inbox + keyString = `${origin}\n${activity.id.href}\n${ + recipient == null ? "sharedInbox" : `inbox\n${recipient}` + }`; + break; + default: + // Should never happen due to TypeScript, but handle just in case + keyString = `${origin}\n${activity.id.href}`; + } + } + return keyString == null ? null : [...prefix, keyString] satisfies KvKey; +} + +/** + * Checks whether an incoming activity has already been processed, by its + * idempotency key, or by the key that queue workers of older versions of + * Fedify recorded. + * @internal + */ +export async function isInboxActivityProcessed( + kv: KvStore, + cacheKey: KvKey | null, + activity: Activity, + origin: string, + prefix: KvKey, +): Promise { + if (cacheKey == null) return false; + if (await kv.get(cacheKey) === true) return true; + if (activity.id == null) return false; + return await kv.get([...prefix, origin, activity.id.href]) === true; +} + +/** + * The bytes added to the UTF-8 JSON length of a message for each of its JSON + * values, to bound the size of serialization formats other than JSON, such + * as the structured serialization of Deno KV, which spends more bytes than + * JSON on type tags and lengths of small values, e.g., empty arrays, and up + * to nine bytes on a fractional number that JSON writes in three. + */ +const PER_VALUE_SERIALIZATION_OVERHEAD = 8; + +/** + * Estimates, conservatively, how large a queued message is once a queue + * backend serializes it: the UTF-8 length of its JSON serialization, plus + * a fixed overhead for each JSON value in it, plus the bytes that encoding + * strings beyond Latin-1 as UTF-16 adds. + * @internal + */ +export function estimateQueuedMessageSize(message: unknown): number { + const encoder = new TextEncoder(); + let values = 0; + let stringExpansion = 0; + // A string with any character beyond Latin-1 may be serialized as UTF-16, + // i.e., two bytes for every character, including ASCII ones, which UTF-8 + // encodes in one byte: + const countString = (value: string): void => { + values++; + if (!/[\u0100-\uffff]/.test(value)) return; + const expansion = value.length * 2 - encoder.encode(value).byteLength; + if (expansion > 0) stringExpansion += expansion; + }; + const count = (value: unknown): void => { + if (typeof value === "string") { + countString(value); + return; + } + values++; + if (Array.isArray(value)) { + for (const item of value) count(item); + } else if (value != null && typeof value === "object") { + for (const [key, item] of Object.entries(value)) { + if (item === undefined) continue; + countString(key); + count(item); + } + } + }; + count(message); + return encoder.encode(JSON.stringify(message)).byteLength + + stringExpansion + values * PER_VALUE_SERIALIZATION_OVERHEAD; +} + +/** + * Checks whether an activity that declares no actor is authenticated: at + * least one principal must be authenticated for it, and its ID, if any, must + * have the same [FEP-fe34] origin as one of those principals. + * + * [FEP-fe34]: https://w3id.org/fep/fe34 + * @returns `"ok"` if it is authenticated, or why it is not. + * @internal + */ +export function checkActorlessActivity( + activity: Activity, + authentication: InboxAuthenticationEvidence, +): + | { readonly status: "ok"; readonly principals: readonly InboxPrincipal[] } + | { + readonly status: "noAuthenticatedPrincipal" | "activityOriginMismatch"; + readonly principals: readonly InboxPrincipal[]; + } { + const principals = getEffectivePrincipals(authentication); + if (principals.length < 1) { + return { status: "noAuthenticatedPrincipal", principals }; + } + if (activity.id == null) return { status: "ok", principals }; + const origin = getAuthenticationOrigin(activity.id); + if ( + origin == null || + !principals.some((principal) => + getAuthenticationOrigin(principal.id) === origin + ) + ) { + return { status: "activityOriginMismatch", principals }; + } + return { status: "ok", principals }; +} + +/** + * Same as {@link checkActorlessActivity}, but returns a boolean. + * @internal + */ +export function isActorlessActivityAuthenticated( + activity: Activity, + authentication: InboxAuthenticationEvidence, +): boolean { + return checkActorlessActivity(activity, authentication).status === "ok"; +} + export interface RouteActivityParameters { context: Context; json: unknown; @@ -84,26 +282,33 @@ export interface RouteActivityParameters { * @internal */ ldSignatureVerified?: boolean; + /** + * Who was authenticated for the activity. + * @internal + */ + authentication?: InboxAuthenticationEvidence; + /** + * The interpretation of the activity that was authenticated, to replay in + * queue workers. + * @internal + */ + verifiedView?: SerializedVerifiedActivityView; + /** + * Whether the activity declares no actor and has been admitted for + * a listener that allows such activities. + * @internal + */ + actorless?: boolean; activity: Activity; recipient: string | null; inboxListeners?: ActivityListenerSet>; - inboxContextFactory( - recipient: string | null, - activity: unknown, - activityId: string | undefined, - activityType: string, - ): InboxContext; + inboxContextFactory: InboxContextFactory; /** * An internal context factory for dispatching inbox listeners when Fedify * needs a different payload than the public low-level hook should see. * @internal */ - listenerInboxContextFactory?: ( - recipient: string | null, - activity: unknown, - activityId: string | undefined, - activityType: string, - ) => InboxContext; + listenerInboxContextFactory?: InboxContextFactory; inboxErrorHandler?: InboxErrorHandler; kv: KvStore; kvPrefixes: { activityIdempotence: KvKey }; @@ -135,6 +340,9 @@ export async function routeActivity( originalJson, normalizedActivity, ldSignatureVerified, + authentication, + verifiedView, + actorless, activity, recipient, inboxListeners, @@ -152,104 +360,103 @@ export async function routeActivity( ): Promise { const logger = getLogger(["fedify", "federation", "inbox"]); - if (activity.actorId == null) { + if (activity.actorId == null && !actorless) { logger.error("Missing actor.", { activity: json }); span.setStatus({ code: SpanStatusCode.ERROR, message: "Missing actor." }); recordInboxActivity(meterProvider, "rejected", getTypeId(activity).href); return "missingActor"; } - // Generate idempotency key based on strategy - let cacheKey: KvKey | null = null; - if (activity.id != null) { - const inboxContext = inboxContextFactory( + const contextMetadata = { authentication }; + const cacheKey = await getInboxIdempotencyKey( + idempotencyStrategy, + () => + inboxContextFactory( + recipient, + json, + activity.id?.href, + getTypeId(activity).href, + contextMetadata, + ), + activity, + ctx.origin, + recipient, + kvPrefixes.activityIdempotence, + ); + if ( + await isInboxActivityProcessed( + kv, + cacheKey, + activity, + ctx.origin, + kvPrefixes.activityIdempotence, + ) + ) { + logger.debug("Activity {activityId} has already been processed.", { + activityId: activity.id?.href, + activity: json, recipient, - json, - activity.id?.href, + }); + span.setStatus({ + code: SpanStatusCode.UNSET, + message: `Activity ${activity.id?.href} has already been processed.`, + }); + recordInboxActivity( + meterProvider, + "rejected", getTypeId(activity).href, ); - - // Default to "per-inbox" strategy since Fedify 2.0.0. - // (It had been "per-origin" in Fedify 1.x for backward compatibility.) - const strategy = idempotencyStrategy ?? "per-inbox"; - - let keyString: string | null; - if (typeof strategy === "function") { - // Custom callback strategy - const result = await strategy(inboxContext, activity); - keyString = result; - } else { - // Preset strategies - switch (strategy) { - case "global": - // Global deduplication across all inboxes and inbox origins - keyString = activity.id.href; - break; - case "per-origin": - // Current behavior: deduplicate per inbox origin - keyString = `${ctx.origin}\n${activity.id.href}`; - break; - case "per-inbox": - // Standard ActivityPub behavior: deduplicate per inbox - keyString = `${ctx.origin}\n${activity.id.href}\n${ - recipient == null ? "sharedInbox" : `inbox\n${recipient}` - }`; - break; - default: - // Should never happen due to TypeScript, but handle just in case - keyString = `${ctx.origin}\n${activity.id.href}`; - } - } - - if (keyString != null) { - cacheKey = [ - ...kvPrefixes.activityIdempotence, - keyString, - ] satisfies KvKey; - } + return "alreadyProcessed"; } - if (cacheKey != null) { - const cached = await kv.get(cacheKey); - if (cached === true) { - logger.debug("Activity {activityId} has already been processed.", { - activityId: activity.id?.href, - activity: json, - recipient, - }); - span.setStatus({ - code: SpanStatusCode.UNSET, - message: `Activity ${activity.id?.href} has already been processed.`, - }); - recordInboxActivity( - meterProvider, - "rejected", - getTypeId(activity).href, - ); - return "alreadyProcessed"; - } + if (activity.actorId != null) { + span.setAttribute("activitypub.actor.id", activity.actorId.href); } - span.setAttribute("activitypub.actor.id", activity.actorId.href); + let message: InboxMessage | undefined; if (queue != null) { const carrier: Record = {}; propagation.inject(context.active(), carrier); - try { - await queue.enqueue( - { - type: "inbox", - id: crypto.randomUUID(), - baseUrl: ctx.origin, - activity: originalJson ?? json, - // Keep queued LDS inbox work self-contained. This avoids depending - // on external sidecar lifecycles across retries and redeliveries - // while preserving the original payload for forwarding. - ...(normalizedActivity == null ? {} : { normalizedActivity }), - ...(ldSignatureVerified == null ? {} : { ldSignatureVerified }), - identifier: recipient, - attempt: 0, - started: new Date().toISOString(), - traceContext: carrier, - } satisfies InboxMessage, + message = { + type: "inbox", + id: crypto.randomUUID(), + baseUrl: ctx.origin, + activity: originalJson ?? json, + // Keep queued LDS inbox work self-contained. This avoids depending + // on external sidecar lifecycles across retries and redeliveries + // while preserving the original payload for forwarding. + ...(normalizedActivity == null ? {} : { normalizedActivity }), + ...(ldSignatureVerified == null ? {} : { ldSignatureVerified }), + // The interpretation and the principals that were authenticated travel + // with the message, so that workers and retries neither load contexts + // again nor lose who was authenticated: + ...(verifiedView == null ? {} : { verifiedView }), + ...(authentication == null + ? {} + : { authentication: serializeAuthentication(authentication) }), + identifier: recipient, + attempt: 0, + started: new Date().toISOString(), + traceContext: carrier, + } satisfies InboxMessage; + const maxMessageSize = queue.maxMessageSize; + if ( + maxMessageSize != null && + estimateQueuedMessageSize(message) > + maxMessageSize - QUEUED_MESSAGE_HEADROOM + ) { + // The queue could not hold the message, so the activity is processed + // right away instead, as if there were no queue. This is decided + // before responding, so that the sender is told whether it succeeded: + logger.warn( + "The incoming activity {activityId} is too large to enqueue; " + + "processing it immediately instead.", + { activityId: activity.id?.href, recipient, maxMessageSize }, ); + message = undefined; + } + } + if (queue != null && message != null) { + try { + await queue.enqueue(message); } catch (error) { logger.error( "Failed to enqueue the incoming activity {activityId}:\n{error}", @@ -313,6 +520,7 @@ export async function routeActivity( : originalJson ?? json, activity.id?.href, activityType, + contextMetadata, ), activity, ); diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index b3ce8c5c6..2b86dfa92 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -11871,7 +11871,9 @@ test("FederationImpl.processQueuedTask() rejects untrusted activity origins befo } satisfies InboxMessage, ); assertEquals(listenerCalls, 1); - assertEquals(kv.reads, 1); + // The idempotency key, and the key that workers of older versions of Fedify + // recorded: + assertEquals(kv.reads, 2); assertEquals(kv.writes, 1); }); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 53f9648c5..aa578542a 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -14,10 +14,12 @@ import { CryptographicKey, getTypeId, lookupObject, + lookupObserver, Multikey, Tombstone, traverseCollection, } from "@fedify/vocab"; +import type { DereferenceCandidate } from "@fedify/vocab-runtime/internal/portable-dereference"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoader, @@ -91,11 +93,19 @@ import { getCanonicalPortableId, getGatewayKeyBase, hasPortableActor, + isPortableId, isPortableKeyId, isSameObjectId, } from "../sig/portable-key-id.ts"; -import { verifyPortableObject } from "../sig/portable-collection.ts"; -import { hasProofLike, verifyObject } from "../sig/proof.ts"; +import { + verifyPortableObject, + type VerifyPortableObjectResult, +} from "../sig/portable-collection.ts"; +import { + getProofSigner, + hasProofLike, + verifyObjectDetailed, +} from "../sig/proof.ts"; import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; import { kvCache } from "../utils/kv-cache.ts"; import { isInAudience } from "./audience.ts"; @@ -156,7 +166,34 @@ import { type PortableInboxDelivery, rawInboxContextFactorySymbol, } from "./handler.ts"; -import { actorsHaveSameOrigin, routeActivity } from "./inbox.ts"; +import { + actorsHaveSameOrigin, + getInboxIdempotencyKey, + type InboxContextMetadata, + isActorlessActivityAuthenticated, + isInboxActivityProcessed, + routeActivity, +} from "./inbox.ts"; +import { + createAuthenticationEvidence, + deserializeAuthentication, + getEmptyAuthenticationEvidence, + type InboxAuthenticationEvidence, + type InboxPrincipal, +} from "./inbox-authentication.ts"; +import { + buildVerifiedActivityView, + type ContextSnapshot, + ContextSnapshotMissError, + createContextSnapshot, + createReleasableLoader, + deepFreeze, + getActorPresence, + parseSerializedVerifiedActivityView, + type RestoredVerifiedActivityView, + restoreVerifiedActivityView, + type VerifiedActivityViewSource, +} from "./inbox-view.ts"; import { KvKeyCache } from "./keycache.ts"; import type { KvKey, KvStore } from "./kv.ts"; import { @@ -2515,54 +2552,180 @@ export class FederationImpl | undefined; let parseInput: unknown = undefined; let parseContextLoader = context.contextLoader; - try { - const hasSignatureField = hasSignature(message.activity); - const shouldParseFromNormalizedSignedPayload = - message.ldSignatureVerified === true || - message.normalizedActivity != null || - (message.ldSignatureVerified == null && hasSignatureField); - const parseContext = hasSignatureField - ? { - ...context, - // Verified LDS replay, fallback-authenticated queue items with a - // producer-side normalized cache, and legacy queued LDS - // messages may still reference Fedify's built-in signature - // contexts at the root after we detach the signature object, so - // keep the normalization loader shortcut available whenever a - // signature block remains. Authentication provenance lives in - // ldSignatureVerified; normalizedActivity is a separate parse - // cache that rolling upgrades and stricter worker loaders may - // still depend on. - contextLoader: getNormalizationContextLoader( - context.contextLoader, - ), + let authentication: InboxAuthenticationEvidence = + getEmptyAuthenticationEvidence(); + let actorPresence: "absent" | "present" = "present"; + if (message.verifiedView !== undefined) { + // The interpretation that was authenticated is replayed exactly, + // without loading any context; a message whose interpretation + // cannot be replayed is never processed any other way: + const view = parseSerializedVerifiedActivityView( + message.verifiedView, + ); + let restored: RestoredVerifiedActivityView | undefined; + let restoredAuthentication: InboxAuthenticationEvidence | null = null; + let failure: unknown; + if (view == null) { + failure = new TypeError( + "The queued activity has a malformed verified view.", + ); + } else { + try { + // A queued normalized payload has to satisfy the same JSON-LD + // safety invariants as a freshly compacted one: + if (view.source === "normalizedActivity") { + assertSafeJsonLd(message.normalizedActivity); + } + restored = await restoreVerifiedActivityView(view, message); + } catch (error) { + if ( + !(error instanceof ContextSnapshotMissError) && + !isPermanentInboxParseError(error) + ) { + await handleRetriableFailure(error); + return; + } + failure = error; } - : { - ...context, - contextLoader: wrapContextLoaderForJsonLd( - context.contextLoader, - ), - }; - parseContextLoader = parseContext.contextLoader; - let normalizedActivity: unknown | undefined; - if (shouldParseFromNormalizedSignedPayload) { - normalizedActivity = message.normalizedActivity ?? - await compactJsonLd(message.activity, context.contextLoader); - // Queue backends are trusted in the normal deployment model, but a - // cached normalized payload should still satisfy the same JSON-LD - // safety invariants as a freshly compacted one before the worker - // strips the signature block and parses it. - assertSafeJsonLd(normalizedActivity); } - parseInput = shouldParseFromNormalizedSignedPayload - ? detachSignature(normalizedActivity) - : hasSignatureField - ? detachSignature(message.activity) - : message.activity; - activity = await Activity.fromJsonLd( - parseInput, - parseContext, + if (restored != null) { + const expandedActivity = deepFreeze(restored.expanded); + restoredAuthentication = message.authentication === undefined + ? createAuthenticationEvidence({ + ...getEmptyAuthenticationEvidence(), + expandedActivity, + baseUrl: restored.baseUrl, + }) + : deserializeAuthentication( + message.authentication, + expandedActivity, + restored.baseUrl, + ); + if (restoredAuthentication == null) { + failure = new TypeError( + "The queued activity has malformed authentication evidence.", + ); + } + } + if ( + failure != null || restored == null || + restoredAuthentication == null + ) { + await reportInboxError(failure); + logger.error( + "Failed to replay the queued incoming activity:\n{error}", + { + error: failure, + trial: message.attempt, + activity: message.activity, + recipient: message.identifier, + }, + ); + listenerSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: String(failure), + }); + listenerSpan.end(); + return; + } + authentication = restoredAuthentication; + actorPresence = getActorPresence(restored.expanded); + const policy = view!.policy; + const replayLoader = createReleasableLoader( + restored.loader, + policy === "normalization" + ? getNormalizationContextLoader(context.contextLoader) + : wrapContextLoaderForJsonLd(context.contextLoader), + policy, ); + parseInput = restored.document; + parseContextLoader = restored.loader; + try { + activity = await Activity.fromJsonLd(restored.document, { + ...context, + contextLoader: replayLoader.loader, + ...(restored.baseUrl == null + ? {} + : { baseUrl: restored.baseUrl }), + }); + } catch (error) { + if (isPermanentInboxParseError(error)) { + await reportInboxError(error); + logger.error( + "Failed to parse the queued incoming activity:\n{error}", + { + error, + trial: message.attempt, + activity: message.activity, + recipient: message.identifier, + }, + ); + listenerSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: String(error), + }); + listenerSpan.end(); + return; + } + await handleRetriableFailure(error); + return; + } finally { + // Later dereferences from the activity load contexts as usual: + replayLoader.release(); + } + } + try { + // Messages without a verified view, e.g., those queued by older + // versions of Fedify, are parsed as they used to be: + if (activity == null) { + const hasSignatureField = hasSignature(message.activity); + const shouldParseFromNormalizedSignedPayload = + message.ldSignatureVerified === true || + message.normalizedActivity != null || + (message.ldSignatureVerified == null && hasSignatureField); + const parseContext = hasSignatureField + ? { + ...context, + // Verified LDS replay, fallback-authenticated queue items with a + // producer-side normalized cache, and legacy queued LDS + // messages may still reference Fedify's built-in signature + // contexts at the root after we detach the signature object, so + // keep the normalization loader shortcut available whenever a + // signature block remains. Authentication provenance lives in + // ldSignatureVerified; normalizedActivity is a separate parse + // cache that rolling upgrades and stricter worker loaders may + // still depend on. + contextLoader: getNormalizationContextLoader( + context.contextLoader, + ), + } + : { + ...context, + contextLoader: wrapContextLoaderForJsonLd( + context.contextLoader, + ), + }; + parseContextLoader = parseContext.contextLoader; + let normalizedActivity: unknown | undefined; + if (shouldParseFromNormalizedSignedPayload) { + normalizedActivity = message.normalizedActivity ?? + await compactJsonLd(message.activity, context.contextLoader); + // Queue backends are trusted in the normal deployment model, but a + // cached normalized payload should still satisfy the same JSON-LD + // safety invariants as a freshly compacted one before the worker + // strips the signature block and parses it. + assertSafeJsonLd(normalizedActivity); + } + parseInput = shouldParseFromNormalizedSignedPayload + ? detachSignature(normalizedActivity) + : hasSignatureField + ? detachSignature(message.activity) + : message.activity; + activity = await Activity.fromJsonLd( + parseInput, + parseContext, + ); + } activityType = getTypeId(activity).href; span.setAttribute("activitypub.activity.type", activityType); listenerSpan.setAttribute("activitypub.activity.type", activityType); @@ -2574,7 +2737,15 @@ export class FederationImpl activity.id.href, ); } - if (activity.actorId == null) { + // An activity without an actor is processed only if its listener + // still allows that, and a principal authenticated it under the + // same rules as when it was received: + const actorless = actorPresence === "absent" && + !this.skipSignatureVerification && authentication.verified && + this.inboxListeners?.dispatchWithClass(activity)?.options + .allowActorless === true && + isActorlessActivityAuthenticated(activity, authentication); + if (activity.actorId == null && !actorless) { logger.error("Missing actor in queued incoming activity.", { activityId: activity.id?.href, activity: message.activity, @@ -2589,8 +2760,8 @@ export class FederationImpl return; } if ( - !this.skipSignatureVerification && activity.id != null && - !actorsHaveSameOrigin(activity) + !this.skipSignatureVerification && !actorless && + activity.id != null && !actorsHaveSameOrigin(activity) ) { logger.error( "The queued activity ({activityId}) and its actors do not " + @@ -2611,14 +2782,32 @@ export class FederationImpl listenerSpan.end(); return; } - cacheKey = activity.id == null ? null : [ - ...this.kvPrefixes.activityIdempotence, + const parsedActivity = activity; + const parsedActivityType = activityType; + cacheKey = await getInboxIdempotencyKey( + this.idempotencyStrategy, + () => + context.toInboxContext( + message.identifier, + message.activity, + parsedActivity.id?.href, + parsedActivityType, + { authentication }, + ), + activity, context.origin, - activity.id.href, - ] satisfies KvKey; - if (cacheKey != null) { - const cached = await this.kv.get(cacheKey); - if (cached === true) { + message.identifier, + this.kvPrefixes.activityIdempotence, + ); + { + const cached = await isInboxActivityProcessed( + this.kv, + cacheKey, + activity, + context.origin, + this.kvPrefixes.activityIdempotence, + ); + if (cached) { logger.debug( "Activity {activityId} has already been processed.", { @@ -2723,6 +2912,7 @@ export class FederationImpl message.activity, activity.id?.href, activityType, + { authentication }, ), activity, ); @@ -4763,6 +4953,7 @@ export class ContextImpl implements Context { activity: unknown, activityId: string | undefined, activityType: string, + metadata?: InboxContextMetadata, ): InboxContextImpl { return new InboxContextImpl(recipient, activity, activityId, activityType, { url: this.url, @@ -4772,7 +4963,7 @@ export class ContextImpl implements Context { contextLoader: this.contextLoader, invokedFromActorKeyPairsDispatcher: this.invokedFromActorKeyPairsDispatcher, - }); + }, metadata?.authentication); } toOutboxContext( @@ -5862,9 +6053,34 @@ export class ContextImpl implements Context { activity: Activity, options: RouteActivityOptions | undefined = {}, span: Span, + ): Promise { + // Every JSON-LD processing step sees the same remote context responses, + // which are kept for queue workers, as for activities delivered to inboxes: + const snapshot = createContextSnapshot( + options.contextLoader ?? this.contextLoader, + ); + try { + return await this.#routeActivityWithSnapshot( + recipient, + activity, + options, + span, + snapshot, + ); + } finally { + snapshot.release(); + } + } + + async #routeActivityWithSnapshot( + recipient: string | null, + activity: Activity, + options: RouteActivityOptions, + span: Span, + snapshot: ContextSnapshot, ): Promise { const logger = getLogger(["fedify", "federation", "inbox"]); - const contextLoader = options.contextLoader ?? this.contextLoader; + const contextLoader = snapshot.loader("standard"); let json = await activity.toJsonLd({ contextLoader }); const keyCache = new KvKeyCache( this.federation.kv, @@ -5876,9 +6092,18 @@ export class ContextImpl implements Context { keyTtl: this.federation.publicKeyTtl, }, ); - const verified = await verifyObject( + let documentSigners: InboxPrincipal[] = []; + let originAuthenticated: InboxPrincipal[] = []; + let viewSource: VerifiedActivityViewSource; + let fetchedView: + | Awaited> + | undefined; + // A Linked Data Signature is made after the Object Integrity Proofs, so + // the proofs are verified without it, as for inbox deliveries: + const document = detachSignature(json); + const verified = await verifyObjectDetailed( Activity, - json, + document, { contextLoader, documentLoader: options.documentLoader ?? this.documentLoader, @@ -5887,7 +6112,7 @@ export class ContextImpl implements Context { keyCache, }, ); - if (verified == null) { + if (verified.status !== "verified") { logger.debug( "Object Integrity Proofs are not verified.", { recipient, activity: json }, @@ -5899,8 +6124,46 @@ export class ContextImpl implements Context { ); return false; } - const fetched = await this.lookupObject(activity.id, options); - if (fetched == null) { + // The fetched document is taken from the boundary that returns the + // object, so that the dispatched activity and the kept interpretation + // are the same: + let candidate: DereferenceCandidate | undefined; + let candidateView: + | Awaited> + | undefined; + const verifications = new WeakMap(); + const fetched = await this.lookupObject(activity.id, { + ...options, + contextLoader, + verifyPortableObject: async (document, verifierOptions) => { + const result = await this.verifyPortableObject( + document, + verifierOptions, + ); + if (document != null && typeof document === "object") { + verifications.set(document, result); + } + return result; + }, + [lookupObserver]: { + accept: async (accepted) => { + // The interpretation is taken with the context documents the + // candidate was parsed with, while they are still served: + candidate = accepted; + candidateView = await buildVerifiedActivityView( + createContextSnapshot(accepted.contextLoader), + { + policy: "standard", + source: "activity", + document: detachSignature(accepted.document), + baseUrl: accepted.baseUrl, + }, + ); + return true; + }, + }, + }); + if (fetched == null || candidate == null || candidateView == null) { logger.debug( "Failed to fetch the remote activity object {activityId}.", { recipient, activity: json, activityId: activity.id.href }, @@ -5949,12 +6212,57 @@ export class ContextImpl implements Context { "ignore the original activity and use the fetched one, which is trustworthy.", ); activity = fetched; - json = await fetched.toJsonLd({ contextLoader }); + json = candidate.document; + fetchedView = candidateView; + viewSource = { + policy: "standard", + source: "activity", + document: detachSignature(candidate.document), + baseUrl: candidate.baseUrl, + }; + if (isPortableId(fetched.id)) { + // A portable activity is authenticated by its proofs, which the + // portable object policy verified, never by where it was fetched: + const result = verifications.get(candidate.document as object) as + | VerifyPortableObjectResult + | undefined; + if (result?.verified === true && result.method === "proof") { + documentSigners = result.keys.flatMap((key) => { + const signer = getProofSigner(key); + return signer == null ? [] : [{ + kind: signer.kind, + id: signer.id, + mechanism: "objectIntegrityProofs" as const, + keyId: signer.keyId, + }]; + }); + } + } else { + originAuthenticated = fetched.actorIds.map((actorId) => ({ + kind: "actor" as const, + id: actorId, + mechanism: "origin" as const, + keyId: null, + })); + } } else { logger.debug( "Object Integrity Proofs are verified.", { recipient, activity: json }, ); + activity = verified.object; + documentSigners = verified.signers.map((signer) => ({ + kind: signer.kind, + id: signer.id, + mechanism: "objectIntegrityProofs" as const, + keyId: signer.keyId, + })); + viewSource = { + policy: "standard", + source: "activity", + document, + baseUrl: null, + }; } if (activity.actorId == null) { logger.debug( @@ -5970,6 +6278,8 @@ export class ContextImpl implements Context { ); return false; } + const view = fetchedView ?? + await buildVerifiedActivityView(snapshot, viewSource); const routeResult = await routeActivity({ context: this, json, @@ -5979,6 +6289,15 @@ export class ContextImpl implements Context { // the worker does not mistake a preserved signature field for verified // LDS replay. ldSignatureVerified: false, + authentication: createAuthenticationEvidence({ + verified: true, + requestSigners: [], + documentSigners, + originAuthenticated, + expandedActivity: deepFreeze(view.expanded), + baseUrl: viewSource.baseUrl, + }), + verifiedView: view.view, activity, recipient, inboxListeners: this.federation.inboxListeners, @@ -6626,6 +6945,7 @@ export class InboxContextImpl extends ContextImpl readonly activity: unknown; readonly activityId?: string; readonly activityType: string; + readonly authentication: InboxAuthenticationEvidence; constructor( recipient: string | null, @@ -6633,12 +6953,14 @@ export class InboxContextImpl extends ContextImpl activityId: string | undefined, activityType: string, options: ContextOptions, + authentication?: InboxAuthenticationEvidence, ) { super(options); this.recipient = recipient; this.activity = activity; this.activityId = activityId; this.activityType = activityType; + this.authentication = authentication ?? getEmptyAuthenticationEvidence(); } override clone(data: TContextData): InboxContext { @@ -6656,6 +6978,7 @@ export class InboxContextImpl extends ContextImpl invokedFromActorKeyPairsDispatcher: this.invokedFromActorKeyPairsDispatcher, }, + this.authentication, ); } diff --git a/packages/fedify/src/federation/mod.ts b/packages/fedify/src/federation/mod.ts index 4bdb6369d..79ecf68a3 100644 --- a/packages/fedify/src/federation/mod.ts +++ b/packages/fedify/src/federation/mod.ts @@ -3,6 +3,7 @@ * * @module */ +export type { InboxListenerOptions } from "./activity-listener.ts"; export { createFederationBuilder } from "./builder.ts"; export * from "./callback.ts"; export * from "./circuit-breaker.ts"; @@ -14,6 +15,12 @@ export { respondWithObjectIfAcceptable, type RespondWithObjectOptions, } from "./handler.ts"; +export { + type InboxAuthenticationEvidence, + type InboxPrincipal, + isActorAuthenticated, + type IsActorAuthenticatedOptions, +} from "./inbox-authentication.ts"; export * from "./inbox-report.ts"; export * from "./kv.ts"; export { diff --git a/packages/fedify/src/federation/mq.test.ts b/packages/fedify/src/federation/mq.test.ts index 7c6004773..66b3d7e1c 100644 --- a/packages/fedify/src/federation/mq.test.ts +++ b/packages/fedify/src/federation/mq.test.ts @@ -1,3 +1,4 @@ +import { estimateQueuedMessageSize } from "./inbox.ts"; import { test } from "@fedify/fixture"; import { assert, @@ -444,6 +445,35 @@ test("MessageQueue.nativeRetrial", async (t) => { await globalThis.Deno.openKv(":memory:"), ); assert(mq.nativeRetrial); + // Deno KV limits a serialized value to 64 KiB: + assert(mq.maxMessageSize < 64 * 1024); + // Any message whose estimated size fits the limit can be enqueued, + // however its serialization expands small values: + for ( + const item of [ + [], + {}, + 0.1, + "", + "é", + "한", + "x".repeat(100), + true, + // A long string that is serialized as UTF-16 because of a single + // character beyond Latin-1: + "a".repeat(1000) + "한", + ] + ) { + const items: unknown[] = []; + const message = () => ({ type: "inbox", payload: { items } }); + while ( + estimateQueuedMessageSize(message()) < mq.maxMessageSize - 64 + ) { + items.push(item); + } + items.pop(); + await mq.enqueue(message()); + } await disposeMessageQueue(mq); }); } @@ -760,3 +790,23 @@ async function waitFor( } } } + +test("ParallelMessageQueue inherits maxMessageSize", () => { + class BoundedQueue implements MessageQueue { + readonly maxMessageSize = 1024; + enqueue(): Promise { + return Promise.resolve(); + } + listen(): Promise { + return Promise.resolve(); + } + } + assertEquals( + new ParallelMessageQueue(new BoundedQueue(), 2).maxMessageSize, + 1024, + ); + assertEquals( + new ParallelMessageQueue(new InProcessMessageQueue(), 2).maxMessageSize, + undefined, + ); +}); diff --git a/packages/fedify/src/federation/mq.ts b/packages/fedify/src/federation/mq.ts index b0ab480a4..20f9f4261 100644 --- a/packages/fedify/src/federation/mq.ts +++ b/packages/fedify/src/federation/mq.ts @@ -125,6 +125,19 @@ export interface MessageQueue { */ readonly atomicEnqueueMany?: boolean; + /** + * The largest message, in bytes, that the backend can hold. Fedify compares + * it with a conservative estimate of a message's serialized size: the + * length of its JSON serialization encoded in UTF-8, plus 8 bytes for each + * JSON value in it, plus what encoding strings beyond Latin-1 as UTF-16 + * adds, which also bounds binary serialization formats. When an + * incoming activity would make a larger inbox message, Fedify processes the + * activity right away instead of enqueueing it, as if there were no inbox + * queue. When omitted, messages are enqueued regardless of their size. + * @since 2.5.0 + */ + readonly maxMessageSize?: number; + /** * Enqueues a message in the queue. * @param message The message to enqueue. @@ -422,6 +435,11 @@ export class ParallelMessageQueue implements MessageQueue { * @since 2.4.0 */ readonly atomicEnqueueMany?: boolean; + /** + * Inherits the message size limit from the wrapped queue. + * @since 2.5.0 + */ + readonly maxMessageSize?: number; readonly getDepth?: () => Promise; /** @@ -456,6 +474,7 @@ export class ParallelMessageQueue implements MessageQueue { this.workers = workers; this.nativeRetrial = queue.nativeRetrial; this.nativeDeduplication = queue.nativeDeduplication; + this.maxMessageSize = queue.maxMessageSize; this.atomicEnqueueMany = queue.enqueueMany == null ? false : queue.atomicEnqueueMany; diff --git a/packages/fedify/src/federation/queue.ts b/packages/fedify/src/federation/queue.ts index 77b08365e..02b9af9f3 100644 --- a/packages/fedify/src/federation/queue.ts +++ b/packages/fedify/src/federation/queue.ts @@ -1,3 +1,6 @@ +import type { SerializedInboxAuthentication } from "./inbox-authentication.ts"; +import type { SerializedVerifiedActivityView } from "./inbox-view.ts"; + export interface SenderKeyJwkPair { readonly keyId: string; readonly privateKey: JsonWebKey; @@ -171,6 +174,21 @@ export interface InboxMessage { * @internal */ readonly ldSignatureVerified?: boolean; + /** + * The interpretation of the activity that Fedify authenticated, i.e., which + * document it was parsed from and every JSON-LD context response the parse + * used, so that the worker and its retries dispatch the very same + * interpretation without loading contexts again. Absent in messages queued + * by older Fedify versions. + * @internal + */ + readonly verifiedView?: SerializedVerifiedActivityView; + /** + * Who Fedify authenticated for the activity. Absent in messages queued by + * older Fedify versions. + * @internal + */ + readonly authentication?: SerializedInboxAuthentication; readonly started: string; readonly attempt: number; readonly identifier: string | null; diff --git a/packages/fedify/src/sig/key.ts b/packages/fedify/src/sig/key.ts index f19fbf6b1..2dba6fa52 100644 --- a/packages/fedify/src/sig/key.ts +++ b/packages/fedify/src/sig/key.ts @@ -468,6 +468,10 @@ async function fetchActorDocumentInternal( "Failed to fetch the actor {actorId}: {error}", { actorId: actorId.href, error }, ); + // While a key is being fetched, an owner that cannot be fetched is + // reported as a failure to fetch the key, so that a temporary outage of + // the owner's server is not mistaken for a key nobody owns: + if (propagateContextFetchErrors) throw new KeyContextFetchError(error); return null; } let object: Object; diff --git a/packages/fedify/src/sig/ld.ts b/packages/fedify/src/sig/ld.ts index 6d009ca62..279eff942 100644 --- a/packages/fedify/src/sig/ld.ts +++ b/packages/fedify/src/sig/ld.ts @@ -158,10 +158,47 @@ function createMemoizedDocumentLoader(documentLoader: DocumentLoader) { }; } +/** + * How a context loader marked by {@link markEffectiveContextLoader} already + * resolves contexts. + * @internal + */ +export type EffectiveContextLoaderPolicy = "standard" | "normalization"; + +const effectiveContextLoaderPolicies = new WeakMap< + DocumentLoader, + EffectiveContextLoaderPolicy +>(); + +/** + * Marks a context loader as the *effective* loader of a policy, i.e., one + * that already applies what {@link wrapContextLoaderForJsonLd} (`"standard"`) + * or {@link getNormalizationContextLoader} (`"normalization"`) would add. + * Those functions return a marked loader as is instead of wrapping it again, + * so that a caller can observe every response a parse actually uses, + * including Fedify's own baseline contexts. + * @param loader The effective context loader. + * @param policy The policy the loader already applies. + * @returns The given loader. + * @internal + */ +export function markEffectiveContextLoader( + loader: DocumentLoader, + policy: EffectiveContextLoaderPolicy, +): DocumentLoader { + effectiveContextLoaderPolicies.set(loader, policy); + return loader; +} + /** @internal */ export function wrapContextLoaderForJsonLd( contextLoader: DocumentLoader | undefined, ): DocumentLoader { + if ( + contextLoader != null && effectiveContextLoaderPolicies.has(contextLoader) + ) { + return contextLoader; + } const loader = contextLoader ?? builtInContextLoader; return async (url, options) => { try { @@ -184,6 +221,12 @@ export function wrapContextLoaderForJsonLd( export function getNormalizationContextLoader( contextLoader: DocumentLoader | undefined, ): DocumentLoader { + if ( + contextLoader != null && + effectiveContextLoaderPolicies.get(contextLoader) === "normalization" + ) { + return contextLoader; + } const loader = wrapContextLoaderForJsonLd(contextLoader); return createMemoizedDocumentLoader(async (url, options) => { // Normalized LDS documents are compacted against Fedify's built-in context. @@ -1117,7 +1160,7 @@ export async function verifyJsonLd( jsonLd: unknown, options: VerifyJsonLdOptions = {}, ): Promise { - return await verifyJsonLdInternal(jsonLd, options, true); + return (await verifyJsonLdInternal(jsonLd, options, true)).verified; } /** @internal */ @@ -1125,6 +1168,29 @@ export async function verifyCompactJsonLd( jsonLd: unknown, options: VerifyJsonLdOptions = {}, ): Promise { + return (await verifyJsonLdInternal(jsonLd, options, false)).verified; +} + +/** + * The result of {@link verifyCompactJsonLdDetailed}. + * @internal + */ +export type VerifyJsonLdDetailedResult = + | { readonly verified: true; readonly key: CryptographicKey } + | { readonly verified: false; readonly key: null }; + +/** + * Same as {@link verifyCompactJsonLd}, but also returns the key that verified + * the signature, whose `owner` {@link fetchKey} has confirmed. + * @param jsonLd The compacted JSON-LD document to verify. + * @param options Options for verifying the JSON-LD document. + * @returns The result. + * @internal + */ +export async function verifyCompactJsonLdDetailed( + jsonLd: unknown, + options: VerifyJsonLdOptions = {}, +): Promise { return await verifyJsonLdInternal(jsonLd, options, false); } @@ -1132,7 +1198,7 @@ async function verifyJsonLdInternal( jsonLd: unknown, options: VerifyJsonLdOptions, compact: boolean, -): Promise { +): Promise { if ( options[verificationObservation]?.attempt == null ) { @@ -1148,7 +1214,7 @@ async function verifyJsonLdInternal( ...options, [verificationObservation]: observation, }, compact), - (value) => value, + (value) => value.verified, ); } const observation = options[verificationObservation]; @@ -1156,7 +1222,7 @@ async function verifyJsonLdInternal( const tracer = tracerProvider.getTracer(metadata.name, metadata.version); return await tracer.startActiveSpan( "ld_signatures.verify", - async (span) => { + async (span): Promise => { const start = performance.now(); let verified = false; let threw = false; @@ -1213,14 +1279,14 @@ async function verifyJsonLdInternal( if (observation?.attempt != null && !hasLdSignatureProperty(jsonLd)) { observation.attempt.reason = { type: "noSignature" }; } - return false; + return { verified: false, key: null }; } 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; + return { verified: false, key: null }; } attributions.delete(key.ownerId.href); if (attributions.size > 0) { @@ -1235,10 +1301,10 @@ async function verifyJsonLdInternal( "Signatures: {attributions}.", { attributions: [...attributions] }, ); - return false; + return { verified: false, key: null }; } verified = true; - return true; + return { verified: true, key }; } catch (error) { threw = true; span.setStatus({ diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index 254b00ab6..4d5514ff4 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -20,6 +20,7 @@ import { encodeMultibase, exportDidKey, exportMultibaseKey, + FetchError, formatIri, importMultibaseKey, parseIri, @@ -52,6 +53,7 @@ import { signObject, verifyMapLocalProof, verifyObject, + verifyObjectDetailed, type VerifyObjectOptions, verifyPortableObjectProof, verifyProof, @@ -3940,3 +3942,72 @@ test("verifyObject() rejects a key whose controller redirects to another actor", }; assertEquals(await verifyObject(Create, jsonLd, options), null); }); + +test("verifyObjectDetailed() tells who signed the proofs", async () => { + const keyId = new URL("https://example.com/person2#key4"); + const actor = new URL("https://example.com/person2"); + const signed = await signObject( + new Create({ + id: new URL("https://example.com/activities/detailed"), + actor, + object: new Note({ content: "Hello" }), + }), + ed25519PrivateKey, + keyId, + { contextLoader: mockDocumentLoader }, + ); + const json = await signed.toJsonLd({ contextLoader: mockDocumentLoader }); + const options = { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }; + const result = await verifyObjectDetailed(Create, json, options); + assertEquals(result.status, "verified"); + assertEquals( + result.status === "verified" + ? result.signers.map((s) => [s.kind, s.id.href, s.keyId.href]) + : [], + [["actor", actor.href, keyId.href]], + ); + + // An object without actors and attributions verifies without any proof, + // but then nobody signed it: + const unsigned = await verifyObjectDetailed(Note, { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://example.com/notes/unsigned", + type: "Note", + }, options); + assertEquals(unsigned.status, "verified"); + assertEquals(unsigned.status === "verified" ? unsigned.signers : null, []); + + // A key that cannot be fetched for now makes the result unavailable, not + // invalid: + const unavailable = await verifyObjectDetailed(Create, json, { + ...options, + documentLoader: (url) => + url.startsWith("https://example.com/person2") + ? Promise.reject( + new FetchError( + url, + "Unavailable", + new Response(null, { + status: 503, + }), + ), + ) + : mockDocumentLoader(url), + }); + assertEquals(unavailable.status, "unavailable"); + + const gone = await verifyObjectDetailed(Create, json, { + ...options, + documentLoader: (url) => + url.startsWith("https://example.com/person2") + ? Promise.reject( + new FetchError(url, "Gone", new Response(null, { status: 410 })), + ) + : mockDocumentLoader(url), + }); + assertEquals(gone.status, "invalid"); + assertEquals(await verifyObject(Create, json, options) != null, true); +}); diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 4b22caeb2..46cf468f8 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -1,4 +1,5 @@ import { + type InboxSignatureCheck, observeAttempt, observeCheck, triedKey, @@ -19,6 +20,7 @@ import { fromCompatibleEf61Id, getDocumentLoader, getFe34Origin, + isTransientFetchError, parseGatewayUrl, parseIri, type PortableObjectVerifier, @@ -52,6 +54,7 @@ import { import { bypassKeyCacheReads, fetchKey, + type FetchKeyErrorResult, type FetchKeyResult, type KeyCache, validateCryptoKey, @@ -1651,6 +1654,16 @@ async function verifyProofInternal( "Failed to get the key (verificationMethod) for the proof:\n{proof}", { proof, keyId: proof.verificationMethodId.href, error }, ); + const check = options[verificationObservation]?.check; + if (check != null && check.reason == null) { + check.reason = { + type: "keyFetchError", + keyId: new URL(proof.verificationMethodId.href), + result: { + error: error instanceof Error ? error : new Error(String(error)), + }, + }; + } return null; } const publicKey = fetchedKey.key; @@ -2496,6 +2509,95 @@ export async function verifyObject( jsonLd: unknown, options: VerifyObjectOptions = {}, ): Promise { + const result = await verifyObjectDetailed(cls, jsonLd, options); + return result.status === "verified" ? result.object : null; +} + +/** + * A key that verified one of the Object Integrity Proofs of an object, + * together with whom it authenticates. + * @internal + */ +export interface ProofSigner { + /** + * `"actor"`: `id` is the key's controller, an actor. + * `"portableAuthority"`: `id` is a DID, which authenticates the FEP-ef61 + * portable objects of that DID only. + */ + readonly kind: "actor" | "portableAuthority"; + readonly id: URL; + readonly keyId: URL; +} + +/** + * The result of {@link verifyObjectDetailed}. + * @internal + */ +export type VerifyObjectDetailedResult = + | { + readonly status: "verified"; + readonly object: T; + /** The signers of the verified proofs; empty if there were none. */ + readonly signers: readonly ProofSigner[]; + } + | { readonly status: "invalid" } + | { + /** A proof's key could not be obtained for a retryable reason. */ + readonly status: "unavailable"; + readonly error: unknown; + }; + +/** + * Same as {@link verifyObject}, but also tells who signed the verified proofs, + * and whether a failure was caused by a key that could not be obtained for + * a retryable reason. + * @param cls The class of the object to verify. + * @param jsonLd The JSON-LD object to verify. + * @param options Additional options. + * @returns The result. + * @throws {TypeError} If the object is invalid or unsupported. + * @internal + */ +export async function verifyObjectDetailed( + // deno-lint-ignore no-explicit-any + cls: (new (...args: any[]) => T) & { + fromJsonLd(jsonLd: unknown, options: VerifyObjectOptions): Promise; + }, + jsonLd: unknown, + options: VerifyObjectOptions = {}, +): Promise> { + const classify = ( + result: { object: T; signers: ProofSigner[] } | null, + checks: readonly InboxSignatureCheck[], + ): VerifyObjectDetailedResult => { + if (result != null) { + return { + status: "verified", + object: result.object, + signers: result.signers, + }; + } + for (const check of checks) { + if (check.status === "error" && isTransientFetchError(check.error)) { + return { status: "unavailable", error: check.error }; + } + if ( + check.status === "rejected" && check.reason.type === "keyFetchError" && + isRetryableKeyFetchError(check.reason.result) + ) { + return { + status: "unavailable", + error: "error" in check.reason.result + ? check.reason.result.error + : new Error( + `Fetching key ${check.reason.keyId.href} failed with ` + + `${check.reason.result.status}.`, + ), + }; + } + } + return { status: "invalid" }; + }; if (options[verificationObservation]?.attempt == null) { const observedOptions = { ...options, @@ -2512,11 +2614,11 @@ export async function verifyObject( observedOptions, "objectIntegrity", async (observation) => { - const object = await verifyObject(cls, jsonLd, { + const result = await verifyObjectCore(cls, jsonLd, { ...options, [verificationObservation]: observation, }); - if (object == null) { + if (result == null) { span.setStatus({ code: SpanStatusCode.ERROR }); span.setAttribute( "activitypub.verification.failure_reason", @@ -2524,9 +2626,9 @@ export async function verifyObject( "signatureVerificationFailed", ); } - return object; + return classify(result, observation.attempt?.checks ?? []); }, - (value) => value != null, + (value) => value.status === "verified", ); } catch (error) { span.setStatus({ @@ -2540,6 +2642,26 @@ export async function verifyObject( }, ); } + const checks = options[verificationObservation]!.attempt!.checks; + const before = checks.length; + const result = await verifyObjectCore(cls, jsonLd, options); + return classify(result, checks.slice(before)); +} + +function isRetryableKeyFetchError(result: FetchKeyErrorResult): boolean { + if ("error" in result) return isTransientFetchError(result.error); + return result.status >= 500 || result.status === 408 || + result.status === 429; +} + +async function verifyObjectCore( + // deno-lint-ignore no-explicit-any + cls: (new (...args: any[]) => T) & { + fromJsonLd(jsonLd: unknown, options: VerifyObjectOptions): Promise; + }, + jsonLd: unknown, + options: VerifyObjectOptions, +): Promise<{ object: T; signers: ProofSigner[] } | null> { const observation = options[verificationObservation]; const logger = getLogger(["fedify", "sig", "proof"]); const object = await cls.fromJsonLd(jsonLd, options); @@ -2550,6 +2672,7 @@ export async function verifyObject( const defaultDocumentLoader = getDocumentLoader(); const proofContextLoader = options.contextLoader ?? defaultDocumentLoader; const attributions = new Set(object.attributionIds.map((uri) => uri.href)); + const signers: ProofSigner[] = []; if (object instanceof Activity) { for (const uri of object.actorIds) attributions.add(uri.href); } @@ -2676,6 +2799,19 @@ export async function verifyObject( key.controllerId, proof.verificationMethodId, ); + const signer = getAuthenticatedController( + key.controllerId, + proof.verificationMethodId, + ); + if ( + signer != null && + !signers.some((s) => + s.kind === signer.kind && s.id.href === signer.id.href && + s.keyId.href === proof.verificationMethodId!.href + ) + ) { + signers.push({ ...signer, keyId: proof.verificationMethodId }); + } } if (acceptedPortableProofs < loadedPortableProofs) { logger.debug("Some portable proof references could not be dereferenced."); @@ -2697,7 +2833,43 @@ export async function verifyObject( ); return null; } - return object; + return { object, signers }; +} + +/** + * Tells whom a key that verified an Object Integrity Proof authenticates. + * @param key The key, i.e., the proof's verification method. + * @returns The signer, or `null` if the key authenticates nobody. + * @internal + */ +export function getProofSigner(key: Multikey): ProofSigner | null { + if (key.id == null || key.controllerId == null) return null; + const signer = getAuthenticatedController(key.controllerId, key.id); + return signer == null ? null : { ...signer, keyId: key.id }; +} + +/** + * Tells whom a verified proof authenticates: the key's controller if it is an + * ordinary actor, or the DID of a portable controller if the verification + * method belongs to the same DID. + */ +function getAuthenticatedController( + controllerId: URL, + verificationMethodId: URL, +): { readonly kind: "actor" | "portableAuthority"; readonly id: URL } | null { + if ( + !hasCryptographicOrigin(controllerId.href) && !isPortableId(controllerId) + ) { + return { kind: "actor", id: controllerId }; + } + const did = getPortableDid(controllerId); + if ( + did == null || !hasCryptographicOrigin(verificationMethodId.href) || + getPortableDid(verificationMethodId) !== did || !URL.canParse(did) + ) { + return null; + } + return { kind: "portableAuthority", id: new URL(did) }; } function deleteAuthenticatedAttribution( diff --git a/packages/fedify/src/testing/context.ts b/packages/fedify/src/testing/context.ts index e0a82ddaa..1b4e2ead0 100644 --- a/packages/fedify/src/testing/context.ts +++ b/packages/fedify/src/testing/context.ts @@ -14,6 +14,7 @@ import type { } from "../federation/context.ts"; import { isInAudience } from "../federation/audience.ts"; import type { Federation } from "../federation/federation.ts"; +import { getEmptyAuthenticationEvidence } from "../federation/inbox-authentication.ts"; export function createContext( values: Partial> & { @@ -198,6 +199,7 @@ export function createInboxContext( ...createContext(args), clone: args.clone ?? ((data) => createInboxContext({ ...args, data })), recipient: args.recipient ?? null, + authentication: args.authentication ?? getEmptyAuthenticationEvidence(), forwardActivity: args.forwardActivity ?? ((_forwarder, _recipients, _options) => { throw new Error("Not implemented"); diff --git a/packages/interaction-controls/src/collection.ts b/packages/interaction-controls/src/collection.ts new file mode 100644 index 000000000..93e15eccf --- /dev/null +++ b/packages/interaction-controls/src/collection.ts @@ -0,0 +1,246 @@ +import { + type DocumentLoader, + isTransientFetchError, + parseIri, + type PortableObjectVerifier, +} from "@fedify/vocab-runtime"; +import jsonld from "@fedify/vocab-runtime/jsonld"; +import type { DereferenceFailure } from "@fedify/vocab-runtime/internal/portable-dereference"; +import { + lookupObject, + lookupObserver, + type Object as ASObject, +} from "@fedify/vocab"; +import { + getPropertyValues, + getRootNode, + hasCredentials, + hasType, + resolveNodeId, + sameId, +} from "./evidence.ts"; +import { + classifyFailure, + collectCauses, + trackLoaders, + type UnverifiableFailure, +} from "./loaders.ts"; +import type { InteractionRequestVerificationFailure } from "./types.ts"; + +const ATTRIBUTED_TO = "https://www.w3.org/ns/activitystreams#attributedTo"; + +/** + * Why a document was not accepted as the authoritative representation of an + * interacting object. + */ +export type AuthoritativeObjectRejection = + | "credentials" + | "id" + | "type" + | "attribution"; + +/** + * The result of {@link resolveAuthoritativeObject}. + */ +export type AuthoritativeObjectResolution = + | { + readonly ok: true; + readonly object: T; + /** The only owner the object's own document names. */ + readonly owner: URL; + } + | { + readonly ok: false; + readonly failure: + | UnverifiableFailure + | Extract< + InteractionRequestVerificationFailure, + { readonly type: "credentialsInUrl" | "invalidInteractingObject" } + >; + }; + +/** + * Options for {@link resolveAuthoritativeObject}. + */ +export interface ResolveAuthoritativeObjectOptions { + readonly documentLoader: DocumentLoader | undefined; + readonly contextLoader: DocumentLoader | undefined; + readonly verifyPortableObject: PortableObjectVerifier | undefined; + /** The type the object must have. */ + readonly typeId: URL; + /** Tells whether a parsed object is of the right class. */ + readonly isObject: (object: ASObject) => object is T; +} + +/** + * Fetches an interacting object, such as a featured collection, from its ID + * and determines its owner from that document only. + * + * What the request embeds is never trusted. An ordinary web document must + * be served from the origin of its ID, and an [FEP-ef61] portable object + * must satisfy the portable object policy, as `lookupObject()` checks. The + * owner is the only `attributedTo` value of the document's expanded form, so + * that values typed parsing drops, such as anonymous attributions, are + * counted, and it has to agree with the parsed object. WebFinger is not + * used. + * + * [FEP-ef61]: https://w3id.org/fep/ef61 + * @param id The ID of the object. + * @param options Options. + * @returns The object and its owner, or why they could not be resolved. + */ +export async function resolveAuthoritativeObject( + id: URL, + options: ResolveAuthoritativeObjectOptions, +): Promise> { + if (hasCredentials(id)) { + return { + ok: false, + failure: { category: "invalid", type: "credentialsInUrl", url: id }, + }; + } + const tracker = trackLoaders(options.documentLoader, options.contextLoader); + const failures: DereferenceFailure[] = []; + const rejections: AuthoritativeObjectRejection[] = []; + let accepted: { object: T; owner: URL } | undefined; + const reject = (reason: AuthoritativeObjectRejection): false => { + rejections.push(reason); + return false; + }; + try { + const object = await lookupObject(id, { + documentLoader: tracker.documentLoader, + contextLoader: tracker.contextLoader, + verifyPortableObject: options.verifyPortableObject, + crossOrigin: "ignore", + [lookupObserver]: { + webFinger: false, + failed: (failure) => failures.push(failure), + accept: async (candidate) => { + let documentUrl: URL; + try { + documentUrl = parseIri(candidate.documentUrl); + } catch { + return reject("id"); + } + if (hasCredentials(documentUrl)) return reject("credentials"); + // The candidate's loader serves the context documents the object + // was parsed with, so the expansion is the parsed interpretation: + const expanded = await jsonld.expand( + // deno-lint-ignore no-explicit-any + candidate.document as any, + // deno-lint-ignore no-explicit-any + { documentLoader: candidate.contextLoader } as any, + ); + const root = getRootNode(expanded); + if (root == null) return reject("id"); + const rootId = resolveNodeId(root, candidate.baseUrl); + if ( + rootId == null || !sameId(rootId, id) || + !sameId(candidate.object.id, id) + ) { + return reject("id"); + } + if ( + !hasType(root, options.typeId.href) || + !options.isObject(candidate.object) + ) { + return reject("type"); + } + const owners = getPropertyValues(root, ATTRIBUTED_TO); + if (owners.length !== 1) return reject("attribution"); + const owner = resolveNodeId(owners[0], candidate.baseUrl); + if (owner == null || !sameId(owner, candidate.object.attributionId)) { + return reject("attribution"); + } + accepted = { object: candidate.object, owner }; + return true; + }, + }, + }); + if (object != null && accepted != null) { + return { ok: true, ...accepted }; + } + return { ok: false, failure: classifyNull(id, failures, rejections) }; + } catch (error) { + const parseFailure = failures.find((failure) => + failure.operation === "parse" && failure.error === error + ); + if ( + parseFailure != null && + !collectCauses(error).some((cause) => tracker.records.has(cause)) + ) { + return { + ok: false, + failure: { + category: "invalid", + type: "invalidInteractingObject", + reason: "decoding", + cause: error, + }, + }; + } + return { ok: false, failure: classifyFailure(error, tracker) }; + } finally { + tracker.release(); + } + + function classifyNull( + id: URL, + failures: readonly DereferenceFailure[], + rejections: readonly AuthoritativeObjectRejection[], + ): Extract< + AuthoritativeObjectResolution, + { readonly ok: false } + >["failure"] { + // No candidate succeeded, so every loader failure during the lookup + // counts, including those that a verifier swallowed, e.g., when the + // owner of a portable collection could not be fetched: + const loaderErrors = [ + ...failures.flatMap((failure) => + failure.error === undefined + ? [] + : collectCauses(failure.error).filter((cause) => + failure.operation === "fetch" || tracker.records.has(cause) + ) + ), + ...tracker.records.keys(), + ]; + const transient = loaderErrors.find(isTransientFetchError); + if (transient !== undefined) { + return { + category: "unverifiable", + type: "notDereferenceable", + url: id, + cause: transient, + transient: true, + }; + } + const decoding = failures.find((failure) => + failure.operation === "parse" && + !collectCauses(failure.error).some((cause) => tracker.records.has(cause)) + ); + if (decoding != null) { + return { + category: "invalid", + type: "invalidInteractingObject", + reason: "decoding", + cause: decoding.error, + }; + } + if (rejections.length > 0) { + return { + category: "invalid", + type: "invalidInteractingObject", + reason: rejections[rejections.length - 1], + }; + } + return { + category: "unverifiable", + type: "notDereferenceable", + url: id, + ...(loaderErrors.length > 0 ? { cause: loaderErrors[0] } : {}), + transient: false, + }; + } +} diff --git a/packages/interaction-controls/src/control.ts b/packages/interaction-controls/src/control.ts index b643da623..dfcf23648 100644 --- a/packages/interaction-controls/src/control.ts +++ b/packages/interaction-controls/src/control.ts @@ -1,11 +1,11 @@ -import type { Context } from "@fedify/fedify"; +import { type Context, isActorAuthenticated } from "@fedify/fedify"; import { type DocumentLoader, - FetchError, + getDocumentLoader, getFe34Origin, - isTransientFetchError, parseIri, } from "@fedify/vocab-runtime"; +import jsonld from "@fedify/vocab-runtime/jsonld"; import { Accept, type Activity, @@ -33,6 +33,22 @@ import type { MatchesApprovalCollection, RecognizedImpoliteInteraction, } from "./types.ts"; +import { + classifyFailure, + notDereferenceable, + trackLoaders, + type UnverifiableFailure, +} from "./loaders.ts"; +import { resolveAuthoritativeObject } from "./collection.ts"; +import { + getPropertyValues, + getRootNode, + hasCredentials, + hasType, + isPortableClaim, + resolveNodeId, + sameId, +} from "./evidence.ts"; type VocabConstructor = { readonly typeId: URL; @@ -102,6 +118,14 @@ interface ControlConfig< subject: TTarget, options: DereferenceOptions, ) => AsyncIterable | Iterable; + /** + * Whether the interacting object is always fetched from its ID and its + * owner taken from that document, the request being bound to the + * interpretation Fedify authenticated. The requester of a request without + * an actor is then the owner, who must have been authenticated. The first + * of {@link interactingObjectTypes} is the type the object must have. + */ + readonly authoritativeInteractingObject?: boolean; } export interface DereferenceOptions { @@ -389,6 +413,62 @@ async function verifyRequest< }, }; } + // Evidence that authenticated nothing and kept no interpretation, e.g., for + // an activity queued by an older version of Fedify, is no evidence at all: + const authentication = options.authentication == null || + (!options.authentication.verified && + options.authentication.expandedActivity.length < 1) + ? undefined + : options.authentication; + if (config.authoritativeInteractingObject) { + const evidence = authentication != null + ? { + expanded: authentication.expandedActivity, + baseUrl: authentication.baseUrl, + } + : requestResult.evidence; + if (authentication == null && expectedRequestId != null) { + if (evidence == null) { + return { + verified: false, + request, + requestId: request.id, + failure: { + category: "unauthorized", + type: "unauthenticatedRequester", + }, + }; + } + } + const bindingFailure = bindRequest( + request, + evidence, + config.requestClass.typeId, + ); + if (bindingFailure != null) { + return { + verified: false, + request, + requestId: request.id, + failure: bindingFailure, + }; + } + if ( + authentication != null && request.actorId != null && + !isActorAuthenticated(authentication, request.actorId) + ) { + return { + verified: false, + request, + requestId: request.id, + failure: { + category: "unauthorized", + type: "unauthenticatedRequester", + requester: request.actorId, + }, + }; + } + } const targetResult = options.resolvedInteractionTarget != null && request.objectId != null // The type is checked by isInteractionTarget() below: @@ -433,8 +513,29 @@ async function verifyRequest< }, }; } - const interactingResult = options.resolvedInteractingObject != null && + let owner: URL | undefined; + const interactingResult = config.authoritativeInteractingObject && request.instrumentId != null + ? await (async () => { + const resolution = await resolveAuthoritativeObject( + request.instrumentId!, + { + documentLoader, + contextLoader, + verifyPortableObject: context.verifyPortableObject, + typeId: config.interactingObjectTypes![0], + isObject: (object): object is TInteracting => + config.isInteractingObject?.(object) ?? true, + }, + ); + if (!resolution.ok) { + return { ok: false as const, failure: resolution.failure }; + } + owner = resolution.owner; + return { ok: true as const, value: resolution.object }; + })() + : options.resolvedInteractingObject != null && + request.instrumentId != null // The type is checked by isInteractingObject() below: ? { ok: true as const, @@ -480,8 +581,30 @@ async function verifyRequest< }, }; } + if ( + config.authoritativeInteractingObject && request.actorId == null && + (owner == null || authentication == null || + !isActorAuthenticated(authentication, owner)) + ) { + // Without an actor, the requester is the owner of the interacting object, + // as its own document names it, and only if the owner was authenticated: + return { + verified: false, + request, + requestId: request.id, + failure: authentication == null + ? { category: "invalid", type: "missingActor" } + : { + category: "unauthorized", + type: "unauthenticatedRequester", + ...(owner == null ? {} : { requester: owner }), + }, + }; + } const requester = request.actorId ?? - config.getRequester?.(request, interactingObject, interactionTarget); + (config.authoritativeInteractingObject + ? owner + : config.getRequester?.(request, interactingObject, interactionTarget)); if (requester == null) { return { verified: false, @@ -831,158 +954,6 @@ async function verifyAuthorization< return { verified: true, authorization, authorizationId: authorization.id }; } -type UnverifiableFailure = Extract< - InteractionRequestVerificationFailure, - { - readonly category: "unverifiable"; - readonly type: "notDereferenceable" | "invalidJsonLd"; - } ->; - -interface LoaderTracker { - readonly documentLoader: DocumentLoader | undefined; - readonly contextLoader: DocumentLoader | undefined; - readonly records: ReadonlyMap; - readonly release: () => void; -} - -/** - * Wraps the loaders for a single dereferencing operation so that errors they - * throw can be told apart from other errors afterwards. Parsed objects keep - * the loaders they were given, so the wrappers stop recording and pass calls - * through once the operation is released. - */ -function trackLoaders( - documentLoader: DocumentLoader | undefined, - contextLoader: DocumentLoader | undefined, -): LoaderTracker { - const records = new Map(); - let released = false; - const wrap = ( - loader: DocumentLoader | undefined, - ): DocumentLoader | undefined => { - if (loader == null) return undefined; - return async (url, options) => { - if (released) return await loader(url, options); - let remoteDocument; - try { - remoteDocument = await loader(url, options); - } catch (error) { - if (!released) records.set(error, url); - throw error; - } - if (remoteDocument == null) { - const message = "The document loader returned no document."; - let error: Error; - try { - // FetchError's constructor cannot parse portable URLs by itself: - error = new FetchError(parseIri(url), message); - } catch { - error = new Error(`${url}: ${message}`); - } - if (!released) records.set(error, url); - throw error; - } - return remoteDocument; - }; - }; - const wrappedDocumentLoader = wrap(documentLoader); - return { - documentLoader: wrappedDocumentLoader, - contextLoader: contextLoader === documentLoader - ? wrappedDocumentLoader - : wrap(contextLoader), - records, - release: () => { - released = true; - records.clear(); - }, - }; -} - -const MAX_CAUSE_DEPTH = 8; - -function collectCauses( - error: unknown, - causes: unknown[] = [], - depth = 0, -): unknown[] { - if (depth > MAX_CAUSE_DEPTH || causes.includes(error)) return causes; - causes.push(error); - if (typeof error !== "object" || error == null) return causes; - if (error instanceof AggregateError) { - for (const e of error.errors) collectCauses(e, causes, depth + 1); - } - // Loaders may throw anything, even null, so check for the properties - // rather than their values: - if ("cause" in error) collectCauses(error.cause, causes, depth + 1); - // jsonld.js reports a failed remote context load as `details.cause`: - const details = (error as { readonly details?: unknown }).details; - if (typeof details === "object" && details != null && "cause" in details) { - collectCauses(details.cause, causes, depth + 1); - } - return causes; -} - -/** - * Classifies the error thrown by a dereferencing operation. It is a fetch - * failure only if a loader error is among its causes; errors that loaders - * threw during attempts the operation recovered from are ignored. - */ -function classifyFailure( - error: unknown, - tracker: LoaderTracker, -): UnverifiableFailure { - const loaderErrors = collectCauses(error).filter((e) => - tracker.records.has(e) - ); - // Loaders may throw anything, even null, so look up indices, not values: - const transientIndex = loaderErrors.findIndex(isTransientFetchError); - const selected = loaderErrors[transientIndex < 0 ? 0 : transientIndex]; - const url = loaderErrors.length < 1 - ? null - : getFailedUrl(selected, tracker.records.get(selected)!); - // A document referring to a URL that cannot even be parsed is malformed: - if (url == null) { - return { - category: "unverifiable", - type: "invalidJsonLd", - cause: error, - transient: false, - }; - } - return { - category: "unverifiable", - type: "notDereferenceable", - url, - cause: loaderErrors.length === 1 ? selected : error, - transient: transientIndex >= 0, - }; -} - -function getFailedUrl(error: unknown, requestedUrl: string): URL | null { - const url = (error as { readonly url?: unknown } | null)?.url; - if (url instanceof URL) return url; - for (const candidate of [url, requestedUrl]) { - if (typeof candidate !== "string") continue; - try { - return parseIri(candidate); - } catch { - continue; - } - } - return null; -} - -function notDereferenceable(url: URL): UnverifiableFailure { - return { - category: "unverifiable", - type: "notDereferenceable", - url, - transient: false, - }; -} - /** * Dereferences a field of the request, such as its `object` or `instrument`. * Returns `null` if the request has no such field at all. @@ -1018,30 +989,90 @@ async function dereferenceField( } } +/** + * The interpretation of a request that authorization may rely on: its + * expanded form, and the base URL it was decoded with. + */ +interface RequestEvidence { + readonly expanded: readonly unknown[]; + readonly baseUrl: URL | null; +} + async function materialize( value: T | URL, constructor: VocabConstructor, documentLoader: DocumentLoader | undefined, contextLoader: DocumentLoader | undefined, ): Promise< - | { readonly ok: true; readonly object: T } + | { + readonly ok: true; + readonly object: T; + /** + * The fetched request's interpretation, if the request was fetched from + * the web origin that speaks for it and its actors. + */ + readonly evidence: RequestEvidence | null; + } | { readonly ok: false; readonly failure: UnverifiableFailure } > { - if (!(value instanceof URL)) return { ok: true, object: value }; + if (!(value instanceof URL)) { + return { ok: true, object: value, evidence: null }; + } if (documentLoader == null) { return { ok: false, failure: notDereferenceable(value) }; } const tracker = trackLoaders(documentLoader, contextLoader); try { const remoteDocument = await tracker.documentLoader!(value.href); - return { - ok: true, - object: await constructor.fromJsonLd(remoteDocument.document, { - documentLoader: tracker.documentLoader, - contextLoader: tracker.contextLoader, - baseUrl: value, - }), - }; + // The request is parsed and expanded with the same context documents: + const memoizedContextLoader = memoizeLoader( + tracker.contextLoader ?? getDocumentLoader(), + ); + const object = await constructor.fromJsonLd(remoteDocument.document, { + documentLoader: tracker.documentLoader, + contextLoader: memoizedContextLoader, + baseUrl: value, + }); + let evidence: RequestEvidence | null = null; + let documentUrl: URL | null; + try { + documentUrl = parseIri(remoteDocument.documentUrl); + } catch { + documentUrl = null; + } + // Only the web origin of an ordinary ID speaks for the request and its + // actors; FEP-ef61 portable objects are never authenticated by where + // they were fetched from: + if ( + documentUrl != null && !isPortableClaim(value) && + !isPortableClaim(documentUrl) && !hasCredentials(documentUrl) && + documentUrl.origin === value.origin + ) { + const expanded = await jsonld.expand( + // deno-lint-ignore no-explicit-any + remoteDocument.document as any, + // deno-lint-ignore no-explicit-any + { documentLoader: memoizedContextLoader } as any, + ); + const root = getRootNode(expanded); + const rootId = root == null ? null : resolveNodeId(root, value); + const actors = root == null + ? [] + : getPropertyValues(root, AS_ACTOR).map((actor) => + resolveNodeId(actor, value) + ); + if ( + rootId != null && !isPortableClaim(rootId) && + rootId.href === value.href && + actors.every((actor) => + actor != null && !isPortableClaim(actor) && + actor.origin === value.origin + ) + ) { + evidence = { expanded, baseUrl: value }; + } + } + return { ok: true, object, evidence }; } catch (error) { return { ok: false, failure: classifyFailure(error, tracker) }; } finally { @@ -1049,6 +1080,99 @@ async function materialize( } } +function memoizeLoader(loader: DocumentLoader): DocumentLoader { + const cache = new Map>(); + return async (url, options) => { + let promise = cache.get(url); + if (promise == null) { + promise = loader(url, options); + cache.set(url, promise); + } + return structuredClone(await promise); + }; +} + +const AS_ACTOR = "https://www.w3.org/ns/activitystreams#actor"; +const AS_OBJECT = "https://www.w3.org/ns/activitystreams#object"; +const AS_INSTRUMENT = "https://www.w3.org/ns/activitystreams#instrument"; + +/** + * Checks that a request has exactly the type, ID, actors, `object`, and + * `instrument` that its authenticated interpretation has, counting every + * value, including those typed parsing drops. + * @returns The failure, or `null` if the request is bound to the evidence. + */ +function bindRequest( + request: Activity, + evidence: RequestEvidence | null, + typeId: URL, +): InteractionRequestVerificationFailure | null { + if (evidence == null) { + // Nothing tells which values typed parsing dropped, so only an actor that + // Fedify authenticated before parsing makes the request acceptable: + if (request.actorIds.length < 1) { + return { category: "invalid", type: "missingActor" }; + } + if ( + request.objectIds.length !== 1 || + !sameId(request.objectIds[0], request.objectId) + ) { + return { + category: "invalid", + type: "ambiguousRequest", + property: "object", + }; + } + if ( + request.instrumentIds.length !== 1 || + !sameId(request.instrumentIds[0], request.instrumentId) + ) { + return { + category: "invalid", + type: "ambiguousRequest", + property: "instrument", + }; + } + return null; + } + const root = getRootNode(evidence.expanded); + if (root == null) { + return { category: "invalid", type: "ambiguousRequest", property: "id" }; + } + if (!hasType(root, typeId.href)) { + return { category: "invalid", type: "ambiguousRequest", property: "type" }; + } + const rootId = resolveNodeId(root, evidence.baseUrl); + if (rootId == null || !sameId(rootId, request.id)) { + return { category: "invalid", type: "ambiguousRequest", property: "id" }; + } + // The decoder resolves references against the given base URL, or against + // the object's own ID: + const base = evidence.baseUrl ?? rootId; + const actors = getPropertyValues(root, AS_ACTOR); + if ( + actors.length !== request.actorIds.length || + !actors.every((actor, i) => + sameId(resolveNodeId(actor, base), request.actorIds[i]) + ) + ) { + return { category: "invalid", type: "ambiguousRequest", property: "actor" }; + } + for ( + const [iri, property, missing, id] of [ + [AS_OBJECT, "object", "missingObject", request.objectId], + [AS_INSTRUMENT, "instrument", "missingInstrument", request.instrumentId], + ] as const + ) { + const values = getPropertyValues(root, iri); + if (values.length < 1) return { category: "invalid", type: missing }; + if (values.length !== 1 || !sameId(resolveNodeId(values[0], base), id)) { + return { category: "invalid", type: "ambiguousRequest", property }; + } + } + return null; +} + async function evaluatePolicy< TRequest extends Activity, TAuthorization extends ASObject, diff --git a/packages/interaction-controls/src/evidence.ts b/packages/interaction-controls/src/evidence.ts new file mode 100644 index 000000000..fb0645ddf --- /dev/null +++ b/packages/interaction-controls/src/evidence.ts @@ -0,0 +1,133 @@ +import { + canonicalizePortableUri, + formatIri, + fromCompatibleEf61Id, + parseIri, +} from "@fedify/vocab-runtime"; + +const COMPATIBLE_ID_PATH_PATTERN = /^\/\.well-known\/apgateway\//i; + +/** + * Tells whether an ID is an [FEP-ef61] portable ID or a compatible + * identifier, which no web origin speaks for. + * + * [FEP-ef61]: https://w3id.org/fep/ef61 + */ +export function isPortableClaim(id: URL): boolean { + return id.protocol === "ap:" || id.protocol === "ap+ef61:" || + ((id.protocol === "http:" || id.protocol === "https:") && + COMPATIBLE_ID_PATH_PATTERN.test(id.pathname)); +} + +/** + * Normalizes an ID for comparison: [FEP-ef61] portable IDs and compatible + * identifiers to their canonical portable IDs, other IDs to their `href`. + * + * [FEP-ef61]: https://w3id.org/fep/ef61 + * @returns The normalized ID, or `null` if it is a malformed portable ID or + * compatible identifier, which never matches anything. + */ +export function canonicalizeId(id: URL): string | null { + try { + if (id.protocol === "ap:" || id.protocol === "ap+ef61:") { + return canonicalizePortableUri(formatIri(id)); + } + if (isPortableClaim(id)) { + const portable = fromCompatibleEf61Id(id); + return portable == null + ? null + : canonicalizePortableUri(formatIri(portable)); + } + return id.href; + } catch { + return null; + } +} + +/** + * Compares two IDs, canonicalizing [FEP-ef61] portable IDs. + * + * [FEP-ef61]: https://w3id.org/fep/ef61 + */ +export function sameId( + left: URL | null | undefined, + right: URL | null | undefined, +): boolean { + if (left == null || right == null) return false; + const canonical = canonicalizeId(left); + return canonical != null && canonical === canonicalizeId(right); +} + +/** Tells whether a URL carries a username or a password. */ +export function hasCredentials(url: URL): boolean { + return url.username !== "" || url.password !== ""; +} + +/** + * Gets the single root node of an expanded document. + * @returns The root node, or `null` if there is not exactly one. + */ +export function getRootNode( + expanded: readonly unknown[], +): Readonly> | null { + if (expanded.length !== 1) return null; + const node = expanded[0]; + if (node == null || typeof node !== "object" || Array.isArray(node)) { + return null; + } + return node as Readonly>; +} + +/** + * Gets the values of a property of an expanded node, unwrapping a single + * `@list` container the way generated vocabulary decoders do. + */ +export function getPropertyValues( + node: Readonly>, + iri: string, +): readonly unknown[] { + const values = node[iri]; + if (values == null) return []; + if (!Array.isArray(values)) return [values]; + if ( + values.length === 1 && values[0] != null && + typeof values[0] === "object" && "@list" in values[0] + ) { + const list = (values[0] as { readonly "@list": unknown })["@list"]; + return Array.isArray(list) ? list : [list]; + } + return values; +} + +/** + * Resolves the `@id` of an expanded value, the way generated vocabulary + * decoders resolve references. + * @param value The expanded value, a node object or a node reference. + * @param base The base URL to resolve a relative IRI against. + * @returns The IRI, or `null` if the value is not a node with a non-blank + * `@id` that can be resolved. + */ +export function resolveNodeId( + value: unknown, + base: URL | null, +): URL | null { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const id = (value as { readonly "@id"?: unknown })["@id"]; + if (typeof id !== "string" || id.startsWith("_:")) return null; + try { + return base == null ? parseIri(id) : parseIri(id, base); + } catch { + return null; + } +} + +/** Tells whether an expanded node has the given type. */ +export function hasType( + node: Readonly>, + typeIri: string, +): boolean { + const types = node["@type"]; + return Array.isArray(types) ? types.includes(typeIri) : types === typeIri; +} diff --git a/packages/interaction-controls/src/feature.test.ts b/packages/interaction-controls/src/feature.test.ts index 08ce2f7a7..117a21422 100644 --- a/packages/interaction-controls/src/feature.test.ts +++ b/packages/interaction-controls/src/feature.test.ts @@ -1,6 +1,20 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import type { Context } from "@fedify/fedify"; +import { + type Context, + type InboxAuthenticationEvidence, + verifyPortableObject, +} from "@fedify/fedify"; +import { + type DocumentLoader, + encodeMultibase, + exportDidKey, + FetchError, + getDocumentLoader, + parseIri, + preloadedContexts, +} from "@fedify/vocab-runtime"; +import jsonld from "@fedify/vocab-runtime/jsonld"; import { FeatureAuthorization, FeaturedCollection, @@ -20,14 +34,135 @@ const collectionId = new URL("https://example.com/users/alice/featured"); const authorizationId = new URL("https://example.net/authorizations/5"); const verifyAuthenticity = () => true; +interface ServedDocument { + readonly document?: unknown; + readonly documentUrl?: string; + readonly error?: unknown; +} + +/** + * A context whose document loader serves the given documents, and Fedify's + * preloaded JSON-LD contexts. + */ +function createServingContext( + documents: Record, + fetched: string[] = [], +): Context { + const preloaded = getDocumentLoader(); + const documentLoader: DocumentLoader = (url, options) => { + fetched.push(url); + const served = documents[url]; + if (served == null) { + return url in preloadedContexts + ? preloaded(url, options) + : Promise.reject( + new FetchError( + new URL(url), + "Not found", + new Response(null, { status: 404 }), + ), + ); + } + if (served.error !== undefined) return Promise.reject(served.error); + return Promise.resolve({ + contextUrl: null, + document: structuredClone(served.document), + documentUrl: served.documentUrl ?? url, + }); + }; + return { + documentLoader, + contextLoader: preloaded, + } as unknown as Context; +} + +const CONTEXT = [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/fep/7aa9", +]; + +function collectionDocument( + attributedTo: unknown = actor.href, + extra: Record = {}, +): Record { + return { + "@context": CONTEXT, + id: collectionId.href, + type: "FeaturedCollection", + name: "Cute cats", + attributedTo, + totalItems: 0, + orderedItems: [], + ...extra, + }; +} + +const requestId = new URL("https://example.com/requests/5"); + +function mastodonRequestDocument( + extra: Record = {}, +): Record { + return { + "@context": CONTEXT, + id: requestId.href, + type: "FeatureRequest", + object: targetId.href, + instrument: collectionId.href, + ...extra, + }; +} + +async function parseRequest(document: unknown): Promise { + return await FeatureRequest.fromJsonLd(document, { + documentLoader: getDocumentLoader(), + contextLoader: getDocumentLoader(), + }); +} + +/** + * Builds the evidence Fedify would pass to a listener for the given request + * document, signed by the given principals. + */ +async function evidenceFor( + document: unknown, + documentSigners: readonly URL[], + requestSigners: readonly URL[] = [], +): Promise { + const expandedActivity = await jsonld.expand( + // deno-lint-ignore no-explicit-any + document as any, + // deno-lint-ignore no-explicit-any + { documentLoader: getDocumentLoader() } as any, + ) as Record[]; + return { + verified: true, + requestSigners: requestSigners.map((id) => ({ + kind: "actor", + id, + mechanism: "httpSignatures", + keyId: new URL(`${id.href}#main-key`), + })), + documentSigners: documentSigners.map((id) => ({ + kind: "actor", + id, + mechanism: "linkedDataSignatures", + keyId: new URL(`${id.href}#main-key`), + })), + originAuthenticated: [], + expandedActivity, + baseUrl: null, + }; +} + +const target = new Person({ id: targetId }); + test("featureInteraction creates and verifies requests", async () => { - const target = new Person({ id: targetId }); const collection = new FeaturedCollection({ id: collectionId, attribution: actor, }); const request = featureInteraction.createRequest({ - id: new URL("https://example.com/requests/5"), + id: requestId, actor, object: target, instrument: collection, @@ -35,7 +170,15 @@ test("featureInteraction creates and verifies requests", async () => { assert.ok(request instanceof FeatureRequest); - const result = await featureInteraction.verifyRequest(context, { request }); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, + }), + { request }, + ); assert.equal(result.verified, true); assert.equal(result.requester.href, actor.href); @@ -43,62 +186,586 @@ test("featureInteraction creates and verifies requests", async () => { assert.equal(result.interactionTargetId.href, targetId.href); }); -test("featureInteraction accepts actorless requests from collection owner", async () => { - const target = new Person({ id: targetId }); - const collection = new FeaturedCollection({ - id: collectionId, - attribution: actor, +test("featureInteraction verifies Mastodon's actorless requests", async () => { + const document = mastodonRequestDocument(); + const request = await parseRequest(document); + const context = createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, }); + + for ( + const authentication of [ + await evidenceFor(document, [actor]), + // Without document signatures, the HTTP request signer counts: + await evidenceFor(document, [], [actor]), + ] + ) { + const result = await featureInteraction.verifyRequest(context, { + request, + authentication, + }); + assert.equal(result.verified, true); + assert.equal(result.requester.href, actor.href); + assert.equal(result.interactingObjectId.href, collectionId.href); + assert.ok(result.interactingObject instanceof FeaturedCollection); + } +}); + +test("featureInteraction requires authentication for actorless requests", async () => { + const document = mastodonRequestDocument(); + const request = await parseRequest(document); + const context = createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, + }); + + const unauthenticated = await featureInteraction.verifyRequest(context, { + request, + }); + assert.equal(unauthenticated.verified, false); + assert.equal(unauthenticated.failure.type, "missingActor"); + + // A valid signature of someone else does not make them the requester: + const carol = new URL("https://example.com/users/carol"); + const someoneElse = await featureInteraction.verifyRequest(context, { + request, + authentication: await evidenceFor(document, [carol]), + }); + assert.equal(someoneElse.verified, false); + assert.deepEqual(someoneElse.failure, { + category: "unauthorized", + type: "unauthenticatedRequester", + requester: actor, + }); + + // A relay's HTTP signature does not count when the document is signed: + const relayed = await featureInteraction.verifyRequest(context, { + request, + authentication: await evidenceFor(document, [carol], [actor]), + }); + assert.equal(relayed.verified, false); + assert.equal(relayed.failure.type, "unauthenticatedRequester"); +}); + +test("featureInteraction requires the request's actor to be authenticated", async () => { + const document = mastodonRequestDocument({ actor: actor.href }); + const request = await parseRequest(document); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, + }), + { + request, + authentication: await evidenceFor(document, [ + new URL("https://example.com/users/carol"), + ]), + }, + ); + assert.equal(result.verified, false); + assert.equal(result.failure.type, "unauthenticatedRequester"); +}); + +test("featureInteraction ignores embedded collections", async () => { + // The attacker embeds a collection whose ID is the victim's, attributing it + // to themselves; the collection's own document names its real owner: + const attacker = new URL("https://attacker.example/users/mallory"); const request = new FeatureRequest({ - id: new URL("https://example.com/requests/5"), + id: new URL("https://attacker.example/requests/1"), + actor: attacker, object: target, - instrument: collection, + instrument: new FeaturedCollection({ + id: collectionId, + attribution: attacker, + }), }); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, + }), + { request }, + ); + assert.equal(result.verified, false); + assert.equal(result.failure.type, "requesterMismatch"); +}); - const result = await featureInteraction.verifyRequest(context, { request }); - - assert.equal(result.verified, true); - assert.equal(result.requester.href, actor.href); - assert.equal(result.interactingObjectId.href, collectionId.href); - assert.equal(result.interactionTargetId.href, targetId.href); +test("featureInteraction ignores resolved interacting objects", async () => { + const document = mastodonRequestDocument(); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, + }), + { + request: await parseRequest(document), + authentication: await evidenceFor(document, [ + new URL("https://example.org/users/carol"), + ]), + resolvedInteractingObject: new FeaturedCollection({ + id: collectionId, + attribution: new URL("https://example.org/users/carol"), + }), + }, + ); + assert.equal(result.verified, false); + assert.equal(result.failure.type, "unauthenticatedRequester"); }); -test("featureInteraction rejects non-actor request targets", async () => { - const target = new Note({ id: targetId }); +test("featureInteraction denies mismatched collection owners", async () => { const collection = new FeaturedCollection({ id: collectionId, attribution: actor, }); const request = new FeatureRequest({ - id: new URL("https://example.com/requests/5"), + id: requestId, actor, object: target, instrument: collection, }); - const result = await featureInteraction.verifyRequest(context, { request }); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { + document: collectionDocument("https://example.org/users/carol"), + }, + }), + { request }, + ); assert.equal(result.verified, false); - assert.equal(result.failure.type, "wrongObjectType"); + assert.equal(result.failure.type, "requesterMismatch"); }); -test("featureInteraction denies mismatched collection owners", async () => { - const target = new Person({ id: targetId }); +for ( + const [label, attributedTo, valid] of [ + ["an anonymous attribution before the owner", [ + { type: "Person", name: "Anonymous" }, + actor.href, + ], false], + ["an anonymous attribution after the owner", [ + actor.href, + { type: "Person", name: "Anonymous" }, + ], false], + ["an untyped attribution before the owner", [ + { name: "Anonymous" }, + actor.href, + ], false], + ["a blank node attribution after the owner", [ + actor.href, + { id: "_:b0" }, + ], false], + ["a list with an anonymous attribution before the owner", { + "@list": [{ type: "Person", name: "Anonymous" }, actor.href], + }, false], + ["a list with an anonymous attribution after the owner", { + "@list": [actor.href, { type: "Person", name: "Anonymous" }], + }, false], + ["two owners", [actor.href, "https://example.com/users/carol"], false], + ["no owner", [], false], + ["a list with only the owner", { "@list": [actor.href] }, true], + ] as const +) { + test(`featureInteraction checks a collection with ${label}`, async () => { + const document = mastodonRequestDocument(); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ + contextLoader: getDocumentLoader(), + }), + }, + [collectionId.href]: { + document: collectionDocument(attributedTo), + }, + }), + { + request: await parseRequest(document), + authentication: await evidenceFor(document, [actor]), + }, + ); + if (valid) { + assert.equal(result.verified, true); + } else { + assert.equal(result.verified, false); + assert.deepEqual(result.failure, { + category: "invalid", + type: "invalidInteractingObject", + reason: "attribution", + }); + } + }); +} + +for ( + const [label, extra, property] of [ + ["an anonymous instrument before the collection", { + instrument: [{ type: "Object", name: "x" }, collectionId.href], + }, "instrument"], + ["an anonymous instrument after the collection", { + instrument: [collectionId.href, { type: "Object", name: "x" }], + }, "instrument"], + ["an untyped instrument before the collection", { + instrument: [{ name: "x" }, collectionId.href], + }, "instrument"], + ["a list of instruments", { + instrument: { "@list": [collectionId.href, { name: "x" }] }, + }, "instrument"], + ["two objects", { + object: [targetId.href, "https://example.net/users/dave"], + }, "object"], + ["an anonymous actor", { actor: { type: "Person", name: "x" } }, "actor"], + ["a blank node actor", { actor: { id: "_:b0" } }, "actor"], + ] as const +) { + test(`featureInteraction rejects a request with ${label}`, async () => { + const document = mastodonRequestDocument(extra); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ + contextLoader: getDocumentLoader(), + }), + }, + [collectionId.href]: { document: collectionDocument() }, + }), + { + request: await parseRequest(document), + authentication: await evidenceFor(document, [actor]), + }, + ); + assert.equal(result.verified, false); + assert.deepEqual(result.failure, { + category: "invalid", + type: "ambiguousRequest", + property, + }); + }); +} + +test("featureInteraction binds authentication to the verified request", async () => { + const context = createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, + }); + const request = await parseRequest(mastodonRequestDocument()); + for ( + const [other, property] of [ + [mastodonRequestDocument({ type: "Offer" }), "type"], + [mastodonRequestDocument({ id: "https://example.com/requests/6" }), "id"], + [mastodonRequestDocument({ actor: actor.href }), "actor"], + [ + mastodonRequestDocument({ instrument: "https://example.com/other" }), + "instrument", + ], + ] as const + ) { + const result = await featureInteraction.verifyRequest(context, { + request, + authentication: await evidenceFor(other, [actor]), + }); + assert.equal(result.verified, false); + assert.deepEqual(result.failure, { + category: "invalid", + type: "ambiguousRequest", + property, + }); + } +}); + +test("featureInteraction resolves relative references like the decoder", async () => { + // Without a base URL, the decoder resolves references against the + // activity's own ID: + const document = mastodonRequestDocument({ + "@context": [...CONTEXT, { "@base": "https://example.com/requests/" }], + id: "5", + object: targetId.href, + instrument: "/users/alice/featured", + }); + const request = await parseRequest(document); + assert.equal(request.instrumentId?.href, collectionId.href); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, + }), + { request, authentication: await evidenceFor(document, [actor]) }, + ); + assert.equal(result.verified, true); +}); + +test("featureInteraction rejects collections from other origins", async () => { + const document = mastodonRequestDocument(); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + // A redirect, e.g., an open redirect, to another origin that claims the + // collection's ID: + [collectionId.href]: { + document: collectionDocument(), + documentUrl: "https://attacker.example/featured", + }, + }), + { + request: await parseRequest(document), + authentication: await evidenceFor(document, [actor]), + }, + ); + assert.equal(result.verified, false); + assert.equal(result.failure.type, "notDereferenceable"); + assert.equal( + result.failure.type === "notDereferenceable" && result.failure.transient, + false, + ); +}); + +test("featureInteraction accepts same-origin redirects of collections", async () => { + const document = mastodonRequestDocument(); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { + document: collectionDocument(), + documentUrl: "https://example.com/collections/alice-featured", + }, + }), + { + request: await parseRequest(document), + authentication: await evidenceFor(document, [actor]), + }, + ); + assert.equal(result.verified, true); +}); + +test("featureInteraction rejects collection URLs with credentials", async () => { + const instrument = "https://user:pass@example.com/users/alice/featured"; + const document = mastodonRequestDocument({ instrument }); + const fetched: string[] = []; + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + }, fetched), + { + request: await parseRequest(document), + authentication: await evidenceFor(document, [actor]), + }, + ); + assert.equal(result.verified, false); + assert.equal(result.failure.type, "credentialsInUrl"); + assert.ok(!fetched.includes(instrument)); +}); + +test("featureInteraction tells transient collection failures", async () => { + const document = mastodonRequestDocument(); + for ( + const [status, transient] of [ + [503, true], + [429, true], + [408, true], + [404, false], + [410, false], + ] as const + ) { + const fetched: string[] = []; + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ + contextLoader: getDocumentLoader(), + }), + }, + [collectionId.href]: { + error: new FetchError( + collectionId, + `HTTP ${status}`, + new Response(null, { status }), + ), + }, + }, fetched), + { + request: await parseRequest(document), + authentication: await evidenceFor(document, [actor]), + }, + ); + assert.equal(result.verified, false); + assert.equal(result.failure.type, "notDereferenceable"); + assert.equal( + result.failure.type === "notDereferenceable" && result.failure.transient, + transient, + ); + // WebFinger is never asked for the collection: + assert.ok(fetched.every((url) => !url.includes(".well-known/webfinger"))); + } +}); + +test("featureInteraction rejects malformed collections", async () => { + const document = mastodonRequestDocument(); + for ( + const extra of [ + { published: "not-a-date" }, + { + attachment: { + type: "Link", + href: "https://x.example/", + hreflang: "not a tag", + }, + }, + { + "@context": [...CONTEXT, "https://w3id.org/security/data-integrity/v1"], + proof: { + type: "DataIntegrityProof", + cryptosuite: "eddsa-jcs-2022", + verificationMethod: "https://example.com/users/alice#key", + proofPurpose: "assertionMethod", + proofValue: "!invalid-multibase", + created: "2026-01-01T00:00:00Z", + }, + }, + ] + ) { + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ + contextLoader: getDocumentLoader(), + }), + }, + [collectionId.href]: { + document: collectionDocument(actor.href, extra), + }, + }), + { + request: await parseRequest(document), + authentication: await evidenceFor(document, [actor]), + }, + ); + assert.equal(result.verified, false, JSON.stringify(extra)); + assert.equal(result.failure.type, "invalidInteractingObject"); + assert.equal( + result.failure.type === "invalidInteractingObject" && + result.failure.reason, + "decoding", + ); + } +}); + +test("featureInteraction keeps the verified collection usable", async () => { + const document = mastodonRequestDocument(); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, + [actor.href]: { + document: { + "@context": "https://www.w3.org/ns/activitystreams", + id: actor.href, + type: "Person", + name: "Alice", + }, + }, + }), + { + request: await parseRequest(document), + authentication: await evidenceFor(document, [actor]), + }, + ); + assert.equal(result.verified, true); + const owner = await result.interactingObject.getAttribution(); + assert.equal(owner?.id?.href, actor.href); +}); + +test("featureInteraction authenticates fetched requests by their origin", async () => { + const fetchedRequest = new URL("https://example.com/requests/5"); + const context = createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, + [fetchedRequest.href]: { + document: mastodonRequestDocument({ actor: actor.href }), + }, + // The attacker serves a request that claims to be the victim's: + "https://attacker.example/requests/1": { + document: mastodonRequestDocument({ + id: "https://attacker.example/requests/1", + actor: actor.href, + }), + }, + // The victim's URL redirects to the attacker's request: + "https://example.com/requests/redirect": { + document: mastodonRequestDocument({ + id: "https://example.com/requests/redirect", + actor: actor.href, + }), + documentUrl: "https://attacker.example/requests/redirect", + }, + }); + const ok = await featureInteraction.verifyRequest(context, { + request: fetchedRequest, + }); + assert.equal(ok.verified, true); + for ( + const url of [ + "https://attacker.example/requests/1", + "https://example.com/requests/redirect", + ] + ) { + const result = await featureInteraction.verifyRequest(context, { + request: new URL(url), + }); + assert.equal(result.verified, false); + assert.equal(result.failure.type, "unauthenticatedRequester"); + } +}); + +test("featureInteraction rejects non-actor request targets", async () => { + const target = new Note({ id: targetId }); const collection = new FeaturedCollection({ id: collectionId, - attribution: new URL("https://example.org/users/carol"), + attribution: actor, }); const request = new FeatureRequest({ - id: new URL("https://example.com/requests/5"), + id: requestId, actor, object: target, instrument: collection, }); - const result = await featureInteraction.verifyRequest(context, { request }); + const result = await featureInteraction.verifyRequest( + createServingContext({ + [collectionId.href]: { document: collectionDocument() }, + }), + { request }, + ); assert.equal(result.verified, false); - assert.equal(result.failure.type, "requesterMismatch"); + assert.equal(result.failure.type, "wrongObjectType"); }); test("featureInteraction denies missing canFeature by default", async () => { @@ -237,3 +904,247 @@ test("featureInteraction does not recognize standalone featured items", () => { assert.equal(featureInteraction.recognizeImpolite(target), null); }); + +const portableKeyPair = await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", +]) as CryptoKeyPair; +const portableDid = await exportDidKey(portableKeyPair.publicKey); +const portableActor = `ap://${portableDid}/actor`; +const portableCollection = `ap://${portableDid}/featured`; +const PORTABLE_CONTEXT = [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + "https://w3id.org/fep/ef61", + "https://w3id.org/fep/7aa9", +]; + +function compatibleId(gateway: string, id: string): string { + return `${gateway}/.well-known/apgateway/${id.replace(/^ap:\/\//, "")}`; +} + +/** RFC 8785 canonical JSON, enough for the plain documents of these tests. */ +function serialize(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(serialize).join(",")}]`; + if (value != null && typeof value === "object") { + return `{${ + Object.keys(value).sort().map((key) => + `${JSON.stringify(key)}:${ + serialize((value as Record)[key]) + }` + ).join(",") + }}`; + } + return JSON.stringify(value); +} + +async function signPortable( + document: Record, +): Promise> { + const proofConfig = { + "@context": document["@context"], + type: "DataIntegrityProof", + cryptosuite: "eddsa-jcs-2022", + verificationMethod: `${portableDid}#${portableDid.slice(8)}`, + proofPurpose: "assertionMethod", + created: "2026-01-01T00:00:00Z", + }; + const digest = async (value: unknown) => + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(serialize(value)), + ), + ); + const signature = new Uint8Array( + await crypto.subtle.sign( + "Ed25519", + portableKeyPair.privateKey, + new Uint8Array([ + ...await digest(proofConfig), + ...await digest(document), + ]), + ), + ); + return { + ...document, + proof: { + ...proofConfig, + proofValue: new TextDecoder().decode( + encodeMultibase("base58btc", signature), + ), + }, + }; +} + +function createPortableContext( + documents: Record, +): Context { + const context = createServingContext(documents); + return { + ...context, + verifyPortableObject: (document: unknown, options: object) => + verifyPortableObject(document, { + documentLoader: context.documentLoader, + contextLoader: context.contextLoader, + ...options, + }), + } as unknown as Context; +} + +for ( + const [label, attributedTo, valid] of [ + ["its owner", portableActor, true], + ["an anonymous attribution before its owner", [ + { type: "Person", name: "Anonymous" }, + portableActor, + ], false], + ["an anonymous attribution after its owner", [ + portableActor, + { name: "Anonymous" }, + ], false], + ] as const +) { + test(`featureInteraction checks a portable collection with ${label}`, async () => { + const instrument = compatibleId("https://gw.example", portableCollection); + const document = mastodonRequestDocument({ instrument }); + const collection = await signPortable({ + "@context": PORTABLE_CONTEXT, + id: portableCollection, + type: "FeaturedCollection", + name: "Portable cats", + attributedTo, + totalItems: 0, + }); + const result = await featureInteraction.verifyRequest( + createPortableContext({ + [targetId.href]: { + document: await target.toJsonLd({ + contextLoader: getDocumentLoader(), + }), + }, + [instrument]: { document: collection }, + }), + { + request: await parseRequest(document), + // The requester is authenticated by a proof of the owner's DID: + authentication: { + ...await evidenceFor(document, []), + documentSigners: [{ + kind: "portableAuthority", + id: new URL(portableDid), + mechanism: "objectIntegrityProofs", + keyId: null, + }], + }, + }, + ); + if (valid) { + assert.equal(result.verified, true); + assert.equal( + result.verified && result.requester.href, + parseIri(portableActor).href, + ); + } else { + assert.equal(result.verified, false); + assert.deepEqual(result.failure, { + category: "invalid", + type: "invalidInteractingObject", + reason: "attribution", + }); + } + }); +} + +test("featureInteraction rejects an unsigned portable collection", async () => { + const instrument = compatibleId("https://gw.example", portableCollection); + const document = mastodonRequestDocument({ instrument }); + const result = await featureInteraction.verifyRequest( + createPortableContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [instrument]: { + document: { + "@context": PORTABLE_CONTEXT, + id: portableCollection, + type: "FeaturedCollection", + attributedTo: portableActor, + }, + }, + }), + { + request: await parseRequest(document), + authentication: await evidenceFor(document, [parseIri(portableActor)]), + }, + ); + assert.equal(result.verified, false); + assert.notEqual(result.failure.category, "unauthorized"); +}); + +test("featureInteraction tells an unavailable portable collection owner", async () => { + const instrument = compatibleId("https://gw.example", portableCollection); + const document = mastodonRequestDocument({ instrument }); + const context = createPortableContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + // An unsecured portable collection is accepted only if its owner lists + // the gateway, so the owner has to be fetched, and its gateway is down: + [instrument]: { + document: { + "@context": PORTABLE_CONTEXT, + id: portableCollection, + type: ["OrderedCollection", "FeaturedCollection"], + attributedTo: portableActor, + totalItems: 0, + }, + }, + [compatibleId("https://gw.example", portableActor)]: { + error: new FetchError( + compatibleId("https://gw.example", portableActor), + "Unavailable", + new Response(null, { status: 503 }), + ), + }, + }); + const result = await featureInteraction.verifyRequest(context, { + request: await parseRequest(document), + authentication: await evidenceFor(document, [parseIri(portableActor)]), + }); + assert.equal(result.verified, false); + assert.equal(result.failure.type, "notDereferenceable"); + assert.equal( + result.failure.type === "notDereferenceable" && result.failure.transient, + true, + ); +}); + +test("featureInteraction treats empty evidence as no evidence", async () => { + // An activity queued by an older version of Fedify has no evidence: + const empty: InboxAuthenticationEvidence = { + verified: false, + requestSigners: [], + documentSigners: [], + originAuthenticated: [], + expandedActivity: [], + baseUrl: null, + }; + const context = createServingContext({ + [targetId.href]: { + document: await target.toJsonLd({ contextLoader: getDocumentLoader() }), + }, + [collectionId.href]: { document: collectionDocument() }, + }); + const actorful = await featureInteraction.verifyRequest(context, { + request: await parseRequest(mastodonRequestDocument({ actor: actor.href })), + authentication: empty, + }); + assert.equal(actorful.verified, true); + const actorless = await featureInteraction.verifyRequest(context, { + request: await parseRequest(mastodonRequestDocument()), + authentication: empty, + }); + assert.equal(actorless.verified, false); + assert.equal(actorless.failure.type, "missingActor"); +}); diff --git a/packages/interaction-controls/src/feature.ts b/packages/interaction-controls/src/feature.ts index c8e271634..6392ec88f 100644 --- a/packages/interaction-controls/src/feature.ts +++ b/packages/interaction-controls/src/feature.ts @@ -11,7 +11,8 @@ import { Person, Service, } from "@fedify/vocab"; -import { createInteractionControl, idsEqual } from "./control.ts"; +import { createInteractionControl } from "./control.ts"; +import { sameId } from "./evidence.ts"; import type { InteractionControl } from "./types.ts"; export const featureInteraction: InteractionControl< @@ -40,9 +41,13 @@ export const featureInteraction: InteractionControl< Person.typeId, Service.typeId, ], - getRequester: (_request, collection) => collection.attributionId, + // Mastodon sends FeatureRequest activities without an actor, whose + // requester is the authenticated owner of the collection, as the + // collection's own document names it; a collection embedded in the request + // is never trusted: + authoritativeInteractingObject: true, validateRequest: (_request, collection, _target, requester) => { - if (!idsEqual(collection.attributionId, requester)) { + if (!sameId(collection.attributionId, requester)) { return { type: "requesterMismatch", expected: requester, diff --git a/packages/interaction-controls/src/loaders.ts b/packages/interaction-controls/src/loaders.ts new file mode 100644 index 000000000..267d89d16 --- /dev/null +++ b/packages/interaction-controls/src/loaders.ts @@ -0,0 +1,159 @@ +import { + type DocumentLoader, + FetchError, + isTransientFetchError, + parseIri, +} from "@fedify/vocab-runtime"; +import type { InteractionRequestVerificationFailure } from "./types.ts"; + +export type UnverifiableFailure = Extract< + InteractionRequestVerificationFailure, + { + readonly category: "unverifiable"; + readonly type: "notDereferenceable" | "invalidJsonLd"; + } +>; + +export interface LoaderTracker { + readonly documentLoader: DocumentLoader | undefined; + readonly contextLoader: DocumentLoader | undefined; + readonly records: ReadonlyMap; + readonly release: () => void; +} + +/** + * Wraps the loaders for a single dereferencing operation so that errors they + * throw can be told apart from other errors afterwards. Parsed objects keep + * the loaders they were given, so the wrappers stop recording and pass calls + * through once the operation is released. + */ +export function trackLoaders( + documentLoader: DocumentLoader | undefined, + contextLoader: DocumentLoader | undefined, +): LoaderTracker { + const records = new Map(); + let released = false; + const wrap = ( + loader: DocumentLoader | undefined, + ): DocumentLoader | undefined => { + if (loader == null) return undefined; + return async (url, options) => { + if (released) return await loader(url, options); + let remoteDocument; + try { + remoteDocument = await loader(url, options); + } catch (error) { + if (!released) records.set(error, url); + throw error; + } + if (remoteDocument == null) { + const message = "The document loader returned no document."; + let error: Error; + try { + // FetchError's constructor cannot parse portable URLs by itself: + error = new FetchError(parseIri(url), message); + } catch { + error = new Error(`${url}: ${message}`); + } + if (!released) records.set(error, url); + throw error; + } + return remoteDocument; + }; + }; + const wrappedDocumentLoader = wrap(documentLoader); + return { + documentLoader: wrappedDocumentLoader, + contextLoader: contextLoader === documentLoader + ? wrappedDocumentLoader + : wrap(contextLoader), + records, + release: () => { + released = true; + records.clear(); + }, + }; +} + +const MAX_CAUSE_DEPTH = 8; + +export function collectCauses( + error: unknown, + causes: unknown[] = [], + depth = 0, +): unknown[] { + if (depth > MAX_CAUSE_DEPTH || causes.includes(error)) return causes; + causes.push(error); + if (typeof error !== "object" || error == null) return causes; + if (error instanceof AggregateError) { + for (const e of error.errors) collectCauses(e, causes, depth + 1); + } + // Loaders may throw anything, even null, so check for the properties + // rather than their values: + if ("cause" in error) collectCauses(error.cause, causes, depth + 1); + // jsonld.js reports a failed remote context load as `details.cause`: + const details = (error as { readonly details?: unknown }).details; + if (typeof details === "object" && details != null && "cause" in details) { + collectCauses(details.cause, causes, depth + 1); + } + return causes; +} + +/** + * Classifies the error thrown by a dereferencing operation. It is a fetch + * failure only if a loader error is among its causes; errors that loaders + * threw during attempts the operation recovered from are ignored. + */ +export function classifyFailure( + error: unknown, + tracker: LoaderTracker, +): UnverifiableFailure { + const loaderErrors = collectCauses(error).filter((e) => + tracker.records.has(e) + ); + // Loaders may throw anything, even null, so look up indices, not values: + const transientIndex = loaderErrors.findIndex(isTransientFetchError); + const selected = loaderErrors[transientIndex < 0 ? 0 : transientIndex]; + const url = loaderErrors.length < 1 + ? null + : getFailedUrl(selected, tracker.records.get(selected)!); + // A document referring to a URL that cannot even be parsed is malformed: + if (url == null) { + return { + category: "unverifiable", + type: "invalidJsonLd", + cause: error, + transient: false, + }; + } + return { + category: "unverifiable", + type: "notDereferenceable", + url, + cause: loaderErrors.length === 1 ? selected : error, + transient: transientIndex >= 0, + }; +} + +function getFailedUrl(error: unknown, requestedUrl: string): URL | null { + const url = (error as { readonly url?: unknown } | null)?.url; + if (url instanceof URL) return url; + for (const candidate of [url, requestedUrl]) { + if (typeof candidate !== "string") continue; + try { + return parseIri(candidate); + } catch { + continue; + } + } + return null; +} + +export function notDereferenceable(url: URL): UnverifiableFailure { + return { + category: "unverifiable", + type: "notDereferenceable", + url, + transient: false, + }; +} diff --git a/packages/interaction-controls/src/types.ts b/packages/interaction-controls/src/types.ts index d8f3312e4..0376b8063 100644 --- a/packages/interaction-controls/src/types.ts +++ b/packages/interaction-controls/src/types.ts @@ -1,4 +1,4 @@ -import type { Context } from "@fedify/fedify"; +import type { Context, InboxAuthenticationEvidence } from "@fedify/fedify"; import type { DocumentLoader } from "@fedify/vocab-runtime"; import type { Temporal } from "temporal-polyfill"; import type { @@ -142,6 +142,24 @@ export interface InteractionRequestVerificationOptions< * @since 2.5.0 */ readonly resolvedInteractingObject?: ASObject; + + /** + * Who Fedify authenticated for the request, normally + * {@link InboxContext.authentication} of the inbox listener that received + * it. + * + * It is required to verify a request that declares no `actor`, such as + * a [FEP-7aa9] `FeatureRequest` sent by Mastodon: the requester is then the + * owner of the interacting object, who must have been authenticated. For + * a request that declares an actor, the actor must have been + * authenticated, and the request must have exactly one `object` and one + * `instrument` value, which are checked on the interpretation Fedify + * authenticated, including values typed parsing drops. + * + * [FEP-7aa9]: https://w3id.org/fep/7aa9 + * @since 2.5.0 + */ + readonly authentication?: InboxAuthenticationEvidence; } /** @@ -279,6 +297,57 @@ export type InteractionRequestVerificationFailure = readonly type: "requesterMismatch"; readonly expected: URL; readonly actual?: URL; + } + | { + /** + * The URL of the interacting object has a username or a password. + * @since 2.5.0 + */ + readonly category: "invalid"; + readonly type: "credentialsInUrl"; + readonly url: URL; + } + | { + /** + * The document of the interacting object, fetched from its ID, is not + * acceptable: + * + * - `"credentials"`: it was served from a URL with credentials. + * - `"id"`: its ID does not match the requested one. + * - `"type"`: it is not of the expected type. + * - `"attribution"`: it does not have exactly one `attributedTo` value + * that identifies an actor. + * - `"decoding"`: it cannot be decoded, e.g., it has a malformed date. + * @since 2.5.0 + */ + readonly category: "invalid"; + readonly type: "invalidInteractingObject"; + readonly reason: "credentials" | "id" | "type" | "attribution" | "decoding"; + readonly cause?: unknown; + } + | { + /** + * The request, as Fedify authenticated it, does not unambiguously have + * the given property: it has another type or ID than the request being + * verified, its `actor` values differ, or it does not have exactly one + * `object` or `instrument` value. + * @since 2.5.0 + */ + readonly category: "invalid"; + readonly type: "ambiguousRequest"; + readonly property: "type" | "id" | "actor" | "object" | "instrument"; + } + | { + /** + * The requester was not authenticated: the request's actor, or for + * a request without an actor, the owner of its interacting object, is not + * among the principals of the given authentication evidence, or no + * evidence is available. + * @since 2.5.0 + */ + readonly category: "unauthorized"; + readonly type: "unauthenticatedRequester"; + readonly requester?: URL; }; export interface InteractionPolicyEvaluationOptions< diff --git a/packages/testing/src/context.ts b/packages/testing/src/context.ts index 8293899a3..d21eaee9d 100644 --- a/packages/testing/src/context.ts +++ b/packages/testing/src/context.ts @@ -311,6 +311,21 @@ async function isSignedByAudience( return false; } +/** + * The authentication evidence of a mock inbox context that is not given one: + * nothing is authenticated. + */ +const EMPTY_AUTHENTICATION: InboxContext["authentication"] = globalThis + .Object + .freeze({ + verified: false, + requestSigners: globalThis.Object.freeze([]), + documentSigners: globalThis.Object.freeze([]), + originAuthenticated: globalThis.Object.freeze([]), + expandedActivity: globalThis.Object.freeze([]), + baseUrl: null, + }); + /** * Test-specific InboxContext type alias. * This indirection helps avoid JSR type analyzer issues. @@ -348,6 +363,7 @@ function createInboxContext( ...createContext(args), clone: args.clone ?? ((data) => createInboxContext({ ...args, data })), recipient: args.recipient ?? null, + authentication: args.authentication ?? EMPTY_AUTHENTICATION, forwardActivity, }; } diff --git a/packages/vocab-runtime/src/internal/portable-dereference.ts b/packages/vocab-runtime/src/internal/portable-dereference.ts index 871538339..532042b3a 100644 --- a/packages/vocab-runtime/src/internal/portable-dereference.ts +++ b/packages/vocab-runtime/src/internal/portable-dereference.ts @@ -705,6 +705,61 @@ export interface DereferencePortableIriOptions { options: { contextLoader: DocumentLoader; baseUrl: URL }, ) => Promise; span?: Span; + /** + * Observes each candidate document, and may refuse a candidate that was + * otherwise acceptable, in which case the next gateway is tried. + */ + observer?: DereferenceObserver; +} + +/** + * A candidate document that a lookup or a dereference was about to return. + * + * @internal Not part of the public API contract. + */ +export interface DereferenceCandidate { + /** The document as it was retrieved. */ + readonly document: unknown; + /** The URL the document was finally retrieved from. */ + readonly documentUrl: string; + /** The `baseUrl` option the document was parsed with. */ + readonly baseUrl: URL; + /** + * The context loader the document was parsed with. It keeps serving the + * same context documents until the {@link DereferenceObserver.accept} + * call settles. + */ + readonly contextLoader: DocumentLoader; + /** The parsed object. */ + readonly object: T; +} + +/** + * Observes the candidates and the swallowed failures of a lookup or + * a dereference. + * + * @internal Not part of the public API contract. + */ +export interface DereferenceObserver { + /** + * Called for each candidate whose parse succeeded, right before it would be + * returned. If it resolves to `false`, the candidate is refused, and the + * next one is tried. An error it throws is treated as a refusal, and is + * reported to {@link failed}. + */ + accept?(candidate: DereferenceCandidate): Promise; + /** Called for each failure that does not propagate by itself. */ + failed?(failure: DereferenceFailure): void; +} + +/** + * A failure observed by {@link DereferenceObserver.failed}. + * + * @internal Not part of the public API contract. + */ +export interface DereferenceFailure { + readonly operation: "fetch" | "verify" | "parse" | "origin" | "accept"; + readonly error?: unknown; } /** @@ -725,6 +780,34 @@ type Attempt = | { readonly type: "rejected"; readonly source: string } | { readonly type: "error"; readonly error: unknown }; +/** + * Asks an observer whether to accept a candidate. A refusal, including an + * error thrown by the observer, is reported to the observer as an `accept` + * failure. + * @returns `true` if there is no observer, or it accepted the candidate. + * @internal Not part of the public API contract. + */ +export async function acceptCandidate( + observer: DereferenceObserver | undefined, + candidate: DereferenceCandidate, +): Promise { + if (observer?.accept == null) return true; + let accepted: boolean; + try { + accepted = await observer.accept(candidate); + } catch (error) { + observer.failed?.({ operation: "accept", error }); + return false; + } + if (!accepted) observer.failed?.({ operation: "accept" }); + return accepted; +} + +/** Wraps an error that has already been reported to the observer. */ +class ObservedFailure { + constructor(readonly error: unknown) {} +} + class PortableObjectRejection { constructor( readonly message: string, @@ -852,6 +935,7 @@ export async function dereferencePortableIri( requestUrl, error, }); + options.observer?.failed?.({ operation: "fetch", error }); attempts.push({ type: "error", error }); continue; } @@ -887,11 +971,29 @@ export async function dereferencePortableIri( { result }, ); } - const object = await options.parse(document, { - contextLoader, - baseUrl: url, - }); + let object: T; + try { + object = await options.parse(document, { + contextLoader, + baseUrl: url, + }); + } catch (error) { + options.observer?.failed?.({ operation: "parse", error }); + throw new ObservedFailure(error); + } signal?.throwIfAborted(); + if ( + !await acceptCandidate(options.observer, { + document, + documentUrl: remoteDocument.documentUrl, + baseUrl: url, + contextLoader, + object, + }) + ) { + attempts.push({ type: "rejected", source: requestUrl }); + continue; + } if (object != null && typeof object === "object") { provenances.set(object, { acceptance: result.unsecured === true ? "unsecured" : "verified", @@ -913,15 +1015,23 @@ export async function dereferencePortableIri( "because " + error.message, { ...error.values, url: lookupUrl, requestUrl }, ); + options.observer?.failed?.({ + operation: "verify", + error: error.values.error, + }); attempts.push({ type: "rejected", source: requestUrl }); continue; } + const cause = error instanceof ObservedFailure ? error.error : error; + if (!(error instanceof ObservedFailure)) { + options.observer?.failed?.({ operation: "verify", error }); + } logger.debug("Failed to parse {url} from {requestUrl}: {error}", { url: lookupUrl, requestUrl, - error, + error: cause, }); - attempts.push({ type: "error", error }); + attempts.push({ type: "error", error: cause }); } } } finally { diff --git a/packages/vocab/src/lookup.test.ts b/packages/vocab/src/lookup.test.ts index 8ef893f7b..37fed5e0f 100644 --- a/packages/vocab/src/lookup.test.ts +++ b/packages/vocab/src/lookup.test.ts @@ -6,7 +6,15 @@ import { } from "@fedify/fixture"; import fetchMock from "fetch-mock"; import { deepStrictEqual, equal, ok, rejects } from "node:assert/strict"; -import { lookupObject, traverseCollection } from "./lookup.ts"; +import type { + DocumentLoader, + PortableObjectVerifier, +} from "@fedify/vocab-runtime"; +import type { + DereferenceCandidate, + DereferenceFailure, +} from "@fedify/vocab-runtime/internal/portable-dereference"; +import { lookupObject, lookupObserver, traverseCollection } from "./lookup.ts"; import { assertInstanceOf } from "./utils.ts"; import { Collection, Note, Object, Person } from "./vocab.ts"; @@ -1051,3 +1059,118 @@ test("lookupObject() records activitypub.object.lookup counter", { }); // cSpell: ignore gildong + +const OBSERVER_DID = "did:key:z6MkhVPuyvgG1RkMv67azDqDCDERPXVrUg1i3qchXY5EACE3"; + +test("lookupObject() offers each candidate to the observer", async () => { + const note = { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://example.com/notes/1", + type: "Note", + content: "Hello", + }; + const fetched: string[] = []; + const documentLoader: DocumentLoader = (url) => { + fetched.push(url); + if (url === "https://example.com/notes/1") { + return Promise.resolve({ + contextUrl: null, + document: note, + documentUrl: "https://example.com/notes/1?redirected", + }); + } + return Promise.reject(new Error(`Not found: ${url}`)); + }; + const candidates: DereferenceCandidate[] = []; + const failures: DereferenceFailure[] = []; + const accepted = await lookupObject("https://example.com/notes/1", { + documentLoader, + contextLoader: mockDocumentLoader, + [lookupObserver]: { + accept: (candidate) => { + candidates.push(candidate); + return Promise.resolve(true); + }, + failed: (failure) => failures.push(failure), + }, + }); + ok(accepted instanceof Note); + equal(candidates.length, 1); + deepStrictEqual(candidates[0].document, note); + equal(candidates[0].documentUrl, "https://example.com/notes/1?redirected"); + equal(candidates[0].baseUrl.href, "https://example.com/notes/1?redirected"); + equal(candidates[0].object, accepted); + deepStrictEqual(failures, []); + + // A refused candidate is not returned, and neither is an observer error: + for ( + const accept of [ + () => Promise.resolve(false), + () => Promise.reject(new Error("Oops")), + ] + ) { + const refusals: DereferenceFailure[] = []; + equal( + await lookupObject("https://example.com/notes/1", { + documentLoader, + contextLoader: mockDocumentLoader, + [lookupObserver]: { accept, failed: (f) => refusals.push(f) }, + }), + null, + ); + deepStrictEqual(refusals.map((f) => f.operation), ["accept"]); + } + + // Without WebFinger, a URL that cannot be fetched is not looked up further: + fetched.length = 0; + const missing: DereferenceFailure[] = []; + equal( + await lookupObject("https://example.com/notes/2", { + documentLoader, + contextLoader: mockDocumentLoader, + [lookupObserver]: { webFinger: false, failed: (f) => missing.push(f) }, + }), + null, + ); + deepStrictEqual(fetched, ["https://example.com/notes/2"]); + deepStrictEqual(missing.map((f) => f.operation), ["fetch"]); +}); + +test("lookupObject() keeps looking after the observer refuses a portable candidate", async () => { + const id = `ap://${OBSERVER_DID}/notes/1`; + const gateways = ["https://gw1.example", "https://gw2.example"]; + const documentLoader: DocumentLoader = (url) => + Promise.resolve({ + contextUrl: null, + document: { + "@context": "https://www.w3.org/ns/activitystreams", + id, + type: "Note", + content: url.startsWith("https://gw1.example/") ? "first" : "second", + }, + documentUrl: url, + }); + const verifier: PortableObjectVerifier = () => + Promise.resolve({ verified: true }); + const offered: string[] = []; + const failures: DereferenceFailure[] = []; + const object = await lookupObject(id, { + documentLoader, + contextLoader: mockDocumentLoader, + gateways, + verifyPortableObject: verifier, + [lookupObserver]: { + accept: (candidate) => { + offered.push(candidate.documentUrl); + return Promise.resolve(offered.length > 1); + }, + failed: (failure) => failures.push(failure), + }, + }); + ok(object instanceof Note); + equal(object.content, "second"); + equal(offered.length, 2); + ok(offered[0].startsWith("https://gw1.example/")); + ok(offered[1].startsWith("https://gw2.example/")); + deepStrictEqual(failures.map((f) => f.operation), ["accept"]); +}); diff --git a/packages/vocab/src/lookup.ts b/packages/vocab/src/lookup.ts index 53181fe8a..a8e5555a2 100644 --- a/packages/vocab/src/lookup.ts +++ b/packages/vocab/src/lookup.ts @@ -13,7 +13,9 @@ import { type RemoteDocument, } from "@fedify/vocab-runtime"; import { + acceptCandidate, createSnapshotContextLoader, + type DereferenceObserver, dereferencePortableIri, getPortableGatewayCandidates, getPortableResponseClaim, @@ -123,6 +125,38 @@ const logger = getLogger(["fedify", "vocab", "lookup"]); * * @since 0.2.0 */ +/** + * The key of the {@link LookupObjectOptions} property that observes how + * {@link lookupObject} selects the object it returns. + * + * @internal Technically exported for other Fedify packages, but not part of + * the public API contract. This is not considered public API for Semantic + * Versioning decisions. + */ +export const lookupObserver: unique symbol = Symbol.for( + "@fedify/vocab/lookupObserver", +); + +/** + * Observes how {@link lookupObject} selects the object it returns: each + * candidate is offered to {@link DereferenceObserver.accept} at the boundary + * that would otherwise return it, both for ordinary documents and for + * FEP-ef61 portable objects, and each failure that does not propagate is + * reported to {@link DereferenceObserver.failed}. The observer is not passed + * on to the returned object. + * + * @internal Technically exported for other Fedify packages, but not part of + * the public API contract. This is not considered public API for Semantic + * Versioning decisions. + */ +export interface LookupObserver extends DereferenceObserver { + /** + * Whether to fall back to WebFinger when an HTTP(S) URL cannot be fetched. + * Defaults to `true`. + */ + readonly webFinger?: boolean; +} + export interface LookupObjectOptions { /** * The document loader for loading remote JSON-LD documents. @@ -226,6 +260,12 @@ export interface LookupObjectOptions { * @since 2.4.0 */ verifyPortableObject?: PortableObjectVerifier; + + /** + * See {@link LookupObserver}. + * @internal + */ + [lookupObserver]?: LookupObserver; } /** @@ -395,8 +435,12 @@ async function lookupObjectInternal( }); } catch (error) { logger.debug("Failed to fetch remote document:\n{error}", { error }); + options[lookupObserver]?.failed?.({ operation: "fetch", error }); } } + if (remoteDoc == null && options[lookupObserver]?.webFinger === false) { + return null; + } if (remoteDoc == null) { const jrd = await lookupWebFinger(identifier, { userAgent: options.userAgent, @@ -467,12 +511,13 @@ async function lookupObjectInternal( if (remoteDoc == null) return null; // With verifyPortableObject, the document may turn out to be a portable // object, which has to be verified with the same context documents: - const snapshot = options.verifyPortableObject == null - ? null - : createSnapshotContextLoader( - options.contextLoader ?? - getDocumentLoader({ userAgent: options.userAgent }), - ); + const snapshot = + options.verifyPortableObject == null && options[lookupObserver] == null + ? null + : createSnapshotContextLoader( + options.contextLoader ?? + getDocumentLoader({ userAgent: options.userAgent }), + ); try { let object: Object; let documentUrl: URL; @@ -486,6 +531,7 @@ async function lookupObjectInternal( baseUrl: documentUrl, }); } catch (error) { + options[lookupObserver]?.failed?.({ operation: "parse", error }); if (error instanceof TypeError) { logger.debug( "Failed to parse JSON-LD document: {error}\n{document}", @@ -495,7 +541,7 @@ async function lookupObjectInternal( } throw error; } - if (snapshot != null) { + if (options.verifyPortableObject != null && snapshot != null) { // A document whose final URL or @id stands for a portable object is // verified as one instead of being trusted because of its origin: const claim = getPortableResponseClaim(remoteDoc.documentUrl, object.id); @@ -540,6 +586,19 @@ async function lookupObjectInternal( 'implications, set the crossOrigin option to "trust".', { ...remoteDoc, objectId: object.id.href }, ); + options[lookupObserver]?.failed?.({ operation: "origin" }); + return null; + } + if ( + !await acceptCandidate(options[lookupObserver], { + document: remoteDoc.document, + documentUrl: remoteDoc.documentUrl, + baseUrl: documentUrl, + contextLoader: snapshot?.loader ?? options.contextLoader ?? + getDocumentLoader({ userAgent: options.userAgent }), + object, + }) + ) { return null; } return object; @@ -728,6 +787,7 @@ async function dereference( verifyPortableObject: options.verifyPortableObject, crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore", signal: options.signal, + observer: options[lookupObserver], parse: (document, { contextLoader, baseUrl }) => Object.fromJsonLd(document, { documentLoader, From f55d1940586042fb3ae31595d535184408c1cb48 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 13 Oct 2026 23:41:11 +0900 Subject: [PATCH 2/5] Queue only the contexts an interpretation uses The verified view of an inbox activity serialized every context response the delivery's snapshot had recorded for its policy, including those loaded by other steps such as parsing key documents. Queued messages carried responses they never needed, which also made them reach maxMessageSize, and be processed in-request, sooner. The view now records the URLs its own expansion requests and keeps only those entries. The snapshot loader also drops a response's `tag`: jsonld.js caches a context tagged "static" across operations without asking the loader again, which would leave it out of the recorded URLs and out of the snapshot. https://github.com/fedify-dev/fedify/pull/1299#discussion_r4259490883 Assisted-by: Claude Code:claude-opus-5-5 --- .../fedify/src/federation/inbox-view.test.ts | 60 +++++++++++++++++++ packages/fedify/src/federation/inbox-view.ts | 18 +++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/federation/inbox-view.test.ts b/packages/fedify/src/federation/inbox-view.test.ts index bb25a4dbf..3b26de311 100644 --- a/packages/fedify/src/federation/inbox-view.test.ts +++ b/packages/fedify/src/federation/inbox-view.test.ts @@ -318,3 +318,63 @@ test("restoreVerifiedActivityView() survives changes to unused bundled terms", a }); assertEquals(restored.expanded, expanded); }); + +test("buildVerifiedActivityView() keeps only the contexts it uses", async () => { + const { loader } = createMutableLoader(); + const snapshot = createContextSnapshot(loader); + // Another step of the delivery, e.g., parsing a key document, loads + // a context through the same snapshot: + await snapshot.loader("standard")("https://w3id.org/security/v1"); + const { view } = await buildVerifiedActivityView(snapshot, { + policy: "standard", + source: "activity", + document: { + "@context": [AS, CUSTOM], + id: "https://example.com/activities/1", + type: "Create", + tgt: "https://example.com/notes/1", + }, + baseUrl: null, + }); + assertEquals( + view.contexts.map((entry) => entry.url).sort(), + [ + CUSTOM, + AS, + ].sort(), + ); +}); + +test("createContextSnapshot() keeps contexts out of jsonld.js's shared cache", async () => { + let loads = 0; + const document = { + "@context": [AS, "https://remote.example/contexts/static"], + id: "https://example.com/activities/static", + type: "Create", + }; + const tagging: DocumentLoader = (url, options) => { + if (url !== "https://remote.example/contexts/static") { + return getDocumentLoader()(url, options); + } + loads++; + // A response tagged "static" is cached by jsonld.js across operations: + return Promise.resolve({ + contextUrl: null, + documentUrl: url, + document: { "@context": { x: "https://example.com/x" } }, + tag: "static", + } as Awaited>); + }; + for (let i = 0; i < 2; i++) { + const { view } = await buildVerifiedActivityView( + createContextSnapshot(tagging), + { policy: "standard", source: "activity", document, baseUrl: null }, + ); + assert( + view.contexts.some((entry) => + entry.url === "https://remote.example/contexts/static" + ), + ); + } + assertEquals(loads, 2); +}); diff --git a/packages/fedify/src/federation/inbox-view.ts b/packages/fedify/src/federation/inbox-view.ts index 23e6486fc..0ce42c533 100644 --- a/packages/fedify/src/federation/inbox-view.ts +++ b/packages/fedify/src/federation/inbox-view.ts @@ -131,7 +131,10 @@ export function createContextSnapshot( // delivery must not succeed later with another document. promise = Promise.resolve(effective(url, options)).then( (response) => { - const cloned = structuredClone(response); + // A `tag` would let jsonld.js cache the context across + // operations without asking this loader again, which would + // keep it out of the snapshot: + const cloned = normalizeResponse(structuredClone(response)); recorded.set(key, { policy, url: normalizedUrl, @@ -494,12 +497,21 @@ export async function buildVerifiedActivityView( view: SerializedVerifiedActivityView; expanded: Record[]; }> { + // Only the responses this interpretation uses are kept, not those that + // other steps of the delivery loaded, e.g., for key documents: + const loader = snapshot.loader(source.policy); + const used = new Set(); const expanded = await expandDocument( source.document, - snapshot.loader(source.policy), + markEffectiveContextLoader((url, options) => { + used.add(normalizeUrl(url)); + return loader(url, options); + }, source.policy), ); const contexts = await serializeContextSnapshot( - snapshot.entries().filter((entry) => entry.policy === source.policy), + snapshot.entries().filter((entry) => + entry.policy === source.policy && used.has(entry.url) + ), ); return { view: { From 123f1f1421d6cada46f06a8eb86f88377d7d90bb Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 13 Oct 2026 23:42:37 +0900 Subject: [PATCH 3/5] Link FEP-7aa9 in the changelog entries The new entries referred to FEP-7aa9 without defining the link, so the generated changelog escaped the brackets and showed them literally. Sacho's links front matter only takes reference numbers, so the fragments define the link in their bodies, as other FEP references do. CHANGES.md keeps a single definition of each label, so the copy in the 2.4.0 section gives way to the new one. https://github.com/fedify-dev/fedify/pull/1299#discussion_r4259490872 Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- CHANGES.md | 6 +++--- changes.d/fedify/inbox-authentication.md | 2 ++ .../interaction-controls/actorless-feature-requests.md | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6ca5bcd04..29ff92b44 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -21,7 +21,7 @@ To be released. `InboxAuthenticationEvidence` types describe the evidence. [[#1290], [#1299]] - Added the `allowActorless` option to `InboxListenerSetters.on()`, so that - activities declaring no `actor` at all, such as the \[FEP-7aa9\] + activities declaring no `actor` at all, such as the [FEP-7aa9] `FeatureRequest` activities Mastodon sends, can reach a listener. Such an activity is dispatched only if Fedify authenticated a principal for it and its `id` has the same origin as that principal; the listener must authorize @@ -67,6 +67,7 @@ To be released. log with the specific activity it belongs to. [[#1030], [#1205] by Yujin Jung\] +[FEP-7aa9]: https://w3id.org/fep/7aa9 [GHSA-4qhx-hhwf-8685]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-4qhx-hhwf-8685 [#1030]: https://github.com/fedify-dev/fedify/issues/1030 [#1177]: https://github.com/fedify-dev/fedify/issues/1177 @@ -164,7 +165,7 @@ To be released. embeds the authorization in the `Delete` activity with only the IDs of its interacting object and interaction target. - - `featureInteraction.verifyRequest()` can now verify the \[FEP-7aa9\] + - `featureInteraction.verifyRequest()` can now verify the [FEP-7aa9] `FeatureRequest` activities Mastodon sends without an `actor`. Pass the inbox listener's `InboxContext.authentication` as the new `authentication` option; the requester is then the owner of the featured collection, who @@ -1273,7 +1274,6 @@ Released on October 1, 2026. [GoToSocial interaction controls]: https://docs.gotosocial.org/en/v0.21.1/federation/interaction_controls/ [FEP-044f]: https://w3id.org/fep/044f -[FEP-7aa9]: https://w3id.org/fep/7aa9 [#811]: https://github.com/fedify-dev/fedify/issues/811 [#929]: https://github.com/fedify-dev/fedify/pull/929 diff --git a/changes.d/fedify/inbox-authentication.md b/changes.d/fedify/inbox-authentication.md index b69fc85f5..d61958e31 100644 --- a/changes.d/fedify/inbox-authentication.md +++ b/changes.d/fedify/inbox-authentication.md @@ -44,3 +44,5 @@ links: `idempotency` strategy, as direct processing already did, instead of always deduplicating them per origin. Activities recorded by workers of older versions are still recognized. [[#1290], [#1299]] + +[FEP-7aa9]: https://w3id.org/fep/7aa9 diff --git a/changes.d/interaction-controls/actorless-feature-requests.md b/changes.d/interaction-controls/actorless-feature-requests.md index eb39c7b56..2b4d8764d 100644 --- a/changes.d/interaction-controls/actorless-feature-requests.md +++ b/changes.d/interaction-controls/actorless-feature-requests.md @@ -22,3 +22,5 @@ links: `missingActor`. New failure types `credentialsInUrl`, `invalidInteractingObject`, `ambiguousRequest`, and `unauthenticatedRequester` describe the new checks. [[#1290], [#1299]] + +[FEP-7aa9]: https://w3id.org/fep/7aa9 From ec55aa2215644961b8411faca3d0ffe221e362ed Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 15 Oct 2026 20:19:50 +0900 Subject: [PATCH 4/5] Process activities whose contexts can't be queued jsonld.js keeps a context that a loader tagged "static" in a cache shared across operations, and takes it from there without calling the loader again. When an activity's interpretation used such a context, the inbox never saw it being loaded, so the verified view left it out, and a queue worker in another process could not replay the activity. Stripping the tag in the snapshot's own loader does not help when other code put the context in that cache first. jsonld.js offers no way to bypass the shared cache per call, so the inbox now checks instead whether the view covers every remote context that the activity and its recorded contexts refer to, including scoped contexts and @import. If it does not, the activity is processed right away, with the interpretation that was authenticated, instead of being queued. https://github.com/fedify-dev/fedify/pull/1299#discussion_r4282062714 Assisted-by: Claude Code:claude-opus-5-5 --- CHANGES.md | 6 +- changes.d/fedify/inbox-authentication.md | 6 +- packages/fedify/src/federation/handler.ts | 1 + .../src/federation/inbox-actorless.test.ts | 35 +++++++++ .../fedify/src/federation/inbox-view.test.ts | 45 ++++++++++- packages/fedify/src/federation/inbox-view.ts | 77 ++++++++++++++++++- packages/fedify/src/federation/inbox.ts | 20 ++++- packages/fedify/src/federation/middleware.ts | 1 + 8 files changed, 181 insertions(+), 10 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 29ff92b44..c646f3bb9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -37,8 +37,10 @@ To be released. in queued inbox messages. Queue workers and their retries no longer load remote JSON-LD contexts again for such messages, so a remote context that changes in the meantime cannot make a listener see an activity other than - the one that was authenticated. Messages queued by older versions are - processed as before. [[#1290], [#1299]] + the one that was authenticated. An activity using a context that cannot + be kept this way, such as one a custom context loader marks as `static`, + is processed right away instead of being queued. Messages queued by + older versions are processed as before. [[#1290], [#1299]] - Added the `MessageQueue.maxMessageSize` property. When an incoming activity would make an inbox message larger than the queue can hold, it is now processed right away instead of being enqueued. [[#1290], [#1299]] diff --git a/changes.d/fedify/inbox-authentication.md b/changes.d/fedify/inbox-authentication.md index d61958e31..9075d3ea8 100644 --- a/changes.d/fedify/inbox-authentication.md +++ b/changes.d/fedify/inbox-authentication.md @@ -33,8 +33,10 @@ links: in queued inbox messages. Queue workers and their retries no longer load remote JSON-LD contexts again for such messages, so a remote context that changes in the meantime cannot make a listener see an activity other than - the one that was authenticated. Messages queued by older versions are - processed as before. [[#1290], [#1299]] + the one that was authenticated. An activity using a context that cannot + be kept this way, such as one a custom context loader marks as `static`, + is processed right away instead of being queued. Messages queued by + older versions are processed as before. [[#1290], [#1299]] - Added the `MessageQueue.maxMessageSize` property. When an incoming activity would make an inbox message larger than the queue can hold, it is diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 77f315d39..175022265 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -3012,6 +3012,7 @@ async function handleInboxInternal( baseUrl: null, }), verifiedView: view.view, + replayable: view.replayable, actorless, activity, recipient, diff --git a/packages/fedify/src/federation/inbox-actorless.test.ts b/packages/fedify/src/federation/inbox-actorless.test.ts index cc947f535..d0d3bddd3 100644 --- a/packages/fedify/src/federation/inbox-actorless.test.ts +++ b/packages/fedify/src/federation/inbox-actorless.test.ts @@ -24,6 +24,7 @@ import { isActorAuthenticated, } from "./inbox-authentication.ts"; import { MemoryKvStore } from "./kv.ts"; +import { expandDocument } from "./inbox-view.ts"; import { createFederation, type InboxContextImpl } from "./middleware.ts"; import type { MessageQueue } from "./mq.ts"; import type { InboxMessage, Message } from "./queue.ts"; @@ -732,3 +733,37 @@ for (const queued of [false, true]) { ); }); } + +test("Activities whose contexts cannot be kept are processed immediately", async () => { + const queue = new CapturingQueue(); + const { federation, received } = setup({ queue }); + const cached = "https://remote.example/contexts/static-shared"; + // Some other code expanded a document with a loader that tags this context + // "static", so jsonld.js keeps it in a cache shared across operations, and + // the inbox never asks its loader for it: + await expandDocument( + { "@context": cached, "z": "z" }, + (url, options) => + url === cached + ? Promise.resolve({ + contextUrl: null, + documentUrl: url, + document: { "@context": { z: "https://example.com/z" } }, + tag: "static", + } as RemoteDocument) + : mockDocumentLoader(url, options), + ); + const document = featureRequestDocument({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/fep/7aa9", + cached, + ], + }); + const response = await federation.fetch(await postSigned(document), { + contextData: undefined, + }); + assertEquals(response.status, 202); + assertEquals(queue.messages, []); + assertEquals(received.length, 1); +}); diff --git a/packages/fedify/src/federation/inbox-view.test.ts b/packages/fedify/src/federation/inbox-view.test.ts index 3b26de311..40c9c4b52 100644 --- a/packages/fedify/src/federation/inbox-view.test.ts +++ b/packages/fedify/src/federation/inbox-view.test.ts @@ -4,7 +4,7 @@ import { getDocumentLoader, preloadedContexts, } from "@fedify/vocab-runtime"; -import { assert, assertEquals, assertRejects } from "@std/assert"; +import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert"; import { buildVerifiedActivityView, ContextSnapshotMissError, @@ -378,3 +378,46 @@ test("createContextSnapshot() keeps contexts out of jsonld.js's shared cache", a } assertEquals(loads, 2); }); + +test("buildVerifiedActivityView() tells contexts it could not record", async () => { + const cached = "https://remote.example/contexts/shared-cache"; + const preloaded = getDocumentLoader(); + // Some other code expands a document with a loader that tags a context + // "static", so jsonld.js keeps it in a cache shared across operations: + await expandDocument( + { "@context": cached, "y": "z" }, + (url, options) => + url === cached + ? Promise.resolve({ + contextUrl: null, + documentUrl: url, + document: { "@context": { y: "https://example.com/y" } }, + tag: "static", + } as Awaited>) + : preloaded(url, options), + ); + const build = (document: unknown) => + buildVerifiedActivityView(createContextSnapshot(preloaded), { + policy: "standard", + source: "activity", + document, + baseUrl: null, + }); + // The expansion takes the context from that cache without asking the + // snapshot, so a worker in another process could not replay it: + const { replayable, view } = await build({ + "@context": [AS, cached], + id: "https://example.com/activities/cached", + type: "Create", + }); + assertEquals(replayable, false); + assertFalse(view.contexts.some((entry) => entry.url === cached)); + assertEquals( + (await build({ + "@context": AS, + id: "https://example.com/activities/plain", + type: "Create", + })).replayable, + true, + ); +}); diff --git a/packages/fedify/src/federation/inbox-view.ts b/packages/fedify/src/federation/inbox-view.ts index 0ce42c533..7b461886d 100644 --- a/packages/fedify/src/federation/inbox-view.ts +++ b/packages/fedify/src/federation/inbox-view.ts @@ -496,6 +496,15 @@ export async function buildVerifiedActivityView( ): Promise<{ view: SerializedVerifiedActivityView; expanded: Record[]; + /** + * Whether the view has every remote context the interpretation refers + * to, so that a queue worker in another process can replay it. It is + * `false` when jsonld.js took a context from its cache shared across + * operations without asking the loader, which happens to contexts that + * a loader tagged `"static"`; the view then cannot tell which document + * the interpretation used. + */ + replayable: boolean; }> { // Only the responses this interpretation uses are kept, not those that // other steps of the delivery loaded, e.g., for key documents: @@ -508,12 +517,28 @@ export async function buildVerifiedActivityView( return loader(url, options); }, source.policy), ); - const contexts = await serializeContextSnapshot( - snapshot.entries().filter((entry) => - entry.policy === source.policy && used.has(entry.url) - ), + const entries = snapshot.entries().filter((entry) => + entry.policy === source.policy && used.has(entry.url) + ); + const contexts = await serializeContextSnapshot(entries); + // Every context the document and the recorded contexts refer to has to be + // one the expansion actually asked for: + const references = new Set(); + collectContextReferences( + source.document, + source.baseUrl?.href, + references, ); + for (const entry of entries) { + collectContextReferences( + entry.response.document, + entry.response.documentUrl, + references, + ); + } + const replayable = [...references].every((url) => used.has(url)); return { + replayable, view: { version: 1, policy: source.policy, @@ -526,6 +551,50 @@ export async function buildVerifiedActivityView( }; } +/** + * Collects the remote context references of a JSON-LD document or context + * document: every IRI given as a value of `@context` at any depth, including + * scoped contexts of term definitions, and of `@import`. + * @param value The document. + * @param base The URL to resolve relative references against. + * @param references The set to add the normalized references to. + */ +function collectContextReferences( + value: unknown, + base: string | undefined, + references: Set, +): void { + const add = (reference: string) => { + let url = reference; + if (base != null && URL.canParse(reference, base)) { + url = new URL(reference, base).href; + } + references.add(normalizeUrl(url)); + }; + const collectContext = (context: unknown): void => { + if (typeof context === "string") { + add(context); + } else if (Array.isArray(context)) { + for (const item of context) collectContext(item); + } else if (context != null && typeof context === "object") { + const imported = (context as Record)["@import"]; + if (typeof imported === "string") add(imported); + walk(context); + } + }; + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const item of node) walk(item); + } else if (node != null && typeof node === "object") { + for (const [key, item] of Object.entries(node)) { + if (key === "@context") collectContext(item); + else walk(item); + } + } + }; + walk(value); +} + /** * Validates the shape of a serialized view taken from a queued message. * @param value The value of the message's `verifiedView` field. diff --git a/packages/fedify/src/federation/inbox.ts b/packages/fedify/src/federation/inbox.ts index 12cb4c515..e07be771d 100644 --- a/packages/fedify/src/federation/inbox.ts +++ b/packages/fedify/src/federation/inbox.ts @@ -293,6 +293,13 @@ export interface RouteActivityParameters { * @internal */ verifiedView?: SerializedVerifiedActivityView; + /** + * Whether a queue worker can replay {@link verifiedView}. When it is + * `false`, the activity is processed right away instead of being queued, + * as if there were no queue. + * @internal + */ + replayable?: boolean; /** * Whether the activity declares no actor and has been admitted for * a listener that allows such activities. @@ -342,6 +349,7 @@ export async function routeActivity( ldSignatureVerified, authentication, verifiedView, + replayable, actorless, activity, recipient, @@ -438,7 +446,17 @@ export async function routeActivity( traceContext: carrier, } satisfies InboxMessage; const maxMessageSize = queue.maxMessageSize; - if ( + if (replayable === false) { + // A worker could not interpret the activity the way it was + // authenticated, so it is processed while that interpretation is at + // hand: + logger.warn( + "The incoming activity {activityId} uses a JSON-LD context that " + + "cannot be kept for the queue; processing it immediately instead.", + { activityId: activity.id?.href, recipient }, + ); + message = undefined; + } else if ( maxMessageSize != null && estimateQueuedMessageSize(message) > maxMessageSize - QUEUED_MESSAGE_HEADROOM diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index aa578542a..d9dbfe972 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -6298,6 +6298,7 @@ export class ContextImpl implements Context { baseUrl: viewSource.baseUrl, }), verifiedView: view.view, + replayable: view.replayable, activity, recipient, inboxListeners: this.federation.inboxListeners, From ae36326a317b3782fcc80497610dadcbecab9ffe Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 16 Oct 2026 14:01:58 +0900 Subject: [PATCH 5/5] Check contexts linked from context documents jsonld.js appends the context a context document's Link header names to that document's @context. The replayability check only looked at the @context values inside the documents, so a linked context taken from jsonld.js's shared cache went unnoticed: the view left it out yet claimed it was replayable, and a worker in another process failed with jsonld.InvalidUrl. The check now counts the contextUrl of each recorded response too. References in a context document are also resolved against the URL the context was requested with, as jsonld.js does, rather than the document URL the loader returned. https://github.com/fedify-dev/fedify/pull/1299#discussion_r4290950634 Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- .../fedify/src/federation/inbox-view.test.ts | 44 +++++++++++++++++++ packages/fedify/src/federation/inbox-view.ts | 16 ++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/fedify/src/federation/inbox-view.test.ts b/packages/fedify/src/federation/inbox-view.test.ts index 40c9c4b52..7b99b99c6 100644 --- a/packages/fedify/src/federation/inbox-view.test.ts +++ b/packages/fedify/src/federation/inbox-view.test.ts @@ -421,3 +421,47 @@ test("buildVerifiedActivityView() tells contexts it could not record", async () true, ); }); + +test("buildVerifiedActivityView() tells contexts linked from a context", async () => { + const cached = "https://remote.example/contexts/linked-shared-cache"; + const linking = "https://remote.example/contexts/linking"; + const preloaded = getDocumentLoader(); + await expandDocument( + { "@context": cached, "w": "z" }, + (url, options) => + url === cached + ? Promise.resolve({ + contextUrl: null, + documentUrl: url, + document: { "@context": { w: "https://example.com/w" } }, + tag: "static", + } as Awaited>) + : preloaded(url, options), + ); + // The snapshot serves a context whose Link header names the cached one, + // which jsonld.js appends to it and takes from its shared cache: + const { replayable, view } = await buildVerifiedActivityView( + createContextSnapshot((url, options) => + url === linking + ? Promise.resolve({ + contextUrl: cached, + documentUrl: url, + document: { "@context": { v: "https://example.com/v" } }, + }) + : preloaded(url, options) + ), + { + policy: "standard", + source: "activity", + document: { + "@context": [AS, linking], + id: "https://example.com/activities/linked", + type: "Create", + }, + baseUrl: null, + }, + ); + assert(view.contexts.some((entry) => entry.url === linking)); + assertFalse(view.contexts.some((entry) => entry.url === cached)); + assertEquals(replayable, false); +}); diff --git a/packages/fedify/src/federation/inbox-view.ts b/packages/fedify/src/federation/inbox-view.ts index 7b461886d..82c28d5ee 100644 --- a/packages/fedify/src/federation/inbox-view.ts +++ b/packages/fedify/src/federation/inbox-view.ts @@ -530,11 +530,17 @@ export async function buildVerifiedActivityView( references, ); for (const entry of entries) { - collectContextReferences( - entry.response.document, - entry.response.documentUrl, - references, - ); + // jsonld.js resolves a context document's references against the URL it + // asked for, and appends the context its `Link` header gives: + const { contextUrl, document } = entry.response; + collectContextReferences(document, entry.url, references); + if (contextUrl != null) { + collectContextReferences( + { "@context": contextUrl }, + entry.url, + references, + ); + } } const replayable = [...references].every((url) => used.has(url)); return {