11import * as path from "path"
22import fs from "fs/promises"
33import * as fsSync from "fs"
4- import { createHash } from "crypto"
4+ import { createHash , pbkdf2Sync } from "crypto"
55
66import NodeCache from "node-cache"
77import { 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 */
83120function 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