Skip to content

RFC 9421 outbound negotiation: implement Accept-Signature parsing and challenge-driven re-signing in doubleKnock #583

@dahlia

Description

@dahlia

Background

Fedify already implements HTTP Message Signatures from RFC 9421, but it does not yet implement Accept-Signature negotiation from RFC 9421 §5, RFC 9421 §5.1, and RFC 9421 §5.2.

Current implementation references

  • doubleKnock() currently retries by status code and does not parse or apply Accept-Signature challenge metadata:
    export async function doubleKnock(
    request: Request,
    identity: { keyId: URL; privateKey: CryptoKey },
    options: DoubleKnockOptions = {},
    ): Promise<Response> {
    const { specDeterminer, log, tracerProvider, signal } = options;
    const origin = new URL(request.url).origin;
    const firstTrySpec: HttpMessageSignaturesSpec = specDeterminer == null
    ? "rfc9421"
    : await specDeterminer.determineSpec(origin);
    // Get the request body once at the top level to avoid multiple clones
    const body = options.body !== undefined
    ? options.body
    : request.method !== "GET" && request.method !== "HEAD"
    ? await request.clone().arrayBuffer()
    : null;
    let signedRequest = await signRequest(
    request,
    identity.privateKey,
    identity.keyId,
    { spec: firstTrySpec, tracerProvider, body },
    );
    log?.(signedRequest);
    let response = await fetch(signedRequest, {
    // Since Bun has a bug that ignores the `Request.redirect` option,
    // to work around it we specify `redirect: "manual"` here too:
    // https://github.com/oven-sh/bun/issues/10754
    redirect: "manual",
    signal,
    });
    // Follow redirects manually to get the final URL:
    if (
    response.status >= 300 && response.status < 400 &&
    response.headers.has("Location")
    ) {
    const location = response.headers.get("Location")!;
    return doubleKnock(
    createRedirectRequest(request, location, body),
    identity,
    { ...options, body },
    );
    } else if (
    // FIXME: Temporary hotfix for Mastodon RFC 9421 implementation bug (as of 2025-06-19).
    // Some Mastodon servers (including mastodon.social) are running bleeding edge versions
    // with RFC 9421 support that have a bug causing 500 Internal Server Error when receiving
    // RFC 9421 signatures. This extends double-knocking to 5xx errors as a workaround,
    // allowing fallback to draft-cavage signatures. This should be reverted once Mastodon
    // fixes their RFC 9421 implementation and affected servers are updated.
    response.status === 400 || response.status === 401 || response.status > 401
    ) {
    // verification failed; retry with the other spec of HTTP Signatures
    // (double-knocking; see https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions)
    const spec = firstTrySpec === "draft-cavage-http-signatures-12"
    ? "rfc9421"
    : "draft-cavage-http-signatures-12";
    getLogger(["fedify", "sig", "http"]).debug(
    "Failed to verify with the spec {spec} ({status} {statusText}); retrying with spec {secondSpec}... (double-knocking)",
    {
    spec: firstTrySpec,
    secondSpec: spec,
    status: response.status,
    statusText: response.statusText,
    },
    );
    signedRequest = await signRequest(
    request,
    identity.privateKey,
    identity.keyId,
    { spec, tracerProvider, body },
    );
    log?.(signedRequest);
    response = await fetch(signedRequest, {
    // Since Bun has a bug that ignores the `Request.redirect` option,
    // to work around it we specify `redirect: "manual"` here too:
    // https://github.com/oven-sh/bun/issues/10754
    redirect: "manual",
    signal,
    });
    // Follow redirects manually to get the final URL:
    if (
    response.status >= 300 && response.status < 400 &&
    response.headers.has("Location")
    ) {
    const location = response.headers.get("Location")!;
    return doubleKnock(
    createRedirectRequest(request, location, body),
    identity,
    { ...options, body },
    );
    } else if (response.status !== 400 && response.status !== 401) {
    await specDeterminer?.rememberSpec(origin, spec);
    }
    } else {
    await specDeterminer?.rememberSpec(origin, firstTrySpec);
    }
    return response;
  • Retry condition and fallback branch are status-based today:
    } else if (
    // FIXME: Temporary hotfix for Mastodon RFC 9421 implementation bug (as of 2025-06-19).
    // Some Mastodon servers (including mastodon.social) are running bleeding edge versions
    // with RFC 9421 support that have a bug causing 500 Internal Server Error when receiving
    // RFC 9421 signatures. This extends double-knocking to 5xx errors as a workaround,
    // allowing fallback to draft-cavage signatures. This should be reverted once Mastodon
    // fixes their RFC 9421 implementation and affected servers are updated.
    response.status === 400 || response.status === 401 || response.status > 401
    ) {
    // verification failed; retry with the other spec of HTTP Signatures
    // (double-knocking; see https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions)
    const spec = firstTrySpec === "draft-cavage-http-signatures-12"
    ? "rfc9421"
    : "draft-cavage-http-signatures-12";
    getLogger(["fedify", "sig", "http"]).debug(
  • RFC 9421 signature label is effectively fixed (sig1) in current request-signing path:
    export function formatRfc9421Signature(
    signature: ArrayBuffer | Uint8Array,
    components: string[],
    parameters: string,
    ): [string, string] {
    const signatureInputValue = `sig1=("${
    components.join('" "')
    }");${parameters}`;
    const signatureValue = `sig1=:${encodeBase64(signature)}:`;
    return [signatureInputValue, signatureValue];
    }
  • RFC 9421 covered components are currently fixed in request signing:
    // Define components to include in the signature
    const components = [
    "@method",
    "@target-uri",
    "@authority",
    "host",
    "date",
    ];
    if (body != null) {
    components.push("content-digest");
    }
    // Generate the signature base using the headers
    const signatureParams = formatRfc9421SignatureParameters({
    algorithm: "rsa-v1_5-sha256",
    keyId,
    created,
    });
    let signatureBase: string;
    try {
    signatureBase = createRfc9421SignatureBase(
    new Request(request.url, {
    method: request.method,
    headers,
    }),
    components,
    signatureParams,
    );
    } catch (error) {
    throw new TypeError(
    `Failed to create signature base: ${String(error)}; it is probably ` +
    `a bug in the implementation. Please report it at Fedify's issue tracker.`,
    );
    }
    // Sign the signature base
    const signatureBytes = await crypto.subtle.sign(
    "RSASSA-PKCS1-v1_5",
    privateKey,
    new TextEncoder().encode(signatureBase),
    );
    // Format the signature according to RFC 9421
    const [signatureInput, signature] = formatRfc9421Signature(
    signatureBytes,
    components,
    signatureParams,
    );
    // Add the signature headers
    headers.set("Signature-Input", signatureInput);
    headers.set("Signature", signature);
  • Existing parsing helpers are focused on Signature-Input and are not a dedicated Accept-Signature parser per RFC 9421 §5.1:
    export function parseRfc9421SignatureInput(
    signatureInput: string,
    ): Record<
    string,
    {
    keyId: string;
    alg?: string;
    created: number;
    components: string[];
    parameters: string;
    }
    > {
    let dict: Dictionary;
    try {
    dict = decodeDict(signatureInput);
    } catch (error) {
    getLogger(["fedify", "sig", "http"]).debug(
    "Failed to parse Signature-Input header: {signatureInput}",
    { signatureInput, error },
    );
    return {};
    }
    const result: Record<
    string,
    {
    keyId: string;
    alg?: string;
    created: number;
    components: string[];
    parameters: string;
    }
    > = {};
    for (const [label, item] of Object.entries(dict) as [string, Item][]) {
    if (
    !Array.isArray(item.value) ||
    typeof item.params.keyid !== "string" ||
    typeof item.params.created !== "number"
    ) continue;
    const components = item.value
    .map((subitem: Item) => subitem.value)
    .filter((v) => typeof v === "string");
    const params = encodeItem(new Item(0, item.params));
    result[label] = {
    keyId: item.params.keyid,
    alg: item.params.alg,
    created: item.params.created,
    components,
    parameters: params.slice(params.indexOf(";") + 1),
    };
    }
    return result;
    }
  • Documentation currently explains double-knocking as two-spec fallback and does not cover challenge-aware negotiation from RFC 9421 §5.2:

    fedify/docs/manual/send.md

    Lines 973 to 980 in 0ce42c7

    To support both versions of HTTP Signatures, Fedify uses the [double-knocking]
    mechanism: trying one version, then falling back to another if rejected.
    If it's the first encounter with the recipient server, Fedify tries
    the RFC 9421 version first, and if it fails, it falls back to the draft
    cavage version. If the recipient server accepts the RFC 9421 version,
    Fedify remembers it and uses the RFC 9421 version for the next time.
    If the recipient server rejects the RFC 9421 version, Fedify falls back
    to the draft cavage version and remembers it for the next time.

Problem statement

When a remote server requests specific covered components or metadata parameters such as nonce, tag, alg, or keyid (see RFC 9421 §2.3 and RFC 9421 §5.1), Fedify cannot currently adapt the retry signature shape and may fail even when an interoperable retry is possible.

Goal

Add outbound Accept-Signature-aware negotiation so doubleKnock() can consume challenge metadata and retry with a compatible RFC 9421 signature before applying legacy fallback behavior.

Scope

This issue covers stage 1-3 only and is limited to outbound behavior.

Proposed work

  1. Add an Accept-Signature parser and internal model for Dictionary Structured Field members from RFC 9421 §5.1, including label, covered components, and requested metadata parameters.
  2. Add validation for target-message applicability rules from RFC 9421 §5, such as rejecting response-only identifiers like @status for request-target signatures.
  3. Extend RFC 9421 request-signing options to allow challenge-driven label, covered components, and requested metadata parameter inclusion.
  4. Enforce safety constraints so remote-requested alg and keyid are only honored when compatible with local key material and supported algorithms.
  5. Integrate challenge-aware retry into doubleKnock() so relevant failure responses first attempt Accept-Signature-compatible re-signing.
  6. Keep existing fallback behavior when challenge data is missing, invalid, or not fulfillable.
  7. Add loop prevention so challenge-driven retries are bounded per outbound request.
  8. Keep specDeterminer persistence coherent with successful negotiated outcomes:
    export interface HttpMessageSignaturesSpecDeterminer {
    /**
    * Determines the spec to use for signing requests.
    * @param origin The origin of the URL to make the request to.
    * @returns The spec to use for signing requests.
    */
    determineSpec(
    origin: string,
    ): HttpMessageSignaturesSpec | Promise<HttpMessageSignaturesSpec>;
    /**
    * Remembers the successfully used spec for the given origin.
    * @param origin The origin of the URL that was requested.
    * @param spec The spec to remember.
    */
    rememberSpec(
    origin: string,
    spec: HttpMessageSignaturesSpec,
    ): void | Promise<void>;

Out of scope

Server-side challenge emission in inbox 401 responses, response-signing APIs, and request-response binding support from RFC 9421 §2.4 are intentionally excluded and will be tracked in issue #584.

Acceptance criteria

  • Unit tests cover valid and invalid Accept-Signature parsing cases.
  • Signing tests cover challenge-driven label/component/parameter customization.
  • doubleKnock() integration tests verify challenge-aware retry behavior.
  • Regression tests confirm current fallback behavior remains intact when no usable challenge exists.
  • User docs are updated to describe outbound challenge-aware negotiation with links to RFC 9421 §5.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions