From 8e1bcc767e147dcfcf3c80bfe9c339c243b99250 Mon Sep 17 00:00:00 2001 From: Sam Margolis Date: Mon, 2 Mar 2026 08:46:26 -0800 Subject: [PATCH] fix: enforce hosted api-key controls and audit sanitization --- .../src/app/api/settings/api-keys/route.ts | 104 ++++++++++++++---- apps/web/src/app/layout.tsx | 2 - .../src/__tests__/server-audit.test.ts | 28 +++++ packages/storage/src/api-keys.ts | 5 + packages/storage/src/server-api-keys.ts | 17 +++ 5 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 packages/storage/src/__tests__/server-audit.test.ts diff --git a/apps/web/src/app/api/settings/api-keys/route.ts b/apps/web/src/app/api/settings/api-keys/route.ts index 38d7d02..ae16d97 100644 --- a/apps/web/src/app/api/settings/api-keys/route.ts +++ b/apps/web/src/app/api/settings/api-keys/route.ts @@ -2,30 +2,61 @@ import { NextRequest, NextResponse } from "next/server" import { promises as fs } from "fs" import path from "path" import crypto from "crypto" -import { writeAuditEntry } from "@storage/audit-log" +import { allowUserApiKeys, isHostedMode } from "@storage/hosted-mode" +import { writeServerAuditEntry, logSanitizedServerError } from "@storage/server-audit" // Encryption configuration const ALGORITHM = "aes-256-gcm" const IV_LENGTH = 12 -const AUTH_TAG_LENGTH = 16 const KEY_LENGTH = 32 +function toUint8Array(value: Buffer | Uint8Array): Uint8Array { + return Uint8Array.from(value) +} + +function toBase64(value: Uint8Array): string { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("base64") +} + +function fromBase64(value: string): Uint8Array { + return toUint8Array(Buffer.from(value, "base64")) +} + +function encodeUtf8(value: string): Uint8Array { + return new TextEncoder().encode(value) +} + +function decodeUtf8(value: Uint8Array): string { + return new TextDecoder().decode(value) +} + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0) + const out = new Uint8Array(totalLength) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} + /** * Get or generate encryption key for API key file. * In Electron, we could use safeStorage, but Next.js API routes run in Node.js * so we store an encrypted key in a separate file. */ -async function getEncryptionKey(): Promise { +async function getEncryptionKey(): Promise { const configDir = path.dirname(getConfigPath()) const keyPath = path.join(configDir, ".encryption-key") try { // Try to read existing key - const keyData = await fs.readFile(keyPath) - return keyData + const keyData = await fs.readFile(keyPath, "utf8") + return fromBase64(keyData) } catch { // Generate new key - const key = crypto.randomBytes(KEY_LENGTH) + const key = toUint8Array(crypto.randomBytes(KEY_LENGTH)) // Ensure directory exists try { @@ -33,7 +64,7 @@ async function getEncryptionKey(): Promise { } catch {} // Store key with restrictive permissions - await fs.writeFile(keyPath, key, { mode: 0o600 }) + await fs.writeFile(keyPath, toBase64(key), { mode: 0o600 }) return key } } @@ -43,16 +74,18 @@ async function getEncryptionKey(): Promise { */ async function encryptData(plaintext: string): Promise { const key = await getEncryptionKey() - const iv = crypto.randomBytes(IV_LENGTH) + const iv = toUint8Array(crypto.randomBytes(IV_LENGTH)) const cipher = crypto.createCipheriv(ALGORITHM, key, iv) - let encrypted = cipher.update(plaintext, "utf8") - encrypted = Buffer.concat([encrypted, cipher.final()]) + const encrypted = concatBytes([ + toUint8Array(cipher.update(encodeUtf8(plaintext))), + toUint8Array(cipher.final()), + ]) - const authTag = cipher.getAuthTag() + const authTag = toUint8Array(cipher.getAuthTag()) // Format: enc.v2... (all base64) - return `enc.v2.${iv.toString("base64")}.${authTag.toString("base64")}.${encrypted.toString("base64")}` + return `enc.v2.${toBase64(iv)}.${toBase64(authTag)}.${toBase64(encrypted)}` } /** @@ -64,17 +97,19 @@ async function decryptData(payload: string): Promise { // Check for encrypted format if (parts.length === 5 && parts[0] === "enc" && parts[1] === "v2") { const key = await getEncryptionKey() - const iv = Buffer.from(parts[2], "base64") - const authTag = Buffer.from(parts[3], "base64") - const encrypted = Buffer.from(parts[4], "base64") + const iv = fromBase64(parts[2]) + const authTag = fromBase64(parts[3]) + const encrypted = fromBase64(parts[4]) const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) decipher.setAuthTag(authTag) - let decrypted = decipher.update(encrypted) - decrypted = Buffer.concat([decrypted, decipher.final()]) + const decrypted = concatBytes([ + toUint8Array(decipher.update(encrypted)), + toUint8Array(decipher.final()), + ]) - return decrypted.toString("utf8") + return decodeUtf8(decrypted) } // Legacy unencrypted format - return as-is and will be re-encrypted on next save @@ -90,7 +125,7 @@ function getConfigPath(): string { const userDataPath = app.getPath("userData") return path.join(userDataPath, "api-keys.json") } - } catch (error) { + } catch { // Electron not available (development or build time) } // Development environment - use temp directory @@ -98,6 +133,18 @@ function getConfigPath(): string { } export async function POST(req: NextRequest) { + if (isHostedMode() && !allowUserApiKeys()) { + await writeServerAuditEntry({ + event_type: "settings.api_key_configured", + success: false, + error_code: "disabled_in_hosted_mode", + }) + return NextResponse.json( + { error: "User-managed API keys are disabled in hosted mode" }, + { status: 403 } + ) + } + try { const body = await req.json() const { openaiApiKey, anthropicApiKey } = body @@ -124,7 +171,7 @@ export async function POST(req: NextRequest) { await fs.writeFile(configPath, encrypted, { mode: 0o600 }) // Audit log: API keys configured - await writeAuditEntry({ + await writeServerAuditEntry({ event_type: "settings.api_key_configured", success: true, metadata: { @@ -135,10 +182,10 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true }) } catch (error) { - console.error("Failed to save API keys:", error) + logSanitizedServerError("settings.api-keys.post", error) // Audit log: API key configuration failed - await writeAuditEntry({ + await writeServerAuditEntry({ event_type: "settings.api_key_configured", success: false, error_message: error instanceof Error ? error.message : String(error), @@ -152,6 +199,17 @@ export async function POST(req: NextRequest) { } export async function GET() { + if (isHostedMode() && !allowUserApiKeys()) { + return NextResponse.json( + { + openaiApiKey: "", + anthropicApiKey: "", + managedByPlatform: true, + }, + { status: 200 } + ) + } + try { const configPath = getConfigPath() @@ -171,7 +229,7 @@ export async function GET() { }) } } catch (error) { - console.error("Failed to load API keys:", error) + logSanitizedServerError("settings.api-keys.get", error) return NextResponse.json( { error: "Failed to load API keys" }, { status: 500 } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 7b90d6f..222f4a8 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,5 @@ import type React from "react" import type { Metadata } from "next" -import { Analytics } from "@vercel/analytics/next" import "./globals.css" // Updated metadata for OpenScribe clinical documentation app @@ -24,7 +23,6 @@ export default function RootLayout({ {children} - ) diff --git a/packages/storage/src/__tests__/server-audit.test.ts b/packages/storage/src/__tests__/server-audit.test.ts new file mode 100644 index 0000000..3e831b7 --- /dev/null +++ b/packages/storage/src/__tests__/server-audit.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { sanitizeAuditErrorMessage, sanitizeAuditMetadata } from '../server-audit.js' + +test('sanitizeAuditMetadata removes likely PHI fields', () => { + const sanitized = sanitizeAuditMetadata({ + patient_name: 'Jane Doe', + transcript_text: 'sensitive', + note_text: 'sensitive note', + duration_ms: 12000, + provider: 'gcp_stt_v2', + }) + + assert.ok(sanitized) + assert.equal('patient_name' in sanitized!, false) + assert.equal('transcript_text' in sanitized!, false) + assert.equal('note_text' in sanitized!, false) + assert.equal(sanitized!.duration_ms, 12000) + assert.equal(sanitized!.provider, 'gcp_stt_v2') +}) + +test('sanitizeAuditErrorMessage redacts email addresses', () => { + const input = 'failed for test.user@example.com due to upstream error' + const result = sanitizeAuditErrorMessage(input) + assert.ok(result) + assert.equal(result!.includes('example.com'), false) + assert.equal(result!.includes('[redacted-email]'), true) +}) diff --git a/packages/storage/src/api-keys.ts b/packages/storage/src/api-keys.ts index 3739109..b6708d3 100644 --- a/packages/storage/src/api-keys.ts +++ b/packages/storage/src/api-keys.ts @@ -4,6 +4,7 @@ */ import { loadSecureItem, saveSecureItem } from "./secure-storage" +import { allowUserApiKeys, isHostedMode } from "./hosted-mode" export interface ApiKeys { openaiApiKey: string @@ -34,6 +35,10 @@ export async function getApiKeys(): Promise { } export async function setApiKeys(keys: Partial): Promise { + if (isHostedMode() && !allowUserApiKeys()) { + throw new Error("User-managed API keys are disabled in hosted mode.") + } + try { const current = await getApiKeys() const updated = { diff --git a/packages/storage/src/server-api-keys.ts b/packages/storage/src/server-api-keys.ts index cfd0426..374c932 100644 --- a/packages/storage/src/server-api-keys.ts +++ b/packages/storage/src/server-api-keys.ts @@ -6,6 +6,7 @@ import { readFileSync } from "fs" import { join } from "path" import crypto from "crypto" +import { allowUserApiKeys, isHostedMode } from "./hosted-mode" const ALGORITHM = "aes-256-gcm" @@ -83,6 +84,14 @@ function getConfigPath(): string { } export function getOpenAIApiKey(): string { + if (isHostedMode() && !allowUserApiKeys()) { + const managed = process.env.OPENAI_API_KEY + if (!managed) { + throw new Error("Missing OPENAI_API_KEY in hosted mode configuration.") + } + return managed + } + // First try to load from config file try { const configPath = getConfigPath() @@ -108,6 +117,14 @@ export function getOpenAIApiKey(): string { } export function getAnthropicApiKey(): string { + if (isHostedMode() && !allowUserApiKeys()) { + const managed = process.env.ANTHROPIC_API_KEY + if (!managed) { + throw new Error("Missing ANTHROPIC_API_KEY in hosted mode configuration.") + } + return managed + } + // First try to load from config file try { const configPath = getConfigPath()