refactor(security): consolidate crypto primitives into @sim/security

Move general-purpose crypto primitives out of apps/sim into the
@sim/security package so both apps/sim and apps/realtime can share them.

@sim/security exports (all pure, dependency-free):
  ./compare    safeCompare (constant-time HMAC-wrapped equality)
  ./encryption encrypt/decrypt (AES-256-GCM, iv:cipher:tag format)
  ./hash       sha256Hex
  ./tokens     generateSecureToken (base64url)

Migrate apps/sim call sites to use these + @sim/utils helpers:
  crypto.randomUUID()            -> generateId() from @sim/utils/id
  createHash('sha256').digest    -> sha256Hex
  timingSafeEqual on hashed hex  -> safeCompare
  new Promise(setTimeout)        -> sleep from @sim/utils/helpers

No behavior change: encryption format, digest output, and token
length are preserved exactly.
This commit is contained in:
Waleed Latif
2026-04-22 21:53:39 -07:00
parent a8cc431d77
commit d64e212eb7
28 changed files with 311 additions and 170 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>): 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(),

View File

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

View File

@@ -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<void> {
if (!abortSignal) {
return new Promise((resolve) => setTimeout(resolve, ms))
return sleep(ms)
}
if (abortSignal.aborted) {
return Promise.resolve()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<NextResponse
return new NextResponse('Unauthorized - Invalid Gong JWT claims', { status: 401 })
}
const expectedDigest = createHash('sha256').update(rawBody, 'utf8').digest('hex')
const expectedDigest = sha256Hex(rawBody)
if (claimDigest !== expectedDigest) {
logger.warn(`[${requestId}] Gong JWT body_sha256 mismatch`)
return new NextResponse('Unauthorized - Gong JWT body mismatch', { status: 401 })

View File

@@ -1,5 +1,5 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { NextResponse } from 'next/server'
import type {
AuthContext,
@@ -114,7 +114,7 @@ function stableSerialize(value: unknown): string {
}
function buildFallbackDeliveryFingerprint(body: Record<string, unknown>): string {
return crypto.createHash('sha256').update(stableSerialize(body), 'utf8').digest('hex')
return sha256Hex(stableSerialize(body))
}
function pickRecordId(body: Record<string, unknown>, record: Record<string, unknown>): string {

View File

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

View File

@@ -386,6 +386,7 @@
"@sim/tsconfig": "workspace:*",
"@types/node": "24.2.1",
"typescript": "^5.7.3",
"vitest": "^3.0.8",
},
},
"packages/testing": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string>()
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(/[+/=]/)
})
})

View File

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

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['src/**/*.test.ts'],
},
})