import test from 'brittle'
import {
buildSigningString,
digestHeader,
verifyDigest,
parseSignatureHeader,
signRequest,
verifyRequest
} from '../lib/http-signature.js'
import { TEST_PRIVATE_KEY, TEST_PUBLIC_KEY, OTHER_PUBLIC_KEY } from './fixtures/keys.js'
const KEY_ID = 'https://p2p.7rnx.net/ap/actor#main-key'
const FOLLOW_BODY = '{"@context":"https://www.w3.org/ns/activitystreams","type":"Follow"}'
const resolve = async () => TEST_PUBLIC_KEY
// The signing string IS the protocol. If both sides don't build byte-identical strings the
// signature fails with no diagnostic, so pin the exact bytes rather than only round-tripping.
test('signing string — exact bytes, lowercase method, path with query', (t) => {
const s = buildSigningString({
method: 'POST',
path: '/ap/inbox?x=1',
headers: { Host: 'p2p.7rnx.net', Date: 'Fri, 31 Jul 2026 20:00:00 GMT', Digest: 'SHA-256=abc' },
headerList: ['(request-target)', 'host', 'date', 'digest']
})
t.is(s, [
'(request-target): post /ap/inbox?x=1',
'host: p2p.7rnx.net',
'date: Fri, 31 Jul 2026 20:00:00 GMT',
'digest: SHA-256=abc'
].join('\n'))
})
test('signing string — header lookup is case-insensitive, output name is not', (t) => {
const s = buildSigningString({
method: 'GET',
path: '/ap/actor',
headers: { HOST: 'example.com', dAtE: 'x' },
headerList: ['host', 'date']
})
t.is(s, 'host: example.com\ndate: x')
})
test('signing string — a header named in headers= but absent is a hard error', (t) => {
t.exception(() => buildSigningString({
method: 'GET', path: '/', headers: { host: 'a' }, headerList: ['host', 'digest']
}), /missing header "digest"/)
})
// Cross-checked against: printf '%s' "$BODY" | openssl dgst -sha256 -binary | base64
test('digest matches openssl', (t) => {
t.is(digestHeader(FOLLOW_BODY), 'SHA-256=aLuSRyePOxP0LUQYgmNvzDMRtmxpN0mY+bU83mDtjUo=')
t.ok(verifyDigest(FOLLOW_BODY, digestHeader(FOLLOW_BODY)))
t.absent(verifyDigest('{}', digestHeader(FOLLOW_BODY)))
})
test('Signature header parses; keyId may contain a fragment and slashes', (t) => {
const p = parseSignatureHeader(
`keyId="${KEY_ID}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="AAAA"`
)
t.is(p.keyId, KEY_ID)
t.is(p.algorithm, 'rsa-sha256')
t.alike(p.headers, ['(request-target)', 'host', 'date', 'digest'])
t.is(p.signature, 'AAAA')
t.is(parseSignatureHeader(''), null)
t.is(parseSignatureHeader('garbage'), null)
})
test('sign then verify — POST round trip', async (t) => {
const url = 'https://p2p.7rnx.net/ap/inbox'
const added = signRequest({ method: 'POST', url, body: FOLLOW_BODY, keyId: KEY_ID, privateKey: TEST_PRIVATE_KEY })
t.is(added.host, 'p2p.7rnx.net')
t.ok(added.digest.startsWith('SHA-256='))
t.ok(added.signature.includes(`keyId="${KEY_ID}"`))
t.ok(added.signature.includes('algorithm="rsa-sha256"'))
const res = await verifyRequest({
method: 'POST', path: '/ap/inbox', headers: added, body: FOLLOW_BODY, resolvePublicKey: resolve
})
t.ok(res.ok, res.reason)
t.is(res.keyId, KEY_ID)
})
test('sign then verify — GET round trip omits digest', async (t) => {
const added = signRequest({ method: 'GET', url: 'https://p2p.7rnx.net/ap/actor', keyId: KEY_ID, privateKey: TEST_PRIVATE_KEY })
t.absent(added.digest)
t.ok(added.signature.includes('headers="(request-target) host date"'))
const res = await verifyRequest({
method: 'GET', path: '/ap/actor', headers: added, resolvePublicKey: resolve
})
t.ok(res.ok, res.reason)
})
// A verifier that never says no is worthless. Each of these is a real attack, not a lint.
test('rejects a swapped body even though the signature itself is intact', async (t) => {
const added = signRequest({ method: 'POST', url: 'https://p2p.7rnx.net/ap/inbox', body: FOLLOW_BODY, keyId: KEY_ID, privateKey: TEST_PRIVATE_KEY })
const res = await verifyRequest({
method: 'POST', path: '/ap/inbox', headers: added, body: '{"type":"Delete"}', resolvePublicKey: resolve
})
t.absent(res.ok)
t.is(res.reason, 'body does not match Digest header')
})
test('rejects a tampered path', async (t) => {
const added = signRequest({ method: 'POST', url: 'https://p2p.7rnx.net/ap/inbox', body: FOLLOW_BODY, keyId: KEY_ID, privateKey: TEST_PRIVATE_KEY })
const res = await verifyRequest({
method: 'POST', path: '/ap/other', headers: added, body: FOLLOW_BODY, resolvePublicKey: resolve
})
t.absent(res.ok)
t.is(res.reason, 'signature does not verify')
})
test('rejects a replayed request outside the skew window', async (t) => {
const stale = new Date(Date.now() - 3600_000).toUTCString()
const added = signRequest({ method: 'POST', url: 'https://p2p.7rnx.net/ap/inbox', body: FOLLOW_BODY, keyId: KEY_ID, privateKey: TEST_PRIVATE_KEY, date: stale })
const res = await verifyRequest({
method: 'POST', path: '/ap/inbox', headers: added, body: FOLLOW_BODY, resolvePublicKey: resolve
})
t.absent(res.ok)
t.ok(/clock skew/.test(res.reason), res.reason)
})
test('rejects when the key resolves to a different actor', async (t) => {
const added = signRequest({ method: 'POST', url: 'https://p2p.7rnx.net/ap/inbox', body: FOLLOW_BODY, keyId: KEY_ID, privateKey: TEST_PRIVATE_KEY })
const res = await verifyRequest({
method: 'POST', path: '/ap/inbox', headers: added, body: FOLLOW_BODY, resolvePublicKey: async () => OTHER_PUBLIC_KEY
})
t.absent(res.ok)
t.is(res.reason, 'signature does not verify')
})
test('rejects unresolvable and unsupported cases without throwing', async (t) => {
const added = signRequest({ method: 'POST', url: 'https://p2p.7rnx.net/ap/inbox', body: FOLLOW_BODY, keyId: KEY_ID, privateKey: TEST_PRIVATE_KEY })
const noKey = await verifyRequest({ method: 'POST', path: '/ap/inbox', headers: added, body: FOLLOW_BODY, resolvePublicKey: async () => null })
t.is(noKey.reason, 'no public key for keyId')
const throws = await verifyRequest({ method: 'POST', path: '/ap/inbox', headers: added, body: FOLLOW_BODY, resolvePublicKey: async () => { throw new Error('502') } })
t.ok(/key resolution failed/.test(throws.reason))
const noSig = await verifyRequest({ method: 'POST', path: '/ap/inbox', headers: {}, body: FOLLOW_BODY, resolvePublicKey: resolve })
t.is(noSig.reason, 'missing or unparseable Signature header')
// digest named in headers= but body withheld must fail closed, not skip the check
const noBody = await verifyRequest({ method: 'POST', path: '/ap/inbox', headers: added, resolvePublicKey: resolve })
t.is(noBody.reason, 'digest signed but no body supplied to verify')
})
test('signature is deterministic — PKCS#1 v1.5 has no salt', (t) => {
const args = { method: 'POST', url: 'https://p2p.7rnx.net/ap/inbox', body: FOLLOW_BODY, keyId: KEY_ID, privateKey: TEST_PRIVATE_KEY, date: 'Fri, 31 Jul 2026 20:00:00 GMT' }
t.is(signRequest(args).signature, signRequest(args).signature)
})