Skip to content

Commit a28a834

Browse files
committed
fix(security): derive cache-key discriminator without flagged API-key hashing
Replace the SHA-256 hash of the API key in the LiteLLM/key-scoped cache key with a memoized, truncated PBKDF2-derived 32-bit discriminator. This resolves the CodeQL js/insufficient-password-hash alert at the source (Node crypto.pbkdf2Sync is not modeled as a weak-hash sink) rather than dismissing it as a false positive, and the heavy truncation makes the value written to the on-disk cache filename non-reversible to the API key. Add provider-agnostic tests covering per-key separation, determinism, and the non-identifying discriminator shape.
1 parent bf83be7 commit a28a834

3 files changed

Lines changed: 118 additions & 16 deletions

File tree

.changeset/fix-litellm-model-desync.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ Two bugs are addressed:
1111
profiles backed by different servers silently served the wrong model list and the stale list
1212
persisted across VS Code restarts via the disk cache. Fixed with a compound cache key:
1313
URL-scoped providers use `provider:baseUrl`; key-scoped providers (LiteLLM, Poe, Requesty)
14-
additionally include a short sha256 hash of the API key (`provider:baseUrl:sha256(apiKey)`) so that
15-
two different API keys on the same server never share a cache entry (relevant when the server
16-
enforces per-key model allowlists). The `RouterProvider.getModel()` cold-start fallback is also
17-
corrected to pass the full options so it resolves the same compound key.
14+
additionally include a short, irreversible discriminator derived from the API key
15+
(`provider:baseUrl:<discriminator>`) so that two different API keys on the same server never share
16+
a cache entry (relevant when the server enforces per-key model allowlists). The discriminator is a
17+
32-bit value derived via PBKDF2 and truncated so it cannot be reversed to identify the API key
18+
that is written to the on-disk cache filename. The `RouterProvider.getModel()` cold-start fallback
19+
is also corrected to pass the full options so it resolves the same compound key.
1820

1921
2. **Silent fallback to hardcoded default**: When the LiteLLM model list was empty (due to the
2022
collision above, a failed sync, or a transient error), `useSelectedModel` reset the configured

src/api/providers/fetchers/__tests__/modelCache.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,3 +434,73 @@ describe("empty cache protection", () => {
434434
})
435435
})
436436
})
437+
438+
describe("key-scoped cache key derivation", () => {
439+
// Exercises the per-API-key cache discriminator that all KEY_SCOPED_PROVIDERS share.
440+
// Requesty is used only because it is a key-scoped provider with a mocked fetcher; the
441+
// behavior under test is provider-agnostic.
442+
const keyScopedProvider = "requesty" as const
443+
444+
let mockCache: any
445+
let mockSet: Mock
446+
447+
const mockModels = {
448+
"key-scoped/model": {
449+
maxTokens: 4096,
450+
contextWindow: 200000,
451+
supportsPromptCache: false,
452+
description: "Key-scoped provider model",
453+
},
454+
}
455+
456+
beforeEach(() => {
457+
vi.clearAllMocks()
458+
const MockedNodeCache = vi.mocked(NodeCache)
459+
mockCache = new MockedNodeCache()
460+
mockCache.get.mockReturnValue(undefined)
461+
mockSet = mockCache.set
462+
mockGetRequestyModels.mockResolvedValue(mockModels)
463+
})
464+
465+
// Returns the cache key the result was written under (first arg of the matching set call).
466+
const writtenCacheKey = (): string => {
467+
const call = mockSet.mock.calls.find((c) => c[1] === mockModels)
468+
return call?.[0] as string
469+
}
470+
471+
it("writes different cache keys for different API keys", async () => {
472+
await getModels({ provider: keyScopedProvider, apiKey: "key-one" })
473+
const firstKey = writtenCacheKey()
474+
475+
mockSet.mockClear()
476+
await getModels({ provider: keyScopedProvider, apiKey: "key-two" })
477+
const secondKey = writtenCacheKey()
478+
479+
expect(firstKey).toBeDefined()
480+
expect(secondKey).toBeDefined()
481+
expect(firstKey).not.toEqual(secondKey)
482+
})
483+
484+
it("writes the same cache key for repeated calls with the same API key", async () => {
485+
await getModels({ provider: keyScopedProvider, apiKey: "stable-key" })
486+
const firstKey = writtenCacheKey()
487+
488+
mockSet.mockClear()
489+
await getModels({ provider: keyScopedProvider, apiKey: "stable-key" })
490+
const secondKey = writtenCacheKey()
491+
492+
expect(firstKey).toEqual(secondKey)
493+
})
494+
495+
it("does not embed the raw API key in the cache key and truncates the discriminator", async () => {
496+
const apiKey = "super-secret-api-key-value"
497+
await getModels({ provider: keyScopedProvider, apiKey })
498+
const cacheKey = writtenCacheKey()
499+
500+
// The raw secret must never appear in the on-disk-bound cache key.
501+
expect(cacheKey).not.toContain(apiKey)
502+
// The discriminator is the trailing key-component: an 8-char (32-bit) hex string.
503+
const discriminator = cacheKey.split(":").pop() as string
504+
expect(discriminator).toMatch(/^[0-9a-f]{8}$/)
505+
})
506+
})

src/api/providers/fetchers/modelCache.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as path from "path"
22
import fs from "fs/promises"
33
import * as fsSync from "fs"
4-
import { createHash } from "crypto"
4+
import { createHash, pbkdf2Sync } from "crypto"
55

66
import NodeCache from "node-cache"
77
import { z } from "zod"
@@ -71,14 +71,51 @@ function isAuthScopedProvider(provider: RouterName): boolean {
7171
return AUTH_SCOPED_PROVIDERS.has(provider)
7272
}
7373

74+
// Memoize derived discriminators so the deliberately-structureless KDF runs at most once
75+
// per distinct secret per session (getCacheKey runs on every cache lookup).
76+
const apiKeyDiscriminatorCache = new Map<string, string>()
77+
78+
// Fixed, non-secret application salt. This is NOT credential storage: it derives a short,
79+
// stable cache-key discriminator from the API key so two different keys on the same server
80+
// never share a cache entry. PBKDF2 is used (over a plain hash) only to obtain a uniform,
81+
// structureless mapping with no exploitable internal structure; the iteration count is
82+
// intentionally modest because security here rests on truncation, not on KDF slowness.
83+
const API_KEY_DISCRIMINATOR_SALT = "zoo-model-cache-key-v1"
84+
const API_KEY_DISCRIMINATOR_ITERATIONS = 10_000
85+
// 4 bytes (8 hex chars) = 32 bits. This is deliberately far smaller than the entropy of a
86+
// real API key: collisions across the handful of keys a single user configures are
87+
// negligible (birthday bound ~ n^2 / 2^33), while the output is small enough that any
88+
// preimage search yields an astronomically large set of candidate keys -- so the value
89+
// written to the on-disk cache filename cannot be reversed to identify the actual key.
90+
const API_KEY_DISCRIMINATOR_BYTES = 4
91+
92+
/**
93+
* Derive a short, irreversible, non-identifying cache-key discriminator from an API key.
94+
* See the constants above for the rationale behind the algorithm and parameter choices.
95+
*/
96+
function deriveApiKeyDiscriminator(apiKey: string): string {
97+
const cached = apiKeyDiscriminatorCache.get(apiKey)
98+
if (cached) return cached
99+
const discriminator = pbkdf2Sync(
100+
apiKey,
101+
API_KEY_DISCRIMINATOR_SALT,
102+
API_KEY_DISCRIMINATOR_ITERATIONS,
103+
API_KEY_DISCRIMINATOR_BYTES,
104+
"sha256",
105+
).toString("hex")
106+
apiKeyDiscriminatorCache.set(apiKey, discriminator)
107+
return discriminator
108+
}
109+
74110
/**
75111
* Build a cache key that is unique per provider+server+key combination.
76112
*
77113
* - URL-scoped providers include the normalized baseUrl so that two different servers
78114
* of the same provider type never share a cache entry.
79-
* - Key-scoped providers additionally fold in a short sha256 hash of the API key so that
80-
* two different API keys on the same server never share a cache entry (relevant when
81-
* the server enforces per-key model allowlists, e.g. LiteLLM, Poe, Requesty).
115+
* - Key-scoped providers additionally fold in a short, irreversible discriminator derived
116+
* from the API key so that two different API keys on the same server never share a cache
117+
* entry (relevant when the server enforces per-key model allowlists, e.g. LiteLLM, Poe,
118+
* Requesty). See deriveApiKeyDiscriminator for why the value cannot be reversed to the key.
82119
*/
83120
function getCacheKey(options: GetModelsOptions): string {
84121
const { provider } = options
@@ -90,14 +127,7 @@ function getCacheKey(options: GetModelsOptions): string {
90127
// different keys on the default server would collapse to the same entry).
91128
// Strip trailing slashes so "http://host:4000/" and "http://host:4000" map to the same key.
92129
const urlPart = isUrlScoped && options.baseUrl ? options.baseUrl.replace(/\/+$/, "") : undefined
93-
// Short (16-char) sha256 prefix -- enough to make collisions effectively impossible.
94-
// Not a password hash -- SHA-256 is used here purely as a cache key discriminator to
95-
// distinguish between different API keys on the same server. It is never used for
96-
// authentication or stored as a credential.
97-
const keyPart =
98-
isKeyScoped && options.apiKey
99-
? createHash("sha256").update(options.apiKey).digest("hex").slice(0, 16)
100-
: undefined
130+
const keyPart = isKeyScoped && options.apiKey ? deriveApiKeyDiscriminator(options.apiKey) : undefined
101131

102132
if (urlPart && keyPart) return `${provider}:${urlPart}:${keyPart}`
103133
if (urlPart) return `${provider}:${urlPart}`

0 commit comments

Comments
 (0)