From ea740d9013ef75aaeb016cbcd726eb1880a86375 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Sun, 29 Mar 2026 00:53:40 -0400 Subject: [PATCH] Add optional API key reveal toggle --- .env.example | 1 + docs/USER_GUIDE.md | 35 +++---- .../api-manager/ApiManagerPageClient.tsx | 35 +++++-- .../api/cli-tools/claude-settings/route.ts | 6 +- src/app/api/cli-tools/codex-settings/route.ts | 5 +- src/app/api/keys/[id]/route.ts | 7 +- src/app/api/keys/route.ts | 9 +- src/lib/apiKeyExposure.ts | 18 ++++ tests/unit/api-key-visibility-route.test.mjs | 94 +++++++++++++++++++ 9 files changed, 173 insertions(+), 37 deletions(-) create mode 100644 src/lib/apiKeyExposure.ts create mode 100644 tests/unit/api-key-visibility-route.test.mjs diff --git a/.env.example b/.env.example index 973ca0efe3a..aa650013760 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,7 @@ MACHINE_ID_SALT=endpoint-proxy-salt ENABLE_REQUEST_LOGS=false AUTH_COOKIE_SECURE=false REQUIRE_API_KEY=false +ALLOW_API_KEY_REVEAL=false # Input Sanitizer (FASE-01 — prompt injection & PII protection) # INPUT_SANITIZER_ENABLED=true diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2a2a5562fc3..2045ae1cbfd 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -507,23 +507,24 @@ post_install() { ### Environment Variables -| Variable | Default | Description | -| ------------------------- | ------------------------------------ | ------------------------------------------------------- | -| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) | -| `INITIAL_PASSWORD` | `123456` | First login password | -| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) | -| `PORT` | framework default | Service port (`20128` in examples) | -| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | -| `NODE_ENV` | runtime default | Set `production` for deploy | -| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL | -| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL | -| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | -| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` | -| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs | -| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) | -| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB | -| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries | -| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries | +| Variable | Default | Description | +| ------------------------- | ------------------------------------ | -------------------------------------------------------------- | +| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) | +| `INITIAL_PASSWORD` | `123456` | First login password | +| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) | +| `PORT` | framework default | Service port (`20128` in examples) | +| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | +| `NODE_ENV` | runtime default | Set `production` for deploy | +| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL | +| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | +| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` | +| `ALLOW_API_KEY_REVEAL` | `false` | Allow full API keys to be copied from `/dashboard/api-manager` | +| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs | +| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) | +| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB | +| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries | +| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries | For the full environment variable reference, see the [README](../README.md). diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 2155a01e61e..885c433bde6 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -111,6 +111,7 @@ export default function ApiManagerPageClient() { const [isSubmitting, setIsSubmitting] = useState(false); const [usageStats, setUsageStats] = useState>({}); const [sessionCounts, setSessionCounts] = useState>({}); + const [allowKeyReveal, setAllowKeyReveal] = useState(false); const { copied, copy } = useCopyToClipboard(); @@ -150,6 +151,7 @@ export default function ApiManagerPageClient() { if (res.ok) { const data = await res.json(); setKeys(data.keys || []); + setAllowKeyReveal(data.allowKeyReveal === true); // Fetch usage stats after keys are loaded fetchUsageStats(data.keys || []); fetchSessionCounts(data.keys || []); @@ -506,7 +508,7 @@ export default function ApiManagerPageClient() { -

{t("keysSecurityNote")}

+ {!allowKeyReveal &&

{t("keysSecurityNote")}

} {keys.length === 0 ? (
@@ -560,12 +562,25 @@ export default function ApiManagerPageClient() {
{key.key} - - lock - + {allowKeyReveal ? ( + + ) : ( + + lock + + )}
@@ -752,7 +767,11 @@ export default function ApiManagerPageClient() {

{t("keyCreatedSuccess")}

-

{t("keyCreatedNote")}

+ {!allowKeyReveal && ( +

+ {t("keyCreatedNote")} +

+ )}
diff --git a/src/app/api/cli-tools/claude-settings/route.ts b/src/app/api/cli-tools/claude-settings/route.ts index 1c3dc32edcb..73adca3528c 100644 --- a/src/app/api/cli-tools/claude-settings/route.ts +++ b/src/app/api/cli-tools/claude-settings/route.ts @@ -102,9 +102,9 @@ export async function POST(request: Request) { const { env } = validation.data; // (#523/#526) If a keyId was provided, resolve the real API key from DB. - // The /api/keys list endpoint returns masked key strings — sending those to - // disk would save an unusable half-hidden token. Resolving by ID guarantees - // we always write the full key value to the config file. + // The /api/keys list endpoint may return masked key strings depending on + // ALLOW_API_KEY_REVEAL, so resolving by ID guarantees we always write the + // full key value to the config file. const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; if (keyId) { try { diff --git a/src/app/api/cli-tools/codex-settings/route.ts b/src/app/api/cli-tools/codex-settings/route.ts index 6067f523266..d1bf9824c42 100644 --- a/src/app/api/cli-tools/codex-settings/route.ts +++ b/src/app/api/cli-tools/codex-settings/route.ts @@ -177,8 +177,9 @@ export async function POST(request: Request) { } // (#549) Resolve real key from DB if keyId was provided. - // The dashboard sends masked key strings — resolving by ID guarantees - // we always write the full key value to the config file. + // The dashboard may send masked key strings depending on + // ALLOW_API_KEY_REVEAL, so resolving by ID guarantees we always write the + // full key value to the config file. const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; if (keyId) { try { diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index e3f3b0b739f..519af762b2f 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -9,6 +9,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { updateKeyPermissionsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { isApiKeyRevealEnabled, presentStoredApiKey } from "@/lib/apiKeyExposure"; // GET /api/keys/[id] - Get single API key export async function GET(request, { params }) { @@ -20,11 +21,11 @@ export async function GET(request, { params }) { return NextResponse.json({ error: "Key not found" }, { status: 404 }); } - // Mask the key value - const keyValue = typeof key.key === "string" ? key.key : null; + const allowKeyReveal = isApiKeyRevealEnabled(); return NextResponse.json({ ...key, - key: keyValue ? keyValue.slice(0, 8) + "****" + keyValue.slice(-4) : null, + key: presentStoredApiKey(key.key), + allowKeyReveal, }); } catch (error) { console.log("Error fetching key:", error); diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index fc4393d2acc..9725b850b03 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -4,17 +4,18 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { createKeySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { isApiKeyRevealEnabled, presentStoredApiKey } from "@/lib/apiKeyExposure"; // GET /api/keys - List API keys export async function GET() { try { const keys = await getApiKeys(); - // Mask key values — users should never see full keys after creation - const maskedKeys = keys.map((k) => ({ + const allowKeyReveal = isApiKeyRevealEnabled(); + const presentedKeys = keys.map((k) => ({ ...k, - key: typeof k.key === "string" ? k.key.slice(0, 8) + "****" + k.key.slice(-4) : null, + key: presentStoredApiKey(k.key), })); - return NextResponse.json({ keys: maskedKeys }); + return NextResponse.json({ keys: presentedKeys, allowKeyReveal }); } catch (error) { console.log("Error fetching keys:", error); return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 }); diff --git a/src/lib/apiKeyExposure.ts b/src/lib/apiKeyExposure.ts new file mode 100644 index 00000000000..330ee165448 --- /dev/null +++ b/src/lib/apiKeyExposure.ts @@ -0,0 +1,18 @@ +const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]); + +export function isApiKeyRevealEnabled(): boolean { + const raw = String(process.env.ALLOW_API_KEY_REVEAL || "") + .trim() + .toLowerCase(); + return ENABLED_VALUES.has(raw); +} + +export function maskStoredApiKey(key: unknown): string | null { + if (typeof key !== "string") return null; + return key.slice(0, 8) + "****" + key.slice(-4); +} + +export function presentStoredApiKey(key: unknown): string | null { + if (typeof key !== "string") return null; + return isApiKeyRevealEnabled() ? key : maskStoredApiKey(key); +} diff --git a/tests/unit/api-key-visibility-route.test.mjs b/tests/unit/api-key-visibility-route.test.mjs new file mode 100644 index 00000000000..02c6434fc33 --- /dev/null +++ b/tests/unit/api-key-visibility-route.test.mjs @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-key-visibility-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const listRoute = await import("../../src/app/api/keys/route.ts"); +const detailRoute = await import("../../src/app/api/keys/[id]/route.ts"); + +const MACHINE_ID = "1234567890abcdef"; + +async function resetStorage() { + delete process.env.ALLOW_API_KEY_REVEAL; + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function maskKey(key) { + return key.slice(0, 8) + "****" + key.slice(-4); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + delete process.env.ALLOW_API_KEY_REVEAL; + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /api/keys masks stored keys when reveal is disabled", async () => { + const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID); + + const response = await listRoute.GET(); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.allowKeyReveal, false); + assert.equal(Array.isArray(body.keys), true); + assert.equal(body.keys.length, 1); + assert.equal(body.keys[0].id, created.id); + assert.equal(body.keys[0].key, maskKey(created.key)); + assert.notEqual(body.keys[0].key, created.key); +}); + +test("GET /api/keys returns full keys when reveal is enabled", async () => { + process.env.ALLOW_API_KEY_REVEAL = "true"; + const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID); + + const response = await listRoute.GET(); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.allowKeyReveal, true); + assert.equal(Array.isArray(body.keys), true); + assert.equal(body.keys.length, 1); + assert.equal(body.keys[0].id, created.id); + assert.equal(body.keys[0].key, created.key); +}); + +test("GET /api/keys/[id] mirrors the reveal toggle", async () => { + const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID); + const request = new Request(`http://localhost/api/keys/${created.id}`); + + const maskedResponse = await detailRoute.GET(request, { + params: Promise.resolve({ id: created.id }), + }); + const maskedBody = await maskedResponse.json(); + + assert.equal(maskedResponse.status, 200); + assert.equal(maskedBody.allowKeyReveal, false); + assert.equal(maskedBody.key, maskKey(created.key)); + + process.env.ALLOW_API_KEY_REVEAL = "true"; + + const revealedResponse = await detailRoute.GET(request, { + params: Promise.resolve({ id: created.id }), + }); + const revealedBody = await revealedResponse.json(); + + assert.equal(revealedResponse.status, 200); + assert.equal(revealedBody.allowKeyReveal, true); + assert.equal(revealedBody.key, created.key); +});