mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -386,6 +386,7 @@
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/node": "24.2.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8",
|
||||
},
|
||||
},
|
||||
"packages/testing": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
74
packages/security/src/encryption.test.ts
Normal file
74
packages/security/src/encryption.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
68
packages/security/src/encryption.ts
Normal file
68
packages/security/src/encryption.ts
Normal 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)')
|
||||
}
|
||||
}
|
||||
20
packages/security/src/hash.test.ts
Normal file
20
packages/security/src/hash.test.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
10
packages/security/src/hash.ts
Normal file
10
packages/security/src/hash.ts
Normal 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')
|
||||
}
|
||||
26
packages/security/src/tokens.test.ts
Normal file
26
packages/security/src/tokens.test.ts
Normal 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(/[+/=]/)
|
||||
})
|
||||
})
|
||||
12
packages/security/src/tokens.ts
Normal file
12
packages/security/src/tokens.ts
Normal 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')
|
||||
}
|
||||
9
packages/security/vitest.config.ts
Normal file
9
packages/security/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user