import crypto from 'crypto' export interface SignatureComponents { signatureInput: string signature: string } /** * Parse RFC 9421 Signature-Input header. * Format: sig1=("@method" "@authority" "@path"); keyid="agent-key"; alg="ed25519"; created=1234567890 */ export function parseSignatureInput(input: string): { label: string components: string[] params: Record } | null { try { const match = input.match(/^(\w+)=\(([^)]*)\);\s*(.+)$/) if (!match) return null const [, label, componentStr, paramStr] = match const components = componentStr.match(/"([^"]+)"/g)?.map(s => s.replace(/"/g, '')) ?? [] const params: Record = {} for (const part of paramStr.split(';')) { const [k, v] = part.trim().split('=') if (k && v) params[k.trim()] = v.replace(/"/g, '').trim() } return { label, components, params } } catch { return null } } /** * Build the RFC 9421 signature base string from request components. */ export function buildSignatureBase( components: string[], request: { method: string; url: string; headers: Record }, signatureInputValue: string, label: string ): string { const lines: string[] = [] const url = new URL(request.url, 'http://localhost') for (const comp of components) { if (comp === '@method') lines.push(`"@method": ${request.method.toUpperCase()}`) else if (comp === '@authority') lines.push(`"@authority": ${url.host}`) else if (comp === '@path') lines.push(`"@path": ${url.pathname}`) else if (comp === '@query') lines.push(`"@query": ?${url.search.slice(1)}`) else if (comp.startsWith('@')) lines.push(`"${comp}": `) else lines.push(`"${comp}": ${request.headers[comp.toLowerCase()] ?? ''}`) } lines.push(`"@signature-params": ${signatureInputValue.split('=').slice(1).join('=')}`) return lines.join('\n') } /** * Verify an Ed25519 signature. * Returns true if the signature is valid for the given public key and data. */ export function verifyEd25519Signature( publicKeyBase64: string, signatureBase64: string, data: string ): boolean { try { const pubKeyBuffer = Buffer.from(publicKeyBase64, 'base64') const sigBuffer = Buffer.from(signatureBase64, 'base64') const key = crypto.createPublicKey({ key: Buffer.concat([ // Ed25519 DER prefix Buffer.from('302a300506032b6570032100', 'hex'), pubKeyBuffer ]), format: 'der', type: 'spki' }) return crypto.verify(null, Buffer.from(data), key, sigBuffer) } catch { return false } } // Public key cache: agentKey -> base64 public key const keyCache = new Map() export function registerAgentPublicKey(agentKey: string, publicKeyBase64: string): void { keyCache.set(agentKey, publicKeyBase64) } export function getAgentPublicKey(agentKey: string): string | undefined { return keyCache.get(agentKey) } /** * Full Web Bot Auth verification. * Checks Signature-Input + Signature headers against agent's registered public key. */ export function verifyWebBotAuth( headers: Record, request: { method: string; url: string; headers: Record }, agentKey: string ): boolean { const signatureInput = headers['signature-input'] const signature = headers['signature'] if (!signatureInput || !signature) return false const parsed = parseSignatureInput(signatureInput) if (!parsed) return false // Check algorithm is ed25519 if (parsed.params.alg && parsed.params.alg !== 'ed25519') return false // Check expiry if (parsed.params.expires && Number(parsed.params.expires) < Date.now() / 1000) return false // Get public key const pubKey = getAgentPublicKey(agentKey) || getAgentPublicKey(parsed.params.keyid) if (!pubKey) return false // Build signature base and verify const base = buildSignatureBase(parsed.components, request, signatureInput, parsed.label) // Extract signature value (format: sig1=:base64:) const sigMatch = signature.match(new RegExp(`${parsed.label}=:([^:]+):`)) if (!sigMatch) return false return verifyEd25519Signature(pubKey, sigMatch[1], base) }