Skip to content

Commit 8ce56fe

Browse files
fix(auth): add api key auth via sha256 hash lookup (#4266)
* fix(auth): add api key auth via sha256 hash lookup * Remove promise all logic * Restore feature flag * fix feature flag * Combine auth and hash gate
1 parent 8c9ddef commit 8ce56fe

17 files changed

Lines changed: 15954 additions & 15 deletions

File tree

apps/sim/app/api/users/me/api-keys/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { generateShortId } from '@sim/utils/id'
55
import { and, eq } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
8+
import { hashApiKey } from '@/lib/api-key/crypto'
89
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
910
import { getSession } from '@/lib/auth'
1011
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -102,6 +103,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
102103
workspaceId: null,
103104
name,
104105
key: encryptedKey,
106+
keyHash: hashApiKey(plainKey),
105107
type: 'personal',
106108
createdAt: new Date(),
107109
updatedAt: new Date(),

apps/sim/app/api/workspaces/[id]/api-keys/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { and, eq, inArray } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { z } from 'zod'
88
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
9+
import { hashApiKey } from '@/lib/api-key/crypto'
910
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
1011
import { getSession } from '@/lib/auth'
1112
import { PlatformEvents } from '@/lib/core/telemetry'
@@ -145,6 +146,7 @@ export const POST = withRouteHandler(
145146
createdBy: userId,
146147
name,
147148
key: encryptedKey,
149+
keyHash: hashApiKey(plainKey),
148150
type: 'workspace',
149151
createdAt: new Date(),
150152
updatedAt: new Date(),

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
encryptApiKey,
99
generateApiKey,
1010
generateEncryptedApiKey,
11+
hashApiKey,
1112
isEncryptedApiKeyFormat,
1213
isLegacyApiKeyFormat,
1314
} from '@/lib/api-key/crypto'
@@ -256,6 +257,7 @@ export async function createWorkspaceApiKey(params: {
256257
createdBy: params.userId,
257258
name: params.name,
258259
key: encryptedKey,
260+
keyHash: hashApiKey(plainKey),
259261
type: 'workspace',
260262
createdAt: new Date(),
261263
updatedAt: new Date(),
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Tests for the API-key crypto primitives.
3+
*
4+
* `hashApiKey` is the foundation of both the new hash-first authentication
5+
* path and the `backfill-api-key-hash` script — the backfill is idempotent
6+
* precisely because `hashApiKey` is deterministic and the encrypted round-trip
7+
* recovers the same plain-text key on every run.
8+
*
9+
* @vitest-environment node
10+
*/
11+
import { randomBytes } from 'crypto'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
const { mockEnv } = vi.hoisted(() => ({
15+
mockEnv: { API_ENCRYPTION_KEY: undefined as string | undefined },
16+
}))
17+
18+
vi.mock('@/lib/core/config/env', () => ({
19+
env: mockEnv,
20+
}))
21+
22+
import {
23+
decryptApiKey,
24+
encryptApiKey,
25+
hashApiKey,
26+
isEncryptedApiKeyFormat,
27+
isLegacyApiKeyFormat,
28+
} from '@/lib/api-key/crypto'
29+
30+
const FIXED_ENCRYPTION_KEY = '0'.repeat(64)
31+
32+
describe('hashApiKey', () => {
33+
it('is deterministic — same input produces same hash', () => {
34+
const h1 = hashApiKey('sk-sim-example')
35+
const h2 = hashApiKey('sk-sim-example')
36+
expect(h1).toBe(h2)
37+
})
38+
39+
it('produces a 64-char hex SHA-256 digest', () => {
40+
const hash = hashApiKey('sk-sim-example')
41+
expect(hash).toMatch(/^[0-9a-f]{64}$/)
42+
})
43+
44+
it('produces different hashes for different inputs', () => {
45+
expect(hashApiKey('sk-sim-a')).not.toBe(hashApiKey('sk-sim-b'))
46+
})
47+
48+
it('matches the published SHA-256 vector for the empty string', () => {
49+
expect(hashApiKey('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
50+
})
51+
})
52+
53+
describe('backfill idempotency — encrypted round-trip', () => {
54+
beforeEach(() => {
55+
mockEnv.API_ENCRYPTION_KEY = FIXED_ENCRYPTION_KEY
56+
})
57+
58+
it('re-running the backfill on the same row yields the same keyHash', async () => {
59+
const plainKey = `sk-sim-${randomBytes(12).toString('hex')}`
60+
const { encrypted } = await encryptApiKey(plainKey)
61+
62+
const { decrypted: first } = await decryptApiKey(encrypted)
63+
const { decrypted: second } = await decryptApiKey(encrypted)
64+
65+
expect(first).toBe(plainKey)
66+
expect(second).toBe(plainKey)
67+
expect(hashApiKey(first)).toBe(hashApiKey(second))
68+
})
69+
70+
it('is stable whether the stored key is legacy plain text or encrypted', async () => {
71+
const plainKey = 'sim_legacy-format-key'
72+
const { encrypted } = await encryptApiKey(plainKey)
73+
74+
const { decrypted } = await decryptApiKey(encrypted)
75+
expect(hashApiKey(decrypted)).toBe(hashApiKey(plainKey))
76+
})
77+
})
78+
79+
describe('api-key format helpers', () => {
80+
it('treats sk-sim- prefix as the encrypted format', () => {
81+
expect(isEncryptedApiKeyFormat('sk-sim-abc')).toBe(true)
82+
expect(isLegacyApiKeyFormat('sk-sim-abc')).toBe(false)
83+
})
84+
85+
it('treats sim_ prefix as the legacy format', () => {
86+
expect(isLegacyApiKeyFormat('sim_abc')).toBe(true)
87+
expect(isEncryptedApiKeyFormat('sim_abc')).toBe(false)
88+
})
89+
})

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
1+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
22
import { createLogger } from '@sim/logger'
33
import { env } from '@/lib/core/config/env'
44

@@ -131,3 +131,16 @@ export function isEncryptedApiKeyFormat(apiKey: string): boolean {
131131
export function isLegacyApiKeyFormat(apiKey: string): boolean {
132132
return apiKey.startsWith('sim_') && !apiKey.startsWith('sk-sim-')
133133
}
134+
135+
/**
136+
* Deterministically hashes a plain-text API key for indexed lookup. The hash
137+
* column has a unique index so authentication can match an incoming key via a
138+
* single `WHERE key_hash = $hash` lookup instead of scanning and decrypting
139+
* every stored encrypted key.
140+
*
141+
* @param plainKey - The plain-text API key as presented by the client
142+
* @returns The hex-encoded SHA-256 digest
143+
*/
144+
export function hashApiKey(plainKey: string): string {
145+
return createHash('sha256').update(plainKey, 'utf8').digest('hex')
146+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* Tests for authenticateApiKeyFromHeader.
3+
*
4+
* The path was rewritten to look up rows by the SHA-256 hash of the incoming
5+
* API key. A fallback loop — full scan + decrypt — is preserved while the
6+
* `key_hash` backfill runs, and emits a warn log whenever it actually matches
7+
* a row so we can tell when it's safe to delete.
8+
*
9+
* @vitest-environment node
10+
*/
11+
import { dbChainMock, dbChainMockFns } from '@sim/testing'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
vi.mock('@sim/db', () => dbChainMock)
15+
16+
const { serviceLogger } = vi.hoisted(() => {
17+
const logger = {
18+
info: vi.fn(),
19+
warn: vi.fn(),
20+
error: vi.fn(),
21+
debug: vi.fn(),
22+
trace: vi.fn(),
23+
fatal: vi.fn(),
24+
child: vi.fn(),
25+
withMetadata: vi.fn(),
26+
}
27+
logger.child.mockReturnValue(logger)
28+
logger.withMetadata.mockReturnValue(logger)
29+
return { serviceLogger: logger }
30+
})
31+
32+
vi.mock('@sim/logger', () => ({
33+
createLogger: vi.fn(() => serviceLogger),
34+
logger: serviceLogger,
35+
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
36+
getRequestContext: vi.fn(() => undefined),
37+
}))
38+
39+
const { mockAuthenticateApiKey } = vi.hoisted(() => ({
40+
mockAuthenticateApiKey: vi.fn(),
41+
}))
42+
43+
vi.mock('@/lib/api-key/auth', () => ({
44+
authenticateApiKey: mockAuthenticateApiKey,
45+
}))
46+
47+
const { mockGetWorkspaceBillingSettings } = vi.hoisted(() => ({
48+
mockGetWorkspaceBillingSettings: vi.fn(),
49+
}))
50+
51+
vi.mock('@/lib/workspaces/utils', () => ({
52+
getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings,
53+
}))
54+
55+
const { mockGetUserEntityPermissions } = vi.hoisted(() => ({
56+
mockGetUserEntityPermissions: vi.fn(),
57+
}))
58+
59+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
60+
getUserEntityPermissions: mockGetUserEntityPermissions,
61+
}))
62+
63+
import { hashApiKey } from '@/lib/api-key/crypto'
64+
import { authenticateApiKeyFromHeader } from '@/lib/api-key/service'
65+
66+
const warnSpy = serviceLogger.warn
67+
68+
function personalKeyRecord(overrides: Partial<Record<string, unknown>> = {}) {
69+
return {
70+
id: 'key-1',
71+
userId: 'user-1',
72+
workspaceId: null as string | null,
73+
type: 'personal',
74+
key: 'encrypted:stored:value',
75+
expiresAt: null as Date | null,
76+
...overrides,
77+
}
78+
}
79+
80+
describe('authenticateApiKeyFromHeader', () => {
81+
beforeEach(() => {
82+
vi.clearAllMocks()
83+
mockAuthenticateApiKey.mockReset()
84+
mockGetWorkspaceBillingSettings.mockReset()
85+
mockGetUserEntityPermissions.mockReset()
86+
})
87+
88+
it('returns error when no header is provided', async () => {
89+
const result = await authenticateApiKeyFromHeader('')
90+
expect(result).toEqual({ success: false, error: 'API key required' })
91+
expect(dbChainMockFns.where).not.toHaveBeenCalled()
92+
})
93+
94+
it('resolves on the fast path when the hash lookup finds a row', async () => {
95+
const record = personalKeyRecord()
96+
dbChainMockFns.where.mockResolvedValueOnce([record])
97+
98+
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
99+
userId: 'user-1',
100+
})
101+
102+
expect(result).toEqual({
103+
success: true,
104+
userId: 'user-1',
105+
keyId: 'key-1',
106+
keyType: 'personal',
107+
workspaceId: undefined,
108+
})
109+
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
110+
expect(mockAuthenticateApiKey).not.toHaveBeenCalled()
111+
expect(warnSpy).not.toHaveBeenCalled()
112+
})
113+
114+
it('returns invalid when the hash lookup finds a row that fails scope checks', async () => {
115+
const record = personalKeyRecord({ userId: 'other-user' })
116+
dbChainMockFns.where.mockResolvedValueOnce([record])
117+
118+
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
119+
userId: 'user-1',
120+
})
121+
122+
expect(result).toEqual({ success: false, error: 'Invalid API key' })
123+
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
124+
expect(mockAuthenticateApiKey).not.toHaveBeenCalled()
125+
})
126+
127+
it('falls back to the decrypt loop when no row matches the hash, and warns on success', async () => {
128+
const record = personalKeyRecord()
129+
dbChainMockFns.where.mockResolvedValueOnce([]).mockResolvedValueOnce([record])
130+
mockAuthenticateApiKey.mockResolvedValueOnce(true)
131+
132+
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
133+
userId: 'user-1',
134+
})
135+
136+
expect(result).toEqual({
137+
success: true,
138+
userId: 'user-1',
139+
keyId: 'key-1',
140+
keyType: 'personal',
141+
workspaceId: undefined,
142+
})
143+
expect(dbChainMockFns.where).toHaveBeenCalledTimes(2)
144+
expect(mockAuthenticateApiKey).toHaveBeenCalledWith(
145+
'sk-sim-plain-key',
146+
'encrypted:stored:value'
147+
)
148+
expect(warnSpy).toHaveBeenCalledWith('API key matched via fallback decrypt loop', {
149+
keyId: 'key-1',
150+
})
151+
})
152+
153+
it('returns invalid when the hash lookup misses and the fallback scan also misses', async () => {
154+
dbChainMockFns.where.mockResolvedValueOnce([]).mockResolvedValueOnce([])
155+
156+
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
157+
userId: 'user-1',
158+
})
159+
160+
expect(result).toEqual({ success: false, error: 'Invalid API key' })
161+
expect(dbChainMockFns.where).toHaveBeenCalledTimes(2)
162+
expect(mockAuthenticateApiKey).not.toHaveBeenCalled()
163+
expect(warnSpy).not.toHaveBeenCalled()
164+
})
165+
166+
it('returns invalid when the hash lookup misses and every fallback candidate fails decrypt comparison', async () => {
167+
const record = personalKeyRecord()
168+
dbChainMockFns.where.mockResolvedValueOnce([]).mockResolvedValueOnce([record])
169+
mockAuthenticateApiKey.mockResolvedValueOnce(false)
170+
171+
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
172+
userId: 'user-1',
173+
})
174+
175+
expect(result).toEqual({ success: false, error: 'Invalid API key' })
176+
expect(mockAuthenticateApiKey).toHaveBeenCalledTimes(1)
177+
expect(warnSpy).not.toHaveBeenCalled()
178+
})
179+
180+
it('queries by the sha256 hash of the incoming header on the fast path', async () => {
181+
dbChainMockFns.where.mockResolvedValueOnce([personalKeyRecord()])
182+
183+
await authenticateApiKeyFromHeader('sk-sim-plain-key', { userId: 'user-1' })
184+
185+
const [filter] = dbChainMockFns.where.mock.calls[0]
186+
const expected = hashApiKey('sk-sim-plain-key')
187+
expect(JSON.stringify(filter)).toContain(expected)
188+
})
189+
})

0 commit comments

Comments
 (0)