← 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 · TypeScriptconst 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 }
}