OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / remote-key.js

101 lines · javascript ref:
// 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
}