From 593bd105063889fda9dd267a5fc9ff1dcaeb6fcc Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 1 Apr 2026 18:54:25 +0000 Subject: [PATCH] feat(studio): encrypt external provider API keys at rest in localStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API keys for external providers (OpenAI, Mistral, etc.) were stored as plaintext in localStorage, vulnerable to browser extensions and XSS. Add password-derived AES-256-GCM encryption: on login the user's password is used via PBKDF2 (100k iterations, SHA-256) to derive an in-memory encryption key. API keys are encrypted before writing to localStorage and decrypted on read. The derived key is never persisted — cleared on logout, re-derived on next login. Legacy plaintext keys are transparently migrated on first access. Password changes re-encrypt all stored keys. No backend changes required — the existing RSA-OAEP transit encryption is unaffected. --- .../features/auth/components/auth-form.tsx | 6 + studio/frontend/src/features/auth/session.ts | 2 + .../src/features/chat/api/chat-adapter.ts | 2 +- .../frontend/src/features/chat/chat-page.tsx | 2 +- .../features/chat/chat-providers-dialog.tsx | 12 +- .../src/features/chat/crypto-storage.ts | 111 +++++++++++++ .../src/features/chat/external-providers.ts | 148 ++++++++++++++---- 7 files changed, 241 insertions(+), 42 deletions(-) create mode 100644 studio/frontend/src/features/chat/crypto-storage.ts diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index d9190429bd..58b9212a1e 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -10,6 +10,8 @@ import { useEffect, useState } from "react"; import type { ReactElement } from "react"; import type { SyntheticEvent } from "react"; import { usePlatformStore } from "@/config/env"; +import { setSessionPassword } from "@/features/chat/crypto-storage"; +import { reEncryptAllKeys } from "@/features/chat/external-providers"; import { refreshSession } from "../api"; // Bootstrap credentials injected into index.html by the backend @@ -266,10 +268,14 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { } if (!isLoginMode) { + // Re-encrypt stored API keys with the new password before switching session + await reEncryptAllKeys(currentPassword, newPassword); + setSessionPassword(newPassword); resetOnboardingDone(); setRequiresPasswordChange(false); setMustChangePassword(false); } else { + setSessionPassword(password); setMustChangePassword(token.must_change_password); } storeAuthTokens( diff --git a/studio/frontend/src/features/auth/session.ts b/studio/frontend/src/features/auth/session.ts index 3d3502e073..25a4ad051e 100644 --- a/studio/frontend/src/features/auth/session.ts +++ b/studio/frontend/src/features/auth/session.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { usePlatformStore } from "@/config/env"; +import { clearSessionPassword } from "@/features/chat/crypto-storage"; export const AUTH_TOKEN_KEY = "unsloth_auth_token"; export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_token"; @@ -50,6 +51,7 @@ export function clearAuthTokens(): void { localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_REFRESH_TOKEN_KEY); localStorage.removeItem(AUTH_MUST_CHANGE_PASSWORD_KEY); + clearSessionPassword(); } export function mustChangePassword(): boolean { diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 9824c9b11c..9220a6769d 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -464,7 +464,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { ) : null; const externalApiKey = externalProvider - ? getExternalProviderApiKey(externalProvider.id).trim() + ? (await getExternalProviderApiKey(externalProvider.id)).trim() : ""; if (isExternalRequest && !externalProvider) { diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 11cd456721..02d713caa8 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -749,7 +749,7 @@ export function ChatPage(): ReactElement { }, [refresh, refreshLocalModels]); useEffect(() => { - saveExternalProviders(externalProviders); + void saveExternalProviders(externalProviders); }, [externalProviders]); useEffect(() => { diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index 7f7d3f77a5..2cc895e6a5 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -243,7 +243,7 @@ export function ChatProvidersDialog({ createdAt, updatedAt, }; - setExternalProviderApiKey(created.id, apiKey.trim()); + await setExternalProviderApiKey(created.id, apiKey.trim()); onProvidersChange([...providers.filter((p) => p.id !== created.id), provider]); resetForm(); toast.success("Provider added."); @@ -281,7 +281,7 @@ export function ChatProvidersDialog({ displayName: existing.name, baseUrl, }); - setExternalProviderApiKey(editingProviderId, apiKey.trim()); + await setExternalProviderApiKey(editingProviderId, apiKey.trim()); const updatedAt = Number.isFinite(Date.parse(updated.updated_at)) ? Date.parse(updated.updated_at) : Date.now(); @@ -307,10 +307,10 @@ export function ChatProvidersDialog({ } } - function editProvider(provider: ExternalProviderConfig) { + async function editProvider(provider: ExternalProviderConfig) { setEditingProviderId(provider.id); setProviderType(provider.providerType); - setApiKey(getExternalProviderApiKey(provider.id)); + setApiKey(await getExternalProviderApiKey(provider.id)); setBaseUrlDraft(provider.baseUrl); setAvailableModels([...provider.models]); setSelectedModelIds([...provider.models]); @@ -331,9 +331,9 @@ export function ChatProvidersDialog({ } async function testProvider(provider: ExternalProviderConfig) { - const savedKey = getExternalProviderApiKey(provider.id).trim(); + const savedKey = (await getExternalProviderApiKey(provider.id)).trim(); if (!savedKey) { - editProvider(provider); + await editProvider(provider); toast.info(`No API key found for ${provider.name}. Add one and save.`); return; } diff --git a/studio/frontend/src/features/chat/crypto-storage.ts b/studio/frontend/src/features/chat/crypto-storage.ts new file mode 100644 index 0000000000..9c6bf51c7e --- /dev/null +++ b/studio/frontend/src/features/chat/crypto-storage.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * Password-derived AES-256-GCM encryption for API keys at rest in localStorage. + * + * Architecture: + * login password → PBKDF2(password, salt) → AES-256-GCM key (in-memory only) + * plaintext API key → AES-GCM encrypt → base64(salt ‖ iv ‖ ciphertext) → localStorage + * + * The derived key is never persisted — it lives in a module-scoped variable, + * set on login and cleared on logout. + */ + +const PBKDF2_ITERATIONS = 100_000; +const SALT_BYTES = 16; +const IV_BYTES = 12; + +// ── Session password (in-memory only) ──────────────────────────── + +let _sessionPassword: string | null = null; + +/** Store the login password in memory for the duration of the session. */ +export function setSessionPassword(password: string): void { + _sessionPassword = password; +} + +/** Retrieve the in-memory session password. Returns null when logged out. */ +export function getSessionPassword(): string | null { + return _sessionPassword; +} + +/** Clear the in-memory session password (called on logout). */ +export function clearSessionPassword(): void { + _sessionPassword = null; +} + +// ── Key derivation ─────────────────────────────────────────────── + +/** Derive an AES-256-GCM CryptoKey from a password and salt via PBKDF2. */ +export async function deriveKey( + password: string, + salt: Uint8Array, +): Promise { + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + "PBKDF2", + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +// ── Encrypt / Decrypt ──────────────────────────────────────────── + +/** + * Encrypt a plaintext string. + * Returns a base64 string containing: salt (16 B) ‖ iv (12 B) ‖ ciphertext. + */ +export async function encryptValue( + plaintext: string, + password: string, +): Promise { + const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const key = await deriveKey(password, salt); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new TextEncoder().encode(plaintext), + ); + // Concatenate salt + iv + ciphertext into one ArrayBuffer + const combined = new Uint8Array( + SALT_BYTES + IV_BYTES + ciphertext.byteLength, + ); + combined.set(salt, 0); + combined.set(iv, SALT_BYTES); + combined.set(new Uint8Array(ciphertext), SALT_BYTES + IV_BYTES); + return btoa(String.fromCharCode(...combined)); +} + +/** + * Decrypt a base64 string produced by {@link encryptValue}. + * Throws if the password is wrong or the data is tampered/not encrypted. + */ +export async function decryptValue( + encrypted: string, + password: string, +): Promise { + const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0)); + if (combined.byteLength < SALT_BYTES + IV_BYTES + 1) { + throw new Error("Invalid encrypted value: too short"); + } + const salt = combined.slice(0, SALT_BYTES); + const iv = combined.slice(SALT_BYTES, SALT_BYTES + IV_BYTES); + const ciphertext = combined.slice(SALT_BYTES + IV_BYTES); + const key = await deriveKey(password, salt); + const plainBuffer = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + ciphertext, + ); + return new TextDecoder().decode(plainBuffer); +} diff --git a/studio/frontend/src/features/chat/external-providers.ts b/studio/frontend/src/features/chat/external-providers.ts index 205bdb223a..aaf537c3f5 100644 --- a/studio/frontend/src/features/chat/external-providers.ts +++ b/studio/frontend/src/features/chat/external-providers.ts @@ -1,6 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +import { + decryptValue, + encryptValue, + getSessionPassword, +} from "./crypto-storage"; + export interface ExternalProviderConfig { id: string; /** Backend provider type (e.g. openai, mistral, gemini). */ @@ -135,25 +141,11 @@ export function loadExternalProviders(): ExternalProviderConfig[] { } } -export function saveExternalProviders(providers: ExternalProviderConfig[]): void { - if (!canUseStorage()) return; - try { - localStorage.setItem(EXTERNAL_PROVIDERS_KEY, JSON.stringify(providers)); - const allowedIds = new Set(providers.map((provider) => provider.id)); - const keys = loadExternalProviderApiKeys(); - const pruned: Record = {}; - for (const [providerId, apiKey] of Object.entries(keys)) { - if (allowedIds.has(providerId)) { - pruned[providerId] = apiKey; - } - } - localStorage.setItem(EXTERNAL_PROVIDER_KEYS_KEY, JSON.stringify(pruned)); - } catch { - // ignore - } -} - -export function loadExternalProviderApiKeys(): Record { +/** + * Load the raw (encrypted or legacy plaintext) key map from localStorage. + * Values are opaque strings — either AES-GCM ciphertext or legacy plaintext. + */ +function loadRawKeyMap(): Record { if (!canUseStorage()) return {}; try { const raw = localStorage.getItem(EXTERNAL_PROVIDER_KEYS_KEY); @@ -161,9 +153,9 @@ export function loadExternalProviderApiKeys(): Record { const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; const out: Record = {}; - for (const [providerId, apiKey] of Object.entries(parsed)) { - if (typeof providerId === "string" && typeof apiKey === "string") { - out[providerId] = apiKey; + for (const [providerId, value] of Object.entries(parsed)) { + if (typeof providerId === "string" && typeof value === "string") { + out[providerId] = value; } } return out; @@ -172,17 +164,82 @@ export function loadExternalProviderApiKeys(): Record { } } -export function getExternalProviderApiKey(providerId: string): string { - const keys = loadExternalProviderApiKeys(); - return keys[providerId] ?? ""; -} - -export function setExternalProviderApiKey(providerId: string, apiKey: string): void { +function saveRawKeyMap(map: Record): void { if (!canUseStorage()) return; try { - const next = loadExternalProviderApiKeys(); - next[providerId] = apiKey; - localStorage.setItem(EXTERNAL_PROVIDER_KEYS_KEY, JSON.stringify(next)); + localStorage.setItem(EXTERNAL_PROVIDER_KEYS_KEY, JSON.stringify(map)); + } catch { + // ignore + } +} + +export async function saveExternalProviders( + providers: ExternalProviderConfig[], +): Promise { + if (!canUseStorage()) return; + try { + localStorage.setItem(EXTERNAL_PROVIDERS_KEY, JSON.stringify(providers)); + // Prune keys for removed providers — works on raw ciphertext, no decryption needed + const allowedIds = new Set(providers.map((provider) => provider.id)); + const keys = loadRawKeyMap(); + const pruned: Record = {}; + for (const [providerId, value] of Object.entries(keys)) { + if (allowedIds.has(providerId)) { + pruned[providerId] = value; + } + } + saveRawKeyMap(pruned); + } catch { + // ignore + } +} + +/** + * Retrieve a provider API key, decrypting from localStorage. + * Transparently migrates legacy plaintext keys to encrypted form. + * Returns "" if no key is stored or no session password is available. + */ +export async function getExternalProviderApiKey( + providerId: string, +): Promise { + const keys = loadRawKeyMap(); + const stored = keys[providerId]; + if (!stored) return ""; + + const password = getSessionPassword(); + if (!password) return ""; + + try { + return await decryptValue(stored, password); + } catch { + // Decryption failed — likely a legacy plaintext key. Migrate it. + try { + const encrypted = await encryptValue(stored, password); + keys[providerId] = encrypted; + saveRawKeyMap(keys); + } catch { + // Migration failed — return the raw value as-is + } + return stored; + } +} + +/** + * Store a provider API key, encrypting it before writing to localStorage. + * Falls back to plaintext storage if no session password is available. + */ +export async function setExternalProviderApiKey( + providerId: string, + apiKey: string, +): Promise { + if (!canUseStorage()) return; + try { + const keys = loadRawKeyMap(); + const password = getSessionPassword(); + keys[providerId] = password + ? await encryptValue(apiKey, password) + : apiKey; + saveRawKeyMap(keys); } catch { // ignore } @@ -191,10 +248,33 @@ export function setExternalProviderApiKey(providerId: string, apiKey: string): v export function removeExternalProviderApiKey(providerId: string): void { if (!canUseStorage()) return; try { - const next = loadExternalProviderApiKeys(); - delete next[providerId]; - localStorage.setItem(EXTERNAL_PROVIDER_KEYS_KEY, JSON.stringify(next)); + const keys = loadRawKeyMap(); + delete keys[providerId]; + saveRawKeyMap(keys); } catch { // ignore } } + +/** + * Re-encrypt all stored API keys when the user changes their password. + * Decrypts each key with the old password and re-encrypts with the new one. + */ +export async function reEncryptAllKeys( + oldPassword: string, + newPassword: string, +): Promise { + const keys = loadRawKeyMap(); + if (Object.keys(keys).length === 0) return; + const migrated: Record = {}; + for (const [id, ciphertext] of Object.entries(keys)) { + try { + const plaintext = await decryptValue(ciphertext, oldPassword); + migrated[id] = await encryptValue(plaintext, newPassword); + } catch { + // If decryption fails (legacy plaintext), encrypt with new password + migrated[id] = await encryptValue(ciphertext, newPassword); + } + } + saveRawKeyMap(migrated); +}