Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/sim/app/api/users/me/api-keys/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { generateShortId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { hashApiKey } from '@/lib/api-key/crypto'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -102,6 +103,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
workspaceId: null,
name,
key: encryptedKey,
keyHash: hashApiKey(plainKey),
type: 'personal',
createdAt: new Date(),
updatedAt: new Date(),
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/api/workspaces/[id]/api-keys/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { hashApiKey } from '@/lib/api-key/crypto'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
Expand Down Expand Up @@ -145,6 +146,7 @@ export const POST = withRouteHandler(
createdBy: userId,
name,
key: encryptedKey,
keyHash: hashApiKey(plainKey),
type: 'workspace',
createdAt: new Date(),
updatedAt: new Date(),
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/api-key/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
encryptApiKey,
generateApiKey,
generateEncryptedApiKey,
hashApiKey,
isEncryptedApiKeyFormat,
isLegacyApiKeyFormat,
} from '@/lib/api-key/crypto'
Expand Down Expand Up @@ -256,6 +257,7 @@ export async function createWorkspaceApiKey(params: {
createdBy: params.userId,
name: params.name,
key: encryptedKey,
keyHash: hashApiKey(plainKey),
type: 'workspace',
createdAt: new Date(),
updatedAt: new Date(),
Expand Down
89 changes: 89 additions & 0 deletions apps/sim/lib/api-key/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Tests for the API-key crypto primitives.
*
* `hashApiKey` is the foundation of both the new hash-first authentication
* path and the `backfill-api-key-hash` script — the backfill is idempotent
* precisely because `hashApiKey` is deterministic and the encrypted round-trip
* recovers the same plain-text key on every run.
*
* @vitest-environment node
*/
import { randomBytes } from 'crypto'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockEnv } = vi.hoisted(() => ({
mockEnv: { API_ENCRYPTION_KEY: undefined as string | undefined },
}))

vi.mock('@/lib/core/config/env', () => ({
env: mockEnv,
}))

import {
decryptApiKey,
encryptApiKey,
hashApiKey,
isEncryptedApiKeyFormat,
isLegacyApiKeyFormat,
} from '@/lib/api-key/crypto'

const FIXED_ENCRYPTION_KEY = '0'.repeat(64)

describe('hashApiKey', () => {
it('is deterministic — same input produces same hash', () => {
const h1 = hashApiKey('sk-sim-example')
const h2 = hashApiKey('sk-sim-example')
expect(h1).toBe(h2)
})

it('produces a 64-char hex SHA-256 digest', () => {
const hash = hashApiKey('sk-sim-example')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})

it('produces different hashes for different inputs', () => {
expect(hashApiKey('sk-sim-a')).not.toBe(hashApiKey('sk-sim-b'))
})

it('matches the published SHA-256 vector for the empty string', () => {
expect(hashApiKey('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
})
})

describe('backfill idempotency — encrypted round-trip', () => {
beforeEach(() => {
mockEnv.API_ENCRYPTION_KEY = FIXED_ENCRYPTION_KEY
})

it('re-running the backfill on the same row yields the same keyHash', async () => {
const plainKey = `sk-sim-${randomBytes(12).toString('hex')}`
const { encrypted } = await encryptApiKey(plainKey)

const { decrypted: first } = await decryptApiKey(encrypted)
const { decrypted: second } = await decryptApiKey(encrypted)

expect(first).toBe(plainKey)
expect(second).toBe(plainKey)
expect(hashApiKey(first)).toBe(hashApiKey(second))
})

it('is stable whether the stored key is legacy plain text or encrypted', async () => {
const plainKey = 'sim_legacy-format-key'
const { encrypted } = await encryptApiKey(plainKey)

const { decrypted } = await decryptApiKey(encrypted)
expect(hashApiKey(decrypted)).toBe(hashApiKey(plainKey))
})
})

describe('api-key format helpers', () => {
it('treats sk-sim- prefix as the encrypted format', () => {
expect(isEncryptedApiKeyFormat('sk-sim-abc')).toBe(true)
expect(isLegacyApiKeyFormat('sk-sim-abc')).toBe(false)
})

it('treats sim_ prefix as the legacy format', () => {
expect(isLegacyApiKeyFormat('sim_abc')).toBe(true)
expect(isEncryptedApiKeyFormat('sim_abc')).toBe(false)
})
})
15 changes: 14 additions & 1 deletion apps/sim/lib/api-key/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'

Expand Down Expand Up @@ -131,3 +131,16 @@ export function isEncryptedApiKeyFormat(apiKey: string): boolean {
export function isLegacyApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sim_') && !apiKey.startsWith('sk-sim-')
}

/**
* Deterministically hashes a plain-text API key for indexed lookup. The hash
* column has a unique index so authentication can match an incoming key via a
* single `WHERE key_hash = $hash` lookup instead of scanning and decrypting
* every stored encrypted key.
*
* @param plainKey - The plain-text API key as presented by the client
* @returns The hex-encoded SHA-256 digest
*/
export function hashApiKey(plainKey: string): string {
return createHash('sha256').update(plainKey, 'utf8').digest('hex')
}
189 changes: 189 additions & 0 deletions apps/sim/lib/api-key/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* Tests for authenticateApiKeyFromHeader.
*
* The path was rewritten to look up rows by the SHA-256 hash of the incoming
* API key. A fallback loop — full scan + decrypt — is preserved while the
* `key_hash` backfill runs, and emits a warn log whenever it actually matches
* a row so we can tell when it's safe to delete.
*
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@sim/db', () => dbChainMock)

const { serviceLogger } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
withMetadata: vi.fn(),
}
logger.child.mockReturnValue(logger)
logger.withMetadata.mockReturnValue(logger)
return { serviceLogger: logger }
})

vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => serviceLogger),
logger: serviceLogger,
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
getRequestContext: vi.fn(() => undefined),
}))

const { mockAuthenticateApiKey } = vi.hoisted(() => ({
mockAuthenticateApiKey: vi.fn(),
}))

vi.mock('@/lib/api-key/auth', () => ({
authenticateApiKey: mockAuthenticateApiKey,
}))

const { mockGetWorkspaceBillingSettings } = vi.hoisted(() => ({
mockGetWorkspaceBillingSettings: vi.fn(),
}))

vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings,
}))

const { mockGetUserEntityPermissions } = vi.hoisted(() => ({
mockGetUserEntityPermissions: vi.fn(),
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))

import { hashApiKey } from '@/lib/api-key/crypto'
import { authenticateApiKeyFromHeader } from '@/lib/api-key/service'

const warnSpy = serviceLogger.warn

function personalKeyRecord(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'key-1',
userId: 'user-1',
workspaceId: null as string | null,
type: 'personal',
key: 'encrypted:stored:value',
expiresAt: null as Date | null,
...overrides,
}
}

describe('authenticateApiKeyFromHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuthenticateApiKey.mockReset()
mockGetWorkspaceBillingSettings.mockReset()
mockGetUserEntityPermissions.mockReset()
})

it('returns error when no header is provided', async () => {
const result = await authenticateApiKeyFromHeader('')
expect(result).toEqual({ success: false, error: 'API key required' })
expect(dbChainMockFns.where).not.toHaveBeenCalled()
})

it('resolves on the fast path when the hash lookup finds a row', async () => {
const record = personalKeyRecord()
dbChainMockFns.where.mockResolvedValueOnce([record])

const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})

expect(result).toEqual({
success: true,
userId: 'user-1',
keyId: 'key-1',
keyType: 'personal',
workspaceId: undefined,
})
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
expect(mockAuthenticateApiKey).not.toHaveBeenCalled()
expect(warnSpy).not.toHaveBeenCalled()
})

it('returns invalid when the hash lookup finds a row that fails scope checks', async () => {
const record = personalKeyRecord({ userId: 'other-user' })
dbChainMockFns.where.mockResolvedValueOnce([record])

const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})

expect(result).toEqual({ success: false, error: 'Invalid API key' })
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
expect(mockAuthenticateApiKey).not.toHaveBeenCalled()
})

it('falls back to the decrypt loop when no row matches the hash, and warns on success', async () => {
const record = personalKeyRecord()
dbChainMockFns.where.mockResolvedValueOnce([]).mockResolvedValueOnce([record])
mockAuthenticateApiKey.mockResolvedValueOnce(true)

const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})

expect(result).toEqual({
success: true,
userId: 'user-1',
keyId: 'key-1',
keyType: 'personal',
workspaceId: undefined,
})
expect(dbChainMockFns.where).toHaveBeenCalledTimes(2)
expect(mockAuthenticateApiKey).toHaveBeenCalledWith(
'sk-sim-plain-key',
'encrypted:stored:value'
)
expect(warnSpy).toHaveBeenCalledWith('API key matched via fallback decrypt loop', {
keyId: 'key-1',
})
})

it('returns invalid when the hash lookup misses and the fallback scan also misses', async () => {
dbChainMockFns.where.mockResolvedValueOnce([]).mockResolvedValueOnce([])

const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})

expect(result).toEqual({ success: false, error: 'Invalid API key' })
expect(dbChainMockFns.where).toHaveBeenCalledTimes(2)
expect(mockAuthenticateApiKey).not.toHaveBeenCalled()
expect(warnSpy).not.toHaveBeenCalled()
})

it('returns invalid when the hash lookup misses and every fallback candidate fails decrypt comparison', async () => {
const record = personalKeyRecord()
dbChainMockFns.where.mockResolvedValueOnce([]).mockResolvedValueOnce([record])
mockAuthenticateApiKey.mockResolvedValueOnce(false)

const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})

expect(result).toEqual({ success: false, error: 'Invalid API key' })
expect(mockAuthenticateApiKey).toHaveBeenCalledTimes(1)
expect(warnSpy).not.toHaveBeenCalled()
})

it('queries by the sha256 hash of the incoming header on the fast path', async () => {
dbChainMockFns.where.mockResolvedValueOnce([personalKeyRecord()])

await authenticateApiKeyFromHeader('sk-sim-plain-key', { userId: 'user-1' })

const [filter] = dbChainMockFns.where.mock.calls[0]
const expected = hashApiKey('sk-sim-plain-key')
expect(JSON.stringify(filter)).toContain(expected)
})
})
Loading
Loading