Skip to content

Conversation

@longsizhuo
Copy link
Member

原因

  • 目前 API Key 明文保存在 localStorage,风险较高(导出本地存储、磁盘快照、共享计算机等)。
  • 在不改动后端的前提下,用浏览器侧口令加密可显著降低“意外泄露”的风险。
  • 同时,解决 TS 与 WebCrypto 的类型不一致导致的报错(deriveKey/encrypt/decrypt 处)。

  • 新增加密格式:enc:v1:<base64(salt)>:<base64(iv)>:<base64(cipher)>

    • PBKDF2: iterations=310_000, hash=SHA-256, salt=16B
    • AES-GCM: key=256bit, iv=12B
  • 新增 Context API:

    • setPassphrase(passphrase: string | null):设置/清空口令(保存在 sessionStorage,浏览器关闭即清空)
    • hasPassphrase: boolean:是否已设置口令
  • 存取逻辑:

    • 写入:若存在口令 → 加密后写到 localStorage;否则以明文写入(兼容旧行为)
    • 读取:若检测到 enc:v1: 前缀 → 尝试用口令解密;无口令或失败 → 返回空字符串
  • TS 修复:

    • 保证 salt/iv 的底层 bufferArrayBuffer(而非 ArrayBufferLike),统一标注为 BufferSource
    • 去除不可靠的 instanceof 检查,改用通用视图 viewOf 处理 ArrayBuffer | ArrayBufferView
    • 避免 ... 展开大数组,提供安全的 Base64 编解码工具

小提示

  • 本地加密用于降低意外泄露风险;对 XSS 无法提供强保证(拿到 JS 执行权即可读出明文)。
  • 口令仅保存在 sessionStorage,关闭浏览器后清空,减少长期驻留风险。
  • 不在 SSR 环境调用 window.crypto;所有加解密仅在客户端执行。

@longsizhuo longsizhuo requested review from Crokily and Mira190 November 2, 2025 18:45
@longsizhuo longsizhuo added the enhancement New feature or request label Nov 2, 2025
@longsizhuo longsizhuo added this to AI Nov 2, 2025
@vercel
Copy link

vercel bot commented Nov 2, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
involutionhell-github-io Ready Ready Preview Comment Nov 3, 2025 10:54am
website-preview Ready Ready Preview Comment Nov 3, 2025 10:54am

@longsizhuo
Copy link
Member Author

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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

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 👍 / 👎.

@longsizhuo longsizhuo requested review from Crokily, RavenCaffeine and Copilot and removed request for Crokily November 3, 2025 07:19
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR adds encryption functionality to the assistant settings hook to securely store API keys. The implementation uses AES-GCM encryption with PBKDF2 key derivation, storing the passphrase in sessionStorage and encrypted API keys in localStorage.

Key changes:

  • Implements client-side encryption/decryption for API keys using Web Crypto API (AES-256-GCM with PBKDF2)
  • Adds passphrase management stored in sessionStorage
  • Refactors storage logic to handle both encrypted and plaintext data for backward compatibility

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

);
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.
Comment on lines +271 to +275
useEffect(() => {
(async () => {
await writeStoredSettings(settings);
})();
}, [settings]);
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.
Comment on lines +284 to +296
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],
);
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.
Comment on lines +263 to +264
const s = await readStoredSettings();
setSettings(s);
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.
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.
/* ---------------- 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.
longsizhuo and others added 2 commits November 3, 2025 18:48
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@Crokily
Copy link
Contributor

Crokily commented Nov 3, 2025

稍等合并,我补充一点改动

Copy link
Contributor

@Crokily Crokily left a comment

Choose a reason for hiding this comment

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

没有入口调用setPassphrase,口令一直为null,因此加密功能没有生效。
有首轮渲染错误覆盖已存储内容,解密失败似乎也会覆盖,等问题,代码可以进一步解耦。
辛苦啦,目前需要的调整,我改好后会直接在这个分支上commit。

@Crokily Crokily closed this Nov 4, 2025
@github-project-automation github-project-automation bot moved this to Done in AI Nov 4, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants