Skip to content

RFC 9421 inbound negotiation: emit Accept-Signature challenges on inbox authentication failures #584

@dahlia

Description

@dahlia

Background

This issue is the server-side follow-up to #583 and targets RFC 9421 Accept-Signature usage in HTTP responses so Fedify can request properly signed next client requests as described in RFC 9421 §5, §5.1, and §5.2.

Related issue

Outbound processing of Accept-Signature challenges is tracked separately in #583.

Current implementation references

  • Inbox verification failure currently returns plain 401 without Accept-Signature:
    let httpSigKey: CryptographicKey | null = null;
    if (activity == null) {
    if (!skipSignatureVerification) {
    const key = await verifyRequest(request, {
    contextLoader: ctx.contextLoader,
    documentLoader: ctx.documentLoader,
    timeWindow: signatureTimeWindow,
    keyCache,
    tracerProvider,
    });
    if (key == null) {
    logger.error(
    "Failed to verify the request's HTTP Signatures.",
    { recipient },
    );
    span.setStatus({
    code: SpanStatusCode.ERROR,
    message: `Failed to verify the request's HTTP Signatures.`,
    });
    const response = new Response(
    "Failed to verify the request signature.",
    {
    status: 401,
    headers: { "Content-Type": "text/plain; charset=utf-8" },
    },
    );
    return response;
    } else {
  • Actor/key mismatch currently returns plain 401 without Accept-Signature:
    if (
    httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx)
    ) {
    logger.error(
    "The signer ({keyId}) and the actor ({actorId}) do not match.",
    {
    activity: json,
    recipient,
    keyId: httpSigKey.id?.href,
    actorId: activity.actorId?.href,
    },
    );
    span.setStatus({
    code: SpanStatusCode.ERROR,
    message: `The signer (${httpSigKey.id?.href}) and ` +
    `the actor (${activity.actorId?.href}) do not match.`,
    });
    return new Response("The signer and the actor do not match.", {
    status: 401,
    headers: { "Content-Type": "text/plain; charset=utf-8" },
    });
    }
  • Inbox handler parameters currently have no unauthorized/challenge callback surface:
    export interface InboxHandlerParameters<TContextData> {
    recipient: string | null;
    context: RequestContext<TContextData>;
    inboxContextFactory(
    recipient: string | null,
    activity: unknown,
    activityId: string | undefined,
    activityType: string,
    ): InboxContext<TContextData>;
    kv: KvStore;
    kvPrefixes: {
    activityIdempotence: KvKey;
    publicKey: KvKey;
    };
    queue?: MessageQueue;
    actorDispatcher?: ActorDispatcher<TContextData>;
    inboxListeners?: InboxListenerSet<TContextData>;
    inboxErrorHandler?: InboxErrorHandler<TContextData>;
    onNotFound(request: Request): Response | Promise<Response>;
    signatureTimeWindow: Temporal.Duration | Temporal.DurationLike | false;
    skipSignatureVerification: boolean;
    idempotencyStrategy?:
    | IdempotencyStrategy
    | IdempotencyKeyCallback<TContextData>;
    tracerProvider?: TracerProvider;
    }
  • Middleware wires inbox handling directly through handleInbox(...):
    return await handleInbox(request, {
    recipient: route.values.identifier ?? null,
    context,
    inboxContextFactory: context.toInboxContext.bind(context),
    kv: this.kv,
    kvPrefixes: this.kvPrefixes,
    queue: this.inboxQueue,
    actorDispatcher: this.actorCallbacks?.dispatcher,
    inboxListeners: this.inboxListeners,
    inboxErrorHandler: this.inboxErrorHandler,
    onNotFound,
    signatureTimeWindow: this.signatureTimeWindow,
    skipSignatureVerification: this.skipSignatureVerification,
    tracerProvider: this.tracerProvider,
    idempotencyStrategy: this.idempotencyStrategy,
    });
  • Existing KV prefixes have no dedicated namespace for challenge nonces:
    export interface FederationKvPrefixes {
    /**
    * The key prefix used for storing whether activities have already been
    * processed or not.
    * @default `["_fedify", "activityIdempotence"]`
    */
    readonly activityIdempotence: KvKey;
    /**
    * The key prefix used for storing remote JSON-LD documents.
    * @default `["_fedify", "remoteDocument"]`
    */
    readonly remoteDocument: KvKey;
    /**
    * The key prefix used for caching public keys.
    * @default `["_fedify", "publicKey"]`
    * @since 0.12.0
    */
    readonly publicKey: KvKey;
    /**
    * The key prefix used for caching HTTP Message Signatures specs.
    * The cached spec is used to reduce the number of requests to make signed
    * requests ("double-knocking" technique).
    * @default `["_fedify", "httpMessageSignaturesSpec"]`
    * @since 1.6.0
    */
    readonly httpMessageSignaturesSpec: KvKey;
    }
  • Existing tests currently assert 401 outcomes but no challenge header behavior:
    response = await handleInbox(unsignedRequest, {
    recipient: null,
    context: unsignedContext,
    inboxContextFactory(_activity) {
    return createInboxContext({ ...unsignedContext, clone: undefined });
    },
    ...inboxOptions,
    });
    assertEquals(onNotFoundCalled, null);
    assertEquals(response.status, 401);
    response = await handleInbox(unsignedRequest, {
    recipient: "someone",
    context: unsignedContext,
    inboxContextFactory(_activity) {
    return createInboxContext({
    ...unsignedContext,
    clone: undefined,
    recipient: "someone",
    });
    },
    ...inboxOptions,
    });
    assertEquals(onNotFoundCalled, null);
    assertEquals(response.status, 401);

Problem statement

When inbox authentication fails, Fedify returns 401 without server-side signature negotiation hints, so senders do not learn the expected signature shape and cannot reliably retry with RFC 9421 parameters such as created and, when enabled, nonce from RFC 9421 §5.1.

Goal

Add inbound challenge emission so 401 responses from inbox authentication failures can include Accept-Signature requests that are valid for request messages and actionable by clients.

Scope

This issue covers inbox and shared inbox authentication-failure responses only, and it does not include outbound challenge processing or response-signing APIs.

Proposed work

  1. Add a configurable inbox challenge policy that can emit an Accept-Signature request for the client’s next request according to RFC 9421 §5.
  2. Define a default requested component set that is request-applicable and verifiable by Fedify, and explicitly avoid response-only identifiers such as @status per RFC 9421 §5.
  3. Support challenge metadata parameters from RFC 9421 §5.1, with created requested by default and nonce explicitly optional.
  4. If optional nonce mode is enabled, add nonce storage/consumption with a new KV prefix and bounded TTL for replay resistance.
  5. Attach Accept-Signature on 401 responses for missing/invalid HTTP signature failures, and keep cache behavior explicit (for example Cache-Control: no-store) to avoid stale challenges.
  6. Keep actor/key mismatch handling separate by default, because re-signing does not resolve impersonation, and only challenge that path if an explicit policy opts in.

Out of scope

This issue does not implement outbound Accept-Signature processing (covered by #583), does not add full response-signature support from RFC 9421 §2.4, and does not change non-inbox endpoint authorization flows.

Acceptance criteria

  • Unsigned or invalidly signed inbox POST requests return 401 with an Accept-Signature header when challenge policy is enabled.
  • Emitted Accept-Signature values only use request-applicable covered components and conform to RFC 9421 §5.1.
  • Nonce support remains optional, and nonce checks are enforced only when optional nonce mode is enabled.
  • Existing successful inbox processing remains unchanged for valid signatures.
  • Existing non-challenge behavior remains available when challenge policy is disabled.
  • Documentation is updated to describe inbox 401 challenge behavior and its RFC references.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions