-
Notifications
You must be signed in to change notification settings - Fork 40
修复前端明文传输了Key,会被攻击 #224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
修复前端明文传输了Key,会被攻击 #224
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||
| 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) { | ||||||||||||||||||
|
||||||||||||||||||
| // 无口令或非浏览器环境:明文存储(兼容旧数据) | ||||||||||||||||||
| 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); | ||||||||||||||||||
|
||||||||||||||||||
| const [, rest] = token.split(ENC_PREFIX); | |
| const rest = token.slice(ENC_PREFIX.length); |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
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.
| 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); | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
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
AI
Nov 3, 2025
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.