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
JavaScript
// 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