UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

240 lines 8.84 kB
// A2A push-notification webhook receiver — verifies HMAC-SHA256 signatures // using the Stripe-style `X-AIWG-Signature: t=<ts>,v1=<hmac>` header, rejects // stale timestamps (replay protection), and deduplicates by event-id // (idempotency). // // The webhook is the push-based alternative to SSE described in // roctinam/agentic-sandbox issue #211. The executor signs the raw request // body with a per-config secret (registered via // A2AClient.createPushNotificationConfig) and POSTs StreamResponse-shaped // payloads to the AIWG receiver URL. // // This module is HTTP-framework-agnostic. The serve.ts wiring adapts Hono // requests to `verifyWebhookSignature`; tests can call the same function // directly with synthesized Buffers. // // @issue #1256 import { createHmac, timingSafeEqual } from 'node:crypto'; /** Header name (case-insensitive). */ export const SIGNATURE_HEADER = 'x-aiwg-signature'; /** Five minutes — RFC 8941 timestamp tolerance. */ export const DEFAULT_TIMESTAMP_TOLERANCE_SECONDS = 300; /** Idempotency cache size. Old entries are evicted FIFO. */ export const DEFAULT_IDEMPOTENCY_CAPACITY = 4096; /** * Verify the `X-AIWG-Signature` header against the raw request body. * * Stripe-style format: * `X-AIWG-Signature: t=1700000000,v1=<hex hmac-sha256>` * * The HMAC is computed over `t=<timestamp>.<raw body>` (the timestamp and * a literal dot, prepended to the body). Multiple `v1=` entries may be * present in a single header (during a key rotation); any match accepts. */ export async function verifyWebhookSignature(signature, body, configId, opts) { if (!signature) { return { ok: false, code: 'signature_missing', detail: 'missing X-AIWG-Signature header' }; } const parsed = parseSignatureHeader(signature); if (!parsed) { return { ok: false, code: 'signature_malformed', detail: 'X-AIWG-Signature did not parse' }; } const tolerance = opts.toleranceSeconds ?? DEFAULT_TIMESTAMP_TOLERANCE_SECONDS; const now = opts.now ? opts.now() : Math.floor(Date.now() / 1000); if (Math.abs(now - parsed.timestamp) > tolerance) { return { ok: false, code: 'timestamp_skew', detail: `timestamp skew ${Math.abs(now - parsed.timestamp)}s exceeds tolerance ${tolerance}s`, }; } const secret = await opts.lookupSecret(configId); if (!secret) { return { ok: false, code: 'secret_unknown', detail: `no secret registered for configId='${configId}'` }; } const signedPayload = `${parsed.timestamp}.${body.toString('utf8')}`; const expected = createHmac('sha256', secret).update(signedPayload).digest('hex'); for (const candidate of parsed.v1) { if (constantTimeHexEqual(candidate, expected)) { return { ok: true, timestamp: parsed.timestamp }; } } return { ok: false, code: 'signature_mismatch', detail: 'no v1 signature matched' }; } /** Parse `t=...,v1=...,(v1=...,)*` into structured fields. */ export function parseSignatureHeader(header) { const parts = header.split(',').map(s => s.trim()).filter(Boolean); let timestamp = null; const v1 = []; for (const p of parts) { const eq = p.indexOf('='); if (eq <= 0) return null; const key = p.slice(0, eq); const value = p.slice(eq + 1); if (key === 't') { const n = Number(value); if (!Number.isFinite(n)) return null; timestamp = n; } else if (key === 'v1') { if (!/^[0-9a-f]+$/i.test(value)) return null; v1.push(value); } // ignore unknown keys forward-compat } if (timestamp === null || v1.length === 0) return null; return { timestamp, v1 }; } function constantTimeHexEqual(a, b) { if (a.length !== b.length) return false; const ab = Buffer.from(a, 'hex'); const bb = Buffer.from(b, 'hex'); if (ab.length !== bb.length || ab.length === 0) return false; return timingSafeEqual(ab, bb); } // ── idempotency cache ────────────────────────────────────────────────── /** * Bounded FIFO event-id deduper. The first call with a given id returns * true; subsequent calls return false until the id falls out of the * window. * * Subscribers MUST dedupe per the spec — the executor's delivery worker * retries on non-2xx responses, so a flaky downstream handler will * receive the same event-id multiple times. */ export class IdempotencyCache { capacity; seen = new Set(); order = []; constructor(capacity = DEFAULT_IDEMPOTENCY_CAPACITY) { this.capacity = Math.max(16, capacity); } /** Returns true if `id` is new (and stores it); false if it's a duplicate. */ markFresh(id) { if (this.seen.has(id)) return false; this.seen.add(id); this.order.push(id); while (this.order.length > this.capacity) { const evicted = this.order.shift(); if (evicted !== undefined) this.seen.delete(evicted); } return true; } size() { return this.seen.size; } } export class PushSecretRegistry { entries = new Map(); register(entry) { this.entries.set(entry.configId, entry); } lookup(configId) { return this.entries.get(configId) ?? null; } unregister(configId) { return this.entries.delete(configId); } /** Test/debug helper. */ size() { return this.entries.size; } } /** * One-shot processor: takes a raw webhook request (configId + body + * signature header + event-id header), verifies, dedupes, routes. * * Returns the status code and body the HTTP layer should emit. The HTTP * adapter (serve.ts) is responsible for reading the raw body bytes * BEFORE any JSON parsing — the signature is computed over raw bytes * (whitespace-sensitive). */ export async function handleWebhook(configId, body, signature, eventId, opts) { if (!configId) { return { status: 400, body: errorBody('aiwg.webhook_config_missing', 'configId query parameter missing'), }; } if (!eventId) { return { status: 400, body: errorBody('aiwg.webhook_event_id_missing', 'event-id header missing'), }; } // Verify signature first — never touch the body's contents until the // HMAC has been checked. const verification = await verifyWebhookSignature(signature, body, configId, { lookupSecret: id => { const entry = opts.registry.lookup(id); return entry ? entry.secret : null; }, ...(opts.toleranceSeconds !== undefined ? { toleranceSeconds: opts.toleranceSeconds } : {}), ...(opts.now ? { now: opts.now } : {}), }); if (!verification.ok) { return { status: verification.code === 'timestamp_skew' ? 401 : verification.code === 'secret_unknown' ? 404 : 401, body: errorBody(`aiwg.webhook_${verification.code}`, verification.detail ?? ''), }; } // Idempotency check — duplicate event-ids are accepted with 200 but // not re-routed. The executor's retry logic depends on a 2xx response // to mark delivery complete; failing here would cause infinite retry. const fresh = opts.idempotency.markFresh(eventId); if (!fresh) { return { status: 200, body: { ok: true, deduped: true } }; } // Route the verified payload. Errors thrown here become 500 so the // executor will retry — pick the abstraction carefully on the // mission-state side. const entry = opts.registry.lookup(configId); if (!entry) { // Edge case: secret was unregistered between verify and route. return { status: 404, body: errorBody('aiwg.webhook_secret_unknown', `configId='${configId}' no longer registered`), }; } let parsed; try { parsed = JSON.parse(body.toString('utf8')); } catch (e) { return { status: 400, body: errorBody('aiwg.webhook_body_not_json', e.message), }; } try { await opts.route(entry, parsed); } catch (e) { return { status: 500, body: errorBody('aiwg.webhook_route_failed', e.message), }; } return { status: 200, body: { ok: true } }; } function errorBody(code, detail) { return { type: 'about:blank', title: 'Webhook rejected', code, detail, }; } //# sourceMappingURL=webhook.js.map