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:
parent
bd02a0ff50
commit
8347bcecdc
6 changed files with 16 additions and 207 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -503,7 +503,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
)
|
||||
: null;
|
||||
const externalApiKey = externalProvider
|
||||
? (await getExternalProviderApiKey(externalProvider.id)).trim()
|
||||
? getExternalProviderApiKey(externalProvider.id).trim()
|
||||
: "";
|
||||
|
||||
if (isExternalRequest && !externalProvider) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue