import { createHash } from 'crypto'; import { log } from './config.js'; import { trustedClientIp } from './net-safety.js'; function sha256Hex(value) { return createHash('sha256').update(String(value || '')).digest('hex'); } // A body field only carries a usable scope signal when it's a string with // non-whitespace content. An empty/whitespace value must NOT mint a scope: // user:"" would otherwise hash to the constant sha256("") prefix // (e3b0c44298fc1c14...), collapsing every distinct end user of a shared key // into one :user: segment and re-enabling cross-tenant cascade/cache bleed. // Returns the TRIMMED non-empty string, else ''. Trimming matters: returning the // raw value made " alice " and "alice" hash into different :user: buckets, so the // same end user got split tenant scopes across requests with incidental // whitespace. (audit S6) function usableSignal(value) { return typeof value === 'string' && value.trim() !== '' ? value.trim() : ''; } // Extract a per-user / per-session signal from the request body so two // different end users sharing one API key get different conversation pool // scopes. v2.0.25 HIGH-3: chat & responses now look at body.user / // conversation / metadata.{conversation_id,session_id}. previous_response_id is // deliberately NOT inspected — see the note at the candidates list below. // // metadata.user_id is INTENTIONALLY NOT inspected here — handlers/messages.js // has a specialized parser for it (Claude Code's JSON-encoded // {device_id, session_id, account_uuid} shape) and appends its own // `:user:` to keep the two extraction paths from double-stamping // the same callerKey. // // The returned subkey is appended to the API-key callerKey so reuse stays // pinned to (apiKey, user/session). Returns '' when no usable signal. export function extractBodyCallerSubKey(body) { if (!body || typeof body !== 'object') return ''; const user = usableSignal(body.user); if (user) return sha256Hex(user).slice(0, 16); // OpenAI's designated successors to `user`: safety_identifier is the stable // end-user id, prompt_cache_key is the client's EXPLICIT cache-affinity // routing key (codex sends it per conversation). Both are turn-stable by // contract, unlike previous_response_id below which changes every turn and // re-scopes the caller each time — so a chained Responses client never // accrues sticky/cascade affinity. Honoring these two (each short-circuits, // like `user`) gives such clients a stable scope without weakening // isolation: both are client-supplied per-user/per-conversation signals. const safety = usableSignal(body.safety_identifier); if (safety) return sha256Hex(safety).slice(0, 16); const cacheKey = usableSignal(body.prompt_cache_key); if (cacheKey) return sha256Hex(cacheKey).slice(0, 16); // previous_response_id is deliberately NOT a scope signal. It changes on every // turn, so folding it in re-mints the callerKey each turn — and once the // response store shipped that became a hard failure rather than just a lost // cache: turn 1 (no previous_response_id) and turn 2 (with one) derive // DIFFERENT callerKeys, the store's per-caller isolation check rejects the // lookup, and a standard OpenAI SDK client that sends neither `user` nor // `prompt_cache_key` gets a 404 on every chained turn. Live-reproduced. // // Clients that DO want a per-conversation scope have three stable signals // above (user / safety_identifier / prompt_cache_key) plus the conversation // ids below. A chained caller with none of them falls through to the // ip+ua fingerprint, which is stable across the turns of one session — which // is exactly what chaining needs. const candidates = [ usableSignal(body?.metadata?.conversation_id), usableSignal(body.conversation), usableSignal(body?.metadata?.session_id), ].filter(Boolean); if (!candidates.length) return ''; return sha256Hex(candidates.join('|')).slice(0, 16); } // IP + UA fallback used when an apiKey-mode caller has no explicit body // user signal. Without this, every Claude Code / claudecode CLI on a // self-hosted single-user setup hits "shared API key, no per-user scope" // and cascade reuse stays disabled — exactly the symptom reported in // #93 follow-up by zhangzhang-bit (claude-opus-4-6-thinking, msgs growing // 33→97 across turns, reuse=false on every Cascade started). // // Two physical clients sharing one apiKey will land on different IP/UA // hashes and stay isolated; same client across turns lands on the same // hash and lets the cascade pool reuse the upstream session. // // Client-IP trust policy (XFF hop counting) lives in net-safety.js:trustedClientIp // — the single source of truth shared with src/dashboard/api.js so the caller-key // fingerprint and the dashboard lockout bucket can't drift apart. See that // function for the audit-H2 (default-ignore XFF) and audit-P1/XFF-1 (count from // the right, never take the spoofable leftmost) rationale. // // ⚠️ Behaviour change (audit S4): this module previously captured // TRUST_PROXY_X_FORWARDED_FOR into a module-load `const`, so flipping the env at // runtime had no effect here while the dashboard copy read it live — a latent // drift. trustedClientIp reads it live, matching the dashboard and this module's // own already-live hop reader. The xff-spoof test still fresh-imports per case, // so its coverage is unchanged. function ipUaFingerprint(req) { const ip = trustedClientIp(req); const ua = req?.headers?.['user-agent'] || ''; if (!ip && !ua) return ''; return sha256Hex(`${ip}\0${ua}`).slice(0, 16); } export function callerKeyFromRequest(req, apiKey = '', body = null) { const bodySubKey = body ? extractBodyCallerSubKey(body) : ''; const hasUserInBody = !!(body && usableSignal(body.user)); // Don't log the raw body.user — OpenAI's `user` field is often an end-user // email or stable account id (PII). bodySubKey is already its hash. log.debug(`[caller-key] hasUser=${hasUserInBody ? 'yes' : 'no'} subKey=${bodySubKey || '(none)'}`); if (apiKey) { const base = `api:${sha256Hex(apiKey).slice(0, 32)}`; if (bodySubKey) return `${base}:user:${bodySubKey}`; const ipua = ipUaFingerprint(req); return ipua ? `${base}:client:${ipua}` : base; } const sessionId = req?.headers?.['x-dashboard-session'] || req?.headers?.['x-session-id'] || ''; if (sessionId) { const base = `session:${sha256Hex(sessionId).slice(0, 32)}`; return bodySubKey ? `${base}:user:${bodySubKey}` : base; } const ip = trustedClientIp(req); const ua = req?.headers?.['user-agent'] || ''; const base = `client:${sha256Hex(`${ip}\0${ua}`).slice(0, 32)}`; return bodySubKey ? `${base}:user:${bodySubKey}` : base; } // NOTE: a `hasCallerScope()` export used to live here. It was DEAD (zero // production imports — grep) and had silently diverged from the LIVE gate // `hasPerUserScope()` in src/handlers/chat.js (the live one gates the guessed // `:client:` bucket behind SINGLE_TENANT_CACHE; this dead twin trusted it // unconditionally). Keeping a diverged, more-permissive copy exported was a // cross-tenant-cache landmine if anyone had imported it. Removed 2026-07-11 // (audit S1). The authoritative scope gate is chat.js:hasPerUserScope.