diff --git a/app/api/keys/route.ts b/app/api/keys/route.ts index 9446d47..90f5d0f 100644 --- a/app/api/keys/route.ts +++ b/app/api/keys/route.ts @@ -10,7 +10,8 @@ * tests/auth-provider-keys.test.ts) — the settings UI reads it optionally * with a graceful fallback. * - * Masked only — last 4 + length; PLAINTEXT NEVER LEAVES THE STORE THROUGH + * Masked only — last 4 + length for ordinary keys; custom keys are fully + * redacted at every length (no credential suffix). PLAINTEXT NEVER LEAVES THE STORE THROUGH * THIS API (owner decision). `source` tells the page whether the live * value comes from the environment (dev .env.local / launch env) or the * file, and `shadowedByEnv` explains why a saved key is not the live one diff --git a/src/core/auth-keys.ts b/src/core/auth-keys.ts index 492ff15..b92faf7 100644 --- a/src/core/auth-keys.ts +++ b/src/core/auth-keys.ts @@ -207,7 +207,8 @@ export interface AuthKeyStatus { info: AuthKeyInfo | null; /** A key is present when EITHER the environment or the file has it. */ present: boolean; - /** The masked tail + character count, e.g. "…ab12 (64 chars)". */ + /** Ordinary masked tail + count, e.g. "…ab12 (64 chars)"; custom keys + * use full redaction, with no credential characters at any length. */ masked: string | null; /** Where the live value comes from, given env-wins precedence. */ source: "env" | "file" | "none"; diff --git a/src/server/keys.ts b/src/server/keys.ts index 5bb2b6a..3e197f1 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -162,7 +162,9 @@ export function resolveKeyStatuses( id, info: AUTH_KEYS.find((k) => k.id === id) ?? null, present: live !== undefined, - masked: live !== undefined ? `${maskKey(live)} (${live.trim().length} chars)` : null, + // Custom keys may be one character: even a masked suffix can expose + // the entire credential. Preserve inventory, return no key characters. + masked: live !== undefined ? id === CUSTOM_PROVIDER.envVar ? "••••" : `${maskKey(live)} (${live.trim().length} chars)` : null, source: isRealEnv ? "env" : live !== undefined ? "file" : "none", shadowedByEnv: isRealEnv && fileValue !== undefined && envValue !== fileValue, }); diff --git a/tests/custom-provider-boundaries.test.ts b/tests/custom-provider-boundaries.test.ts index 2ea1006..7c86a88 100644 --- a/tests/custom-provider-boundaries.test.ts +++ b/tests/custom-provider-boundaries.test.ts @@ -52,6 +52,32 @@ test("new keys have no arbitrary minimum; the exact probed key is stored", async assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "k"); const body = await get().text(); assert.ok(!body.includes("Bearer")); }); +test("actual GET /api/keys fully redacts every custom key length and preserves other providers", async () => { + const inventory = await import("../app/api/keys/route.ts"); + const rows = async () => (await (await inventory.GET()).json()).keys as import("../src/core/auth-keys.ts").AuthKeyStatus[]; + assert.equal(keys.saveKey("OPENAI_API_KEY", "ordinary-provider-key-abcd").ok, true); + const ordinary = (await rows()).find(row => row.id === "OPENAI_API_KEY")!; + assert.equal(ordinary.masked, "…abcd (26 chars)"); + try { + for (const value of ["1", "12", "123", "1234", "synthetic-custom-secret-longer-than-four"]) { + assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: value })).status, 200); + assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), value, "short keys still save unchanged"); + const statuses = await rows(); + const custom = statuses.find(row => row.id === CUSTOM_PROVIDER.envVar)!; + assert.equal(custom.masked, "••••"); assert.equal(custom.present, true); assert.equal(custom.source, "file"); + assert.ok(!JSON.stringify(custom).includes(value)); + assert.deepEqual(statuses.find(row => row.id === "OPENAI_API_KEY"), ordinary); + } + process.env.LH_IGNORE_ENV_KEYS = "0"; + process.env[CUSTOM_PROVIDER.envVar] = "765"; authEnvRecord().realEnv.add(CUSTOM_PROVIDER.envVar); + const custom = (await rows()).find(row => row.id === CUSTOM_PROVIDER.envVar)!; + assert.equal(custom.masked, "••••"); assert.equal(custom.source, "env"); assert.equal(custom.present, true); assert.equal(custom.shadowedByEnv, true); + assert.ok(!JSON.stringify(custom).includes("765")); + } finally { + authEnvRecord().realEnv.delete(CUSTOM_PROVIDER.envVar); delete process.env[CUSTOM_PROVIDER.envVar]; process.env.LH_IGNORE_ENV_KEYS = "1"; + keys.deleteKey("OPENAI_API_KEY"); + } +}); test("changing destination cannot silently reuse an existing credential", async () => { setFileKey("private-old"); const payload = { baseUrl: "http://new/v1", authMode: "apiKey" };