OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / http-signature.test.js

154 lines · javascript ref:
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)
})