OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / signer.js

89 lines · javascript ref:
// signer.js — the key-custody seam. THIS IS THE POINT OF M2.
//
// M1 shipped with the actor's private key held by the bridge process, which is a documented
// deviation from the target architecture (DESIGN.md §11): the bridge is supposed to be KEYLESS,
// exactly as atproto-bridge is. atproto-bridge achieves that because a peer's signature travels
// in its firehose, so the membrane re-serves a signature it never produced. ActivityPub has no
// equivalent — HTTP Signatures are computed per-request over live request headers, so something
// must sign at delivery time.
//
// The resolution is the same shape hyper-proto-peer already uses for `apply-write`: the bridge
// does not hold the key, it ASKS the keyholder to sign, over an authenticated RPC. This module is
// that boundary. Everything above it (http-signature, deliver) depends only on the interface, so
// swapping local custody for a remote virtual-ap-device changes one line of wiring and nothing else.
//
//   interface Signer {
//     readonly keyId: string           // the `keyId=` published in the Signature header
//     sign(bytes: Buffer): Promise<Buffer>   // raw RSA PKCS#1 v1.5 / SHA-256 signature
//   }
//
// sign() is ASYNC even for the local case. A remote signer cannot be synchronous, and a sync
// interface that later has to change is worse than an async one that is briefly over-general.

import { sign as rsaSign, parsePrivateKey } from './rsa.js'

// Holds the key in-process. This is M1's behaviour, kept as the default so the bridge still runs
// standalone — but it is explicitly NOT the target architecture. A LocalSigner means a compromised
// bridge yields the actor's identity; a RemoteSigner means it yields nothing but the ability to
// ask for signatures while the connection lasts.
export class LocalSigner {
  constructor ({ keyId, privateKeyPem }) {
    if (!keyId) throw new Error('signer: keyId is required')
    if (!privateKeyPem) throw new Error('signer: privateKeyPem is required')
    this.keyId = keyId
    // Parse once — parsePrivateKey does ASN.1 + BigInt conversion, and re-doing it per delivery
    // would be pure waste on the hot path.
    this._key = parsePrivateKey(privateKeyPem)
  }

  async sign (bytes) {
    return rsaSign(bytes, this._key)
  }

  get custody () { return 'local' }
}

// Asks a remote keyholder (the virtual-ap-device) to sign. The device gates on the caller's
// connection public key exactly as hyper-proto-peer's `apply-write` gates on
// `conn.remotePublicKey` via a caller-supplied canWrite predicate — see
// hyper-proto-peer/lib/peer.js:313-320.
//
// ⚠ NOT YET WIRED TO A TRANSPORT. `call` is injected so this class is testable, and so the
// transport choice (protomux-rpc over Hyperswarm, matching hpp, vs a local UDS) stays a separate
// decision. What is settled here is the CONTRACT: the bridge sends bytes and receives a signature,
// and never sees key material.
export class RemoteSigner {
  constructor ({ keyId, call, timeoutMs = 10_000 }) {
    if (!keyId) throw new Error('signer: keyId is required')
    if (typeof call !== 'function') throw new Error('signer: call(method, params) is required')
    this.keyId = keyId
    this._call = call
    this._timeoutMs = timeoutMs
  }

  async sign (bytes) {
    const res = await withTimeout(
      this._call('sign-http-request', { keyId: this.keyId, payload: bytes }),
      this._timeoutMs,
      'sign-http-request'
    )
    // Fail loudly. A signer that silently returns something unusable produces a signature that no
    // remote server can verify, and the only symptom is a delivery rejected for reasons that look
    // like our crypto is wrong.
    if (!res || !res.signature) throw new Error('sign-http-request returned no signature')
    return res.signature
  }

  get custody () { return 'remote' }
}

function withTimeout (promise, ms, what) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error(`${what}: timed out after ${ms}ms`)), ms)
    promise.then(
      (v) => { clearTimeout(timer); resolve(v) },
      (e) => { clearTimeout(timer); reject(e) }
    )
  })
}