src/caller-key.js
Rama master · réplica de lectura del repositorio original.
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:<digest>` 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.