Files
simstudioai--sim/packages/db/scripts/backfill-api-key-hash.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

231 lines
7.3 KiB
TypeScript

#!/usr/bin/env bun
/**
* Backfills the `key_hash` column on the `api_key` table.
*
* The authentication hot path is being rewritten to look up API keys by the
* SHA-256 hash of the plain-text key — a single indexed equality lookup rather
* than a full-table scan + AES-GCM decrypt loop. This script populates
* `key_hash` for every existing row so the new fast path can match historic
* keys.
*
* For each row where `key_hash IS NULL`:
* - If `key` is in encrypted format (iv:encrypted:authTag), decrypt it using
* `API_ENCRYPTION_KEY` to recover the plain-text key.
* - Otherwise treat `key` as legacy plain text.
* - Compute `sha256(plainKey)` and update the row.
*
* The script is idempotent: it only touches rows where `key_hash IS NULL`, and
* re-running after a partial failure continues where it left off.
*
* Usage:
* POSTGRES_URL=... API_ENCRYPTION_KEY=... \
* bun run packages/db/scripts/backfill-api-key-hash.ts
* # or
* POSTGRES_URL=... API_ENCRYPTION_KEY=... \
* bun run packages/db/scripts/backfill-api-key-hash.ts --dry-run
*/
import { createCipheriv, createDecipheriv, createHash } from 'crypto'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { apiKey } from '../schema'
const BATCH_SIZE = 500
export function isEncryptedKey(storedKey: string): boolean {
return storedKey.includes(':') && storedKey.split(':').length === 3
}
export function hashApiKey(plainKey: string): string {
return createHash('sha256').update(plainKey, 'utf8').digest('hex')
}
function decryptApiKey(encryptedValue: string, apiEncryptionKey: string): string {
const parts = encryptedValue.split(':')
if (parts.length !== 3) {
return encryptedValue
}
const key = Buffer.from(apiEncryptionKey, 'hex')
const [ivHex, encrypted, authTagHex] = parts
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')
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
}
/**
* Computes the hash to write to `key_hash` for a row during backfill. Pure:
* no I/O, no globals — safe to import from tests. Throws when the stored
* value looks encrypted but the caller has no encryption key.
*/
export function deriveKeyHashForStoredKey(
storedKey: string,
apiEncryptionKey: string | null
): string {
if (isEncryptedKey(storedKey)) {
if (!apiEncryptionKey) {
throw new Error('API_ENCRYPTION_KEY is required to decrypt an encrypted stored key')
}
return hashApiKey(decryptApiKey(storedKey, apiEncryptionKey))
}
return hashApiKey(storedKey)
}
interface BackfillStats {
scanned: number
updated: number
skippedEncryptedNoKey: number
failed: number
}
export async function runBackfill(): Promise<void> {
const dryRun = process.argv.includes('--dry-run')
const connectionString = process.env.POSTGRES_URL ?? process.env.DATABASE_URL
if (!connectionString) {
console.error('Missing POSTGRES_URL or DATABASE_URL environment variable')
process.exit(1)
}
const apiEncryptionKey = process.env.API_ENCRYPTION_KEY ?? null
if (!apiEncryptionKey) {
console.warn(
'API_ENCRYPTION_KEY is not set. Rows whose stored key is encrypted will fail to decrypt. ' +
'Only rows whose stored key is already plain text will be backfilled in this run.'
)
} else if (apiEncryptionKey.length !== 64) {
console.error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
process.exit(1)
}
assertCryptoRoundTrip(apiEncryptionKey)
const postgresClient = postgres(connectionString, {
prepare: false,
idle_timeout: 20,
connect_timeout: 30,
max: 5,
onnotice: () => {},
})
const db = drizzle(postgresClient)
const stats: BackfillStats = {
scanned: 0,
updated: 0,
skippedEncryptedNoKey: 0,
failed: 0,
}
try {
const [{ count: pendingBefore }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(apiKey)
.where(isNull(apiKey.keyHash))
console.log(
`Backfill starting — ${pendingBefore} row(s) with NULL key_hash${dryRun ? ' [DRY RUN]' : ''}`
)
for (;;) {
const rows = await db
.select({ id: apiKey.id, key: apiKey.key })
.from(apiKey)
.where(isNull(apiKey.keyHash))
.limit(BATCH_SIZE)
if (rows.length === 0) break
await Promise.all(
rows.map(async (row) => {
stats.scanned += 1
try {
if (isEncryptedKey(row.key) && !apiEncryptionKey) {
stats.skippedEncryptedNoKey += 1
return
}
const keyHash = deriveKeyHashForStoredKey(row.key, apiEncryptionKey)
if (dryRun) {
stats.updated += 1
return
}
await db
.update(apiKey)
.set({ keyHash })
.where(and(eq(apiKey.id, row.id), isNull(apiKey.keyHash)))
stats.updated += 1
} catch (error) {
stats.failed += 1
console.error(`Failed to backfill api_key id=${row.id}: ${getErrorMessage(error)}`)
}
})
)
console.log(
` progress: scanned=${stats.scanned} updated=${stats.updated} skipped=${stats.skippedEncryptedNoKey} failed=${stats.failed}`
)
if (dryRun) break
}
const [{ count: pendingAfter }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(apiKey)
.where(isNull(apiKey.keyHash))
console.log('Backfill complete.')
console.log(` scanned: ${stats.scanned}`)
console.log(` updated: ${stats.updated}`)
console.log(` skipped (no api key): ${stats.skippedEncryptedNoKey}`)
console.log(` failed: ${stats.failed}`)
console.log(` remaining null: ${pendingAfter}`)
if (stats.failed > 0 || pendingAfter > 0) {
process.exitCode = 1
}
} finally {
await postgresClient.end({ timeout: 5 }).catch(() => {})
}
}
/** Fails fast if the AES-GCM round-trip disagrees with itself in this env. */
function assertCryptoRoundTrip(apiEncryptionKey: string | null): void {
if (!apiEncryptionKey) return
const key = Buffer.from(apiEncryptionKey, 'hex')
const sample = 'sk-sim-roundtrip-test-value'
const iv = Buffer.from('00'.repeat(16), 'hex')
const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 })
let encrypted = cipher.update(sample, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag()
const assembled = `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`
const roundTripped = decryptApiKey(assembled, apiEncryptionKey)
if (roundTripped !== sample) {
throw new Error('Crypto self-test failed — refusing to run backfill')
}
}
if ((import.meta as { main?: boolean }).main) {
try {
await runBackfill()
} catch (error) {
console.error('Backfill aborted:', getErrorMessage(error))
process.exitCode = 1
}
}