import { AuthError } from './errors.js' import { Hex } from './hex.js' /** Signs requests. Implement this to use another algorithm (e.g. secp256k1 via libauth). */ export interface RequestSigner { /** Algorithm name sent in `X-Signature-Algorithm`; the server must have a matching verifier. */ readonly algorithm: string /** Public key as lowercase hex. Also the owner segment of the caller's keys (`//...`). */ readonly publicKey: string sign(payload: Uint8Array): Promise } /** Verifies signatures for one algorithm. */ export interface SignatureVerifier { readonly algorithm: string verify(publicKey: string, payload: Uint8Array, signature: Uint8Array): Promise } export const AUTH_HEADERS = { publicKey: 'X-Public-Key', timestamp: 'X-Timestamp', signature: 'X-Signature', algorithm: 'X-Signature-Algorithm', } as const export interface RequestAuthOptions { /** Accepted difference between the request timestamp and the server clock. Default 60 000 ms. */ maxSkewMs?: number now?: () => number } /** * Signed-request authentication. The client signs `${timestamp}:${path}:${body}` (path = URL * pathname, body = the exact request body) and sends the public key, timestamp, signature (hex) * and algorithm as headers. The server checks the timestamp is recent, verifies the signature * with the verifier for that algorithm, and rejects a signature it has already seen within the * timestamp window (replay protection). */ export class RequestAuth { private readonly verifiers: Map private readonly maxSkewMs: number private readonly now: () => number /** Signatures accepted within the window → when they expire; insertion order ≈ expiry order. */ private readonly seen = new Map() constructor(verifiers: SignatureVerifier[], options: RequestAuthOptions = {}) { if (verifiers.length === 0) throw new TypeError('RequestAuth needs at least one verifier') this.verifiers = new Map(verifiers.map((v) => [v.algorithm, v])) this.maxSkewMs = options.maxSkewMs ?? 60_000 this.now = options.now ?? Date.now } static payload(timestamp: string, path: string, body: string): Uint8Array { return new TextEncoder().encode(`${timestamp}:${path}:${body}`) } /** Auth headers for a request to `path` with `body`. */ static async headers(signer: RequestSigner, path: string, body: string, now: number = Date.now()): Promise> { const timestamp = String(now) const signature = await signer.sign(RequestAuth.payload(timestamp, path, body)) return { [AUTH_HEADERS.publicKey]: signer.publicKey, [AUTH_HEADERS.timestamp]: timestamp, [AUTH_HEADERS.signature]: Hex.encode(signature), [AUTH_HEADERS.algorithm]: signer.algorithm, } } /** Returns the caller's public key, or throws `AuthError` (401). */ async verify(headers: Headers, path: string, body: string): Promise { const publicKey = headers.get(AUTH_HEADERS.publicKey) const timestamp = headers.get(AUTH_HEADERS.timestamp) const signature = headers.get(AUTH_HEADERS.signature) const algorithm = headers.get(AUTH_HEADERS.algorithm) if (!publicKey || !timestamp || !signature || !algorithm) throw new AuthError(401, 'Missing authentication headers') const verifier = this.verifiers.get(algorithm) if (!verifier) throw new AuthError(401, `Unsupported signature algorithm "${algorithm}"`) if (!/^[0-9a-f]+$/.test(publicKey)) throw new AuthError(401, 'Public key must be lowercase hex') const now = this.now() const time = Number(timestamp) if (!Number.isInteger(time) || Math.abs(now - time) > this.maxSkewMs) throw new AuthError(401, 'Timestamp outside the accepted window') this.forgetExpired(now) if (this.seen.has(signature)) throw new AuthError(401, 'Replayed request') let valid = false try { valid = await verifier.verify(publicKey, RequestAuth.payload(timestamp, path, body), Hex.decode(signature)) } catch { valid = false } if (!valid) throw new AuthError(401, 'Invalid signature') this.seen.set(signature, time + this.maxSkewMs) return publicKey } private forgetExpired(now: number): void { for (const [signature, expires] of this.seen) { if (expires >= now) break this.seen.delete(signature) } } } /** Ed25519 signer using WebCrypto (Node 22+ and current browsers; no dependencies). */ export class Ed25519Signer implements RequestSigner { readonly algorithm = 'ed25519' private constructor( private readonly privateKey: CryptoKey, readonly publicKey: string, ) {} /** A new random key pair. */ static async generate(): Promise { const pair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as CryptoKeyPair return new Ed25519Signer(pair.privateKey, Hex.encode(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)))) } /** Restores a key exported with `exportPrivateKey()` (PKCS #8). */ static async fromPrivateKey(pkcs8: Uint8Array): Promise { const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8 as BufferSource, { name: 'Ed25519' }, true, ['sign']) // WebCrypto can't derive the public key directly; the JWK export of a private key includes it (`x`). const jwk = await crypto.subtle.exportKey('jwk', privateKey) const publicKey = Uint8Array.from(atob(jwk.x!.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)) return new Ed25519Signer(privateKey, Hex.encode(publicKey)) } async exportPrivateKey(): Promise { return new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.privateKey)) } async sign(payload: Uint8Array): Promise { return new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, this.privateKey, payload as BufferSource)) } } /** Ed25519 verifier using WebCrypto. */ export class Ed25519Verifier implements SignatureVerifier { readonly algorithm = 'ed25519' private readonly keys = new Map>() async verify(publicKey: string, payload: Uint8Array, signature: Uint8Array): Promise { let key = this.keys.get(publicKey) if (!key) { key = crypto.subtle.importKey('raw', Hex.decode(publicKey) as BufferSource, { name: 'Ed25519' }, false, ['verify']) this.keys.set(publicKey, key) if (this.keys.size > 10_000) this.keys.delete(this.keys.keys().next().value!) } return crypto.subtle.verify({ name: 'Ed25519' }, await key, signature as BufferSource, payload as BufferSource) } }