// remote-key.js — resolve a Signature keyId to the remote actor's publicKeyPem.
//
// This is the FIRST outbound network I/O in the codebase, and it sits on the inbox path, so it is
// also the first place a remote party influences what we fetch. Two consequences drive the design:
//
// 1. CACHE, or every inbox POST becomes an outbound HTTPS round trip and a busy sender can make us
// hammer their server. Keys rotate rarely; a long TTL is correct.
// 2. NEGATIVE-CACHE failures too, briefly. Without it, a dead or hostile keyId turns every retry
// from that sender into a fresh outbound request.
//
// ⚠ Mastodon in "secure mode" requires the actor fetch itself to be signed. We fetch unsigned,
// which works against default configurations (including prsm.space). Signed fetches are an M2
// item — they need the actor's own key, which in the target architecture lives on the
// virtual-ap-device, not here.
import { httpClient } from './https.js'
const DEFAULT_TTL = 3600_000 // 1h — keys rotate rarely
const NEGATIVE_TTL = 60_000 // 1m — don't let a bad keyId become a fetch loop
const MAX_BODY = 512 * 1024
export class RemoteKeyResolver {
constructor ({ ttl = DEFAULT_TTL, negativeTtl = NEGATIVE_TTL, doFetch = httpClient, now = Date.now } = {}) {
this._cache = new Map() // docUri -> { pem | null, expires }
this._ttl = ttl
this._negativeTtl = negativeTtl
this._fetch = doFetch
this._now = now
}
// Returns a PEM string, or null if the key can't be resolved. Never throws — the caller
// (verifyRequest) treats null as "reject", which is the correct outcome either way.
async resolve (keyId) {
if (!keyId) return null
const docUri = keyId.split('#')[0]
let url
try {
url = new URL(docUri)
} catch {
return null
}
// Never follow a keyId off https — a plaintext fetch is trivially spoofable and there is no
// legitimate fediverse actor served over http.
if (url.protocol !== 'https:') return null
const hit = this._cache.get(docUri)
if (hit && hit.expires > this._now()) return hit.pem
let pem = null
try {
pem = await this._fetchKey(docUri, keyId)
} catch {
pem = null
}
this._cache.set(docUri, {
pem,
expires: this._now() + (pem ? this._ttl : this._negativeTtl)
})
return pem
}
async _fetchKey (docUri, keyId) {
const res = await this._fetch(docUri, {
headers: {
accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'user-agent': 'ap-bridge'
}
})
if (!res.ok) return null
const text = await res.text()
if (text.length > MAX_BODY) return null
const doc = JSON.parse(text)
return extractPublicKey(doc, keyId)
}
// Test/ops seam.
clear () { this._cache.clear() }
get size () { return this._cache.size }
}
// An actor may carry one publicKey object or several. Prefer an exact id match so that an actor
// with multiple keys can't have the wrong one silently accepted; fall back to the sole key only
// when there is exactly one and no id matched.
export function extractPublicKey (doc, keyId) {
if (!doc || typeof doc !== 'object') return null
const keys = []
const pk = doc.publicKey
if (Array.isArray(pk)) keys.push(...pk)
else if (pk && typeof pk === 'object') keys.push(pk)
const exact = keys.find((k) => k && k.id === keyId)
if (exact && typeof exact.publicKeyPem === 'string') return exact.publicKeyPem
if (keys.length === 1 && typeof keys[0].publicKeyPem === 'string') return keys[0].publicKeyPem
return null
}