diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index 58b9212a1e..d9190429bd 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -10,8 +10,6 @@ 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 @@ -268,14 +266,10 @@ 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 25a4ad051e..3d3502e073 100644 --- a/studio/frontend/src/features/auth/session.ts +++ b/studio/frontend/src/features/auth/session.ts @@ -2,7 +2,6 @@ // 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"; @@ -51,7 +50,6 @@ 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 41d5c047ef..ba51af37cf 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -503,7 +503,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { ) : null; const externalApiKey = externalProvider - ? (await getExternalProviderApiKey(externalProvider.id)).trim() + ? getExternalProviderApiKey(externalProvider.id).trim() : ""; if (isExternalRequest && !externalProvider) { diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index e36f972da2..8ea13d9868 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -389,7 +389,7 @@ export function ChatProvidersDialog({ updatedAt, }; if (apiKey.trim()) { - await setExternalProviderApiKey(created.id, apiKey.trim()); + setExternalProviderApiKey(created.id, apiKey.trim()); } onProvidersChange([...providers.filter((p) => p.id !== created.id), provider]); resetForm(); @@ -445,7 +445,7 @@ export function ChatProvidersDialog({ baseUrl, }); if (apiKey.trim()) { - await setExternalProviderApiKey(editingProviderId, apiKey.trim()); + setExternalProviderApiKey(editingProviderId, apiKey.trim()); } else if (isEditingCustomProvider) { removeExternalProviderApiKey(editingProviderId); } @@ -479,7 +479,7 @@ export function ChatProvidersDialog({ setEditingProviderId(provider.id); setProviderType(provider.providerType); setCustomProviderName(provider.name || "Custom"); - setApiKey(await getExternalProviderApiKey(provider.id)); + setApiKey(getExternalProviderApiKey(provider.id)); setBaseUrlDraft(provider.baseUrl); if (provider.providerType === CUSTOM_PROVIDER_TYPE) { setAvailableModels([]); @@ -517,7 +517,7 @@ export function ChatProvidersDialog({ } async function testProvider(provider: ExternalProviderConfig) { - const savedKey = (await getExternalProviderApiKey(provider.id)).trim(); + const savedKey = getExternalProviderApiKey(provider.id).trim(); if (!savedKey) { if (provider.providerType === CUSTOM_PROVIDER_TYPE) { await editProvider(provider); diff --git a/studio/frontend/src/features/chat/crypto-storage.ts b/studio/frontend/src/features/chat/crypto-storage.ts deleted file mode 100644 index 5387b4a12e..0000000000 --- a/studio/frontend/src/features/chat/crypto-storage.ts +++ /dev/null @@ -1,128 +0,0 @@ -// 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 forge from "node-forge"; - -/** - * 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 ───────────────────────────────────────────── -// -// Held in a module variable for fast access, backed by sessionStorage -// so it survives page refreshes within the same tab. Cleared on -// logout and when the tab is closed (sessionStorage semantics). - -const SESSION_PW_KEY = "unsloth_chat_session_pw"; - -let _sessionPassword: string | null = null; - -/** Store the login password for the duration of the browser session. */ -export function setSessionPassword(password: string): void { - _sessionPassword = password; - try { - sessionStorage.setItem(SESSION_PW_KEY, password); - } catch { - // Private browsing or quota — in-memory only - } -} - -/** Retrieve the session password. Restores from sessionStorage after a page refresh. */ -export function getSessionPassword(): string | null { - if (_sessionPassword) return _sessionPassword; - try { - const stored = sessionStorage.getItem(SESSION_PW_KEY); - if (stored) { - _sessionPassword = stored; - return stored; - } - } catch { - // ignore - } - return null; -} - -/** Clear the session password (called on logout). */ -export function clearSessionPassword(): void { - _sessionPassword = null; - try { - sessionStorage.removeItem(SESSION_PW_KEY); - } catch { - // ignore - } -} - -// ── Key derivation ─────────────────────────────────────────────── - -/** Derive a 256-bit AES key from a password and salt via PBKDF2-SHA256. */ -export function deriveKeyBytes( - password: string, - salt: Uint8Array, -): string { - const saltStr = forge.util.binary.raw.encode(salt); - return forge.pkcs5.pbkdf2(password, saltStr, PBKDF2_ITERATIONS, 32, forge.md.sha256.create()); -} - -// ── Encrypt / Decrypt ──────────────────────────────────────────── - -/** - * Encrypt a plaintext string. - * Returns a base64 string containing: salt (16 B) ‖ iv (12 B) ‖ ciphertext ‖ tag (16 B). - */ -export async function encryptValue( - plaintext: string, - password: string, -): Promise { - const salt = forge.random.getBytesSync(SALT_BYTES); - const iv = forge.random.getBytesSync(IV_BYTES); - const keyBytes = deriveKeyBytes(password, Uint8Array.from(salt, (c: string) => c.charCodeAt(0))); - const cipher = forge.cipher.createCipher("AES-GCM", keyBytes); - cipher.start({ iv, tagLength: 128 }); - cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(plaintext))); - cipher.finish(); - const ciphertext = cipher.output.getBytes(); - const tag = cipher.mode.tag.getBytes(); - // Concatenate salt + iv + ciphertext + tag - const combined = salt + iv + ciphertext + tag; - return forge.util.encode64(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 = forge.util.decode64(encrypted); - if (combined.length < SALT_BYTES + IV_BYTES + 1 + 16) { - 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 ciphertextAndTag = combined.slice(SALT_BYTES + IV_BYTES); - const ciphertext = ciphertextAndTag.slice(0, ciphertextAndTag.length - 16); - const tag = ciphertextAndTag.slice(ciphertextAndTag.length - 16); - const saltBytes = Uint8Array.from(salt, (c: string) => c.charCodeAt(0)); - const keyBytes = deriveKeyBytes(password, saltBytes); - const decipher = forge.cipher.createDecipher("AES-GCM", keyBytes); - decipher.start({ iv, tag: forge.util.createBuffer(tag) }); - decipher.update(forge.util.createBuffer(ciphertext)); - const ok = decipher.finish(); - if (!ok) { - throw new Error("Decryption failed: wrong password or tampered data"); - } - return forge.util.decodeUtf8(decipher.output.getBytes()); -} diff --git a/studio/frontend/src/features/chat/external-providers.ts b/studio/frontend/src/features/chat/external-providers.ts index b8cedf6e8e..3010b96cce 100644 --- a/studio/frontend/src/features/chat/external-providers.ts +++ b/studio/frontend/src/features/chat/external-providers.ts @@ -1,11 +1,6 @@ // 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; @@ -195,55 +190,27 @@ export async function saveExternalProviders( } /** - * 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. + * Retrieve a provider API key from localStorage. + * Returns "" if no key is stored. */ -export async function getExternalProviderApiKey( +export function getExternalProviderApiKey( providerId: string, -): Promise { +): string { 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; - } + return keys[providerId] ?? ""; } /** - * Store a provider API key, encrypting it before writing to localStorage. - * Throws if no session password is available (should not happen in normal flow). + * Store a provider API key in localStorage. */ -export async function setExternalProviderApiKey( +export function setExternalProviderApiKey( providerId: string, apiKey: string, -): Promise { +): void { if (!canUseStorage()) return; - try { - const keys = loadRawKeyMap(); - const password = getSessionPassword(); - if (!password) { - throw new Error("No session password — cannot encrypt API key"); - } - keys[providerId] = await encryptValue(apiKey, password); - saveRawKeyMap(keys); - } catch { - // ignore - } + const keys = loadRawKeyMap(); + keys[providerId] = apiKey; + saveRawKeyMap(keys); } export function removeExternalProviderApiKey(providerId: string): void { @@ -257,25 +224,3 @@ export function removeExternalProviderApiKey(providerId: string): void { } } -/** - * 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); -}