diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 5291fa10c..f9e7e712c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -30,6 +30,7 @@ import { resolveCodexRuntime, } from "../codex/runtime"; import { CODEX_REAUTH_ACTION, collectOAuthHealthEntriesForCli, MASKED_ACCOUNT_FALLBACK, type OAuthHealthEntry } from "../oauth/health"; +import { collectProviderSecurityDoctorChecks } from "../provider-security/status"; import { getAuthRefreshIntentLockPath, getAuthStorePath } from "../oauth/store"; export { resolveCodexHomeDir } from "../codex/home"; @@ -817,6 +818,11 @@ export async function runDoctor(args: string[] = []): Promise { console.log(` [${check.level}] ${check.message}`); } + console.log("\nProvider security (ChefVault)"); + for (const check of await collectProviderSecurityDoctorChecks(doctorConfig)) { + console.log(` [${check.level}] ${check.message}`); + } + // Hints, not fixes. const hints: string[] = []; const proxyDown = proxyDownRestartHint({ diff --git a/src/cli/status.ts b/src/cli/status.ts index 76d61d7f8..93292bcfb 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -11,6 +11,7 @@ import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortCla import { redactSecretString, redactUserPath } from "../lib/redact"; import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from "../codex/home"; import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status"; +import { collectProviderSecurityStatusAsync, type ProviderSecurityStatusReport } from "../provider-security/status"; type HealthCheck = { ok: boolean; @@ -68,6 +69,7 @@ export type CliStatusJson = { }; }; codexHome: OrcaCodexHomeDiagnostic; + providerSecurity: ProviderSecurityStatusReport; }; export type CliStatusView = { @@ -270,6 +272,8 @@ export async function collectStatus(): Promise { ? "reachable, but PID file is missing or stale" : "not running"; + const providerSecurity = await collectProviderSecurityStatusAsync(config); + return { proxyLabel, healthLabel: health.label, @@ -311,6 +315,7 @@ export async function collectStatus(): Promise { codexPlugins, codexRuntime, codexHome, + providerSecurity, }, }; } diff --git a/src/images/plan.ts b/src/images/plan.ts index b706b5756..39c169f90 100644 --- a/src/images/plan.ts +++ b/src/images/plan.ts @@ -1,6 +1,6 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; import type { ImageBridgePlan, VideoBridgePlan } from "./types"; -import { resolveEnvValue } from "../config"; +import { tryResolveProviderApiKey } from "../providers/credential"; import { getProviderRegistryEntry } from "../providers/registry"; import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isVideoGenName } from "./synthetic-tool"; @@ -33,10 +33,11 @@ export function findXaiProvider(config: OcxConfig): { name: string; provider: Oc * OAuth / Grok CLI proxy transport is not used here (that path is chat-oriented and not a * supported Images transport), so oauth-only configs deliberately do not arm the bridge. */ -export function resolveXaiImageApiKey(provider: OcxProviderConfig): string | undefined { +export async function resolveXaiImageApiKey(provider: OcxProviderConfig): Promise { if (provider.authMode === "oauth") return undefined; - const apiKey = resolveEnvValue(provider.apiKey)?.trim(); - return apiKey || undefined; + // Covers both inline keys and chefvault:// references, so a reference-backed xAI provider + // arms the bridge instead of silently looking unconfigured. + return tryResolveProviderApiKey(provider); } export async function planImageBridge( @@ -51,7 +52,7 @@ export async function planImageBridge( if (host === "api.openai.com") return undefined; const found = findXaiProvider(config); if (!found) return undefined; - const token = resolveXaiImageApiKey(found.provider); + const token = await resolveXaiImageApiKey(found.provider); if (!token) return undefined; // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override. const registryEntry = getProviderRegistryEntry("xai"); @@ -100,7 +101,7 @@ export async function planVideoBridge( if (host === "api.openai.com") return undefined; const found = findXaiProvider(config); if (!found) return undefined; - const token = resolveXaiImageApiKey(found.provider); + const token = await resolveXaiImageApiKey(found.provider); if (!token) return undefined; // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override. const registryEntry = getProviderRegistryEntry("xai"); diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 72e8c6d1f..868ef5e70 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -17,6 +17,7 @@ import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../providers/registry"; import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; import { resolveProviderTransport } from "../providers/xai-transport"; +import { globalProviderCredentialResolver } from "../provider-security/resolve"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; import { logOAuthEvent } from "./log"; export { @@ -465,6 +466,15 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf return undefined; } } + const credentialRef = prov.credentialRef?.trim(); + if (credentialRef) { + try { + const resolved = await globalProviderCredentialResolver.resolveCredentialRef(credentialRef); + return resolved.apiKey; + } catch { + return undefined; + } + } return resolveEnvValue(prov.apiKey); } diff --git a/src/provider-security/client.ts b/src/provider-security/client.ts new file mode 100644 index 000000000..24d6dff4b --- /dev/null +++ b/src/provider-security/client.ts @@ -0,0 +1,267 @@ +/** + * ChefVault provider-security HTTP client (PSP-008). + * + * Talks to the secret authority at CHEF_PROVIDER_SECURITY_URL (default :8323). + * Protected routes require Authorization: Bearer from CHEF_PROVIDER_SECURITY_TOKEN. + * Workload identity headers are assertions only (must match the token binding). + */ +import { + type ChefVaultRenewRequest, + type ChefVaultRenewResponse, + type ChefVaultResolveRequest, + type ChefVaultResolveResponse, + ProviderSecurityError, + type ProviderSecurityClientConfig, + type WorkloadIdentity, +} from "./types"; + +export const DEFAULT_PROVIDER_SECURITY_URL = "http://127.0.0.1:8323"; + +const WORKLOAD_HEADER_WORKLOAD = "x-chef-workload-id"; +const WORKLOAD_HEADER_HOST = "x-chef-host-id"; +const WORKLOAD_HEADER_ACTOR = "x-chef-actor"; + +export function resolveProviderSecurityUrl(env: NodeJS.ProcessEnv = process.env): string { + const raw = env.CHEF_PROVIDER_SECURITY_URL?.trim(); + return raw || DEFAULT_PROVIDER_SECURITY_URL; +} + +export function resolveProviderSecurityToken(env: NodeJS.ProcessEnv = process.env): string | undefined { + const raw = env.CHEF_PROVIDER_SECURITY_TOKEN?.trim(); + return raw || undefined; +} + +export function resolveWorkloadIdentity(env: NodeJS.ProcessEnv = process.env): WorkloadIdentity { + return { + workloadId: env.CHEF_WORKLOAD_ID?.trim() || "opencodex", + hostId: env.CHEF_HOST_ID?.trim() || env.HOSTNAME?.trim() || "local", + actor: env.CHEF_ACTOR?.trim() || "opencodex", + }; +} + +function workloadHeaders(workload: WorkloadIdentity): Record { + return { + [WORKLOAD_HEADER_WORKLOAD]: workload.workloadId, + [WORKLOAD_HEADER_HOST]: workload.hostId, + [WORKLOAD_HEADER_ACTOR]: workload.actor, + }; +} + +function mapAuthorityError(status: number, body: unknown): ProviderSecurityError { + const record = body && typeof body === "object" && !Array.isArray(body) + ? body as Record + : {}; + const code = typeof record.code === "string" ? record.code : undefined; + const message = typeof record.message === "string" + ? record.message + : typeof record.error === "string" + ? record.error + : `ChefVault provider-security returned HTTP ${status}`; + + if (code === "stale_fencing_token") { + return new ProviderSecurityError("stale_fencing_token", message); + } + if (status === 401 || code === "auth_required" || code === "auth_invalid") { + return new ProviderSecurityError( + code === "auth_invalid" ? "auth_invalid" : "auth_required", + message, + ); + } + if ( + status === 403 + || code === "identity_assertion_mismatch" + || code === "admin_required" + || code === "workload_credential_required" + ) { + return new ProviderSecurityError( + code === "identity_assertion_mismatch" ? "identity_assertion_mismatch" : "auth_forbidden", + message, + ); + } + if (code === "auth_not_configured") { + return new ProviderSecurityError("auth_not_configured", message); + } + if (status === 404 || code === "ref_not_found") { + return new ProviderSecurityError("ref_not_found", message); + } + if (status === 400 || code === "ref_invalid") { + return new ProviderSecurityError("ref_invalid", message); + } + if (status === 410 || code === "revoked") { + return new ProviderSecurityError("revoked", message); + } + // An authority that answers 5xx is up but cannot serve; that is an outage, not a rejected + // credential, and only outages are allowed to trip degraded mode. + if (status >= 500) { + return new ProviderSecurityError("authority_unavailable", message); + } + return new ProviderSecurityError("authority_error", message); +} + +function parseLeaseFields(body: unknown, kind: "resolve" | "renew"): { + record: Record; + lease: ChefVaultRenewResponse; +} { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new ProviderSecurityError("authority_error", `${kind} response was not an object`); + } + const record = body as Record; + const leaseId = typeof record.leaseId === "string" ? record.leaseId : ""; + const secret = typeof record.secret === "string" ? record.secret : ""; + const expiresAt = typeof record.expiresAt === "number" ? record.expiresAt : Number(record.expiresAt); + const fencingToken = typeof record.fencingToken === "number" ? record.fencingToken : Number(record.fencingToken); + if (!leaseId || !secret || !Number.isFinite(expiresAt) || !Number.isFinite(fencingToken)) { + throw new ProviderSecurityError("authority_error", `${kind} response missing required lease fields`); + } + return { record, lease: { leaseId, secret, expiresAt, fencingToken } }; +} + +function parseResolveResponse(body: unknown): ChefVaultResolveResponse { + const { record, lease } = parseLeaseFields(body, "resolve"); + const slotHint = record.slotHint; + return { + ...lease, + ...(slotHint === "active" || slotHint === "next" || slotHint === "retiring" + ? { slotHint } + : {}), + }; +} + +function parseRenewResponse(body: unknown): ChefVaultRenewResponse { + return parseLeaseFields(body, "renew").lease; +} + +export class ProviderSecurityClient { + readonly baseUrl: string; + readonly workload: WorkloadIdentity; + readonly token: string | undefined; + private readonly fetchImpl: typeof fetch; + private readonly requestTimeoutMs: number; + + constructor(config: ProviderSecurityClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, ""); + this.workload = config.workload; + this.token = config.token?.trim() || undefined; + this.fetchImpl = config.fetchImpl ?? fetch; + this.requestTimeoutMs = config.requestTimeoutMs ?? 8_000; + } + + static fromEnv(env: NodeJS.ProcessEnv = process.env): ProviderSecurityClient { + return new ProviderSecurityClient({ + baseUrl: resolveProviderSecurityUrl(env), + workload: resolveWorkloadIdentity(env), + token: resolveProviderSecurityToken(env), + }); + } + + private requireToken(): string { + if (!this.token) { + throw new ProviderSecurityError( + "auth_required", + "CHEF_PROVIDER_SECURITY_TOKEN is required for ChefVault credential calls", + ); + } + return this.token; + } + + private async request( + path: string, + init: RequestInit, + opts: { auth: boolean }, + ): Promise<{ ok: boolean; status: number; body: unknown }> { + const headers: Record = { + accept: "application/json", + "content-type": "application/json", + ...workloadHeaders(this.workload), + ...(init.headers as Record | undefined ?? {}), + }; + // Resolve the token before arming the abort timer: requireToken() throws for + // unauthenticated calls, and a timer armed before that throw would leak and keep a + // short-lived process (e.g. the doctor CLI) alive until it fires. + if (opts.auth) { + headers.authorization = `Bearer ${this.requireToken()}`; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs); + try { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + signal: controller.signal, + headers, + }); + // Read the body while the timeout is still armed: a response that stalls mid-body would + // otherwise hang a credential call for as long as the authority keeps the socket open. + const body = await response.json().catch(() => null); + return { ok: response.ok, status: response.status, body }; + } catch (error) { + if (error instanceof ProviderSecurityError) throw error; + if (error instanceof Error && error.name === "AbortError") { + throw new ProviderSecurityError("network_error", "ChefVault provider-security request timed out"); + } + throw new ProviderSecurityError( + "authority_unavailable", + error instanceof Error ? error.message : "ChefVault provider-security is unreachable", + ); + } finally { + clearTimeout(timer); + } + } + + /** Unauthenticated liveness probe. */ + async healthz(): Promise<{ ok: boolean; message: string }> { + try { + const { ok, status } = await this.request("/healthz", { method: "GET" }, { auth: false }); + if (!ok) { + return { ok: false, message: `HTTP ${status}` }; + } + return { ok: true, message: "ok" }; + } catch (error) { + if (error instanceof ProviderSecurityError) { + return { ok: false, message: error.message }; + } + return { ok: false, message: "unreachable" }; + } + } + + /** + * Authenticated readiness: Bearer accepted on a protected route. + * Uses GET /provider-security/status (redacted; no secrets). + */ + async authenticatedReady(): Promise<{ ok: boolean; message: string }> { + try { + const { ok, status, body } = await this.request("/provider-security/status", { method: "GET" }, { auth: true }); + if (ok) { + return { ok: true, message: "authenticated" }; + } + const mapped = mapAuthorityError(status, body); + return { ok: false, message: `${mapped.code}: ${mapped.message}` }; + } catch (error) { + if (error instanceof ProviderSecurityError) { + return { ok: false, message: `${error.code}: ${error.message}` }; + } + return { ok: false, message: "unreachable" }; + } + } + + async resolveLease(input: ChefVaultResolveRequest): Promise { + const { ok, status, body } = await this.request("/v1/credentials/resolve", { + method: "POST", + body: JSON.stringify(input), + }, { auth: true }); + if (!ok) { + throw mapAuthorityError(status, body); + } + return parseResolveResponse(body); + } + + async renewLease(input: ChefVaultRenewRequest): Promise { + const { ok, status, body } = await this.request("/v1/credentials/renew", { + method: "POST", + body: JSON.stringify(input), + }, { auth: true }); + if (!ok) { + throw mapAuthorityError(status, body); + } + return parseRenewResponse(body); + } +} diff --git a/src/provider-security/degraded.ts b/src/provider-security/degraded.ts new file mode 100644 index 000000000..53b4fc541 --- /dev/null +++ b/src/provider-security/degraded.ts @@ -0,0 +1,72 @@ +/** + * Bounded degraded mode when ChefVault is unavailable (PSP-011 stub). + * + * - Existing valid in-memory credentials may still be used (bounded by lease expiry). + * - New resolution against the authority is denied until recovery. + */ +import { CredentialSlotStore } from "./slots"; + +export interface DegradedDecision { + allowed: boolean; + reason?: "degraded_deny_resolve" | "lease_expired" | "revoked" | "missing"; +} + +const RECOVERY_PROBE_INTERVAL_MS = 30_000; + +export class DegradedModeController { + /** Last time the authority was probed (and failed) for a ref. */ + private readonly lastAttemptAt = new Map(); + private readonly probeIntervalMs: number; + + constructor( + private readonly store: CredentialSlotStore, + probeIntervalMs = RECOVERY_PROBE_INTERVAL_MS, + ) { + this.probeIntervalMs = probeIntervalMs; + } + + markUnavailable(ref: string, at = Date.now()): void { + this.store.enterDegraded(ref, at); + this.lastAttemptAt.set(ref, at); + } + + markRecovered(ref: string): void { + this.store.exitDegraded(ref); + this.lastAttemptAt.delete(ref); + } + + isDegraded(ref: string): boolean { + return this.store.getMode(ref) === "degraded"; + } + + /** + * Whether a fresh resolve against ChefVault is permitted. While degraded, new resolution + * is denied except for a bounded recovery probe once per probe interval, so the gate can + * reopen without waiting for a successful renewal. + */ + canResolve(ref: string, at = Date.now()): DegradedDecision { + if (!this.isDegraded(ref)) { + return { allowed: true }; + } + const lastAttempt = this.lastAttemptAt.get(ref) ?? 0; + if (at - lastAttempt >= this.probeIntervalMs) { + return { allowed: true }; + } + return { allowed: false, reason: "degraded_deny_resolve" }; + } + + /** Whether an already-resolved in-memory credential may be used for upstream auth. */ + canUseExisting(ref: string, at = Date.now()): DegradedDecision { + const snapshot = this.store.snapshotForRequest(ref, at); + if (!snapshot) { + if (this.isDegraded(ref)) { + return { allowed: false, reason: "degraded_deny_resolve" }; + } + return { allowed: false, reason: "missing" }; + } + if (snapshot.expiresAt <= at) { + return { allowed: false, reason: "lease_expired" }; + } + return { allowed: true }; + } +} diff --git a/src/provider-security/index.ts b/src/provider-security/index.ts new file mode 100644 index 000000000..578273177 --- /dev/null +++ b/src/provider-security/index.ts @@ -0,0 +1,6 @@ +export * from "./types"; +export * from "./client"; +export * from "./slots"; +export * from "./degraded"; +export * from "./resolve"; +export * from "./status"; diff --git a/src/provider-security/resolve.ts b/src/provider-security/resolve.ts new file mode 100644 index 000000000..b15281b36 --- /dev/null +++ b/src/provider-security/resolve.ts @@ -0,0 +1,193 @@ +/** + * chefvault:// credential resolution for provider auth (PSP-008). + */ +import { ProviderSecurityClient } from "./client"; +import { DegradedModeController } from "./degraded"; +import { + CredentialSlotStore, + globalCredentialSlotStore, + renewalJitterMs, + shouldRenewLease, +} from "./slots"; +import { + ProviderSecurityError, + validateChefVaultRef, + type CredentialSnapshot, + type ProviderSecurityErrorCode, +} from "./types"; + +export interface ResolveCredentialDeps { + client?: ProviderSecurityClient; + slotStore?: CredentialSlotStore; + degraded?: DegradedModeController; + now?: () => number; + jitterMs?: number; +} + +export interface ResolvedProviderCredential { + apiKey: string; + snapshot: CredentialSnapshot; + source: "chefvault" | "memory"; +} + +function toProviderSecurityError(error: unknown): ProviderSecurityError { + if (error instanceof ProviderSecurityError) return error; + return new ProviderSecurityError( + "authority_error", + error instanceof Error ? error.message : "unknown provider-security failure", + ); +} + +/** + * Degraded mode means "ChefVault cannot be reached", so only availability failures may enter it. + * Any delivered answer proves the authority is reachable: a rejected reference (unknown, + * invalid, revoked) must keep surfacing its real 401 cause, and `authority_error` (a malformed + * but delivered response, or a local failure while applying it) must not let a parsing bug + * masquerade as an outage. + */ +const AUTHORITY_OUTAGE_CODES: ReadonlySet = new Set([ + "authority_unavailable", + "network_error", + "auth_not_configured", +]); + +export class ProviderCredentialResolver { + private readonly clientOverride: ProviderSecurityClient | undefined; + readonly slotStore: CredentialSlotStore; + private readonly degraded: DegradedModeController; + private readonly now: () => number; + private readonly inFlight = new Map>(); + + /** + * Authority client. The process-wide resolver is constructed at import time, so the endpoint + * must be read from the environment on use rather than captured at construction. + */ + get client(): ProviderSecurityClient { + return this.clientOverride ?? ProviderSecurityClient.fromEnv(); + } + + constructor(deps: ResolveCredentialDeps = {}) { + this.clientOverride = deps.client; + this.slotStore = deps.slotStore ?? globalCredentialSlotStore; + this.degraded = deps.degraded ?? new DegradedModeController(this.slotStore); + this.now = deps.now ?? (() => Date.now()); + } + + async resolveCredentialRef(ref: string, deps: { jitterMs?: number } = {}): Promise { + const invalid = validateChefVaultRef(ref); + if (invalid) throw invalid; + + const at = this.now(); + const existing = this.slotStore.snapshotForRequest(ref, at); + if (existing && existing.expiresAt > at) { + const renewTarget = this.slotStore.getState(ref)?.slots.active; + if ( + renewTarget && + shouldRenewLease(renewTarget, at, deps.jitterMs ?? renewalJitterMs()) && + // While degraded, renewal traffic is capped to the same bounded recovery-probe cadence + // as fresh resolves; between probes the unexpired snapshot keeps serving without + // touching the authority. + this.degraded.canResolve(ref, at).allowed + ) { + try { + await this.tryRenew(ref, renewTarget.leaseId, renewTarget.fencingToken, "active"); + } catch (error) { + // Only a transient authority failure may keep serving the cached snapshot until expiry. + // An explicit revocation is authoritative: drop the lease and fail the request closed, + // otherwise a revoked credential keeps authenticating until its old expiry. + const code = providerSecurityErrorCode(error); + if (code === "revoked") { + this.slotStore.revokeRef(ref); + throw toProviderSecurityError(error); + } + // A confirmed authority outage enters degraded mode so later requests in the renewal + // window stop hammering ChefVault instead of retrying on every resolve. + if (AUTHORITY_OUTAGE_CODES.has(code)) { + this.degraded.markUnavailable(ref, at); + } + } + const refreshed = this.slotStore.snapshotForRequest(ref, this.now()); + if (refreshed) { + return { apiKey: refreshed.secret, snapshot: refreshed, source: "memory" }; + } + } + return { apiKey: existing.secret, snapshot: existing, source: "memory" }; + } + + // One resolve per ref at a time. Concurrent cold-start requests would otherwise each mint a + // lease, and every apply after the first would be rejected as a stale fencing token. + const pending = this.inFlight.get(ref); + if (pending) return pending; + const attempt = this.resolveFromAuthority(ref, at).finally(() => { + this.inFlight.delete(ref); + }); + this.inFlight.set(ref, attempt); + return attempt; + } + + private async resolveFromAuthority(ref: string, at: number): Promise { + // Degraded mode denies new resolution without touching the authority, except for a + // bounded recovery probe so the gate reopens once ChefVault returns. + if (!this.degraded.canResolve(ref, at).allowed) { + throw new ProviderSecurityError( + "degraded_deny_resolve", + "ChefVault is unavailable; new credential resolution is denied in degraded mode", + ); + } + const state = this.slotStore.getState(ref); + try { + const response = await this.client.resolveLease({ + ref, + ...(state && state.lastFencingToken > 0 ? { fencingToken: state.lastFencingToken } : {}), + }); + this.degraded.markRecovered(ref); + const lease = this.slotStore.applyResolve(ref, response, this.now()); + const snapshot = this.slotStore.snapshotForRequest(ref, this.now()); + if (!snapshot) { + throw new ProviderSecurityError("authority_error", "resolve succeeded but no usable snapshot was stored"); + } + return { apiKey: lease.secret, snapshot, source: "chefvault" }; + } catch (error) { + const mapped = toProviderSecurityError(error); + if (AUTHORITY_OUTAGE_CODES.has(mapped.code)) { + this.degraded.markUnavailable(ref, at); + } + throw mapped; + } + } + + private async tryRenew( + ref: string, + leaseId: string, + fencingToken: number, + phase: "active" | "next" | "retiring", + ): Promise { + const response = await this.client.renewLease({ ref, leaseId, fencingToken }); + this.degraded.markRecovered(ref); + this.slotStore.applyRenew(ref, response, phase, this.now()); + } + + /** Liveness only — `/healthz` is unauthenticated. */ + async probeAuthority(): Promise<{ ok: boolean; message: string }> { + return this.client.healthz(); + } + + /** Authenticated readiness — bearer token + `/provider-security/status`. */ + async probeAuthenticatedReady(): Promise<{ ok: boolean; message: string }> { + return this.client.authenticatedReady(); + } +} + +export const globalProviderCredentialResolver = new ProviderCredentialResolver(); + +export async function resolveChefVaultCredential( + ref: string, + deps?: ResolveCredentialDeps, +): Promise { + return new ProviderCredentialResolver(deps).resolveCredentialRef(ref); +} + +export function providerSecurityErrorCode(error: unknown): ProviderSecurityErrorCode { + if (error instanceof ProviderSecurityError) return error.code; + return "authority_error"; +} diff --git a/src/provider-security/slots.ts b/src/provider-security/slots.ts new file mode 100644 index 000000000..e312e592d --- /dev/null +++ b/src/provider-security/slots.ts @@ -0,0 +1,225 @@ +/** + * In-memory credential slot model (PSP-008). + * + * Slots: active / next / retiring / revoked. Raw secrets live only in process memory. + */ +import { + ProviderSecurityError, + type ChefVaultRenewResponse, + type ChefVaultResolveResponse, + type CredentialLease, + type CredentialSlotPhase, + type CredentialSnapshot, + type ProviderSecurityMode, + type RedactedProviderSecurityStatus, + type RedactedSlotSummary, + type SlotStoreState, +} from "./types"; + +const RENEWAL_LEAD_MS = 5 * 60_000; +const RENEWAL_JITTER_MS = 30_000; + +function nowMs(): number { + return Date.now(); +} + +function isUsableLease(lease: CredentialLease | undefined, at = nowMs()): lease is CredentialLease { + return !!lease && lease.phase !== "revoked" && lease.expiresAt > at; +} + +function freezeSnapshot(lease: CredentialLease): CredentialSnapshot { + if (lease.phase === "revoked") { + throw new Error("revoked leases cannot produce request snapshots"); + } + return Object.freeze({ + ref: lease.ref, + leaseId: lease.leaseId, + secret: lease.secret, + expiresAt: lease.expiresAt, + fencingToken: lease.fencingToken, + phase: lease.phase, + }); +} + +function toLease( + ref: string, + response: ChefVaultResolveResponse | ChefVaultRenewResponse, + phase: CredentialSlotPhase, + resolvedAt = nowMs(), +): CredentialLease { + return { + ref, + leaseId: response.leaseId, + secret: response.secret, + expiresAt: response.expiresAt, + fencingToken: response.fencingToken, + phase, + resolvedAt, + }; +} + +export function renewalJitterMs(seed = Math.random()): number { + return Math.floor(seed * RENEWAL_JITTER_MS); +} + +export function shouldRenewLease(lease: CredentialLease | undefined, at = nowMs(), jitterMs = 0): boolean { + if (!isUsableLease(lease, at)) return false; + return lease.expiresAt - at <= RENEWAL_LEAD_MS + jitterMs; +} + +export class CredentialSlotStore { + private readonly stores = new Map(); + + private ensure(ref: string): SlotStoreState { + const existing = this.stores.get(ref); + if (existing) return existing; + const created: SlotStoreState = { + ref, + mode: "normal", + lastFencingToken: 0, + slots: {}, + degradedSince: null, + lastRenewalAt: null, + }; + this.stores.set(ref, created); + return created; + } + + getState(ref: string): SlotStoreState | undefined { + const state = this.stores.get(ref); + if (!state) return undefined; + return { + ...state, + slots: { ...state.slots }, + }; + } + + enterDegraded(ref: string, at = nowMs()): void { + const state = this.ensure(ref); + state.mode = "degraded"; + state.degradedSince ??= at; + } + + exitDegraded(ref: string): void { + const state = this.stores.get(ref); + if (!state) return; + state.mode = "normal"; + state.degradedSince = null; + } + + getMode(ref: string): ProviderSecurityMode { + return this.stores.get(ref)?.mode ?? "normal"; + } + + /** Immutable snapshot for an in-flight upstream request. Prefers active, then retiring. */ + snapshotForRequest(ref: string, at = nowMs()): CredentialSnapshot | null { + const state = this.stores.get(ref); + if (!state) return null; + const active = state.slots.active; + if (isUsableLease(active, at)) return freezeSnapshot(active); + const retiring = state.slots.retiring; + if (isUsableLease(retiring, at)) return freezeSnapshot(retiring); + return null; + } + + applyResolve(ref: string, response: ChefVaultResolveResponse, at = nowMs()): CredentialLease { + const state = this.ensure(ref); + if (response.fencingToken <= state.lastFencingToken) { + throw new ProviderSecurityError("stale_fencing_token", "fencing token is stale"); + } + + const targetPhase = response.slotHint ?? "active"; + const previousActive = state.slots.active; + const lease = toLease(ref, response, targetPhase, at); + + if (targetPhase === "active") { + if (previousActive && previousActive.leaseId !== lease.leaseId) { + state.slots.retiring = { ...previousActive, phase: "retiring" }; + } + state.slots.active = lease; + } else if (targetPhase === "next") { + state.slots.next = lease; + } else { + state.slots.retiring = lease; + } + + state.lastFencingToken = response.fencingToken; + state.lastRenewalAt = at; + return lease; + } + + applyRenew(ref: string, response: ChefVaultRenewResponse, phase: CredentialSlotPhase, at = nowMs()): CredentialLease { + const state = this.ensure(ref); + if (response.fencingToken <= state.lastFencingToken) { + throw new ProviderSecurityError("stale_fencing_token", "fencing token is stale"); + } + const lease = toLease(ref, response, phase, at); + state.slots[phase] = lease; + state.lastFencingToken = response.fencingToken; + state.lastRenewalAt = at; + return lease; + } + + promoteNextToActive(ref: string, at = nowMs()): CredentialLease | null { + const state = this.stores.get(ref); + const next = state?.slots.next; + if (!state || !isUsableLease(next, at)) return null; + const previousActive = state.slots.active; + if (previousActive) { + state.slots.retiring = { ...previousActive, phase: "retiring" }; + } + const promoted: CredentialLease = { ...next, phase: "active", resolvedAt: at }; + state.slots.active = promoted; + delete state.slots.next; + return promoted; + } + + revokePhase(ref: string, phase: CredentialSlotPhase): void { + const state = this.stores.get(ref); + const lease = state?.slots[phase]; + if (!state || !lease) return; + state.slots[phase] = { ...lease, phase: "revoked" }; + } + + /** + * Revocation from the authority is authoritative for the whole ref: no slot (including a + * retiring lease copied aside during rotation) may keep serving the secret. + */ + revokeRef(ref: string): void { + const state = this.stores.get(ref); + if (!state) return; + for (const phase of ["active", "next", "retiring"] as const) { + const lease = state.slots[phase]; + if (lease) state.slots[phase] = { ...lease, phase: "revoked" }; + } + } + + /** Read-only: reporting on a ref must not create slot state for it. */ + redactedStatus(ref: string, at = nowMs()): RedactedProviderSecurityStatus { + const state = this.stores.get(ref); + const slots: RedactedSlotSummary[] = (["active", "next", "retiring", "revoked"] as const).flatMap(phase => { + const lease = state?.slots[phase]; + if (!lease) return []; + return [{ + // Report the lease's own phase: a revoked lease stays stored under its slot key, + // and operators must be able to tell "revoked" apart from "expired". + phase: lease.phase, + leaseId: lease.leaseId, + expiresAt: lease.expiresAt, + fencingToken: lease.fencingToken, + valid: isUsableLease(lease, at), + }]; + }); + return { + ref, + mode: state?.mode ?? "normal", + degradedSince: state?.degradedSince ?? null, + lastFencingToken: state?.lastFencingToken ?? 0, + slots, + hasUsableCredential: slots.some(slot => slot.valid && slot.phase !== "revoked"), + }; + } +} + +/** Process-wide slot store — secrets never leave memory or hit disk. */ +export const globalCredentialSlotStore = new CredentialSlotStore(); diff --git a/src/provider-security/status.ts b/src/provider-security/status.ts new file mode 100644 index 000000000..c5e5970ca --- /dev/null +++ b/src/provider-security/status.ts @@ -0,0 +1,212 @@ +/** + * Redacted provider-security status for doctor/status surfaces (PSP-008). + */ +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { ProviderSecurityClient } from "./client"; +import { globalCredentialSlotStore } from "./slots"; +import { ProviderCredentialResolver } from "./resolve"; +import { redactProviderSecurityDetail, type RedactedProviderSecurityStatus } from "./types"; + +export interface ProviderSecurityDoctorCheck { + level: "OK" | "WARN"; + provider: string; + /** liveness | authenticated | credential | provider */ + layer?: "liveness" | "authenticated" | "credential" | "provider"; + message: string; +} + +export interface ProviderSecurityStatusReport { + authority: { + ok: boolean; + url: string; + message: string; + livenessOk?: boolean; + authenticatedOk?: boolean; + }; + providers: Array<{ + provider: string; + credentialRef: string; + status: RedactedProviderSecurityStatus; + }>; +} + +/** + * Providers that delegate their secret to the plane. Malformed references are included on + * purpose: a typo'd ref fails every request closed, so hiding it from doctor/status is the worst + * possible place for it to be invisible. + */ +export function listChefVaultProviders(config: OcxConfig): Array<{ name: string; ref: string; provider: OcxProviderConfig }> { + return Object.entries(config.providers).flatMap(([name, provider]) => { + const ref = provider.credentialRef?.trim(); + if (!ref) return []; + return [{ name, ref, provider }]; + }); +} + +export function collectProviderSecurityStatus( + config: OcxConfig, + client: ProviderSecurityClient = ProviderSecurityClient.fromEnv(), + slotStore: typeof globalCredentialSlotStore = globalCredentialSlotStore, +): ProviderSecurityStatusReport { + const authority = client.baseUrl; + return { + authority: { ok: false, url: authority, message: "not probed" }, + providers: listChefVaultProviders(config).map(entry => ({ + provider: entry.name, + credentialRef: entry.ref, + status: slotStore.redactedStatus(entry.ref), + })), + }; +} + +/** + * Liveness plus authenticated readiness, for status surfaces. `/healthz` alone can report green + * while every lease call is rejected, so `authority.ok` requires both probes. Nothing here + * resolves a credential: reporting status must not mint leases as a side effect, and a proxy + * with no reference-backed provider must not reach out to the authority at all. + */ +export async function collectProviderSecurityStatusAsync( + config: OcxConfig, + resolver: ProviderCredentialResolver = new ProviderCredentialResolver(), +): Promise { + const base = collectProviderSecurityStatus(config, resolver.client, resolver.slotStore); + if (base.providers.length === 0) { + base.authority.message = "not probed (no provider uses a credential reference)"; + return base; + } + + const liveness = await resolver.probeAuthority(); + const authenticated = liveness.ok + ? await resolver.probeAuthenticatedReady() + : { ok: false, message: "skipped (liveness failed)" }; + base.authority = { + ok: liveness.ok && authenticated.ok, + url: resolver.client.baseUrl, + message: redactProviderSecurityDetail(liveness.ok ? authenticated.message : liveness.message), + livenessOk: liveness.ok, + authenticatedOk: authenticated.ok, + }; + return base; +} + +export async function collectProviderSecurityDoctorChecks( + config: OcxConfig, + resolver: ProviderCredentialResolver = new ProviderCredentialResolver(), +): Promise { + const checks: ProviderSecurityDoctorCheck[] = []; + const refs = listChefVaultProviders(config); + + // A proxy with no reference-backed provider must not reach out to the authority at all: + // guard before any liveness/authenticated probe, not after. + if (refs.length === 0) { + checks.push({ + level: "OK", + provider: "*", + layer: "provider", + message: "No providers configured with chefvault:// credentialRef.", + }); + return checks; + } + + const liveness = await resolver.client.healthz(); + if (liveness.ok) { + checks.push({ + level: "OK", + provider: "*", + layer: "liveness", + message: `Liveness OK: ChefVault /healthz reachable (${resolver.client.baseUrl}).`, + }); + } else { + checks.push({ + level: "WARN", + provider: "*", + layer: "liveness", + message: `Liveness FAIL: ChefVault /healthz unavailable (${redactProviderSecurityDetail(liveness.message)}). Degraded mode: existing in-memory leases only; new resolve denied.`, + }); + } + + if (liveness.ok) { + const authenticated = await resolver.client.authenticatedReady(); + if (authenticated.ok) { + checks.push({ + level: "OK", + provider: "*", + layer: "authenticated", + message: "Authenticated readiness OK: Bearer accepted on protected status route.", + }); + } else { + checks.push({ + level: "WARN", + provider: "*", + layer: "authenticated", + message: `Authenticated readiness FAIL: ${redactProviderSecurityDetail(authenticated.message)}. Set CHEF_PROVIDER_SECURITY_TOKEN to the workload bearer registered in ChefVault.`, + }); + } + + if (authenticated.ok && refs.length > 0) { + let credentialOk = 0; + for (const { name, ref } of refs) { + try { + await resolver.resolveCredentialRef(ref); + credentialOk += 1; + checks.push({ + level: "OK", + provider: name, + layer: "credential", + message: `Credential resolve OK for "${name}" (${ref}).`, + }); + } catch (error) { + const detail = redactProviderSecurityDetail(error instanceof Error ? error.message : String(error)); + checks.push({ + level: "WARN", + provider: name, + layer: "credential", + message: `Credential resolve/renew FAIL for "${name}" (${ref}): ${detail}`, + }); + } + } + if (credentialOk === refs.length) { + checks.push({ + level: "OK", + provider: "*", + layer: "credential", + message: `Credential readiness OK: resolved ${credentialOk}/${refs.length} chefvault:// refs.`, + }); + } + } else if (!authenticated.ok && refs.length > 0) { + checks.push({ + level: "WARN", + provider: "*", + layer: "credential", + message: `Credential readiness skipped: ${refs.length} chefvault:// ref(s) configured but authenticated readiness failed.`, + }); + } + } + + for (const { name, ref } of refs) { + const status = resolver.slotStore.redactedStatus(ref); + if (status.mode === "degraded") { + checks.push({ + level: "WARN", + provider: name, + layer: "provider", + message: `Provider "${name}" is in degraded mode for ${ref}; only bounded in-memory credentials may be used.`, + }); + continue; + } + if (status.hasUsableCredential) { + checks.push({ + level: "OK", + provider: name, + layer: "provider", + message: `Provider "${name}" has a usable in-memory lease for ${ref}.`, + }); + } + } + + return checks; +} + +export function serializeProviderSecurityStatus(report: ProviderSecurityStatusReport): string { + return JSON.stringify(report); +} diff --git a/src/provider-security/types.ts b/src/provider-security/types.ts new file mode 100644 index 000000000..95b8562c9 --- /dev/null +++ b/src/provider-security/types.ts @@ -0,0 +1,155 @@ +/** ChefVault provider-security plane — shared types and error taxonomy (PSP-008). */ + +export const CHEFVAULT_REF_PREFIX = "chefvault://"; + +export type CredentialSlotPhase = "active" | "next" | "retiring" | "revoked"; + +export type ProviderSecurityMode = "normal" | "degraded"; + +/** Stable error codes surfaced to callers and doctor/status. */ +export type ProviderSecurityErrorCode = + | "authority_unavailable" + | "stale_fencing_token" + | "ref_invalid" + | "ref_not_found" + | "lease_expired" + | "degraded_deny_resolve" + | "revoked" + | "network_error" + | "authority_error" + | "auth_required" + | "auth_invalid" + | "auth_forbidden" + | "identity_assertion_mismatch" + | "auth_not_configured"; + +export class ProviderSecurityError extends Error { + readonly code: ProviderSecurityErrorCode; + + constructor(code: ProviderSecurityErrorCode, message: string) { + super(message); + this.name = "ProviderSecurityError"; + this.code = code; + } +} + +export interface WorkloadIdentity { + workloadId: string; + hostId: string; + actor: string; +} + +export interface ChefVaultResolveRequest { + ref: string; + fencingToken?: number; +} + +export interface ChefVaultResolveResponse { + leaseId: string; + secret: string; + expiresAt: number; + fencingToken: number; + slotHint?: Exclude; +} + +export interface ChefVaultRenewRequest { + ref: string; + leaseId: string; + fencingToken: number; +} + +export interface ChefVaultRenewResponse { + leaseId: string; + secret: string; + expiresAt: number; + fencingToken: number; +} + +/** In-memory lease material — never persisted to disk. */ +export interface CredentialLease { + ref: string; + leaseId: string; + secret: string; + expiresAt: number; + fencingToken: number; + phase: CredentialSlotPhase; + resolvedAt: number; +} + +/** Immutable credential view handed to a single upstream request. */ +export interface CredentialSnapshot { + readonly ref: string; + readonly leaseId: string; + readonly secret: string; + readonly expiresAt: number; + readonly fencingToken: number; + readonly phase: Exclude; +} + +export interface SlotStoreState { + ref: string; + mode: ProviderSecurityMode; + lastFencingToken: number; + slots: Partial>; + degradedSince: number | null; + lastRenewalAt: number | null; +} + +/** Redacted slot summary safe for doctor/status/telemetry. */ +export interface RedactedSlotSummary { + phase: CredentialSlotPhase; + leaseId: string; + expiresAt: number; + fencingToken: number; + valid: boolean; +} + +export interface RedactedProviderSecurityStatus { + ref: string; + mode: ProviderSecurityMode; + degradedSince: number | null; + lastFencingToken: number; + slots: RedactedSlotSummary[]; + hasUsableCredential: boolean; +} + +export interface ProviderSecurityClientConfig { + baseUrl: string; + workload: WorkloadIdentity; + /** Bearer token for protected ChefVault routes (`CHEF_PROVIDER_SECURITY_TOKEN`). */ + token?: string; + fetchImpl?: typeof fetch; + requestTimeoutMs?: number; +} + +export function isChefVaultRef(value: string | undefined): value is string { + return typeof value === "string" && value.startsWith(CHEFVAULT_REF_PREFIX) && value.length > CHEFVAULT_REF_PREFIX.length; +} + +export function validateChefVaultRef(ref: string): ProviderSecurityError | null { + if (!isChefVaultRef(ref)) { + return new ProviderSecurityError("ref_invalid", `credential ref must start with ${CHEFVAULT_REF_PREFIX}`); + } + // The raw ref string is the authority lookup key, so validate it verbatim: URL parsing + // would normalize away traversal segments and percent-encode whitespace. + if (/[\s\u0000-\u001f\u007f]/.test(ref)) { + return new ProviderSecurityError("ref_invalid", "credential ref must not contain whitespace or control characters"); + } + // Host-only refs (chefvault://name) are accepted, matching isChefVaultRef. + const segments = ref.slice(CHEFVAULT_REF_PREFIX.length).split("/"); + if (segments.some(segment => segment === "" || segment === "." || segment === "..")) { + return new ProviderSecurityError("ref_invalid", "credential ref must use non-empty path segments without traversal"); + } + return null; +} + +/** + * Scrub credential material from authority error text before it reaches doctor/status + * output. Authority responses and transport errors may echo header or token fragments; + * redact anything shaped like bearer/token material rather than trusting the source. + */ +export function redactProviderSecurityDetail(text: string): string { + return text + .replace(/\bbearer\s+[^\s"',;]+/gi, "Bearer [redacted]") + .replace(/\b(authorization|x-api-key|api[-_]?key|token|secret)(\s*[:=]\s*)[^\s"',;]+/gi, "$1$2[redacted]"); +} diff --git a/src/providers/credential.ts b/src/providers/credential.ts new file mode 100644 index 000000000..f01040005 --- /dev/null +++ b/src/providers/credential.ts @@ -0,0 +1,87 @@ +/** + * Per-request provider credential resolution. + * + * A provider may carry its secret either inline (`apiKey`, possibly an `$ENV` reference) or as a + * `chefvault://` reference (`credentialRef`) that is leased from the provider-security plane. + * Every outbound path that authenticates with a provider key must go through here: resolving the + * reference only in one place (model discovery) leaves ordinary forwarding unauthenticated. + * + * The resolved secret is returned for the lifetime of the current request only. It is never + * written back into the persisted config — callers apply it to a per-request provider copy. + */ +import { resolveEnvValue } from "../config"; +import { + globalProviderCredentialResolver, + providerSecurityErrorCode, + ProviderSecurityError, + redactProviderSecurityDetail, +} from "../provider-security"; +import type { OcxProviderConfig } from "../types"; + +/** + * The provider's credential reference, or undefined when it authenticates with a plain key. + * + * A configured-but-malformed reference is deliberately returned rather than ignored: silently + * falling back to an absent `apiKey` would forward the request with no Authorization header at + * all. Resolution rejects it as `ref_invalid` instead, so a typo fails closed and is visible. + */ +export function providerCredentialRef(provider: Pick): string | undefined { + return provider.credentialRef?.trim() || undefined; +} + +/** + * Effective API key for this request: a leased ChefVault secret when the provider is + * reference-backed, otherwise the configured (env-expanded) key. Throws + * `ProviderSecurityError` when a reference cannot be leased, so callers fail closed instead of + * forwarding an unauthenticated request. + */ +export async function resolveProviderApiKey(provider: OcxProviderConfig): Promise { + const ref = providerCredentialRef(provider); + if (!ref) return resolveEnvValue(provider.apiKey)?.trim() || undefined; + const resolved = await globalProviderCredentialResolver.resolveCredentialRef(ref); + return resolved.apiKey; +} + +/** + * Provider copy whose `apiKey` holds the leased secret. Returns the input untouched for + * providers without a `chefvault://` reference so non-ChefVault routing is unaffected. + */ +export async function withResolvedProviderCredential(provider: OcxProviderConfig): Promise { + const ref = providerCredentialRef(provider); + if (!ref) return provider; + const resolved = await globalProviderCredentialResolver.resolveCredentialRef(ref); + return { ...provider, apiKey: resolved.apiKey }; +} + +export interface ProviderCredentialFailure { + status: number; + type: string; + message: string; +} + +/** + * HTTP shape for a failed lease. A rejected or unknown reference is a credential problem (401); + * an unreachable/degraded authority is a temporary upstream condition (503). + */ +export function providerCredentialFailure(providerName: string, error: unknown): ProviderCredentialFailure { + const code = providerSecurityErrorCode(error); + // Authority error text may echo header or token fragments; this message reaches data-plane + // clients through Responses/compact/Images error bodies, so scrub it like doctor/status output. + const detail = redactProviderSecurityDetail(error instanceof Error ? error.message : String(error)); + const denied = code === "ref_invalid" || code === "ref_not_found" || code === "revoked" || code === "lease_expired"; + return { + status: denied ? 401 : 503, + type: denied ? "authentication_error" : "api_error", + message: `Provider '${providerName}' credential could not be resolved (${code}): ${detail}`, + }; +} + +/** Same as `resolveProviderApiKey`, but a failed lease yields undefined instead of throwing. */ +export async function tryResolveProviderApiKey(provider: OcxProviderConfig): Promise { + try { + return await resolveProviderApiKey(provider); + } catch (error) { + if (error instanceof ProviderSecurityError) return undefined; + throw error; + } +} diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 40728a32f..eae1761f2 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -1,4 +1,5 @@ -import { resolveEnvValue } from "../config"; +import { providerCredentialFailure, resolveProviderApiKey, tryResolveProviderApiKey } from "./credential"; +import { ProviderSecurityError } from "../provider-security"; import { headersForCodexAuthContext, hasCallerCodexBearer, @@ -37,6 +38,9 @@ export interface OpenAiImagesProviderSelection { apiKey: string; }; error?: string; + /** Response shape for `error`. Defaults to a 400 invalid_request_error (a configuration mistake). */ + errorStatus?: number; + errorType?: string; } export function listOpenAiForwardSidecarCandidates(config: OcxConfig): OpenAiForwardSidecarCandidate[] { @@ -110,7 +114,7 @@ export async function resolveFirstUsableOpenAiSidecar( return undefined; } -export function selectOpenAiImagesProvider(config: OcxConfig): OpenAiImagesProviderSelection { +export async function selectOpenAiImagesProvider(config: OcxConfig): Promise { const selection: OpenAiImagesProviderSelection = { forwardCandidates: listOpenAiForwardSidecarCandidates(config), }; @@ -122,14 +126,16 @@ export function selectOpenAiImagesProvider(config: OcxConfig): OpenAiImagesProvi && provider.authMode !== "forward" && provider.baseUrl.replace(/\/+$/, "") === "https://api.openai.com/v1" ) { - const apiKey = resolveEnvValue(provider.apiKey)?.trim(); + // A chefvault-backed provider has no inline key; lease it here so the keyed tier is armed + // with real authentication instead of being skipped as unconfigured. + const apiKey = await tryResolveProviderApiKey(provider); if (apiKey) selection.keyed = { providerName: OPENAI_API_PROVIDER_ID, provider, apiKey }; } return selection; } /** Resolve an explicit custom Images provider, otherwise preserve the existing OpenAI fallback. */ -export function selectImagesProvider(config: OcxConfig): OpenAiImagesProviderSelection { +export async function selectImagesProvider(config: OcxConfig): Promise { const configuredProvider = config.images?.provider; if (configuredProvider === undefined) return selectOpenAiImagesProvider(config); if (typeof configuredProvider !== "string" || !configuredProvider.trim()) { @@ -160,7 +166,22 @@ export function selectImagesProvider(config: OcxConfig): OpenAiImagesProviderSel }; } - const apiKey = resolveEnvValue(provider.apiKey)?.trim(); + // An explicitly configured Images provider has no other tier to fall back to, so a failed + // ChefVault lease is reported as the credential/authority failure it is rather than as the + // "no usable API key" configuration error. + let apiKey: string | undefined; + try { + apiKey = await resolveProviderApiKey(provider); + } catch (error) { + if (!(error instanceof ProviderSecurityError)) throw error; + const failure = providerCredentialFailure(providerName, error); + return { + forwardCandidates: [], + error: failure.message, + errorStatus: failure.status, + errorType: failure.type, + }; + } if (!apiKey) { return { forwardCandidates: [], error: `images.provider "${providerName}" has no usable API key` }; } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 4f806e87e..d24945eb9 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -386,7 +386,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { const dto: Record = { adapter: provider.adapter, baseUrl: publicProviderBaseUrl(provider.baseUrl), - hasApiKey: !!provider.apiKey, + hasApiKey: !!provider.apiKey || !!provider.credentialRef, hasHeaders: !!provider.headers && Object.keys(provider.headers).length > 0, }; for (const key of [ @@ -395,6 +395,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "allowPrivateNetwork", "authMode", "apiKeyTransport", + "credentialRef", "keyOptional", "freeTier", "liveModels", diff --git a/src/server/images.ts b/src/server/images.ts index 533098bb1..33856aaf6 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -324,9 +324,13 @@ export async function handleImages( endpoint: ImagesEndpoint, logCtx: RequestLogContext, ): Promise { - const candidates = selectImagesProvider(config); + const candidates = await selectImagesProvider(config); if (candidates.error) { - return formatErrorResponse(400, "invalid_request_error", candidates.error); + return formatErrorResponse( + candidates.errorStatus ?? 400, + candidates.errorType ?? "invalid_request_error", + candidates.error, + ); } const explicitKeyedProvider = config.images?.provider !== undefined && candidates.keyed !== undefined; // Admission bearer is valid proxy auth (requireApiAuth already passed) but must never be diff --git a/src/server/live.ts b/src/server/live.ts index bbce9e6aa..e6c8361fb 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -369,7 +369,7 @@ export async function resolveLiveRelay( throw err; } - const candidates = selectOpenAiImagesProvider(config); + const candidates = await selectOpenAiImagesProvider(config); if (candidates.forwardCandidates.length === 0 && !candidates.keyed) { return formatErrorResponse( 400, diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 3d536afd1..91c9ffb67 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -36,6 +36,7 @@ import { type OAuthAccessSnapshot, UnsupportedOAuthProviderError, } from "../../oauth"; +import { providerCredentialFailure, providerCredentialRef, withResolvedProviderCredential } from "../../providers/credential"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue"; @@ -246,8 +247,21 @@ export async function handleResponsesCompact( } throw err; } + // Same fail-closed lease as /v1/responses: a chefvault-backed provider must not compact + // against the upstream without an Authorization header. A configured key may be an `$ENV` + // reference; a leased secret is literal credential material and must not be expanded. + let compactAuthKey = resolveEnvValue(compactProvider.apiKey); + if (compactProvider.authMode !== "oauth" && compactProvider.authMode !== "forward" && providerCredentialRef(compactProvider)) { + try { + compactProvider = await withResolvedProviderCredential(compactProvider); + compactAuthKey = compactProvider.apiKey; + } catch (err) { + const failure = providerCredentialFailure(route.providerName, err); + return formatErrorResponse(failure.status, failure.type, failure.message); + } + } const base = (compactProvider.baseUrl ?? "").replace(/\/$/, ""); - if (compactProvider.apiKey) headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`); + if (compactAuthKey) headers.set("authorization", `Bearer ${compactAuthKey}`); const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown }; // The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's // buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 480cbda30..4aeb1207b 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -26,6 +26,7 @@ import { pickComboTarget, targetKey, } from "../../combos"; +import { providerCredentialFailure, providerCredentialRef, withResolvedProviderCredential } from "../../providers/credential"; import { comboIdLabel, isProviderFallbackComboId, providerFallbackPlan } from "../../providers/fallback"; import { isInjectionDebugEnabled } from "../../lib/debug-settings"; import { injectionDebugLog } from "../../lib/injection-debug-log"; @@ -1492,6 +1493,16 @@ export async function handleResponses( return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err)); } } + // ChefVault-backed providers carry no secret in config: lease it now so ordinary forwarding + // authenticates like model discovery does. The lease lives on this request's provider copy only. + if (route.provider.authMode !== "oauth" && route.provider.authMode !== "forward" && providerCredentialRef(route.provider)) { + try { + route.provider = await withResolvedProviderCredential(route.provider); + } catch (err) { + const failure = providerCredentialFailure(route.providerName, err); + return formatErrorResponse(failure.status, failure.type, failure.message); + } + } route.provider = resolveProviderTransport( route.providerName, route.provider, diff --git a/src/types.ts b/src/types.ts index 4ede9c80c..ffda13e6e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -998,6 +998,11 @@ export interface OcxProviderConfig { * failover engine; "direct" pins the caller's main Codex login and never touches pool state. */ codexAccountMode?: CodexAccountMode; + /** + * ChefVault credential reference (`chefvault://…`) resolved via the provider-security plane. + * When set, raw secrets are not stored in config — only in-memory leases at runtime. + */ + credentialRef?: string; apiKey?: string; /** * Key-auth header style for Anthropic-compatible providers. diff --git a/tests/provider-security-forwarding.test.ts b/tests/provider-security-forwarding.test.ts new file mode 100644 index 000000000..dcf00fad3 --- /dev/null +++ b/tests/provider-security-forwarding.test.ts @@ -0,0 +1,148 @@ +/** + * A provider whose secret lives in ChefVault (`credentialRef`, no inline `apiKey`) must + * authenticate ordinary request forwarding, not just model discovery: without the lease the + * upstream call used to leave with no Authorization header at all. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +const UPSTREAM = "https://vaulted.example.test/v1/chat/completions"; +const VAULT = "http://vault.test"; + +let testDir = ""; +let previousHome: string | undefined; +let previousVaultUrl: string | undefined; +let previousVaultToken: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +const VAULT_TOKEN = "forwarding-test-bearer-token-32ch"; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousVaultUrl = process.env.CHEF_PROVIDER_SECURITY_URL; + previousVaultToken = process.env.CHEF_PROVIDER_SECURITY_TOKEN; + isolatedCodexHome = installIsolatedCodexHome("ocx-chefvault-fwd-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-chefvault-fwd-")); + process.env.OPENCODEX_HOME = testDir; + process.env.CHEF_PROVIDER_SECURITY_URL = VAULT; + process.env.CHEF_PROVIDER_SECURITY_TOKEN = VAULT_TOKEN; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousVaultUrl === undefined) delete process.env.CHEF_PROVIDER_SECURITY_URL; + else process.env.CHEF_PROVIDER_SECURITY_URL = previousVaultUrl; + if (previousVaultToken === undefined) delete process.env.CHEF_PROVIDER_SECURITY_TOKEN; + else process.env.CHEF_PROVIDER_SECURITY_TOKEN = previousVaultToken; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +function vaultedConfig(ref: string): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "vaulted", + providers: { + vaulted: { + adapter: "openai-chat", + baseUrl: "https://vaulted.example.test/v1", + authMode: "key", + credentialRef: ref, + }, + }, + } as OcxConfig; +} + +test("a credentialRef-only provider forwards with the leased ChefVault secret", async () => { + const ref = "chefvault://providers/forwarding/ok"; + const originalFetch = globalThis.fetch; + const seen: Array = []; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === `${VAULT}/v1/credentials/resolve`) { + // The proxy must authenticate to ChefVault with the configured workload bearer; + // an unauthenticated resolve must not mint a lease. + if (new Headers(init?.headers).get("authorization") !== `Bearer ${VAULT_TOKEN}`) { + return Response.json({ code: "auth_invalid", message: "credential is not recognised" }, { status: 401 }); + } + return Response.json({ + leaseId: "lease-fwd", + secret: "vaulted-secret-1", + expiresAt: Date.now() + 60 * 60_000, + fencingToken: Date.now(), + }); + } + if (url === UPSTREAM) { + seen.push(new Headers(init?.headers).get("authorization")); + return Response.json({ + id: "chatcmpl-vaulted", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + let server: ReturnType | null = null; + try { + saveConfig(vaultedConfig(ref)); + server = startServer(0); + const res = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "vaulted/some-model", input: "hello", stream: false }), + }); + + expect(res.status).toBe(200); + expect(seen).toEqual(["Bearer vaulted-secret-1"]); + } finally { + server?.stop(true); + globalThis.fetch = originalFetch; + } +}); + +test("an unresolvable credentialRef fails closed instead of forwarding unauthenticated", async () => { + const ref = "chefvault://providers/forwarding/missing"; + const originalFetch = globalThis.fetch; + let upstreamCalls = 0; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === `${VAULT}/v1/credentials/resolve`) { + return Response.json({ code: "ref_not_found", message: "unknown ref" }, { status: 404 }); + } + if (url === UPSTREAM) { + upstreamCalls += 1; + return Response.json({ id: "chatcmpl-should-not-happen", object: "chat.completion", choices: [] }); + } + return originalFetch(input, init); + }) as typeof fetch; + + let server: ReturnType | null = null; + try { + saveConfig(vaultedConfig(ref)); + server = startServer(0); + const res = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "vaulted/some-model", input: "hello", stream: false }), + }); + + expect(res.status).toBe(401); + expect(upstreamCalls).toBe(0); + const body = await res.text(); + expect(body).toContain("ref_not_found"); + } finally { + server?.stop(true); + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/provider-security.test.ts b/tests/provider-security.test.ts new file mode 100644 index 000000000..a046f4bd5 --- /dev/null +++ b/tests/provider-security.test.ts @@ -0,0 +1,634 @@ +import { describe, expect, test } from "bun:test"; +import { + CredentialSlotStore, + renewalJitterMs, + shouldRenewLease, +} from "../src/provider-security/slots"; +import { DegradedModeController } from "../src/provider-security/degraded"; +import { ProviderSecurityClient } from "../src/provider-security/client"; +import { ProviderCredentialResolver } from "../src/provider-security/resolve"; +import { + ProviderSecurityError, + validateChefVaultRef, +} from "../src/provider-security"; +import { + collectProviderSecurityStatus, + collectProviderSecurityDoctorChecks, + serializeProviderSecurityStatus, +} from "../src/provider-security/status"; +import { providerCredentialFailure } from "../src/providers/credential"; +import type { OcxConfig } from "../src/types"; + +const REF = "chefvault://providers/demo/prod"; +const TEST_TOKEN = "test-workload-bearer-token-32chars"; + +function testClient( + overrides: Partial[0]> = {}, +): ProviderSecurityClient { + return new ProviderSecurityClient({ + baseUrl: "http://vault.test", + workload: { workloadId: "t", hostId: "h", actor: "a" }, + token: TEST_TOKEN, + ...overrides, + }); +} + +function leaseResponse( + overrides: Partial<{ leaseId: string; secret: string; expiresAt: number; fencingToken: number; slotHint?: "active" | "next" | "retiring" }> = {}, +) { + return { + leaseId: overrides.leaseId ?? "lease-1", + secret: overrides.secret ?? "skfix1", + expiresAt: overrides.expiresAt ?? Date.now() + 60_000, + fencingToken: overrides.fencingToken ?? 1, + ...(overrides.slotHint ? { slotHint: overrides.slotHint } : {}), + }; +} + +function mockFetch(handlers: { + healthz?: () => Response | Promise; + status?: () => Response | Promise; + resolve?: (body: unknown) => Response | Promise; + renew?: (body: unknown) => Response | Promise; +}): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/healthz")) { + return handlers.healthz?.() ?? new Response("ok", { status: 200 }); + } + if (url.endsWith("/provider-security/status")) { + return handlers.status?.() ?? Response.json({ ok: true }, { status: 200 }); + } + if (url.endsWith("/v1/credentials/resolve")) { + const body = init?.body ? JSON.parse(String(init.body)) : {}; + return handlers.resolve?.(body) ?? Response.json(leaseResponse(), { status: 200 }); + } + if (url.endsWith("/v1/credentials/renew")) { + const body = init?.body ? JSON.parse(String(init.body)) : {}; + return handlers.renew?.(body) ?? Response.json(leaseResponse({ fencingToken: 2 }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; +} + +describe("chefvault ref validation", () => { + test("accepts a scoped chefvault:// ref", () => { + expect(validateChefVaultRef(REF)).toBeNull(); + }); + + test("accepts a host-only ref", () => { + expect(validateChefVaultRef("chefvault://providers")).toBeNull(); + }); + + test("rejects bare paths", () => { + expect(validateChefVaultRef("providers/demo")?.code).toBe("ref_invalid"); + }); + + test.each([ + ["wrong scheme", "vault://providers/demo/prod"], + ["scheme only", "chefvault://"], + ["path traversal", "chefvault://providers/../secrets"], + ["current-dir segment", "chefvault://providers/./prod"], + ["embedded whitespace", "chefvault://providers/demo prod"], + ["control character", "chefvault://providers/demo\u0000prod"], + ["empty segment", "chefvault://providers//prod"], + ["empty string", ""], + ])("rejects %s", (_label, ref) => { + expect(validateChefVaultRef(ref)?.code).toBe("ref_invalid"); + }); +}); + +describe("credential slot transitions", () => { + test("moves previous active lease to retiring on rotation", () => { + const store = new CredentialSlotStore(); + const at = 1_700_000_000_000; + store.applyResolve(REF, leaseResponse({ leaseId: "a", fencingToken: 1 }), at); + store.applyResolve(REF, leaseResponse({ leaseId: "b", fencingToken: 2, secret: "skn2" }), at + 1); + + const state = store.getState(REF)!; + expect(state.slots.active?.leaseId).toBe("b"); + expect(state.slots.retiring?.leaseId).toBe("a"); + expect(state.slots.retiring?.phase).toBe("retiring"); + }); + + test("promotes next slot to active", () => { + const store = new CredentialSlotStore(); + const at = 1_700_000_000_000; + store.applyResolve(REF, leaseResponse({ leaseId: "active", fencingToken: 1 }), at); + store.applyResolve(REF, leaseResponse({ leaseId: "queued", fencingToken: 2, slotHint: "next" }), at + 1); + + const promoted = store.promoteNextToActive(REF, at + 2); + expect(promoted?.leaseId).toBe("queued"); + expect(store.getState(REF)?.slots.active?.leaseId).toBe("queued"); + expect(store.getState(REF)?.slots.retiring?.leaseId).toBe("active"); + }); + + test("request snapshots are immutable", () => { + const store = new CredentialSlotStore(); + store.applyResolve(REF, leaseResponse(), Date.now()); + const snapshot = store.snapshotForRequest(REF)!; + expect(Object.isFrozen(snapshot)).toBe(true); + expect(() => { + (snapshot as { secret: string }).secret = "mutated"; + }).toThrow(); + }); + + test("rejects stale fencing tokens", () => { + const store = new CredentialSlotStore(); + store.applyResolve(REF, leaseResponse({ fencingToken: 5 }), Date.now()); + expect(() => store.applyResolve(REF, leaseResponse({ fencingToken: 4 }), Date.now())) + .toThrow(ProviderSecurityError); + let caught: unknown; + try { + store.applyResolve(REF, leaseResponse({ fencingToken: 3 }), Date.now()); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ProviderSecurityError); + expect((caught as ProviderSecurityError).code).toBe("stale_fencing_token"); + }); + + test("renewal jitter stays within bound", () => { + expect(renewalJitterMs(0)).toBe(0); + expect(renewalJitterMs(0.999)).toBeLessThan(30_000); + }); + + test("shouldRenewLease triggers inside lead window", () => { + const store = new CredentialSlotStore(); + const at = 1_000_000; + store.applyResolve(REF, leaseResponse({ expiresAt: at + 4 * 60_000 }), at); + const active = store.getState(REF)?.slots.active; + expect(shouldRenewLease(active, at)).toBe(true); + store.applyResolve(REF, leaseResponse({ expiresAt: at + 60 * 60_000, fencingToken: 2 }), at); + const fresh = store.getState(REF)?.slots.active; + expect(shouldRenewLease(fresh, at)).toBe(false); + }); +}); + +describe("degraded mode", () => { + test("denies new resolve but allows bounded existing credentials", async () => { + const store = new CredentialSlotStore(); + const degraded = new DegradedModeController(store); + const at = 1_700_000_000_000; + store.applyResolve(REF, leaseResponse({ expiresAt: at + 60 * 60_000 }), at); + degraded.markUnavailable(REF, at); + + expect(degraded.canResolve(REF, at).allowed).toBe(false); + expect(degraded.canUseExisting(REF, at).allowed).toBe(true); + + const resolver = new ProviderCredentialResolver({ + slotStore: store, + degraded, + now: () => at, + client: testClient({ + fetchImpl: mockFetch({ + resolve: () => Response.json({ code: "stale", message: "should not resolve" }, { status: 503 }), + }), + }), + }); + + const fromMemory = await resolver.resolveCredentialRef(REF); + expect(fromMemory.source).toBe("memory"); + + store.revokePhase(REF, "active"); + store.revokePhase(REF, "retiring"); + await expect(resolver.resolveCredentialRef(REF)).rejects.toMatchObject({ + code: "degraded_deny_resolve", + }); + }); + + test("a resolve during degraded mode issues no authority request", async () => { + const store = new CredentialSlotStore(); + const degraded = new DegradedModeController(store); + const at = 1_700_000_000_000; + degraded.markUnavailable(REF, at); + let calls = 0; + const resolver = new ProviderCredentialResolver({ + slotStore: store, + degraded, + now: () => at + 1_000, + client: testClient({ + fetchImpl: (async () => { + calls += 1; + return Response.json(leaseResponse(), { status: 200 }); + }) as typeof fetch, + }), + }); + + await expect(resolver.resolveCredentialRef(REF)).rejects.toMatchObject({ + code: "degraded_deny_resolve", + }); + expect(calls).toBe(0); + }); + + test("a malformed authority response surfaces authority_error without entering degraded mode", async () => { + const store = new CredentialSlotStore(); + const degraded = new DegradedModeController(store); + const resolver = new ProviderCredentialResolver({ + slotStore: store, + degraded, + client: testClient({ + fetchImpl: mockFetch({ + resolve: () => Response.json({ nonsense: true }, { status: 200 }), + }), + }), + }); + + await expect(resolver.resolveCredentialRef(REF)).rejects.toMatchObject({ + code: "authority_error", + }); + expect(store.getMode(REF)).toBe("normal"); + }); + + test("recovers after authority returns", async () => { + const store = new CredentialSlotStore(); + const degraded = new DegradedModeController(store); + let t = Date.now(); + let calls = 0; + const client = testClient({ + fetchImpl: mockFetch({ + resolve: () => { + calls += 1; + if (calls === 1) { + return new Response(JSON.stringify({ message: "down" }), { status: 503 }); + } + return Response.json(leaseResponse({ fencingToken: 1, leaseId: "fresh" }), { status: 200 }); + }, + }), + }); + const resolver = new ProviderCredentialResolver({ slotStore: store, degraded, client, now: () => t }); + + await expect(resolver.resolveCredentialRef(REF)).rejects.toBeInstanceOf(ProviderSecurityError); + expect(store.getMode(REF)).toBe("degraded"); + + // Inside the recovery probe interval the gate stays closed and never touches the authority. + t += 1_000; + await expect(resolver.resolveCredentialRef(REF)).rejects.toMatchObject({ + code: "degraded_deny_resolve", + }); + expect(calls).toBe(1); + + // After the interval elapses a single probe is allowed and recovery reopens the gate. + t += 31_000; + const resolved = await resolver.resolveCredentialRef(REF); + expect(resolved.snapshot.leaseId).toBe("fresh"); + expect(store.getMode(REF)).toBe("normal"); + }); +}); + +describe("renewal failures", () => { + function renewingResolver(renew: () => Response, store: CredentialSlotStore, at: number) { + return new ProviderCredentialResolver({ + slotStore: store, + degraded: new DegradedModeController(store), + now: () => at, + client: testClient({ fetchImpl: mockFetch({ renew }) }), + }); + } + + test("a revoked renewal evicts the lease and rejects the request", async () => { + const store = new CredentialSlotStore(); + const at = 1_700_000_000_000; + // Inside the renewal lead window but not yet expired: the old snapshot is still "usable". + store.applyResolve(REF, leaseResponse({ secret: "skrevoked", expiresAt: at + 60_000 }), at); + const resolver = renewingResolver( + () => Response.json({ code: "revoked", message: "lease revoked" }, { status: 410 }), + store, + at, + ); + + await expect(resolver.resolveCredentialRef(REF, { jitterMs: 0 })).rejects.toMatchObject({ code: "revoked" }); + expect(store.snapshotForRequest(REF, at)).toBeNull(); + }); + + test("a revoked renewal also revokes a retiring lease so the ref fails closed", async () => { + const store = new CredentialSlotStore(); + const at = 1_700_000_000_000; + // Rotation leaves the previous active lease in the retiring slot with its secret intact. + store.applyResolve(REF, leaseResponse({ leaseId: "old", secret: "skold", expiresAt: at + 60_000 }), at); + store.applyResolve(REF, leaseResponse({ leaseId: "new", secret: "sknew", fencingToken: 2, expiresAt: at + 60_000 }), at + 1); + const resolver = renewingResolver( + () => Response.json({ code: "revoked", message: "lease revoked" }, { status: 410 }), + store, + at + 2, + ); + + await expect(resolver.resolveCredentialRef(REF, { jitterMs: 0 })).rejects.toMatchObject({ code: "revoked" }); + expect(store.snapshotForRequest(REF, at + 2)).toBeNull(); + const slots = store.getState(REF)!.slots; + for (const phase of ["active", "next", "retiring"] as const) { + const lease = slots[phase]; + if (lease) expect(lease.phase).toBe("revoked"); + } + expect(slots.retiring?.phase).toBe("revoked"); + }); + + test("a transient renewal failure keeps serving the cached lease", async () => { + const store = new CredentialSlotStore(); + const at = 1_700_000_000_000; + store.applyResolve(REF, leaseResponse({ secret: "skcached", expiresAt: at + 60_000 }), at); + const resolver = renewingResolver( + () => new Response(JSON.stringify({ message: "down" }), { status: 503 }), + store, + at, + ); + + const resolved = await resolver.resolveCredentialRef(REF, { jitterMs: 0 }); + expect(resolved.source).toBe("memory"); + expect(resolved.apiKey).toBe("skcached"); + }); + + test("a renewal outage enters degraded mode and gates further renewal traffic", async () => { + const store = new CredentialSlotStore(); + let t = 1_700_000_000_000; + store.applyResolve(REF, leaseResponse({ secret: "skcached", expiresAt: t + 120_000 }), t); + let renewCalls = 0; + let authorityUp = false; + const resolver = new ProviderCredentialResolver({ + slotStore: store, + degraded: new DegradedModeController(store), + now: () => t, + client: testClient({ + fetchImpl: mockFetch({ + renew: () => { + renewCalls += 1; + if (!authorityUp) { + return new Response(JSON.stringify({ message: "down" }), { status: 503 }); + } + return Response.json(leaseResponse({ leaseId: "renewed", secret: "skfresh", fencingToken: 2, expiresAt: t + 120_000 }), { status: 200 }); + }, + }), + }), + }); + + // The failed renewal enters degraded mode but keeps serving the cached lease. + const first = await resolver.resolveCredentialRef(REF, { jitterMs: 0 }); + expect(first.apiKey).toBe("skcached"); + expect(renewCalls).toBe(1); + expect(store.getMode(REF)).toBe("degraded"); + + // Inside the recovery-probe interval no further renewal call reaches the authority. + t += 1_000; + const second = await resolver.resolveCredentialRef(REF, { jitterMs: 0 }); + expect(second.apiKey).toBe("skcached"); + expect(renewCalls).toBe(1); + + // After the interval elapses a single renewal probe is allowed; success exits degraded mode. + t += 31_000; + authorityUp = true; + const third = await resolver.resolveCredentialRef(REF, { jitterMs: 0 }); + expect(third.apiKey).toBe("skfresh"); + expect(renewCalls).toBe(2); + expect(store.getMode(REF)).toBe("normal"); + }); +}); + +describe("provider-security client headers", () => { + test("sends workload identity headers on resolve", async () => { + let headers: Record = {}; + const client = testClient({ + fetchImpl: (async (_input, init) => { + headers = Object.fromEntries(new Headers(init?.headers).entries()); + return Response.json(leaseResponse(), { status: 200 }); + }) as typeof fetch, + }); + + await client.resolveLease({ ref: REF }); + expect(headers["x-chef-workload-id"]).toBe("t"); + expect(headers["x-chef-host-id"]).toBe("h"); + expect(headers["x-chef-actor"]).toBe("a"); + }); + + test("sends Authorization bearer on protected routes when token is configured", async () => { + let headers: Record = {}; + const client = testClient({ + token: "access-token-chefvault-bearer", + fetchImpl: (async (_input, init) => { + headers = Object.fromEntries(new Headers(init?.headers).entries()); + return Response.json(leaseResponse(), { status: 200 }); + }) as typeof fetch, + }); + + await client.resolveLease({ ref: REF }); + expect(headers.authorization).toBe("Bearer access-token-chefvault-bearer"); + }); + + test("does not send Authorization on healthz", async () => { + let headers: Record = {}; + const client = testClient({ + fetchImpl: (async (_input, init) => { + headers = Object.fromEntries(new Headers(init?.headers).entries()); + return new Response("ok", { status: 200 }); + }) as typeof fetch, + }); + + await client.healthz(); + expect(headers.authorization).toBeUndefined(); + }); +}); + +describe("provider-security bearer auth", () => { + test("resolve without token throws auth_required before calling fetch", async () => { + let called = false; + const client = testClient({ + token: undefined, + fetchImpl: (async () => { + called = true; + return Response.json(leaseResponse(), { status: 200 }); + }) as typeof fetch, + }); + + await expect(client.resolveLease({ ref: REF })).rejects.toMatchObject({ code: "auth_required" }); + expect(called).toBe(false); + }); + + test("maps HTTP 401 to auth_invalid", async () => { + const client = testClient({ + fetchImpl: mockFetch({ + resolve: () => Response.json({ code: "auth_invalid", message: "credential is not recognised" }, { status: 401 }), + }), + }); + + await expect(client.resolveLease({ ref: REF })).rejects.toMatchObject({ + code: "auth_invalid", + message: "credential is not recognised", + }); + }); + + test("maps HTTP 403 identity mismatch", async () => { + const client = testClient({ + fetchImpl: mockFetch({ + resolve: () => Response.json( + { code: "identity_assertion_mismatch", message: "asserted identity headers do not match" }, + { status: 403 }, + ), + }), + }); + + await expect(client.resolveLease({ ref: REF })).rejects.toMatchObject({ + code: "identity_assertion_mismatch", + }); + }); + + test("authenticatedReady probes /provider-security/status with bearer", async () => { + let url = ""; + let headers: Record = {}; + const client = testClient({ + fetchImpl: (async (input, init) => { + url = String(input); + headers = Object.fromEntries(new Headers(init?.headers).entries()); + return Response.json({ mode: "normal" }, { status: 200 }); + }) as typeof fetch, + }); + + const ready = await client.authenticatedReady(); + expect(ready.ok).toBe(true); + expect(url).toContain("/provider-security/status"); + expect(headers.authorization).toBe(`Bearer ${TEST_TOKEN}`); + }); + + test("doctor reports liveness OK and auth WARN when token missing", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "demo", + providers: { + demo: { + adapter: "openai-chat", + baseUrl: "https://example/v1", + credentialRef: REF, + }, + }, + }; + + const doctor = await collectProviderSecurityDoctorChecks(config, new ProviderCredentialResolver({ + client: testClient({ + token: undefined, + fetchImpl: mockFetch({ healthz: () => new Response("ok", { status: 200 }) }), + }), + })); + + expect(doctor.some(c => c.level === "OK" && c.layer === "liveness")).toBe(true); + expect(doctor.some(c => c.level === "WARN" && c.message.includes("CHEF_PROVIDER_SECURITY_TOKEN"))).toBe(true); + }); + + test("doctor reports auth WARN on 401 status probe", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "demo", + providers: { + demo: { + adapter: "openai-chat", + baseUrl: "https://example/v1", + credentialRef: REF, + }, + }, + }; + + const doctor = await collectProviderSecurityDoctorChecks(config, new ProviderCredentialResolver({ + client: testClient({ + fetchImpl: mockFetch({ + healthz: () => new Response("ok", { status: 200 }), + status: () => Response.json({ code: "auth_invalid", message: "credential is not recognised" }, { status: 401 }), + }), + }), + })); + + expect(doctor.some(c => c.level === "OK" && c.layer === "liveness")).toBe(true); + expect(doctor.some(c => c.level === "WARN" && c.layer === "authenticated")).toBe(true); + }); + + test("doctor makes no authority probe when no chefvault refs are configured", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "demo", + providers: { + demo: { + adapter: "openai-chat", + baseUrl: "https://example/v1", + apiKey: "env:DEMO_KEY", + }, + }, + }; + + let fetchCalls = 0; + const doctor = await collectProviderSecurityDoctorChecks(config, new ProviderCredentialResolver({ + client: testClient({ + fetchImpl: (async () => { + fetchCalls += 1; + return new Response("ok", { status: 200 }); + }) as typeof fetch, + }), + })); + + expect(fetchCalls).toBe(0); + expect(doctor).toEqual([ + { + level: "OK", + provider: "*", + layer: "provider", + message: "No providers configured with chefvault:// credentialRef.", + }, + ]); + }); +}); + +describe("redacted status serialization", () => { + test("never includes raw secret material", async () => { + const store = new CredentialSlotStore(); + store.applyResolve(REF, leaseResponse({ secret: "skfix" }), Date.now()); + + const config: OcxConfig = { + port: 10100, + defaultProvider: "demo", + providers: { + demo: { + adapter: "openai-chat", + baseUrl: "https://example/v1", + credentialRef: REF, + }, + }, + }; + + const report = collectProviderSecurityStatus(config, undefined, store); + const serialized = serializeProviderSecurityStatus(report); + expect(serialized).not.toContain("skfix"); + expect(serialized).not.toContain("secret"); + expect(report.providers[0]?.status.slots[0]?.leaseId).toBe("lease-1"); + + const doctor = await collectProviderSecurityDoctorChecks(config, new ProviderCredentialResolver({ + slotStore: store, + client: testClient({ + fetchImpl: mockFetch({ healthz: () => new Response("ok", { status: 200 }) }), + }), + })); + const doctorText = JSON.stringify(doctor); + expect(doctorText).not.toContain("skfix"); + }); +}); + +describe("data-plane credential failure redaction", () => { + // Responses, compact, and Images all surface lease failures through providerCredentialFailure, + // so redaction here covers every data-plane error body. + test("scrubs bearer and secret material from authority error detail", () => { + const failure = providerCredentialFailure("demo", new ProviderSecurityError( + "authority_unavailable", + "upstream rejected Authorization: Bearer access-token-leaked-fixture (secret=access-token-secret-fixture)", + )); + expect(failure.status).toBe(503); + expect(failure.type).toBe("api_error"); + expect(failure.message).not.toContain("access-token-leaked-fixture"); + expect(failure.message).not.toContain("access-token-secret-fixture"); + expect(failure.message).toContain("[redacted]"); + expect(failure.message).toContain("authority_unavailable"); + }); + + test("scrubs credential material from denied-reference detail", () => { + const failure = providerCredentialFailure("demo", new ProviderSecurityError( + "revoked", + "lease revoked; last seen token: access-token-revoked-fixture", + )); + expect(failure.status).toBe(401); + expect(failure.type).toBe("authentication_error"); + expect(failure.message).not.toContain("access-token-revoked-fixture"); + }); +}); diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 437c76c25..08ff01e9e 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -500,8 +500,8 @@ test.each([ ["oauth auth", { adapter: "openai-responses", baseUrl: "https://images.example.test/v1", apiKey: "key", authMode: "oauth" }, "must be an API-key openai-responses provider"], ["local auth", { adapter: "openai-responses", baseUrl: "https://images.example.test/v1", apiKey: "key", authMode: "local" }, "must be an API-key openai-responses provider"], ["missing key", { adapter: "openai-responses", baseUrl: "https://images.example.test/v1", authMode: "key" }, "has no usable API key"], -] as const)("explicit Images provider rejects %s configuration", (_case, provider, expectedError) => { - const selection = selectImagesProvider({ +] as const)("explicit Images provider rejects %s configuration", async (_case, provider, expectedError) => { + const selection = await selectImagesProvider({ port: 0, defaultProvider: "custom-images", providers: provider ? { "custom-images": provider } : {}, @@ -513,6 +513,57 @@ test.each([ expect(selection.error).toContain(expectedError); }); +// A failed ChefVault lease on an explicit Images provider must keep its credential/authority +// split: a revoked lease is a non-retryable 401 authentication_error, an unreachable authority +// is a retryable 503 api_error. Codex uses the type to decide whether to retry. +test.each([ + ["revoked", "chefvault://providers/images/revoked", 410, { code: "revoked", message: "lease revoked" }, 401, "authentication_error"], + ["authority_unavailable", "chefvault://providers/images/outage", 503, { message: "authority down" }, 503, "api_error"], +] as const)( + "a %s ChefVault lease failure maps the Images provider error to %d/%s", + async (_case, ref, vaultStatus, vaultBody, expectedStatus, expectedType) => { + const previousVaultUrl = process.env.CHEF_PROVIDER_SECURITY_URL; + const previousVaultToken = process.env.CHEF_PROVIDER_SECURITY_TOKEN; + process.env.CHEF_PROVIDER_SECURITY_URL = "http://vault.test"; + process.env.CHEF_PROVIDER_SECURITY_TOKEN = "access-token-images-vault-32chars"; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "http://vault.test/v1/credentials/resolve") { + return Response.json(vaultBody, { status: vaultStatus }); + } + return originalFetch(input, init); + }) as typeof fetch; + + try { + const selection = await selectImagesProvider({ + port: 0, + defaultProvider: "custom-images", + providers: { + "custom-images": { + adapter: "openai-responses", + baseUrl: "https://images.example.test/v1", + authMode: "key", + credentialRef: ref, + }, + }, + images: { provider: "custom-images" }, + } as OcxConfig); + + expect(selection.keyed).toBeUndefined(); + expect(selection.forwardCandidates).toHaveLength(0); + expect(selection.error).toContain("credential could not be resolved"); + expect(selection.errorStatus).toBe(expectedStatus); + expect(selection.errorType).toBe(expectedType); + } finally { + globalThis.fetch = originalFetch; + if (previousVaultUrl === undefined) delete process.env.CHEF_PROVIDER_SECURITY_URL; + else process.env.CHEF_PROVIDER_SECURITY_URL = previousVaultUrl; + if (previousVaultToken === undefined) delete process.env.CHEF_PROVIDER_SECURITY_TOKEN; + else process.env.CHEF_PROVIDER_SECURITY_TOKEN = previousVaultToken; + } + }, +); + test("keyed baseUrl with a /v1 suffix is normalized (no double /v1)", async () => { const captured: CapturedRequest[] = []; const upstream = fakeImagesUpstream(captured);