feat(studio): encrypt external provider API keys at rest in localStorage
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.
This commit is contained in:
parent
5508c16208
commit
593bd10506
7 changed files with 238 additions and 39 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
)
|
||||
: null;
|
||||
const externalApiKey = externalProvider
|
||||
? getExternalProviderApiKey(externalProvider.id).trim()
|
||||
? (await getExternalProviderApiKey(externalProvider.id)).trim()
|
||||
: "";
|
||||
|
||||
if (isExternalRequest && !externalProvider) {
|
||||
|
|
|
|||
|
|
@ -749,7 +749,7 @@ export function ChatPage(): ReactElement {
|
|||
}, [refresh, refreshLocalModels]);
|
||||
|
||||
useEffect(() => {
|
||||
saveExternalProviders(externalProviders);
|
||||
void saveExternalProviders(externalProviders);
|
||||
}, [externalProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
111
studio/frontend/src/features/chat/crypto-storage.ts
Normal file
111
studio/frontend/src/features/chat/crypto-storage.ts
Normal file
|
|
@ -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<CryptoKey> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<string, string> = {};
|
||||
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<string, string> {
|
||||
/**
|
||||
* 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<string, string> {
|
||||
if (!canUseStorage()) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(EXTERNAL_PROVIDER_KEYS_KEY);
|
||||
|
|
@ -161,9 +153,9 @@ export function loadExternalProviderApiKeys(): Record<string, string> {
|
|||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string> {
|
|||
}
|
||||
}
|
||||
|
||||
export function getExternalProviderApiKey(providerId: string): string {
|
||||
const keys = loadExternalProviderApiKeys();
|
||||
return keys[providerId] ?? "";
|
||||
}
|
||||
|
||||
export function setExternalProviderApiKey(providerId: string, apiKey: string): void {
|
||||
function saveRawKeyMap(map: Record<string, string>): 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<void> {
|
||||
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<string, string> = {};
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue