// http-signature.js — draft-cavage-http-signatures-12, the dialect the fediverse actually speaks.
//
// WHY cavage-12 AND NOT RFC 9421: the SWICG survey (swicg.github.io/activitypub-http-signature) is
// explicit that "cavage-12 is still the most commonly supported version in the fediverse by far",
// and that RFC 9421 dropped the `hs2019` placeholder, breaking compatibility both ways. The spec's
// own remedy is "double-knocking" — try one, fall back to the other. We implement cavage-12 first
// because that is what reaches real servers today; RFC 9421 is a later addition, not a replacement.
//
// WHY rsa-sha256: FEP-521a inbound shipped RSA-only (mastodon#39497 — "Ed25519 is planned but will
// come later"), so there is no Ed25519 peer to talk to yet. See ../DESIGN.md §4.
//
// THE SIGNING STRING is the whole protocol. It is the ordered, newline-joined list of
// `lowercased-name: value` lines named by `headers=`, where the pseudo-header `(request-target)`
// expands to `<lowercase-method> <path-with-query>`. Both sides must build byte-identical strings
// or the signature fails with no diagnostic — which is why buildSigningString is exported and
// tested on its own rather than buried inside sign/verify.
import crypto from 'bare-crypto'
import b4a from 'b4a'
import { sign as rsaSign, verify as rsaVerify } from './rsa.js'
const DEFAULT_POST_HEADERS = ['(request-target)', 'host', 'date', 'digest']
const DEFAULT_GET_HEADERS = ['(request-target)', 'host', 'date']
// --- signing string -----------------------------------------------------------------------
// headers: a plain object; lookup is case-insensitive because HTTP header case is not significant
// but the signing string is built from lowercase names.
function headerValue (headers, name) {
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === name) {
const v = headers[key]
return Array.isArray(v) ? v.join(', ') : String(v)
}
}
return undefined
}
export function buildSigningString ({ method, path, headers, headerList }) {
const lines = []
for (const name of headerList) {
if (name === '(request-target)') {
lines.push(`(request-target): ${method.toLowerCase()} ${path}`)
continue
}
const value = headerValue(headers, name)
if (value === undefined) throw new Error(`http-signature: missing header "${name}" named in headers=`)
lines.push(`${name}: ${value}`)
}
return lines.join('\n')
}
// --- Digest -------------------------------------------------------------------------------
// RFC 3230 / RFC 5843. Mastodon sends and expects `SHA-256=<base64>` over the RAW body bytes —
// hash the bytes you actually transmit, never a re-serialised object, or the digest won't match.
export function digestHeader (body) {
const bytes = typeof body === 'string' ? b4a.from(body) : body
const hash = crypto.createHash('sha256').update(bytes).digest()
return 'SHA-256=' + b4a.toString(b4a.from(hash), 'base64')
}
export function verifyDigest (body, headerValueStr) {
if (!headerValueStr) return false
// Some implementations send multiple comma-separated digests; accept if any SHA-256 one matches.
for (const part of headerValueStr.split(',')) {
const trimmed = part.trim()
if (!/^SHA-256=/i.test(trimmed)) continue
if (constantTimeStringEqual(trimmed.slice(8), digestHeader(body).slice(8))) return true
}
return false
}
// --- Signature header parsing -------------------------------------------------------------
// Values are quoted and may contain commas (notably `headers="(request-target) host date"` does
// not, but keyId URLs can), so split on quotes rather than on commas.
export function parseSignatureHeader (value) {
if (!value) return null
const out = {}
const re = /([a-zA-Z]+)\s*=\s*"([^"]*)"/g
let m
while ((m = re.exec(value)) !== null) out[m[1]] = m[2]
if (!out.keyId || !out.signature) return null
out.headers = (out.headers || 'date').toLowerCase().split(/\s+/).filter(Boolean)
out.algorithm = (out.algorithm || 'rsa-sha256').toLowerCase()
return out
}
function formatSignatureHeader ({ keyId, algorithm, headerList, signature }) {
return [
`keyId="${keyId}"`,
`algorithm="${algorithm}"`,
`headers="${headerList.join(' ')}"`,
`signature="${signature}"`
].join(',')
}
// --- signing ------------------------------------------------------------------------------
// Everything up to and including the bytes that must be signed — and NO key.
//
// ⛔ This split is the point. The signing string is a pure function of the request, so producing it
// never needs custody of anything. That is what lets one code path serve a local key, a remote
// signer over a socket, or an envelope signed in advance and handed to a courier. Before the split,
// signing was a synchronous call inlined here, and a signer that has to cross a boundary — which is
// every remote-custody option — could not be expressed at all.
//
// Returns the headers to ADD to the outgoing request. Does not mutate `headers`.
// `url` must be absolute — host is taken from it so the Host header and the signature agree.
export function prepareRequest ({ method, url, headers = {}, body = null, date = null }) {
const u = new URL(url)
const path = u.pathname + u.search
const isPost = method.toUpperCase() === 'POST'
const out = {
host: u.host,
date: date || new Date().toUTCString()
}
if (isPost) {
if (body === null) throw new Error('http-signature: POST requires a body to digest')
out.digest = digestHeader(body)
}
const headerList = isPost ? DEFAULT_POST_HEADERS : DEFAULT_GET_HEADERS
const merged = { ...headers, ...out }
return { headers: out, signingString: buildSigningString({ method, path, headers: merged, headerList }), headerList }
}
// Local custody: the caller holds the key. Kept because it is the right shape for a test acting as
// a REMOTE server signing to us, and for a co-located deployment where a remote signer buys nothing.
export function signRequest ({ method, url, headers = {}, body = null, keyId, privateKey, date = null }) {
const { headers: out, signingString, headerList } = prepareRequest({ method, url, headers, body, date })
const sig = rsaSign(b4a.from(signingString), privateKey)
out.signature = formatSignatureHeader({
keyId,
algorithm: 'rsa-sha256',
headerList,
signature: b4a.toString(sig, 'base64')
})
return out
}
// Delegated custody: we hold nothing and ASK. `signer` is lib/signer.js's interface —
// { keyId, sign(bytes) => Promise<Buffer> } — so LocalSigner, RemoteSigner and anything else that
// can produce a signature over bytes all work here without this module knowing which it got.
//
// `signer.algorithm` is honoured so an Ed25519 keyring (FEP-521a / FEP-8b32, DESIGN.md §4) can be
// wired later without touching this function. It defaults to RSA, which is what interop needs today.
export async function signRequestWith ({ method, url, headers = {}, body = null, signer, date = null }) {
if (!signer || typeof signer.sign !== 'function') {
throw new Error('http-signature: a signer with sign(bytes) is required')
}
if (!signer.keyId) throw new Error('http-signature: signer.keyId is required')
const { headers: out, signingString, headerList } = prepareRequest({ method, url, headers, body, date })
const sig = await signer.sign(b4a.from(signingString))
// Fail loudly rather than sending something no remote server can verify — the only symptom of
// that is a rejection that looks like our crypto is broken. signer.js makes the same argument.
if (!sig || !sig.length) throw new Error('http-signature: signer returned no signature')
out.signature = formatSignatureHeader({
keyId: signer.keyId,
algorithm: signer.algorithm || 'rsa-sha256',
headerList,
signature: b4a.toString(sig, 'base64')
})
return out
}
// --- verifying ----------------------------------------------------------------------------
// resolvePublicKey: async (keyId) => publicKeyPem — normally "fetch the actor doc, read
// publicKey.publicKeyPem". Injected so this module stays I/O-free and testable offline.
//
// Returns { ok, keyId, reason }. NEVER throws on a bad signature: a malformed inbound request is
// an expected condition on a public inbox, not an exceptional one.
export async function verifyRequest ({ method, path, headers, body = null, resolvePublicKey, maxSkewSeconds = 300 }) {
const sigHeader = headerValue(headers, 'signature')
const parsed = parseSignatureHeader(sigHeader)
if (!parsed) return { ok: false, reason: 'missing or unparseable Signature header' }
if (parsed.algorithm !== 'rsa-sha256' && parsed.algorithm !== 'hs2019') {
return { ok: false, keyId: parsed.keyId, reason: `unsupported algorithm "${parsed.algorithm}"` }
}
// Replay window. A signature with no date is not replay-protected at all, so require one.
const dateStr = headerValue(headers, 'date')
if (!dateStr) return { ok: false, keyId: parsed.keyId, reason: 'no Date header' }
const skew = Math.abs(Date.now() - Date.parse(dateStr)) / 1000
if (!Number.isFinite(skew)) return { ok: false, keyId: parsed.keyId, reason: 'unparseable Date header' }
if (skew > maxSkewSeconds) return { ok: false, keyId: parsed.keyId, reason: `clock skew ${Math.round(skew)}s exceeds ${maxSkewSeconds}s` }
// A digest in `headers=` that we don't actually check is worse than no digest — it lets a body
// be swapped while the signature still verifies over the header set.
if (parsed.headers.includes('digest')) {
if (body === null) return { ok: false, keyId: parsed.keyId, reason: 'digest signed but no body supplied to verify' }
if (!verifyDigest(body, headerValue(headers, 'digest'))) {
return { ok: false, keyId: parsed.keyId, reason: 'body does not match Digest header' }
}
}
let signingString
try {
signingString = buildSigningString({ method, path, headers, headerList: parsed.headers })
} catch (err) {
return { ok: false, keyId: parsed.keyId, reason: err.message }
}
let pem
try {
pem = await resolvePublicKey(parsed.keyId)
} catch (err) {
return { ok: false, keyId: parsed.keyId, reason: `key resolution failed: ${err.message}` }
}
if (!pem) return { ok: false, keyId: parsed.keyId, reason: 'no public key for keyId' }
let sigBytes
try {
sigBytes = b4a.from(parsed.signature, 'base64')
} catch {
return { ok: false, keyId: parsed.keyId, reason: 'signature is not valid base64' }
}
const ok = rsaVerify(b4a.from(signingString), sigBytes, pem)
return ok
? { ok: true, keyId: parsed.keyId }
: { ok: false, keyId: parsed.keyId, reason: 'signature does not verify' }
}
function constantTimeStringEqual (a, b) {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
return diff === 0
}