import { createHmac, timingSafeEqual } from 'node:crypto'; const SIGNATURE_MAX_AGE_MS = 5 * 60 * 1_000; const HUBSPOT_URI_DECODE_PATTERN = /%3A|%2F|%3F|%40|%21|%24|%27|%28|%29|%2A|%2C|%3B/gi; const HUBSPOT_URI_DECODINGS: Record = { '%3A': ':', '%2F': '/', '%3F': '?', '%40': '@', '%21': '!', '%24': '$', '%27': "'", '%28': '(', '%29': ')', '%2A': '*', '%2C': ',', '%3B': ';', }; export interface HubSpotV3SignatureInput { clientSecret: string; method: string; publicUri: string; rawBody: string; signature: string | undefined; timestamp: string | undefined; now?: Date; } export type HubSpotSignatureResult = | { valid: true } | { valid: false; reason: 'missing_headers' | 'invalid_timestamp' | 'stale_timestamp' | 'mismatch' }; export function normalizeHubSpotSignatureUri(uri: string): string { const withoutFragment = uri.split('#', 1)[0] ?? uri; const queryIndex = withoutFragment.indexOf('?'); if (queryIndex < 0) return withoutFragment; const prefix = withoutFragment.slice(0, queryIndex + 1); const query = withoutFragment.slice(queryIndex + 1).replace( HUBSPOT_URI_DECODE_PATTERN, (encoded) => HUBSPOT_URI_DECODINGS[encoded.toUpperCase()] ?? encoded, ); return prefix + query; } export function verifyHubSpotV3Signature(input: HubSpotV3SignatureInput): HubSpotSignatureResult { if (!input.signature || !input.timestamp) return { valid: false, reason: 'missing_headers' }; if (!/^\d+$/.test(input.timestamp)) return { valid: false, reason: 'invalid_timestamp' }; const timestamp = Number(input.timestamp); if (!Number.isSafeInteger(timestamp)) return { valid: false, reason: 'invalid_timestamp' }; const now = input.now ?? new Date(); if (Math.abs(now.getTime() - timestamp) > SIGNATURE_MAX_AGE_MS) { return { valid: false, reason: 'stale_timestamp' }; } const source = `${input.method}${normalizeHubSpotSignatureUri(input.publicUri)}${input.rawBody}${input.timestamp}`; const expected = createHmac('sha256', input.clientSecret).update(source, 'utf8').digest('base64'); const expectedBytes = Buffer.from(expected, 'utf8'); const suppliedBytes = Buffer.from(input.signature, 'utf8'); if (expectedBytes.length !== suppliedBytes.length) return { valid: false, reason: 'mismatch' }; return timingSafeEqual(expectedBytes, suppliedBytes) ? { valid: true } : { valid: false, reason: 'mismatch' }; }