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
312 changes: 247 additions & 65 deletions app/hooks/useAssistantSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,115 +10,297 @@ import {
} from "react";
import type { ReactNode } from "react";

/* ---------------- Types ---------------- */

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

interface AssistantSettingsState {
provider: Provider;
openaiApiKey: string;
geminiApiKey: string;
openaiApiKey: string; // 解密后的明文,内存中持有
geminiApiKey: string; // 解密后的明文,内存中持有
}

interface AssistantSettingsContextValue extends AssistantSettingsState {
setProvider: (provider: Provider) => void;
setOpenaiApiKey: (key: string) => void;
setGeminiApiKey: (key: string) => void;
setOpenaiApiKey: (key: string) => void; // 传入明文,内部负责加密存储
setGeminiApiKey: (key: string) => void; // 传入明文,内部负责加密存储
refreshFromStorage: () => void;
setPassphrase: (passphrase: string | null) => void; // 设置/清空加密口令
hasPassphrase: boolean;
}

/* ---------------- Constants ---------------- */

const SETTINGS_KEY = "assistant-settings-storage";
const PASSPHRASE_KEY = "assistant-settings-passphrase"; // 仅 sessionStorage
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing the passphrase in sessionStorage means it persists for the entire browser session and is accessible to any JavaScript code on the same origin. Consider using in-memory storage only (a module-level variable) and requiring re-entry on page refresh for better security, especially since the passphrase is the key to decrypt sensitive API keys.

Suggested change
const PASSPHRASE_KEY = "assistant-settings-passphrase"; // 仅 sessionStorage
// Passphrase is now only stored in memory, not in sessionStorage, for security.

Copilot uses AI. Check for mistakes.
const ENC_PREFIX = "enc:v1:"; // enc:v1:<saltB64>:<ivB64>:<cipherB64>

const defaultSettings: AssistantSettingsState = {
provider: "openai",
openaiApiKey: "",
geminiApiKey: "",
};

const AssistantSettingsContext = createContext<
AssistantSettingsContextValue | undefined
>(undefined);
/* ---------------- Crypto helpers (browser only) ---------------- */

const parseStoredSettings = (raw: string | null): AssistantSettingsState => {
if (!raw) {
return { ...defaultSettings };
const enc = new TextEncoder();
const dec = new TextDecoder();

// 保证底层 buffer 是 ArrayBuffer,避免 ArrayBufferLike 带来的 TS 不匹配
type U8 = Uint8Array & { buffer: ArrayBuffer };
const u8 = (len: number): U8 =>
crypto.getRandomValues(new Uint8Array(new ArrayBuffer(len))) as U8;

// 统一把 ArrayBuffer / ArrayBufferView 转成可遍历的 Uint8Array 视图(无 instanceof Uint8Array)
function viewOf(input: ArrayBuffer | ArrayBufferView): Uint8Array {
return input instanceof ArrayBuffer
? new Uint8Array(input)
: new Uint8Array(
input.buffer as ArrayBuffer,
input.byteOffset,
input.byteLength,
);
}

function toB64(input: ArrayBuffer | ArrayBufferView): string {
const bytes = viewOf(input);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function fromB64(b64: string): U8 {
const bin = atob(b64);
const out = new Uint8Array(new ArrayBuffer(bin.length));
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out as U8; // buffer: ArrayBuffer
}

async function getKeyFromPassphrase(passphrase: string, salt: BufferSource) {
const baseKey = await crypto.subtle.importKey(
"raw",
enc.encode(passphrase.normalize?.("NFKC") ?? passphrase),
{ name: "PBKDF2" },
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 310_000, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}

async function encryptIfNeeded(
plain: string,
passphrase: string | null,
): Promise<string> {
if (!passphrase || typeof window === "undefined" || !window.crypto?.subtle) {
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When encryption fails due to missing passphrase or unsupported environment, the function returns plaintext API keys. This could lead to unintentional storage of sensitive data in plaintext. Consider throwing an error or requiring explicit opt-in for plaintext storage, especially in production environments.

Copilot uses AI. Check for mistakes.
// 无口令或非浏览器环境:明文存储(兼容旧数据)
console.warn(
"Cannot encrypt assistant setting: missing passphrase or unsupported environment",
);
return plain;
}
const salt: BufferSource = u8(16);
const iv: BufferSource = u8(12);
const key = await getKeyFromPassphrase(passphrase, salt);
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(plain),
);
return `${ENC_PREFIX}${toB64(salt as ArrayBufferView)}:${toB64(iv as ArrayBufferView)}:${toB64(ct)}`;
}

async function decryptIfNeeded(
token: string,
passphrase: string | null,
): Promise<string> {
try {
const parsed = JSON.parse(raw) as Partial<AssistantSettingsState>;
return {
provider:
parsed.provider === "gemini"
? "gemini"
: parsed.provider === "intern"
? "intern"
: "openai",
openaiApiKey:
typeof parsed.openaiApiKey === "string" ? parsed.openaiApiKey : "",
geminiApiKey:
typeof parsed.geminiApiKey === "string" ? parsed.geminiApiKey : "",
};
} catch (error) {
console.error(
"Failed to parse assistant settings from localStorage",
error,
);
return { ...defaultSettings };
if (!token.startsWith(ENC_PREFIX)) return token; // 明文
if (
!passphrase ||
typeof window === "undefined" ||
!window.crypto?.subtle
) {
// 有密文但无口令/环境不支持,无法解密:返回空,避免把密文当明文用
console.error(
"Cannot decrypt assistant setting: missing passphrase or unsupported environment",
);
return "";
}
const [, rest] = token.split(ENC_PREFIX);
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The split operation on line 134 will create an array with only 2 elements when ENC_PREFIX is found once. However, if the token contains multiple instances of ENC_PREFIX, this logic will fail. Consider using token.slice(ENC_PREFIX.length) instead to safely extract everything after the prefix.

Suggested change
const [, rest] = token.split(ENC_PREFIX);
const rest = token.slice(ENC_PREFIX.length);

Copilot uses AI. Check for mistakes.
const [saltB64, ivB64, ctB64] = rest.split(":");
if (!saltB64 || !ivB64 || !ctB64) return "";

const salt: BufferSource = fromB64(saltB64);
const iv: BufferSource = fromB64(ivB64);
const ct = fromB64(ctB64);

const key = await getKeyFromPassphrase(passphrase, salt);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
return dec.decode(pt);
} catch {
console.error("Failed to decrypt assistant setting with given passphrase");
return ""; // 口令不匹配或数据损坏
}
}

/* ---------------- Storage helpers ---------------- */

type StoredShape = {
provider?: Provider;
openaiApiKey?: string; // 明文或 enc:v1:...
geminiApiKey?: string; // 明文或 enc:v1:...
};

const parseStored = (raw: string | null): StoredShape => {
if (!raw) return {};
try {
return JSON.parse(raw) as StoredShape;
} catch {
return {};
}
};

const readStoredSettings = (): AssistantSettingsState => {
if (typeof window === "undefined") {
return { ...defaultSettings };
const readPassphrase = (): string | null => {
if (typeof window === "undefined") return null;
try {
return window.sessionStorage.getItem(PASSPHRASE_KEY);
} catch {
return null;
}
};

const writePassphrase = (pass: string | null) => {
if (typeof window === "undefined") return;
try {
if (!pass) window.sessionStorage.removeItem(PASSPHRASE_KEY);
else window.sessionStorage.setItem(PASSPHRASE_KEY, pass);
} catch {
console.error("Failed to write passphrase to sessionStorage");
}
};

const readStoredSettings = async (): Promise<AssistantSettingsState> => {
if (typeof window === "undefined") return { ...defaultSettings };
const raw = window.localStorage.getItem(SETTINGS_KEY);
return parseStoredSettings(raw);
const stored = parseStored(raw);
const passphrase = readPassphrase();

const provider: Provider =
stored.provider === "gemini"
? "gemini"
: stored.provider === "intern"
? "intern"
: "openai";

const openaiApiKey = await decryptIfNeeded(
stored.openaiApiKey ?? "",
passphrase,
);
const geminiApiKey = await decryptIfNeeded(
stored.geminiApiKey ?? "",
passphrase,
);

return { provider, openaiApiKey, geminiApiKey };
};

const writeStoredSettings = async (state: AssistantSettingsState) => {
if (typeof window === "undefined") return;
const passphrase = readPassphrase();

const payload: StoredShape = {
provider: state.provider,
openaiApiKey: await encryptIfNeeded(state.openaiApiKey, passphrase),
geminiApiKey: await encryptIfNeeded(state.geminiApiKey, passphrase),
};

try {
window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(payload));
} catch (error) {
console.error("Failed to save assistant settings to localStorage", error);
}
};

/* ---------------- Context ---------------- */

const AssistantSettingsContext = createContext<
AssistantSettingsContextValue | undefined
>(undefined);

export const AssistantSettingsProvider = ({
children,
}: {
children: ReactNode;
}) => {
const [settings, setSettings] = useState<AssistantSettingsState>(() =>
readStoredSettings(),
const [settings, setSettings] =
useState<AssistantSettingsState>(defaultSettings);
const [hasPassphrase, setHasPassphrase] = useState<boolean>(
() => !!readPassphrase(),
);

// 初次装载:从 storage 读取并(必要时)解密
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);
}
}, [settings]);
let alive = true;
(async () => {
const s = await readStoredSettings();
if (alive) setSettings(s);
})();
return () => {
alive = false;
};
}, []);

// 监听跨标签页的 storage 变化
useEffect(() => {
if (typeof window === "undefined") {
return;
}

const handleStorage = (event: StorageEvent) => {
if (event.key !== SETTINGS_KEY) {
return;
}

setSettings(parseStoredSettings(event.newValue));
if (typeof window === "undefined") return;
const onStorage = async (event: StorageEvent) => {
if (event.key !== SETTINGS_KEY) return;
const s = await readStoredSettings();
setSettings(s);
Comment on lines +263 to +264
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The async event handler onStorage has no error handling. If readStoredSettings throws an error (e.g., corrupted data in localStorage), the handler fails silently. Add try-catch with appropriate error logging or user notification.

Suggested change
const s = await readStoredSettings();
setSettings(s);
try {
const s = await readStoredSettings();
setSettings(s);
} catch (error) {
console.error("Failed to read stored settings from storage event:", error);
}

Copilot uses AI. Check for mistakes.
};

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

// settings 变化即写回(必要时加密)
useEffect(() => {
(async () => {
await writeStoredSettings(settings);
})();
}, [settings]);
Comment on lines +270 to +275

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid overwriting encrypted API keys on load without passphrase

The hook writes whatever is in state back to localStorage on every change, even when the stored keys cannot be decrypted yet. On a fresh browser session the passphrase lives only in sessionStorage, so readStoredSettings() returns empty strings when decryptIfNeeded fails. The subsequent [settings] effect immediately persists those empty values (and setPassphrase does the same before re‑reading), destroying the original ciphertext. As a result, previously encrypted API keys are irretrievably lost as soon as the provider mounts or the user supplies the passphrase after a reload. The write should be deferred until decryption succeeds or the user intentionally replaces the value.

Useful? React with 👍 / 👎.

Comment on lines +271 to +275
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect will trigger on initial mount, writing default empty settings to storage before the initial readStoredSettings completes in the first effect (lines 247-256). This creates a race condition that could overwrite existing stored settings. Add a flag to skip the first write, or use a ref to track if initial load is complete.

Copilot uses AI. Check for mistakes.

const refreshFromStorage = useCallback(() => {
const latestSettings = readStoredSettings();
setSettings(latestSettings);
(async () => {
try {
const s = await readStoredSettings();
setSettings(s);
} catch (error) {
console.error("Failed to refresh settings from storage:", error);
}
})();
}, []);

const value = useMemo(
(): AssistantSettingsContextValue => ({
const setPassphrase = useCallback(
(pass: string | null) => {
writePassphrase(pass && pass.length ? pass : null);
setHasPassphrase(!!(pass && pass.length));
// 口令变化后立即重写一份(把已有明文转密文或反之)
(async () => {
await writeStoredSettings(settings);
const s = await readStoredSettings();
setSettings(s);
})();
},
[settings],
);
Comment on lines +288 to +300
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The async operation in setPassphrase (lines 289-293) can race with the effect on line 271. If a user changes the passphrase while settings is being updated, multiple concurrent writes to localStorage could occur with different passphrases, potentially corrupting data. Consider debouncing writes or using a write queue to serialize operations.

Copilot uses AI. Check for mistakes.

const value = useMemo<AssistantSettingsContextValue>(
() => ({
...settings,
setProvider: (provider: Provider) => {
setSettings((prev) => ({ ...prev, provider }));
Expand All @@ -130,8 +312,10 @@ export const AssistantSettingsProvider = ({
setSettings((prev) => ({ ...prev, geminiApiKey: key }));
},
refreshFromStorage,
setPassphrase,
hasPassphrase,
}),
[settings, refreshFromStorage],
[settings, refreshFromStorage, setPassphrase, hasPassphrase],
);

return (
Expand All @@ -143,12 +327,10 @@ export const AssistantSettingsProvider = ({

export const useAssistantSettings = () => {
const context = useContext(AssistantSettingsContext);

if (!context) {
throw new Error(
"useAssistantSettings must be used within an AssistantSettingsProvider",
);
}

return context;
};