Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 122 additions & 14 deletions app/hooks/useAssistantSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,89 @@ import {
} from "react";
import type { ReactNode } from "react";

// --- Encryption helpers using Web Crypto API, with a placeholder key ---
// In a real app, get the passphrase from the user. Here, use a placeholder.
const ENCRYPTION_KEY_PASSPHRASE = "replace-this-passphrase"; // TODO: Prompt user!
const ENCRYPTION_SALT = "assistant-settings-salt"; // static salt (insecure, for demo)

async function getKeyFromPassphrase(passphrase: string) {
const enc = new TextEncoder();
const keyMaterial = await window.crypto.subtle.importKey(
"raw",
enc.encode(passphrase),
{ name: "PBKDF2" },
false,
["deriveKey"]
);
return window.crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: enc.encode(ENCRYPTION_SALT),
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}

// encrypt text, return base64(iv):base64(ciphertext)
export async function encryptString(plainText: string, passphrase: string): Promise<string> {
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const key = await getKeyFromPassphrase(passphrase);
const enc = new TextEncoder();
const ciphertext = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(plainText)
);
// Pack IV and ciphertext into a base64 string
return (
btoa(String.fromCharCode(...iv)) +
":" +
btoa(String.fromCharCode(...new Uint8Array(ciphertext)))
);
}

// decrypt base64(iv):base64(ciphertext)
export async function decryptString(cipherText: string, passphrase: string): Promise<string> {
if (!cipherText.includes(":")) return "";
const [ivPart, cipherPart] = cipherText.split(":");
const iv = Uint8Array.from(atob(ivPart), c => c.charCodeAt(0));
const data = Uint8Array.from(atob(cipherPart), c => c.charCodeAt(0));
const key = await getKeyFromPassphrase(passphrase);
const decrypted = await window.crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
data
);
return new TextDecoder().decode(decrypted);
}

// Returns a settings object with encrypted keys, for storage.
export async function encryptSettings(settings: AssistantSettingsState, passphrase: string): Promise<Omit<AssistantSettingsState, "openaiApiKey" | "geminiApiKey"> & { openaiApiKey: string, geminiApiKey: string }> {
return {
provider: settings.provider,
openaiApiKey: settings.openaiApiKey ? await encryptString(settings.openaiApiKey, passphrase) : "",
geminiApiKey: settings.geminiApiKey ? await encryptString(settings.geminiApiKey, passphrase) : "",
};
}

// Returns a settings object with decrypted keys, for in-memory use.
export async function decryptSettings(stored: AssistantSettingsState, passphrase: string): Promise<AssistantSettingsState> {
return {
provider: stored.provider,
openaiApiKey: stored.openaiApiKey
? await decryptString(stored.openaiApiKey, passphrase)
: "",
geminiApiKey: stored.geminiApiKey
? await decryptString(stored.geminiApiKey, passphrase)
: "",
};
}

type Provider = "openai" | "gemini" | "intern";

interface AssistantSettingsState {
Expand Down Expand Up @@ -79,42 +162,67 @@ export const AssistantSettingsProvider = ({
}: {
children: ReactNode;
}) => {
const [settings, setSettings] = useState<AssistantSettingsState>(() =>
readStoredSettings(),
);
// Need to initialize state async due to decryption; use blank, then load in useEffect.
const [settings, setSettings] = useState<AssistantSettingsState>({ ...defaultSettings });

// Encrypt and store the settings whenever they change
useEffect(() => {
if (typeof window === "undefined") {
return;
}

try {
window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch (error) {
console.error("Failed to save assistant settings to localStorage", error);
}
(async () => {
try {
// Only store encrypted API keys in localStorage
const encrypted = await encryptSettings(settings, ENCRYPTION_KEY_PASSPHRASE);
window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(encrypted));
} catch (error) {
console.error("Failed to save assistant settings to localStorage", error);
}
})();
}, [settings]);

// On mount, load encrypted settings from localStorage and decrypt
useEffect(() => {
if (typeof window === "undefined") {
return;
}
(async () => {
const raw = window.localStorage.getItem(SETTINGS_KEY);
const parsed = parseStoredSettings(raw);
const decrypted = await decryptSettings(parsed, ENCRYPTION_KEY_PASSPHRASE);
setSettings(decrypted);
})();
}, []);

// Storage event for cross-tab sync. Decrypt new values!
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const handleStorage = (event: StorageEvent) => {
if (event.key !== SETTINGS_KEY) {
return;
}

setSettings(parseStoredSettings(event.newValue));
(async () => {
const parsed = parseStoredSettings(event.newValue);
const decrypted = await decryptSettings(parsed, ENCRYPTION_KEY_PASSPHRASE);
setSettings(decrypted);
})();
};

window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);

// Refresh from storage (decrypt)
const refreshFromStorage = useCallback(() => {
const latestSettings = readStoredSettings();
setSettings(latestSettings);
if (typeof window === "undefined") {
return;
}
(async () => {
const latest = readStoredSettings();
const decrypted = await decryptSettings(latest, ENCRYPTION_KEY_PASSPHRASE);
setSettings(decrypted);
})();
}, []);

const value = useMemo(
Expand Down