Skip to content

Commit d64e212

Browse files
committed
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.
1 parent a8cc431 commit d64e212

28 files changed

Lines changed: 311 additions & 170 deletions

File tree

apps/sim/app/api/auth/oauth2/callback/shopify/route.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import crypto from 'crypto'
22
import { createLogger } from '@sim/logger'
3+
import { safeCompare } from '@sim/security/compare'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { getSession } from '@/lib/auth'
56
import { env } from '@/lib/core/config/env'
@@ -36,11 +37,7 @@ function validateHmac(searchParams: URLSearchParams, clientSecret: string): bool
3637

3738
const generatedHmac = crypto.createHmac('sha256', clientSecret).update(message).digest('hex')
3839

39-
try {
40-
return crypto.timingSafeEqual(Buffer.from(hmac, 'hex'), Buffer.from(generatedHmac, 'hex'))
41-
} catch {
42-
return false
43-
}
40+
return safeCompare(hmac, generatedHmac)
4441
}
4542

4643
export const GET = withRouteHandler(async (request: NextRequest) => {

apps/sim/app/api/copilot/chat/stream/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { context as otelContext, trace } from '@opentelemetry/api'
22
import { createLogger } from '@sim/logger'
3+
import { sleep } from '@sim/utils/helpers'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
56
import {
@@ -407,7 +408,7 @@ async function handleResumeRequestBody({
407408
break
408409
}
409410

410-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
411+
await sleep(POLL_INTERVAL_MS)
411412
}
412413
if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) {
413414
emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, {

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { createHash } from 'crypto'
21
import { readFile } from 'fs/promises'
32
import { createLogger } from '@sim/logger'
3+
import { sha256Hex } from '@sim/security/hash'
44
import type { NextRequest } from 'next/server'
55
import { NextResponse } from 'next/server'
66
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
@@ -80,11 +80,7 @@ async function compileDocumentIfNeeded(
8080
}
8181

8282
const code = buffer.toString('utf-8')
83-
const cacheKey = createHash('sha256')
84-
.update(ext)
85-
.update(code)
86-
.update(workspaceId ?? '')
87-
.digest('hex')
83+
const cacheKey = sha256Hex(`${ext}${code}${workspaceId ?? ''}`)
8884
const cached = compiledDocCache.get(cacheKey)
8985
if (cached) {
9086
return { buffer: cached, contentType: format.contentType }

apps/sim/app/api/v1/admin/auth.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
* curl -H "x-admin-key: your_admin_key" https://your-instance/api/v1/admin/...
99
*/
1010

11-
import { createHash, timingSafeEqual } from 'crypto'
1211
import { createLogger } from '@sim/logger'
12+
import { safeCompare } from '@sim/security/compare'
1313
import type { NextRequest } from 'next/server'
1414
import { env } from '@/lib/core/config/env'
1515

@@ -54,7 +54,7 @@ export function authenticateAdminRequest(request: NextRequest): AdminAuthResult
5454
}
5555
}
5656

57-
if (!constantTimeCompare(providedKey, adminKey)) {
57+
if (!safeCompare(providedKey, adminKey)) {
5858
logger.warn('Invalid admin API key attempted', { keyPrefix: providedKey.slice(0, 8) })
5959
return {
6060
authenticated: false,
@@ -64,16 +64,3 @@ export function authenticateAdminRequest(request: NextRequest): AdminAuthResult
6464

6565
return { authenticated: true }
6666
}
67-
68-
/**
69-
* Constant-time string comparison.
70-
*
71-
* @param a - First string to compare
72-
* @param b - Second string to compare
73-
* @returns True if strings are equal, false otherwise
74-
*/
75-
function constantTimeCompare(a: string, b: string): boolean {
76-
const aHash = createHash('sha256').update(a).digest()
77-
const bHash = createHash('sha256').update(b).digest()
78-
return timingSafeEqual(aHash, bHash)
79-
}

apps/sim/lib/api-key/crypto.ts

Lines changed: 17 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
21
import { createLogger } from '@sim/logger'
2+
import { decrypt, encrypt } from '@sim/security/encryption'
3+
import { sha256Hex } from '@sim/security/hash'
4+
import { generateSecureToken } from '@sim/security/tokens'
5+
import { toError } from '@sim/utils/errors'
36
import { env } from '@/lib/core/config/env'
47

58
const logger = createLogger('ApiKeyCrypto')
69

7-
/**
8-
* Get the API encryption key from the environment
9-
* @returns The API encryption key
10-
*/
1110
function getApiEncryptionKey(): Buffer | null {
1211
const key = env.API_ENCRYPTION_KEY
1312
if (!key) {
@@ -23,77 +22,38 @@ function getApiEncryptionKey(): Buffer | null {
2322
}
2423

2524
/**
26-
* Encrypts an API key using the dedicated API encryption key
27-
* @param apiKey - The API key to encrypt
28-
* @returns A promise that resolves to an object containing the encrypted API key and IV
25+
* Encrypts an API key using the dedicated API encryption key. Falls back to
26+
* returning the plain key when `API_ENCRYPTION_KEY` is unset, for backward
27+
* compatibility with deployments that predate encryption-at-rest.
2928
*/
3029
export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> {
3130
const key = getApiEncryptionKey()
32-
33-
// If no API encryption key is set, return the key as-is for backward compatibility
3431
if (!key) {
3532
return { encrypted: apiKey, iv: '' }
3633
}
37-
38-
const iv = randomBytes(16)
39-
const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 })
40-
let encrypted = cipher.update(apiKey, 'utf8', 'hex')
41-
encrypted += cipher.final('hex')
42-
43-
const authTag = cipher.getAuthTag()
44-
const ivHex = iv.toString('hex')
45-
46-
// Format: iv:encrypted:authTag
47-
return {
48-
encrypted: `${ivHex}:${encrypted}:${authTag.toString('hex')}`,
49-
iv: ivHex,
50-
}
34+
return encrypt(apiKey, key)
5135
}
5236

5337
/**
54-
* Decrypts an API key using the dedicated API encryption key
55-
* @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" or plain text
56-
* @returns A promise that resolves to an object containing the decrypted API key
38+
* Decrypts an API key previously produced by {@link encryptApiKey}. Values
39+
* that lack the `iv:ciphertext:authTag` shape are assumed to be legacy plain
40+
* text and returned unchanged.
5741
*/
5842
export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> {
5943
const parts = encryptedValue.split(':')
60-
61-
// Check if this is actually encrypted (contains colons)
6244
if (parts.length !== 3) {
63-
// This is a plain text key, return as-is
6445
return { decrypted: encryptedValue }
6546
}
6647

6748
const key = getApiEncryptionKey()
68-
69-
// If no API encryption key is set, assume it's plain text
7049
if (!key) {
7150
return { decrypted: encryptedValue }
7251
}
7352

74-
const ivHex = parts[0]
75-
const authTagHex = parts[2]
76-
const encrypted = parts[1]
77-
78-
if (!ivHex || !encrypted || !authTagHex) {
79-
throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"')
80-
}
81-
82-
const iv = Buffer.from(ivHex, 'hex')
83-
const authTag = Buffer.from(authTagHex, 'hex')
84-
8553
try {
86-
const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 })
87-
decipher.setAuthTag(authTag)
88-
89-
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
90-
decrypted += decipher.final('utf8')
91-
92-
return { decrypted }
93-
} catch (error: unknown) {
94-
logger.error('API key decryption error:', {
95-
error: error instanceof Error ? error.message : 'Unknown error',
96-
})
54+
return await decrypt(encryptedValue, key)
55+
} catch (error) {
56+
logger.error('API key decryption error:', { error: toError(error).message })
9757
throw error
9858
}
9959
}
@@ -103,15 +63,15 @@ export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted
10363
* @returns A new API key string
10464
*/
10565
export function generateApiKey(): string {
106-
return `sim_${randomBytes(24).toString('base64url')}`
66+
return `sim_${generateSecureToken(24)}`
10767
}
10868

10969
/**
11070
* Generates a new encrypted API key with the 'sk-sim-' prefix
11171
* @returns A new encrypted API key string
11272
*/
11373
export function generateEncryptedApiKey(): string {
114-
return `sk-sim-${randomBytes(24).toString('base64url')}`
74+
return `sk-sim-${generateSecureToken(24)}`
11575
}
11676

11777
/**
@@ -142,5 +102,5 @@ export function isLegacyApiKeyFormat(apiKey: string): boolean {
142102
* @returns The hex-encoded SHA-256 digest
143103
*/
144104
export function hashApiKey(plainKey: string): string {
145-
return createHash('sha256').update(plainKey, 'utf8').digest('hex')
105+
return sha256Hex(plainKey)
146106
}

apps/sim/lib/copilot/chat/persisted-message.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { generateId } from '@sim/utils/id'
12
import {
23
MothershipStreamV1CompletionStatus,
34
MothershipStreamV1EventType,
@@ -191,7 +192,7 @@ export function buildPersistedAssistantMessage(
191192
requestId?: string
192193
): PersistedMessage {
193194
const message: PersistedMessage = {
194-
id: crypto.randomUUID(),
195+
id: generateId(),
195196
role: 'assistant',
196197
content: result.content,
197198
timestamp: new Date().toISOString(),
@@ -488,7 +489,7 @@ function normalizeBlocks(rawBlocks: RawBlock[], messageContent: string): Persist
488489

489490
export function normalizeMessage(raw: Record<string, unknown>): PersistedMessage {
490491
const msg: PersistedMessage = {
491-
id: (raw.id as string) ?? crypto.randomUUID(),
492+
id: (raw.id as string) ?? generateId(),
492493
role: (raw.role as 'user' | 'assistant') ?? 'assistant',
493494
content: (raw.content as string) ?? '',
494495
timestamp: (raw.timestamp as string) ?? new Date().toISOString(),

apps/sim/lib/copilot/chat/post.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type Context as OtelContext, context as otelContextApi } from '@opentel
22
import { db } from '@sim/db'
33
import { copilotChats } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5+
import { generateId } from '@sim/utils/id'
56
import { eq, sql } from 'drizzle-orm'
67
import { type NextRequest, NextResponse } from 'next/server'
78
import { z } from 'zod'
@@ -615,8 +616,8 @@ export async function handleUnifiedChatPost(req: NextRequest) {
615616
// trace ID) as soon as startCopilotOtelRoot runs. Empty only in the
616617
// narrow pre-otelRoot window where errors don't correlate anyway.
617618
let requestId = ''
618-
const executionId = crypto.randomUUID()
619-
const runId = crypto.randomUUID()
619+
const executionId = generateId()
620+
const runId = generateId()
620621

621622
try {
622623
const session = await getSession()
@@ -628,7 +629,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
628629

629630
const body = ChatMessageSchema.parse(await req.json())
630631
const normalizedContexts = normalizeContexts(body.contexts) ?? []
631-
userMessageId = body.userMessageId || crypto.randomUUID()
632+
userMessageId = body.userMessageId || generateId()
632633

633634
otelRoot = startCopilotOtelRoot({
634635
streamId: userMessageId,

apps/sim/lib/copilot/request/lifecycle/run.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Context } from '@opentelemetry/api'
22
import { createLogger } from '@sim/logger'
33
import { toError } from '@sim/utils/errors'
4+
import { sleep } from '@sim/utils/helpers'
45
import { generateId } from '@sim/utils/id'
56
import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository'
67
import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
@@ -556,7 +557,7 @@ function isRetryableStreamError(error: unknown): boolean {
556557

557558
function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise<void> {
558559
if (!abortSignal) {
559-
return new Promise((resolve) => setTimeout(resolve, ms))
560+
return sleep(ms)
560561
}
561562
if (abortSignal.aborted) {
562563
return Promise.resolve()

apps/sim/lib/copilot/request/tools/tables.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { userTableRows } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { toError } from '@sim/utils/errors'
5+
import { generateId } from '@sim/utils/id'
56
import { parse as csvParse } from 'csv-parse/sync'
67
import { eq } from 'drizzle-orm'
78
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
@@ -107,7 +108,7 @@ export async function maybeWriteOutputToTable(
107108
}
108109
const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE)
109110
const values = chunk.map((rowData, j) => ({
110-
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
111+
id: `row_${generateId().replace(/-/g, '')}`,
111112
tableId: outputTable,
112113
workspaceId: context.workspaceId!,
113114
data: rowData,
@@ -251,7 +252,7 @@ export async function maybeWriteReadCsvToTable(
251252
}
252253
const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE)
253254
const values = chunk.map((rowData, j) => ({
254-
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
255+
id: `row_${generateId().replace(/-/g, '')}`,
255256
tableId: outputTable,
256257
workspaceId: context.workspaceId!,
257258
data: rowData,

apps/sim/lib/copilot/tools/handlers/oauth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { db } from '@sim/db'
22
import { pendingCredentialDraft, user } from '@sim/db/schema'
33
import { toError } from '@sim/utils/errors'
4+
import { generateId } from '@sim/utils/id'
45
import { and, eq, lt } from 'drizzle-orm'
56
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
67
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -140,7 +141,7 @@ export async function generateOAuthLink(
140141
await db
141142
.insert(pendingCredentialDraft)
142143
.values({
143-
id: crypto.randomUUID(),
144+
id: generateId(),
144145
userId,
145146
workspaceId,
146147
providerId,

0 commit comments

Comments
 (0)