// 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) }
)
})
}