feat: multiple named API keys in /settings/secrets#200
Conversation
Replaces the single-key model with a named key manager. Users can now
create multiple API keys with distinct names, view all keys in a list,
and delete them individually. All active keys continue to authenticate
external /api callers via x-api-key. Existing single keys (stored with
key='api_key') are backward-compatible and appear in the list unchanged.
- lib/db/api-keys.js: cache is now a Map<keyHash, {id, name}>;
createApiKeyRecord accepts a name param and no longer deletes existing
keys; getApiKeys() returns an array; deleteApiKeyById(id) replaces
deleteApiKey(); verifyApiKey iterates the Map with timing-safe comparison
- lib/chat/actions.js: createNewApiKey(name) validates name server-side;
getApiKeys() calls the renamed DB function; deleteApiKey(id) accepts UUID
- settings-secrets-page.jsx: ApiKeySection rewritten as a key manager with
persistent "Add API key" button, inline create form, key list with
per-row confirm-before-delete, and empty state; Regenerate removed
Closes #197
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
stephengpope
left a comment
There was a problem hiding this comment.
CTO Review — Security Findings Assessment
Reviewed against the security audit posted on this issue.
HIGH — NOT FIXED: deleteApiKeyById() Missing Type Guard
The security audit required adding AND type='api_key' to the WHERE clause in deleteApiKeyById. The PR code is:
db.delete(settings).where(eq(settings.id, id)).run();This only filters by id. Any UUID from any settings row could be deleted if an attacker — or a logic bug in a caller — passes a non-API-key row UUID. The settings table stores other data (roles, configurations) that could be silently destroyed this way. The fix is one line:
db.delete(settings).where(and(eq(settings.id, id), eq(settings.type, 'api_key'))).run();This must be fixed before merge.
CRITICAL — Partial: Timing-Safe Comparison Has Remaining Position Leak
verifyApiKey now iterates with timingSafeEqual, which is correct. However, it returns immediately on match:
if (a.length === b.length && timingSafeEqual(a, b)) {
// ...
return entry; // ← early return leaks map position via timing
}The security review explicitly required continuing the loop after a match to prevent position leakage. The fix is to collect the match and continue:
let matched = null;
for (const [storedHash, entry] of cache) {
const b = Buffer.from(storedHash, 'hex');
if (a.length === b.length && timingSafeEqual(a, b)) matched = entry;
// Do NOT break — continue to prevent position leak
}
// Handle last_used_at update for matched entry here, then return matchedFor typical deployments (1–5 keys) the practical exploit surface of this timing leak is minimal. However the fix is straightforward and was explicitly called out in the security review. Recommend addressing before merge.
CONFIRMED FIXED: Server-Side Name Validation
createNewApiKey(name) now validates non-empty, trims whitespace, and enforces ≤64 chars. Correct.
CONFIRMED FIXED: Timing-Safe Key Comparison (per-entry)
Each entry in the Map is compared with timingSafeEqual. No Map.get() shortcut. Correct — the remaining concern above is about loop control flow only.
Merge Recommendation
Block on: The HIGH type-guard fix in deleteApiKeyById.
Recommended before merge: The timing position-leak loop fix.
Closes #197
Summary
DB layer (
lib/db/api-keys.js): In-memory cache changed from a single entry to aMap<keyHash, {id, name}>.createApiKeyRecord(createdBy, name)accepts a name and no longer deletes existing keys.getApiKeys()returns an array of all keys.deleteApiKeyById(id)deletes by UUID.verifyApiKeyiterates the Map with per-entry timing-safe comparison. Backward-compatible: existingkey='api_key'rows appear in the list with name'api_key'.Server actions (
lib/chat/actions.js):createNewApiKey(name)validates name server-side (non-empty, max 64 chars).getApiKeys()calls the renamed DB function and returns an array.deleteApiKey(id)accepts a UUID and callsdeleteApiKeyById.UI (
lib/chat/components/settings-secrets-page.jsx):ApiKeySectionrewritten as a key manager. Persistent "Add API key" button at top of section. Inline create form with auto-focused name input, client+server validation, loading/error states. Key list with per-row confirm-before-delete (3s timeout, only the clicked row enters confirm state). Empty state dashed card. One-time key banner unchanged. Regenerate button removed — users create a new key then delete the old one.Test plan
x-api-keyrequests to/apikey='api_key'row) appears with name'api_key'and authenticates normally🤖 Generated with Claude Code