refactor(security): consolidate HMAC-SHA256 primitives into @sim/security

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 <noreply@anthropic.com>
This commit is contained in:
Waleed Latif
2026-04-22 22:08:58 -07:00
parent 92405615dc
commit 3d4be6dce5
23 changed files with 112 additions and 55 deletions

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)}...`,

View File

@@ -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)}...`,

View File

@@ -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)}...`,

View File

@@ -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)}...`,

View File

@@ -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')

View File

@@ -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,

View File

@@ -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)}...`,

View File

@@ -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)

View File

@@ -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)}...`,

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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<WebhookRequestParams, RequestResponse> = {

View File

@@ -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"

View File

@@ -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)
})
})

View File

@@ -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: <hex>` or `X-Hub-Signature-256: sha256=<hex>`). 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')
}