From 3d4be6dce5522e657fa646e80ce997889d6c9fd1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 22 Apr 2026 22:08:58 -0700 Subject: [PATCH] refactor(security): consolidate HMAC-SHA256 primitives into @sim/security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hmacSha256Hex and hmacSha256Base64 to @sim/security/hmac and migrates 15 webhook providers plus 5 other hot paths (deployment token signing, outbound webhook requests, workspace notification delivery, notification test route, Shopify OAuth callback) off bare `createHmac` calls. Secret parameter accepts `string | Buffer` to cover base64-decoded Svix-style secrets (Resend) and MS Teams' HMAC scheme. AWS SigV4 signing in S3 and Textract tools intentionally retains direct `createHmac` usage — its multi-step key derivation chain doesn't fit a generic helper. Co-Authored-By: Claude Opus 4.7 --- .../api/auth/oauth2/callback/shopify/route.ts | 4 +- .../[notificationId]/test/route.ts | 6 +-- .../workspace-notification-delivery.ts | 6 +-- apps/sim/lib/core/security/deployment.ts | 4 +- apps/sim/lib/webhooks/providers/ashby.ts | 4 +- apps/sim/lib/webhooks/providers/attio.ts | 4 +- apps/sim/lib/webhooks/providers/calcom.ts | 4 +- apps/sim/lib/webhooks/providers/circleback.ts | 4 +- apps/sim/lib/webhooks/providers/fireflies.ts | 4 +- apps/sim/lib/webhooks/providers/greenhouse.ts | 4 +- apps/sim/lib/webhooks/providers/jira.ts | 4 +- apps/sim/lib/webhooks/providers/linear.ts | 4 +- .../lib/webhooks/providers/microsoft-teams.ts | 5 +-- apps/sim/lib/webhooks/providers/notion.ts | 4 +- apps/sim/lib/webhooks/providers/resend.ts | 7 +-- apps/sim/lib/webhooks/providers/slack.ts | 7 +-- apps/sim/lib/webhooks/providers/typeform.ts | 4 +- apps/sim/lib/webhooks/providers/whatsapp.ts | 4 +- apps/sim/lib/webhooks/providers/zoom.ts | 9 ++-- apps/sim/tools/http/webhook_request.ts | 4 +- packages/security/package.json | 4 ++ packages/security/src/hmac.test.ts | 43 +++++++++++++++++++ packages/security/src/hmac.ts | 24 +++++++++++ 23 files changed, 112 insertions(+), 55 deletions(-) create mode 100644 packages/security/src/hmac.test.ts create mode 100644 packages/security/src/hmac.ts diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index 64d7a777bb..c12c52ea4b 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' @@ -35,7 +35,7 @@ function validateHmac(searchParams: URLSearchParams, clientSecret: string): bool .map((key) => `${key}=${params[key]}`) .join('&') - const generatedHmac = crypto.createHmac('sha256', clientSecret).update(message).digest('hex') + const generatedHmac = hmacSha256Hex(message, clientSecret) return safeCompare(hmac, generatedHmac) } diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts index 270522f264..d3afc81e23 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts @@ -1,7 +1,7 @@ -import { createHmac } from 'crypto' import { db } from '@sim/db' import { account, workspaceNotificationSubscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' @@ -36,9 +36,7 @@ interface SlackConfig { function generateSignature(secret: string, timestamp: number, body: string): string { const signatureBase = `${timestamp}.${body}` - const hmac = createHmac('sha256', secret) - hmac.update(signatureBase) - return hmac.digest('hex') + return hmacSha256Hex(signatureBase, secret) } function buildTestPayload(subscription: typeof workspaceNotificationSubscription.$inferSelect) { diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index 18b8e38e18..454e68bea9 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -1,4 +1,3 @@ -import { createHmac } from 'crypto' import { db, workflowExecutionLogs } from '@sim/db' import { account, @@ -6,6 +5,7 @@ import { workspaceNotificationSubscription, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { formatDuration } from '@sim/utils/formatting' import { generateId } from '@sim/utils/id' @@ -62,9 +62,7 @@ interface NotificationPayload { function generateSignature(secret: string, timestamp: number, body: string): string { const signatureBase = `${timestamp}.${body}` - const hmac = createHmac('sha256', secret) - hmac.update(signatureBase) - return hmac.digest('hex') + return hmacSha256Hex(signatureBase, secret) } async function buildPayload( diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index 01d28525da..c3658b9d39 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -1,6 +1,6 @@ -import { createHmac } from 'crypto' import { safeCompare } from '@sim/security/compare' import { sha256Hex } from '@sim/security/hash' +import { hmacSha256Hex } from '@sim/security/hmac' import type { NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import { isDev } from '@/lib/core/config/feature-flags' @@ -11,7 +11,7 @@ import { isDev } from '@/lib/core/config/feature-flags' */ function signPayload(payload: string): string { - return createHmac('sha256', env.BETTER_AUTH_SECRET).update(payload).digest('hex') + return hmacSha256Hex(payload, env.BETTER_AUTH_SECRET) } function passwordSlot(encryptedPassword?: string | null): string { diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index e07dd11e1e..0580a899b8 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { @@ -24,7 +24,7 @@ function validateAshbySignature(secretToken: string, signature: string, body: st return false } const providedSignature = signature.substring(7) - const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secretToken) return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Ashby signature:', error) diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts index fee73a4e17..ea9debff8f 100644 --- a/apps/sim/lib/webhooks/providers/attio.ts +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -29,7 +29,7 @@ function validateAttioSignature(secret: string, signature: string, body: string) }) return false } - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Attio signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, diff --git a/apps/sim/lib/webhooks/providers/calcom.ts b/apps/sim/lib/webhooks/providers/calcom.ts index a17805233e..798cea64f2 100644 --- a/apps/sim/lib/webhooks/providers/calcom.ts +++ b/apps/sim/lib/webhooks/providers/calcom.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' @@ -22,7 +22,7 @@ function validateCalcomSignature(secret: string, signature: string, body: string } else { providedSignature = signature } - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Cal.com signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, diff --git a/apps/sim/lib/webhooks/providers/circleback.ts b/apps/sim/lib/webhooks/providers/circleback.ts index 2dacac8fef..ef0c9facd2 100644 --- a/apps/sim/lib/webhooks/providers/circleback.ts +++ b/apps/sim/lib/webhooks/providers/circleback.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import type { FormatInputContext, FormatInputResult, @@ -20,7 +20,7 @@ function validateCirclebackSignature(secret: string, signature: string, body: st }) return false } - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Circleback signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, diff --git a/apps/sim/lib/webhooks/providers/fireflies.ts b/apps/sim/lib/webhooks/providers/fireflies.ts index 47fd07dd4b..e7e67da059 100644 --- a/apps/sim/lib/webhooks/providers/fireflies.ts +++ b/apps/sim/lib/webhooks/providers/fireflies.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import type { FormatInputContext, FormatInputResult, @@ -27,7 +27,7 @@ function validateFirefliesSignature(secret: string, signature: string, body: str return false } const providedSignature = signature.substring(7) - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Fireflies signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, diff --git a/apps/sim/lib/webhooks/providers/greenhouse.ts b/apps/sim/lib/webhooks/providers/greenhouse.ts index 654fec9194..f7d2c896fd 100644 --- a/apps/sim/lib/webhooks/providers/greenhouse.ts +++ b/apps/sim/lib/webhooks/providers/greenhouse.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import type { EventMatchContext, FormatInputContext, @@ -25,7 +25,7 @@ function validateGreenhouseSignature(secretKey: string, signature: string, body: return false } const providedDigest = signature.substring(prefix.length) - const computedDigest = crypto.createHmac('sha256', secretKey).update(body, 'utf8').digest('hex') + const computedDigest = hmacSha256Hex(body, secretKey) return safeCompare(computedDigest, providedDigest) } catch { logger.error('Error validating Greenhouse signature') diff --git a/apps/sim/lib/webhooks/providers/jira.ts b/apps/sim/lib/webhooks/providers/jira.ts index 28851ed913..0feeecb5a5 100644 --- a/apps/sim/lib/webhooks/providers/jira.ts +++ b/apps/sim/lib/webhooks/providers/jira.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import type { EventMatchContext, FormatInputContext, @@ -28,7 +28,7 @@ export function validateJiraSignature(secret: string, signature: string, body: s return false } const providedSignature = signature.substring(7) - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Jira signature comparison', { computedLength: computedHash.length, providedLength: providedSignature.length, diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 4da52f3a2a..60fb176c28 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { NextResponse } from 'next/server' @@ -28,7 +28,7 @@ function validateLinearSignature(secret: string, signature: string, body: string }) return false } - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Linear signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index de8995d477..5b2c795cbd 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -1,8 +1,8 @@ -import crypto from 'crypto' import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Base64 } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -46,8 +46,7 @@ function validateMicrosoftTeamsSignature( } const providedSignature = signature.substring(5) const secretBytes = Buffer.from(hmacSecret, 'base64') - const bodyBytes = Buffer.from(body, 'utf8') - const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') + const computedHash = hmacSha256Base64(body, secretBytes) return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Microsoft Teams signature:', error) diff --git a/apps/sim/lib/webhooks/providers/notion.ts b/apps/sim/lib/webhooks/providers/notion.ts index d0881552b2..730c1077d3 100644 --- a/apps/sim/lib/webhooks/providers/notion.ts +++ b/apps/sim/lib/webhooks/providers/notion.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { NextResponse } from 'next/server' import type { EventMatchContext, @@ -28,7 +28,7 @@ function validateNotionSignature(secret: string, signature: string, body: string } const providedHash = signature.startsWith('sha256=') ? signature.slice(7) : signature - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedHash = hmacSha256Hex(body, secret) logger.debug('Notion signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts index 3061af91f7..f0da305381 100644 --- a/apps/sim/lib/webhooks/providers/resend.ts +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -1,6 +1,6 @@ -import crypto from 'node:crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Base64 } from '@sim/security/hmac' import { NextResponse } from 'next/server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { @@ -41,10 +41,7 @@ function verifySvixSignature( const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64') const toSign = `${msgId}.${timestamp}.${rawBody}` - const expectedSignature = crypto - .createHmac('sha256', secretBytes) - .update(toSign, 'utf8') - .digest('base64') + const expectedSignature = hmacSha256Base64(toSign, secretBytes) const providedSignatures = signatures.split(' ') for (const versionedSig of providedSignatures) { diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 6e99b1cfb9..a48032c08d 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { @@ -207,10 +207,7 @@ function validateSlackSignature( const providedSignature = signature.substring(3) const basestring = `v0:${timestamp}:${rawBody}` - const computedHash = crypto - .createHmac('sha256', signingSecret) - .update(basestring, 'utf8') - .digest('hex') + const computedHash = hmacSha256Hex(basestring, signingSecret) return safeCompare(computedHash, providedSignature) } catch (error) { diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts index 06c602e3ba..16df0e6c47 100644 --- a/apps/sim/lib/webhooks/providers/typeform.ts +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Base64 } from '@sim/security/hmac' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, @@ -23,7 +23,7 @@ function validateTypeformSignature(secret: string, signature: string, body: stri return false } const providedSignature = signature.substring(7) - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64') + const computedHash = hmacSha256Base64(body, secret) return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Typeform signature:', error) diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index 3b23a5ed08..6e20d4f728 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -1,9 +1,9 @@ -import { createHmac } from 'crypto' import { db, workflowDeploymentVersion } from '@sim/db' import { webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { sha256Hex } from '@sim/security/hash' +import { hmacSha256Hex } from '@sim/security/hmac' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import type { @@ -111,7 +111,7 @@ function validateWhatsAppSignature(secret: string, signature: string, body: stri } const providedSignature = signature.substring(7) - const computedSignature = createHmac('sha256', secret).update(body, 'utf8').digest('hex') + const computedSignature = hmacSha256Hex(body, secret) return safeCompare(computedSignature, providedSignature) } catch (error) { diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 48df3d1465..60f4e0ef74 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -1,7 +1,7 @@ -import crypto from 'crypto' import { db, webhook, workflow } from '@sim/db' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' @@ -41,7 +41,7 @@ export function validateZoomSignature( } const message = `v0:${timestamp}:${body}` - const computedHash = crypto.createHmac('sha256', secretToken).update(message).digest('hex') + const computedHash = hmacSha256Hex(message, secretToken) const expectedSignature = `v0=${computedHash}` return safeCompare(expectedSignature, signature) @@ -206,10 +206,7 @@ export const zoomHandler: WebhookProviderHandler = { secretToken && validateZoomSignature(secretToken, signature, timestamp, bodyForSignature) ) { - const hashForValidate = crypto - .createHmac('sha256', secretToken) - .update(plainToken) - .digest('hex') + const hashForValidate = hmacSha256Hex(plainToken, secretToken) return NextResponse.json({ plainToken, diff --git a/apps/sim/tools/http/webhook_request.ts b/apps/sim/tools/http/webhook_request.ts index 569f535e34..39e4e8e21b 100644 --- a/apps/sim/tools/http/webhook_request.ts +++ b/apps/sim/tools/http/webhook_request.ts @@ -1,4 +1,4 @@ -import { createHmac } from 'crypto' +import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' import type { RequestResponse, WebhookRequestParams } from '@/tools/http/types' import type { ToolConfig } from '@/tools/types' @@ -8,7 +8,7 @@ import type { ToolConfig } from '@/tools/types' */ function generateSignature(secret: string, timestamp: number, body: string): string { const signatureBase = `${timestamp}.${body}` - return createHmac('sha256', secret).update(signatureBase).digest('hex') + return hmacSha256Hex(signatureBase, secret) } export const webhookRequestTool: ToolConfig = { diff --git a/packages/security/package.json b/packages/security/package.json index 7b64933ba1..dc82311179 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -22,6 +22,10 @@ "types": "./src/hash.ts", "default": "./src/hash.ts" }, + "./hmac": { + "types": "./src/hmac.ts", + "default": "./src/hmac.ts" + }, "./tokens": { "types": "./src/tokens.ts", "default": "./src/tokens.ts" diff --git a/packages/security/src/hmac.test.ts b/packages/security/src/hmac.test.ts new file mode 100644 index 0000000000..f0f84d29f8 --- /dev/null +++ b/packages/security/src/hmac.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { hmacSha256Base64, hmacSha256Hex } from './hmac' + +describe('hmacSha256Hex', () => { + it('is deterministic', () => { + expect(hmacSha256Hex('body', 'secret')).toBe(hmacSha256Hex('body', 'secret')) + }) + + it('returns a 64-char hex digest', () => { + expect(hmacSha256Hex('body', 'secret')).toMatch(/^[0-9a-f]{64}$/) + }) + + it('matches RFC 4231 test vector 1', () => { + const key = Buffer.from('0b'.repeat(20), 'hex').toString('binary') + expect(hmacSha256Hex('Hi There', key)).toBe( + 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7' + ) + }) + + it('differs when body changes', () => { + expect(hmacSha256Hex('a', 'k')).not.toBe(hmacSha256Hex('b', 'k')) + }) + + it('differs when secret changes', () => { + expect(hmacSha256Hex('body', 'k1')).not.toBe(hmacSha256Hex('body', 'k2')) + }) +}) + +describe('hmacSha256Base64', () => { + it('is deterministic', () => { + expect(hmacSha256Base64('body', 'secret')).toBe(hmacSha256Base64('body', 'secret')) + }) + + it('returns a base64 digest', () => { + expect(hmacSha256Base64('body', 'secret')).toMatch(/^[A-Za-z0-9+/]+=*$/) + }) + + it('agrees with hex form via Buffer conversion', () => { + const hex = hmacSha256Hex('body', 'secret') + const b64 = hmacSha256Base64('body', 'secret') + expect(Buffer.from(b64, 'base64').toString('hex')).toBe(hex) + }) +}) diff --git a/packages/security/src/hmac.ts b/packages/security/src/hmac.ts new file mode 100644 index 0000000000..5851263410 --- /dev/null +++ b/packages/security/src/hmac.ts @@ -0,0 +1,24 @@ +import { createHmac } from 'node:crypto' + +/** + * HMAC-SHA256 of a UTF-8 body using the given secret, hex-encoded. Use for + * webhook signature verification where the provider sends a hex digest + * (e.g. `X-Signature: ` or `X-Hub-Signature-256: sha256=`). Pass the + * secret as a `Buffer` when the provider's scheme requires base64-decoding + * (e.g. Svix-compatible `whsec_...` secrets). Pair with + * {@link ../compare | safeCompare} for timing-safe comparison. + */ +export function hmacSha256Hex(body: string, secret: string | Buffer): string { + return createHmac('sha256', secret).update(body, 'utf8').digest('hex') +} + +/** + * HMAC-SHA256 of a UTF-8 body using the given secret, base64-encoded. Use for + * webhook signature verification where the provider sends a base64 digest + * (e.g. Typeform, Microsoft Teams outgoing webhooks). Pass the secret as a + * `Buffer` when the provider's scheme requires base64-decoding. Pair with + * {@link ../compare | safeCompare} for timing-safe comparison. + */ +export function hmacSha256Base64(body: string, secret: string | Buffer): string { + return createHmac('sha256', secret).update(body, 'utf8').digest('base64') +}