fix: store provider API keys as plaintext in localStorage

Drop AES-256-GCM at-rest encryption for provider API keys. The
session-password-derived encryption broke on auto-login via refresh
token (password never captured), causing keys to silently vanish.
API keys are still RSA-encrypted in transit via node-forge. At-rest
encryption in localStorage added no real security since the
decryption key also had to live client-side.

Removes crypto-storage.ts, session password plumbing, and
reEncryptAllKeys.
This commit is contained in:
Roland Tannous 2026-04-08 11:05:42 +04:00
commit 8347bcecdc
6 changed files with 16 additions and 207 deletions

View file

@ -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(

View file

@ -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 {

View file

@ -503,7 +503,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
)
: null;
const externalApiKey = externalProvider
? (await getExternalProviderApiKey(externalProvider.id)).trim()
? getExternalProviderApiKey(externalProvider.id).trim()
: "";
if (isExternalRequest && !externalProvider) {

View file

@ -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);

View file

@ -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<string> {
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<string> {
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());
}

View file

@ -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> {
): 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> {
): 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<void> {
const keys = loadRawKeyMap();
if (Object.keys(keys).length === 0) return;
const migrated: Record<string, string> = {};
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);
}