Skip to content
← Public packages

@kody/agent-mail

AgentMail inboxes for agents: create/list inboxes, messages, threads, and Svix webhook ingress.

src/delivery-auth.ts

96 lines · 3.0 KB · TypeScript
const DEFAULT_TOLERANCE_SECONDS = 5 * 60

function headerValue(
	headers: Record<string, string | undefined> | undefined,
	name: string,
) {
	if (!headers) return ''
	const wanted = name.toLowerCase()
	for (const [key, value] of Object.entries(headers)) {
		if (key.toLowerCase() === wanted) return String(value ?? '').trim()
	}
	return ''
}

function b64decode(value: string) {
	const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
	const pad = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))
	const binary = atob(normalized + pad)
	const bytes = new Uint8Array(binary.length)
	for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
	return bytes
}

function b64encode(bytes: ArrayBuffer) {
	const view = new Uint8Array(bytes)
	let binary = ''
	for (const b of view) binary += String.fromCharCode(b)
	return btoa(binary)
}

function constTimeEqual(a: string, b: string) {
	if (a.length !== b.length) return false
	let diff = 0
	for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
	return diff === 0
}

/**
 * Standard AgentMail delivery signature check using Web Crypto.
 * Content: `${id}.${timestamp}.${body}`; header values are space-separated `v1,<base64>`.
 * Secret material is base64 after the `whsec_` prefix.
 */
export async function verifyDeliverySignature(input: {
	secret: string
	body: string
	headers?: Record<string, string | undefined>
	toleranceSeconds?: number
}) {
	const secret = String(input.secret ?? '').trim()
	if (!secret.startsWith('whsec_')) {
		throw new Error('agentmailWebhookSecret must start with whsec_')
	}
	const keyBytes = b64decode(secret.slice('whsec_'.length))
	const id = headerValue(input.headers, 'svix-id')
	const timestamp = headerValue(input.headers, 'svix-timestamp')
	const signatureHeader = headerValue(input.headers, 'svix-signature')
	if (!id || !timestamp || !signatureHeader) {
		throw new Error('Missing svix-id, svix-timestamp, or svix-signature header')
	}

	const ts = Number(timestamp)
	if (!Number.isFinite(ts)) throw new Error('Invalid svix-timestamp')
	const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS
	const now = Math.floor(Date.now() / 1000)
	if (Math.abs(now - ts) > tolerance) {
		throw new Error('svix-timestamp outside tolerance window')
	}

	const algo = { name: 'HMAC', hash: 'SHA-256' } as const
	const signedContent = `${id}.${timestamp}.${input.body}`
	const cryptoKey = await crypto.subtle.importKey(
		'raw',
		keyBytes,
		algo,
		false,
		['sign'],
	)
	const digest = await crypto.subtle.sign(algo.name, cryptoKey, new TextEncoder().encode(signedContent))
	const expected = b64encode(digest)

	const candidates = signatureHeader
		.split(' ')
		.map((part) => part.trim())
		.filter(Boolean)
		.map((part) => {
			const [version, value] = part.split(',', 2)
			return { version, value }
		})
		.filter((part) => part.version === 'v1' && part.value)

	if (!candidates.some((part) => constTimeEqual(part.value!, expected))) {
		throw new Error('delivery signature mismatch')
	}

	return { id, timestamp }
}