|
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; |
Background
Fedify already implements HTTP Message Signatures from RFC 9421, but it does not yet implement
Accept-Signaturenegotiation 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 applyAccept-Signaturechallenge metadata:fedify/packages/fedify/src/sig/http.ts
Lines 1346 to 1443 in 0ce42c7
fedify/packages/fedify/src/sig/http.ts
Lines 1389 to 1403 in 0ce42c7
sig1) in current request-signing path:fedify/packages/fedify/src/sig/http.ts
Lines 291 to 301 in 0ce42c7
fedify/packages/fedify/src/sig/http.ts
Lines 430 to 483 in 0ce42c7
Signature-Inputand are not a dedicatedAccept-Signatureparser per RFC 9421 §5.1:fedify/packages/fedify/src/sig/http.ts
Lines 308 to 359 in 0ce42c7
fedify/docs/manual/send.md
Lines 973 to 980 in 0ce42c7
Problem statement
When a remote server requests specific covered components or metadata parameters such as
nonce,tag,alg, orkeyid(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 sodoubleKnock()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
Accept-Signatureparser and internal model for Dictionary Structured Field members from RFC 9421 §5.1, including label, covered components, and requested metadata parameters.@statusfor request-target signatures.algandkeyidare only honored when compatible with local key material and supported algorithms.doubleKnock()so relevant failure responses first attemptAccept-Signature-compatible re-signing.specDeterminerpersistence coherent with successful negotiated outcomes:fedify/packages/fedify/src/sig/http.ts
Lines 1251 to 1269 in 0ce42c7
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
Accept-Signatureparsing cases.doubleKnock()integration tests verify challenge-aware retry behavior.