import { createHmac, timingSafeEqual } from 'node:crypto'; import type { IncomingHttpHeaders } from 'node:http'; import { getProvider } from '../config/providers.js'; import type { ProviderSlug, SignatureResult } from '../types/integration.js'; const DEFAULT_TOLERANCE_SECONDS = 300; const OK: SignatureResult = { ok: true }; const fail = (reason: Exclude['reason']): SignatureResult => ({ ok: false, reason, }); const header = (headers: IncomingHttpHeaders, name: string): string | undefined => { const value = headers[name.toLowerCase()]; if (Array.isArray(value)) return value[0]; return value; }; const hmacHex = (secret: string, payload: string | Buffer): string => createHmac('sha256', secret).update(payload).digest('hex'); const constantTimeEquals = (a: string, b: string): boolean => { const bufA = Buffer.from(a, 'utf8'); const bufB = Buffer.from(b, 'utf8'); if (bufA.length !== bufB.length) return false; return timingSafeEqual(bufA, bufB); }; const withinTolerance = (timestamp: string, toleranceSeconds: number): boolean => { const sent = Number(timestamp); if (!Number.isFinite(sent)) return false; const nowSeconds = Math.floor(Date.now() / 1000); return Math.abs(nowSeconds - sent) <= toleranceSeconds; }; /** * Never throws. A malformed header from an anonymous caller must be an * ordinary negative result, not an exception reachable from the edge. */ export const verifySignature = (input: { provider: ProviderSlug; rawBody: Buffer; headers: IncomingHttpHeaders; secret: string; toleranceSeconds?: number; }): SignatureResult => { const config = getProvider(input.provider); const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS; if (config.signatureScheme === 'none') return OK; const presented = header(input.headers, config.signatureHeader); if (!presented) return fail('missing'); if (config.signatureScheme === 'slack-v0') { const timestamp = config.timestampHeader ? header(input.headers, config.timestampHeader) : undefined; if (!timestamp) return fail('missing'); if (!withinTolerance(timestamp, tolerance)) return fail('stale'); if (!presented.startsWith('v0=')) return fail('malformed'); const base = `v0:${timestamp}:${input.rawBody.toString('utf8')}`; const expected = `v0=${hmacHex(input.secret, base)}`; return constantTimeEquals(presented, expected) ? OK : fail('mismatch'); } if (config.signatureScheme === 'github-sha256') { if (!presented.startsWith('sha256=')) return fail('malformed'); const expected = `sha256=${hmacHex(input.secret, input.rawBody)}`; return constantTimeEquals(presented, expected) ? OK : fail('mismatch'); } // linear-sha256: bare lowercase hex digest of the raw body. if (!/^[0-9a-f]{64}$/.test(presented)) return fail('malformed'); const expected = hmacHex(input.secret, input.rawBody); return constantTimeEquals(presented, expected) ? OK : fail('mismatch'); };