Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 81 additions & 23 deletions apps/web/src/app/api/settings/api-keys/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,69 @@ 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<Buffer> {
async function getEncryptionKey(): Promise<Uint8Array> {
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 {
await fs.mkdir(configDir, { recursive: true })
} catch {}

// Store key with restrictive permissions
await fs.writeFile(keyPath, key, { mode: 0o600 })
await fs.writeFile(keyPath, toBase64(key), { mode: 0o600 })
return key
}
}
Expand All @@ -43,16 +74,18 @@ async function getEncryptionKey(): Promise<Buffer> {
*/
async function encryptData(plaintext: string): Promise<string> {
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.<iv>.<authTag>.<ciphertext> (all base64)
return `enc.v2.${iv.toString("base64")}.${authTag.toString("base64")}.${encrypted.toString("base64")}`
return `enc.v2.${toBase64(iv)}.${toBase64(authTag)}.${toBase64(encrypted)}`
}

/**
Expand All @@ -64,17 +97,19 @@ async function decryptData(payload: string): Promise<string> {
// 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
Expand All @@ -90,14 +125,26 @@ 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
return path.join(process.cwd(), ".api-keys.json")
}

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
Expand All @@ -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: {
Expand All @@ -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),
Expand All @@ -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()

Expand All @@ -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 }
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,7 +23,6 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body className={`font-sans antialiased`} suppressHydrationWarning>
{children}
<Analytics />
</body>
</html>
)
Expand Down
28 changes: 28 additions & 0 deletions packages/storage/src/__tests__/server-audit.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
5 changes: 5 additions & 0 deletions packages/storage/src/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { loadSecureItem, saveSecureItem } from "./secure-storage"
import { allowUserApiKeys, isHostedMode } from "./hosted-mode"

export interface ApiKeys {
openaiApiKey: string
Expand Down Expand Up @@ -34,6 +35,10 @@ export async function getApiKeys(): Promise<ApiKeys> {
}

export async function setApiKeys(keys: Partial<ApiKeys>): Promise<void> {
if (isHostedMode() && !allowUserApiKeys()) {
throw new Error("User-managed API keys are disabled in hosted mode.")
}

try {
const current = await getApiKeys()
const updated = {
Expand Down
17 changes: 17 additions & 0 deletions packages/storage/src/server-api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down