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 fcf9e389ee..64d7a777bb 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -1,5 +1,6 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' @@ -36,11 +37,7 @@ function validateHmac(searchParams: URLSearchParams, clientSecret: string): bool const generatedHmac = crypto.createHmac('sha256', clientSecret).update(message).digest('hex') - try { - return crypto.timingSafeEqual(Buffer.from(hmac, 'hex'), Buffer.from(generatedHmac, 'hex')) - } catch { - return false - } + return safeCompare(hmac, generatedHmac) } export const GET = withRouteHandler(async (request: NextRequest) => { diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index f443fc397b..45a4c3c987 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -1,5 +1,6 @@ import { context as otelContext, trace } from '@opentelemetry/api' import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' import { type NextRequest, NextResponse } from 'next/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' import { @@ -407,7 +408,7 @@ async function handleResumeRequestBody({ break } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + await sleep(POLL_INTERVAL_MS) } if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) { emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, { diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index a9126e5bb2..1125e1d328 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -1,6 +1,6 @@ -import { createHash } from 'crypto' import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' @@ -80,11 +80,7 @@ async function compileDocumentIfNeeded( } const code = buffer.toString('utf-8') - const cacheKey = createHash('sha256') - .update(ext) - .update(code) - .update(workspaceId ?? '') - .digest('hex') + const cacheKey = sha256Hex(`${ext}${code}${workspaceId ?? ''}`) const cached = compiledDocCache.get(cacheKey) if (cached) { return { buffer: cached, contentType: format.contentType } diff --git a/apps/sim/app/api/v1/admin/auth.ts b/apps/sim/app/api/v1/admin/auth.ts index 5e04bcc1d9..813d3f8c5b 100644 --- a/apps/sim/app/api/v1/admin/auth.ts +++ b/apps/sim/app/api/v1/admin/auth.ts @@ -8,8 +8,8 @@ * curl -H "x-admin-key: your_admin_key" https://your-instance/api/v1/admin/... */ -import { createHash, timingSafeEqual } from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' @@ -54,7 +54,7 @@ export function authenticateAdminRequest(request: NextRequest): AdminAuthResult } } - if (!constantTimeCompare(providedKey, adminKey)) { + if (!safeCompare(providedKey, adminKey)) { logger.warn('Invalid admin API key attempted', { keyPrefix: providedKey.slice(0, 8) }) return { authenticated: false, @@ -64,16 +64,3 @@ export function authenticateAdminRequest(request: NextRequest): AdminAuthResult return { authenticated: true } } - -/** - * Constant-time string comparison. - * - * @param a - First string to compare - * @param b - Second string to compare - * @returns True if strings are equal, false otherwise - */ -function constantTimeCompare(a: string, b: string): boolean { - const aHash = createHash('sha256').update(a).digest() - const bHash = createHash('sha256').update(b).digest() - return timingSafeEqual(aHash, bHash) -} diff --git a/apps/sim/lib/api-key/crypto.ts b/apps/sim/lib/api-key/crypto.ts index 3e3118ccbb..b8e89ecb03 100644 --- a/apps/sim/lib/api-key/crypto.ts +++ b/apps/sim/lib/api-key/crypto.ts @@ -1,13 +1,12 @@ -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto' import { createLogger } from '@sim/logger' +import { decrypt, encrypt } from '@sim/security/encryption' +import { sha256Hex } from '@sim/security/hash' +import { generateSecureToken } from '@sim/security/tokens' +import { toError } from '@sim/utils/errors' import { env } from '@/lib/core/config/env' const logger = createLogger('ApiKeyCrypto') -/** - * Get the API encryption key from the environment - * @returns The API encryption key - */ function getApiEncryptionKey(): Buffer | null { const key = env.API_ENCRYPTION_KEY if (!key) { @@ -23,77 +22,38 @@ function getApiEncryptionKey(): Buffer | null { } /** - * Encrypts an API key using the dedicated API encryption key - * @param apiKey - The API key to encrypt - * @returns A promise that resolves to an object containing the encrypted API key and IV + * Encrypts an API key using the dedicated API encryption key. Falls back to + * returning the plain key when `API_ENCRYPTION_KEY` is unset, for backward + * compatibility with deployments that predate encryption-at-rest. */ export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> { const key = getApiEncryptionKey() - - // If no API encryption key is set, return the key as-is for backward compatibility if (!key) { return { encrypted: apiKey, iv: '' } } - - const iv = randomBytes(16) - const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) - let encrypted = cipher.update(apiKey, 'utf8', 'hex') - encrypted += cipher.final('hex') - - const authTag = cipher.getAuthTag() - const ivHex = iv.toString('hex') - - // Format: iv:encrypted:authTag - return { - encrypted: `${ivHex}:${encrypted}:${authTag.toString('hex')}`, - iv: ivHex, - } + return encrypt(apiKey, key) } /** - * Decrypts an API key using the dedicated API encryption key - * @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" or plain text - * @returns A promise that resolves to an object containing the decrypted API key + * Decrypts an API key previously produced by {@link encryptApiKey}. Values + * that lack the `iv:ciphertext:authTag` shape are assumed to be legacy plain + * text and returned unchanged. */ export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> { const parts = encryptedValue.split(':') - - // Check if this is actually encrypted (contains colons) if (parts.length !== 3) { - // This is a plain text key, return as-is return { decrypted: encryptedValue } } const key = getApiEncryptionKey() - - // If no API encryption key is set, assume it's plain text if (!key) { return { decrypted: encryptedValue } } - const ivHex = parts[0] - const authTagHex = parts[2] - const encrypted = parts[1] - - if (!ivHex || !encrypted || !authTagHex) { - throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"') - } - - const iv = Buffer.from(ivHex, 'hex') - const authTag = Buffer.from(authTagHex, 'hex') - try { - const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) - decipher.setAuthTag(authTag) - - let decrypted = decipher.update(encrypted, 'hex', 'utf8') - decrypted += decipher.final('utf8') - - return { decrypted } - } catch (error: unknown) { - logger.error('API key decryption error:', { - error: error instanceof Error ? error.message : 'Unknown error', - }) + return await decrypt(encryptedValue, key) + } catch (error) { + logger.error('API key decryption error:', { error: toError(error).message }) throw error } } @@ -103,7 +63,7 @@ export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted * @returns A new API key string */ export function generateApiKey(): string { - return `sim_${randomBytes(24).toString('base64url')}` + return `sim_${generateSecureToken(24)}` } /** @@ -111,7 +71,7 @@ export function generateApiKey(): string { * @returns A new encrypted API key string */ export function generateEncryptedApiKey(): string { - return `sk-sim-${randomBytes(24).toString('base64url')}` + return `sk-sim-${generateSecureToken(24)}` } /** @@ -142,5 +102,5 @@ export function isLegacyApiKeyFormat(apiKey: string): boolean { * @returns The hex-encoded SHA-256 digest */ export function hashApiKey(plainKey: string): string { - return createHash('sha256').update(plainKey, 'utf8').digest('hex') + return sha256Hex(plainKey) } diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index ba12391aee..df27a2b140 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -1,3 +1,4 @@ +import { generateId } from '@sim/utils/id' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -191,7 +192,7 @@ export function buildPersistedAssistantMessage( requestId?: string ): PersistedMessage { const message: PersistedMessage = { - id: crypto.randomUUID(), + id: generateId(), role: 'assistant', content: result.content, timestamp: new Date().toISOString(), @@ -488,7 +489,7 @@ function normalizeBlocks(rawBlocks: RawBlock[], messageContent: string): Persist export function normalizeMessage(raw: Record): PersistedMessage { const msg: PersistedMessage = { - id: (raw.id as string) ?? crypto.randomUUID(), + id: (raw.id as string) ?? generateId(), role: (raw.role as 'user' | 'assistant') ?? 'assistant', content: (raw.content as string) ?? '', timestamp: (raw.timestamp as string) ?? new Date().toISOString(), diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index ecd52209df..1d598f1b4c 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -2,6 +2,7 @@ import { type Context as OtelContext, context as otelContextApi } from '@opentel import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -615,8 +616,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { // trace ID) as soon as startCopilotOtelRoot runs. Empty only in the // narrow pre-otelRoot window where errors don't correlate anyway. let requestId = '' - const executionId = crypto.randomUUID() - const runId = crypto.randomUUID() + const executionId = generateId() + const runId = generateId() try { const session = await getSession() @@ -628,7 +629,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { const body = ChatMessageSchema.parse(await req.json()) const normalizedContexts = normalizeContexts(body.contexts) ?? [] - userMessageId = body.userMessageId || crypto.randomUUID() + userMessageId = body.userMessageId || generateId() otelRoot = startCopilotOtelRoot({ streamId: userMessageId, diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index f39ddf6a9c..bc5ab1da6d 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1,6 +1,7 @@ import type { Context } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants' @@ -556,7 +557,7 @@ function isRetryableStreamError(error: unknown): boolean { function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise { if (!abortSignal) { - return new Promise((resolve) => setTimeout(resolve, ms)) + return sleep(ms) } if (abortSignal.aborted) { return Promise.resolve() diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index 1d1760b7f4..772c4ba03b 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { parse as csvParse } from 'csv-parse/sync' import { eq } from 'drizzle-orm' import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' @@ -107,7 +108,7 @@ export async function maybeWriteOutputToTable( } const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) const values = chunk.map((rowData, j) => ({ - id: `row_${crypto.randomUUID().replace(/-/g, '')}`, + id: `row_${generateId().replace(/-/g, '')}`, tableId: outputTable, workspaceId: context.workspaceId!, data: rowData, @@ -251,7 +252,7 @@ export async function maybeWriteReadCsvToTable( } const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) const values = chunk.map((rowData, j) => ({ - id: `row_${crypto.randomUUID().replace(/-/g, '')}`, + id: `row_${generateId().replace(/-/g, '')}`, tableId: outputTable, workspaceId: context.workspaceId!, data: rowData, diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 2fb2e0eb6f..af410e9cf1 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { pendingCredentialDraft, user } from '@sim/db/schema' import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { and, eq, lt } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -140,7 +141,7 @@ export async function generateOAuthLink( await db .insert(pendingCredentialDraft) .values({ - id: crypto.randomUUID(), + id: generateId(), userId, workspaceId, providerId, diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index 10a0781f83..01d28525da 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -1,4 +1,6 @@ -import { createHash, createHmac, timingSafeEqual } from 'crypto' +import { createHmac } from 'crypto' +import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' import type { NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import { isDev } from '@/lib/core/config/feature-flags' @@ -14,7 +16,7 @@ function signPayload(payload: string): string { function passwordSlot(encryptedPassword?: string | null): string { if (!encryptedPassword) return '' - return createHash('sha256').update(encryptedPassword).digest('hex').slice(0, 8) + return sha256Hex(encryptedPassword).slice(0, 8) } function generateAuthToken( @@ -46,10 +48,7 @@ export function validateAuthToken( const sig = decoded.slice(lastColon + 1) const expectedSig = signPayload(payload) - if ( - sig.length !== expectedSig.length || - !timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig)) - ) { + if (!safeCompare(sig, expectedSig)) { return false } diff --git a/apps/sim/lib/core/security/encryption.ts b/apps/sim/lib/core/security/encryption.ts index 97d93aaca1..3d2e7390ec 100644 --- a/apps/sim/lib/core/security/encryption.ts +++ b/apps/sim/lib/core/security/encryption.ts @@ -1,5 +1,6 @@ -import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' import { createLogger } from '@sim/logger' +import { decrypt, encrypt } from '@sim/security/encryption' +import { toError } from '@sim/utils/errors' import { env } from '@/lib/core/config/env' const logger = createLogger('Encryption') @@ -13,59 +14,23 @@ function getEncryptionKey(): Buffer { } /** - * Encrypts a secret using AES-256-GCM + * Encrypts a secret using AES-256-GCM with the app's `ENCRYPTION_KEY`. * @param secret - The secret to encrypt - * @returns A promise that resolves to an object containing the encrypted secret and IV + * @returns A promise resolving to the encrypted value (`iv:ciphertext:authTag`) and the IV. */ export async function encryptSecret(secret: string): Promise<{ encrypted: string; iv: string }> { - const iv = randomBytes(16) - const key = getEncryptionKey() - - const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) - let encrypted = cipher.update(secret, 'utf8', 'hex') - encrypted += cipher.final('hex') - - const authTag = cipher.getAuthTag() - const ivHex = iv.toString('hex') - - // Format: iv:encrypted:authTag - return { - encrypted: `${ivHex}:${encrypted}:${authTag.toString('hex')}`, - iv: ivHex, - } + return encrypt(secret, getEncryptionKey()) } /** - * Decrypts an encrypted secret - * @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" - * @returns A promise that resolves to an object containing the decrypted secret + * Decrypts a secret previously produced by {@link encryptSecret}. Logs and + * rethrows on malformed input or tampered ciphertext. */ export async function decryptSecret(encryptedValue: string): Promise<{ decrypted: string }> { - const parts = encryptedValue.split(':') - const ivHex = parts[0] - const authTagHex = parts[parts.length - 1] - const encrypted = parts.slice(1, -1).join(':') - - if (!ivHex || !encrypted || !authTagHex) { - throw new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"') - } - - const key = getEncryptionKey() - const iv = Buffer.from(ivHex, 'hex') - const authTag = Buffer.from(authTagHex, 'hex') - try { - const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) - decipher.setAuthTag(authTag) - - let decrypted = decipher.update(encrypted, 'hex', 'utf8') - decrypted += decipher.final('utf8') - - return { decrypted } - } catch (error: unknown) { - logger.error('Decryption error:', { - error: error instanceof Error ? error.message : 'Unknown error', - }) + return await decrypt(encryptedValue, getEncryptionKey()) + } catch (error) { + logger.error('Decryption error:', { error: toError(error).message }) throw error } } diff --git a/apps/sim/lib/knowledge/chunks/service.ts b/apps/sim/lib/knowledge/chunks/service.ts index e8bfac6679..5e3cf68918 100644 --- a/apps/sim/lib/knowledge/chunks/service.ts +++ b/apps/sim/lib/knowledge/chunks/service.ts @@ -1,7 +1,7 @@ -import { createHash } from 'crypto' import { db } from '@sim/db' import { document, embedding, knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { generateId } from '@sim/utils/id' import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm' import type { @@ -155,7 +155,7 @@ export async function createChunk( knowledgeBaseId, documentId, chunkIndex: nextChunkIndex, - chunkHash: createHash('sha256').update(chunkData.content).digest('hex'), + chunkHash: sha256Hex(chunkData.content), content: chunkData.content, contentLength: chunkData.content.length, tokenCount: tokenCount.count, @@ -368,7 +368,7 @@ export async function updateChunk( dbUpdateData.content = content dbUpdateData.contentLength = newContentLength dbUpdateData.tokenCount = tokenCount.count - dbUpdateData.chunkHash = createHash('sha256').update(content).digest('hex') + dbUpdateData.chunkHash = sha256Hex(content) // Add the embedding field to the update data dbUpdateData.embedding = embeddings[0] } else { @@ -376,7 +376,7 @@ export async function updateChunk( dbUpdateData.content = content dbUpdateData.contentLength = newContentLength dbUpdateData.tokenCount = oldTokenCount // Keep the same token count if content is identical - dbUpdateData.chunkHash = createHash('sha256').update(content).digest('hex') + dbUpdateData.chunkHash = sha256Hex(content) } if (updateData.enabled !== undefined) { diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 14a7858e00..84241c5772 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -1,4 +1,3 @@ -import crypto from 'crypto' import { db } from '@sim/db' import { document, @@ -8,6 +7,7 @@ import { knowledgeConnector, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { tasks } from '@trigger.dev/sdk' @@ -520,7 +520,7 @@ export async function processDocumentAsync( knowledgeBaseId, documentId, chunkIndex, - chunkHash: crypto.createHash('sha256').update(chunk.text).digest('hex'), + chunkHash: sha256Hex(chunk.text), content: chunk.text, contentLength: chunk.text.length, tokenCount: Math.ceil(chunk.text.length / 4), diff --git a/apps/sim/lib/logs/execution/snapshot/service.ts b/apps/sim/lib/logs/execution/snapshot/service.ts index 82327c1903..ea6cec7e6d 100644 --- a/apps/sim/lib/logs/execution/snapshot/service.ts +++ b/apps/sim/lib/logs/execution/snapshot/service.ts @@ -1,7 +1,7 @@ -import { createHash } from 'crypto' import { db } from '@sim/db' import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { generateId } from '@sim/utils/id' import { and, eq, lt, notExists, sql } from 'drizzle-orm' import type { @@ -85,7 +85,7 @@ export class SnapshotService implements ISnapshotService { computeStateHash(state: WorkflowState): string { const normalizedState = normalizeWorkflowState(state) const stateString = normalizedStringify(normalizedState) - return createHash('sha256').update(stateString).digest('hex') + return sha256Hex(stateString) } async cleanupOrphanedSnapshots(olderThanDays: number): Promise { diff --git a/apps/sim/lib/messaging/email/unsubscribe.ts b/apps/sim/lib/messaging/email/unsubscribe.ts index 30ace1d69a..3c02fdfe3a 100644 --- a/apps/sim/lib/messaging/email/unsubscribe.ts +++ b/apps/sim/lib/messaging/email/unsubscribe.ts @@ -1,7 +1,8 @@ -import { createHash, randomBytes } from 'crypto' +import { randomBytes } from 'crypto' import { db } from '@sim/db' import { settings, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { eq } from 'drizzle-orm' import { env } from '@/lib/core/config/env' import type { EmailType } from '@/lib/messaging/email/mailer' @@ -20,9 +21,7 @@ export interface EmailPreferences { */ export function generateUnsubscribeToken(email: string, emailType = 'marketing'): string { const salt = randomBytes(16).toString('hex') - const hash = createHash('sha256') - .update(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`) - .digest('hex') + const hash = sha256Hex(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`) return `${salt}:${hash}:${emailType}` } @@ -40,9 +39,7 @@ export function verifyUnsubscribeToken( if (parts.length === 2) { const [salt, expectedHash] = parts - const hash = createHash('sha256') - .update(`${email}:${salt}:${env.BETTER_AUTH_SECRET}`) - .digest('hex') + const hash = sha256Hex(`${email}:${salt}:${env.BETTER_AUTH_SECRET}`) return { valid: hash === expectedHash, emailType: 'marketing' } } @@ -50,9 +47,7 @@ export function verifyUnsubscribeToken( const [salt, expectedHash, emailType] = parts if (!salt || !expectedHash || !emailType) return { valid: false } - const hash = createHash('sha256') - .update(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`) - .digest('hex') + const hash = sha256Hex(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`) return { valid: hash === expectedHash, emailType } } catch (error) { diff --git a/apps/sim/lib/webhooks/providers/gong.ts b/apps/sim/lib/webhooks/providers/gong.ts index f1c3f355dc..399e9074d2 100644 --- a/apps/sim/lib/webhooks/providers/gong.ts +++ b/apps/sim/lib/webhooks/providers/gong.ts @@ -1,5 +1,5 @@ -import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { toError } from '@sim/utils/errors' import * as jose from 'jose' import { NextResponse } from 'next/server' @@ -102,7 +102,7 @@ export async function verifyGongJwtAuth(ctx: AuthContext): Promise): string { - return crypto.createHash('sha256').update(stableSerialize(body), 'utf8').digest('hex') + return sha256Hex(stableSerialize(body)) } function pickRecordId(body: Record, record: Record): string { diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index 783c728326..3b23a5ed08 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -1,8 +1,9 @@ -import { createHash, createHmac } from 'crypto' +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 { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import type { @@ -125,7 +126,7 @@ function buildWhatsAppIdempotencyKey(keys: Set): string | null { } const sortedKeys = Array.from(keys).sort() - const digest = createHash('sha256').update(sortedKeys.join('|'), 'utf8').digest('hex') + const digest = sha256Hex(sortedKeys.join('|')) return `whatsapp:${sortedKeys.length}:${digest}` } diff --git a/bun.lock b/bun.lock index 602025ca8e..99b9dad3c7 100644 --- a/bun.lock +++ b/bun.lock @@ -386,6 +386,7 @@ "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", "typescript": "^5.7.3", + "vitest": "^3.0.8", }, }, "packages/testing": { diff --git a/packages/security/package.json b/packages/security/package.json index d5a1dee553..7b64933ba1 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -13,6 +13,18 @@ "./compare": { "types": "./src/compare.ts", "default": "./src/compare.ts" + }, + "./encryption": { + "types": "./src/encryption.ts", + "default": "./src/encryption.ts" + }, + "./hash": { + "types": "./src/hash.ts", + "default": "./src/hash.ts" + }, + "./tokens": { + "types": "./src/tokens.ts", + "default": "./src/tokens.ts" } }, "scripts": { @@ -20,12 +32,15 @@ "lint": "biome check --write --unsafe .", "lint:check": "biome check .", "format": "biome format --write .", - "format:check": "biome format ." + "format:check": "biome format .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": {}, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^3.0.8" } } diff --git a/packages/security/src/encryption.test.ts b/packages/security/src/encryption.test.ts new file mode 100644 index 0000000000..13369500fd --- /dev/null +++ b/packages/security/src/encryption.test.ts @@ -0,0 +1,74 @@ +import { randomBytes } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { decrypt, encrypt } from './encryption' + +const KEY = Buffer.from('0'.repeat(64), 'hex') + +describe('encrypt', () => { + it('returns iv:ciphertext:authTag and a 32-char hex IV', async () => { + const result = await encrypt('secret', KEY) + expect(result.encrypted.split(':')).toHaveLength(3) + expect(result.iv).toHaveLength(32) + }) + + it('produces distinct ciphertexts for the same input', async () => { + const a = await encrypt('same', KEY) + const b = await encrypt('same', KEY) + expect(a.encrypted).not.toBe(b.encrypted) + }) + + it('rejects keys that are not 32 bytes', async () => { + await expect(encrypt('x', Buffer.alloc(16))).rejects.toThrow(/32 bytes/) + }) +}) + +describe('decrypt', () => { + it('round-trips arbitrary UTF-8 input', async () => { + const plaintext = 'Hello, !"#$%&\'()*+,-./0123456789:;<=>?@' + const { encrypted } = await encrypt(plaintext, KEY) + const { decrypted } = await decrypt(encrypted, KEY) + expect(decrypted).toBe(plaintext) + }) + + it('round-trips empty strings', async () => { + const { encrypted } = await encrypt('', KEY) + const { decrypted } = await decrypt(encrypted, KEY) + expect(decrypted).toBe('') + }) + + it('round-trips long inputs', async () => { + const plaintext = 'a'.repeat(10_000) + const { encrypted } = await encrypt(plaintext, KEY) + const { decrypted } = await decrypt(encrypted, KEY) + expect(decrypted).toBe(plaintext) + }) + + it('throws on malformed input', async () => { + await expect(decrypt('invalid', KEY)).rejects.toThrow( + 'Invalid encrypted value format. Expected "iv:encrypted:authTag"' + ) + await expect(decrypt('part1:part2', KEY)).rejects.toThrow( + 'Invalid encrypted value format. Expected "iv:encrypted:authTag"' + ) + }) + + it('throws when ciphertext is tampered', async () => { + const { encrypted } = await encrypt('original', KEY) + const parts = encrypted.split(':') + parts[1] = `deadbeef${parts[1].slice(8)}` + await expect(decrypt(parts.join(':'), KEY)).rejects.toThrow() + }) + + it('throws when auth tag is tampered', async () => { + const { encrypted } = await encrypt('original', KEY) + const parts = encrypted.split(':') + parts[2] = '0'.repeat(32) + await expect(decrypt(parts.join(':'), KEY)).rejects.toThrow() + }) + + it('throws when decrypted with a different key', async () => { + const { encrypted } = await encrypt('original', KEY) + const otherKey = randomBytes(32) + await expect(decrypt(encrypted, otherKey)).rejects.toThrow() + }) +}) diff --git a/packages/security/src/encryption.ts b/packages/security/src/encryption.ts new file mode 100644 index 0000000000..c1bbf078e3 --- /dev/null +++ b/packages/security/src/encryption.ts @@ -0,0 +1,68 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' + +/** + * AES-256-GCM encryption primitive. Produces a self-contained string in the + * format `iv:ciphertext:authTag` (all hex-encoded) that can be stored and + * later passed directly to {@link decrypt}. + * + * @param plaintext - UTF-8 string to encrypt + * @param key - 32-byte encryption key + */ +export async function encrypt( + plaintext: string, + key: Buffer +): Promise<{ encrypted: string; iv: string }> { + assertKey(key) + + const iv = randomBytes(16) + const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) + let encrypted = cipher.update(plaintext, 'utf8', 'hex') + encrypted += cipher.final('hex') + + const authTag = cipher.getAuthTag() + const ivHex = iv.toString('hex') + + return { + encrypted: `${ivHex}:${encrypted}:${authTag.toString('hex')}`, + iv: ivHex, + } +} + +/** + * AES-256-GCM decryption primitive. Expects input produced by {@link encrypt} + * in the format `iv:ciphertext:authTag`. Throws when the format is malformed + * or when the GCM auth tag does not verify (tampered ciphertext, wrong key). + */ +export async function decrypt(encryptedValue: string, key: Buffer): Promise<{ decrypted: string }> { + assertKey(key) + + const parts = encryptedValue.split(':') + if (parts.length < 3) { + throw new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"') + } + + const ivHex = parts[0] + const authTagHex = parts[parts.length - 1] + const encrypted = parts.slice(1, -1).join(':') + + if (!ivHex || !authTagHex) { + throw new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"') + } + + const iv = Buffer.from(ivHex, 'hex') + const authTag = Buffer.from(authTagHex, 'hex') + + const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) + decipher.setAuthTag(authTag) + + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return { decrypted } +} + +function assertKey(key: Buffer): void { + if (key.length !== 32) { + throw new Error('Encryption key must be 32 bytes (256 bits)') + } +} diff --git a/packages/security/src/hash.test.ts b/packages/security/src/hash.test.ts new file mode 100644 index 0000000000..89f9b0929a --- /dev/null +++ b/packages/security/src/hash.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { sha256Hex } from './hash' + +describe('sha256Hex', () => { + it('is deterministic', () => { + expect(sha256Hex('hello')).toBe(sha256Hex('hello')) + }) + + it('returns a 64-char hex digest', () => { + expect(sha256Hex('hello')).toMatch(/^[0-9a-f]{64}$/) + }) + + it('matches the published vector for the empty string', () => { + expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') + }) + + it('differs for different inputs', () => { + expect(sha256Hex('a')).not.toBe(sha256Hex('b')) + }) +}) diff --git a/packages/security/src/hash.ts b/packages/security/src/hash.ts new file mode 100644 index 0000000000..9a9a18b498 --- /dev/null +++ b/packages/security/src/hash.ts @@ -0,0 +1,10 @@ +import { createHash } from 'node:crypto' + +/** + * Deterministic SHA-256 digest of a UTF-8 string, hex-encoded. Use for + * indexed lookup of sensitive values (e.g. API key hash columns) where the + * caller only needs to verify equality without ever reversing the hash. + */ +export function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf8').digest('hex') +} diff --git a/packages/security/src/tokens.test.ts b/packages/security/src/tokens.test.ts new file mode 100644 index 0000000000..035b2a4e1f --- /dev/null +++ b/packages/security/src/tokens.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { generateSecureToken } from './tokens' + +describe('generateSecureToken', () => { + it('defaults to 24 bytes (32-char base64url)', () => { + const token = generateSecureToken() + expect(token).toHaveLength(32) + expect(token).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + it('honors a custom byte length', () => { + const token = generateSecureToken(16) + expect(token).toHaveLength(22) + }) + + it('never repeats across 1000 draws', () => { + const seen = new Set() + for (let i = 0; i < 1000; i++) seen.add(generateSecureToken()) + expect(seen.size).toBe(1000) + }) + + it('is URL-safe (no +, /, or = padding)', () => { + const token = generateSecureToken(64) + expect(token).not.toMatch(/[+/=]/) + }) +}) diff --git a/packages/security/src/tokens.ts b/packages/security/src/tokens.ts new file mode 100644 index 0000000000..311aaf4679 --- /dev/null +++ b/packages/security/src/tokens.ts @@ -0,0 +1,12 @@ +import { randomBytes } from 'node:crypto' + +/** + * Generate a cryptographically secure random token encoded as base64url. The + * returned string is URL-safe (no padding, no `+`/`/`) and suitable for use + * as an API key body, bearer token, or one-time identifier. + * + * @param byteLength - Number of random bytes to draw before encoding. Defaults to 24 (~32 chars). + */ +export function generateSecureToken(byteLength = 24): string { + return randomBytes(byteLength).toString('base64url') +} diff --git a/packages/security/vitest.config.ts b/packages/security/vitest.config.ts new file mode 100644 index 0000000000..471771e48f --- /dev/null +++ b/packages/security/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +})