feat(desktop): protect packaged credentials with safeStorage

Signed-off-by: QCYTSN <2558348385@qq.com>
This commit is contained in:
QCYTSN committed 2026-08-08 19:55:11 +08:00
1 parent 46465ac3cd
commit 2b1b499b42
15 files changed
+396 -10

No files matched your search

+3
View File
@@ -20,6 +20,7 @@ type BackendManagerOptions = {
logDirectory: string;
apiAuthKey: string;
messages: DesktopMessages;
credentialEnvironment?: NodeJS.ProcessEnv;
onStatus: (message: string) => void;
onUnexpectedExit: (message: string) => void;
};
@@ -91,7 +92,9 @@ export class BackendManager {
const gtkBin = path.join(path.dirname(executable), "gtk", "bin");
const childEnvironment: NodeJS.ProcessEnv = {
...process.env,
...this.options.credentialEnvironment,
API_AUTH_KEY: this.options.apiAuthKey,
VIBE_TRADING_DESKTOP_SECURE_CREDENTIALS: "1",
PYTHONUTF8: "1",
PYTHONUNBUFFERED: "1",
ELECTRON_RUN_AS_NODE: "1",
+16
View File
@@ -17,6 +17,7 @@ import {
getRendererLocale,
resolveDesktopLocale,
} from "./locales";
import { SecureCredentialStore } from "./secure-credentials";
let mainWindow: BrowserWindow | undefined;
let backend: BackendManager | undefined;
@@ -27,6 +28,7 @@ let desktopMessages = getDesktopMessages(desktopLocale);
const apiAuthKey = randomBytes(32).toString("base64url");
const productName = "Vibe-Trading Desktop (Unofficial Community Build)";
const testUserData = process.env.VIBE_TRADING_DESKTOP_TEST_USER_DATA;
const credentialStore = new SecureCredentialStore();
if (testUserData) app.setPath("userData", path.resolve(testUserData));
app.setName(productName);
@@ -51,6 +53,7 @@ async function ready(): Promise<void> {
desktopMessages = getDesktopMessages(desktopLocale);
nativeTheme.themeSource = "dark";
app.setAppLogsPath();
await credentialStore.initialize();
createWindow();
registerIpc();
createMenu();
@@ -126,6 +129,18 @@ function registerIpc(): void {
await boot();
return true;
});
ipcMain.handle("desktop:get-credential-status", (event) => {
assertMainWindowSender(event.sender);
return credentialStore.status();
});
ipcMain.handle("desktop:set-credential", async (event, name: unknown, value: unknown) => {
assertMainWindowSender(event.sender);
if (typeof name !== "string" || (typeof value !== "string" && value !== null)) {
throw new Error("Invalid credential request");
}
await credentialStore.set(name, value);
return credentialStore.status();
});
}
async function showLoadingPage(): Promise<void> {
@@ -157,6 +172,7 @@ async function bootInternal(): Promise<void> {
logDirectory: app.getPath("logs"),
apiAuthKey,
messages: desktopMessages,
credentialEnvironment: credentialStore.environment(),
onStatus: (message) => mainWindow?.webContents.send("desktop:status", message),
onUnexpectedExit: (message) => {
if (!quitting) void reportBootError(message);
+3
View File
@@ -11,4 +11,7 @@ contextBridge.exposeInMainWorld("vibeDesktop", {
retry: () => ipcRenderer.send("desktop:retry"),
openLogs: () => ipcRenderer.send("desktop:open-logs"),
restartBackend: () => ipcRenderer.invoke("desktop:restart-backend") as Promise<boolean>,
getCredentialStatus: () => ipcRenderer.invoke("desktop:get-credential-status"),
setCredential: (name: string, value: string | null) =>
ipcRenderer.invoke("desktop:set-credential", name, value),
});
+209
View File
@@ -0,0 +1,209 @@
import { app, safeStorage } from "electron";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
type CredentialFile = {
version: 1;
values: Record<string, string>;
};
const ENV_CREDENTIALS = new Set([
"OPENROUTER_API_KEY",
"REQUESTY_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DEEPSEEK_API_KEY",
"SILICONFLOW_API_KEY",
"SILICONFLOW_GLOBAL_API_KEY",
"NVIDIA_API_KEY",
"GEMINI_API_KEY",
"GROQ_API_KEY",
"DASHSCOPE_API_KEY",
"ZHIPU_API_KEY",
"MOONSHOT_API_KEY",
"KIMI_CODING_API_KEY",
"MINIMAX_API_KEY",
"MIMO_API_KEY",
"SPARK_API_KEY",
"ZAI_API_KEY",
"TUSHARE_TOKEN",
"QVERIS_API_KEY",
]);
const PLACEHOLDER_VALUES = new Set([
"",
"sk-or-v1-your-key-here",
"sk-or-...here",
"sk-xxx",
"xxx",
"gsk_xxx",
"your-tushare-token",
]);
export type CredentialStatus = {
available: boolean;
configured: string[];
migrated: string[];
};
export class SecureCredentialStore {
private readonly filePath = path.join(app.getPath("userData"), "credentials.v1.json");
private values: Record<string, string> = {};
private migrated: string[] = [];
async initialize(): Promise<void> {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error("Windows credential encryption is unavailable for this user session.");
}
await this.load();
await this.migrateDotenv(path.join(os.homedir(), ".vibe-trading", ".env"));
await this.migrateJsonField(
path.join(os.homedir(), ".vibe-trading", "qveris.json"),
"api_key",
"QVERIS_API_KEY",
);
}
status(): CredentialStatus {
return {
available: safeStorage.isEncryptionAvailable(),
configured: Object.keys(this.values).sort(),
migrated: [...this.migrated],
};
}
async set(name: string, value: string | null): Promise<void> {
this.assertAllowed(name);
const normalized = value?.trim() ?? "";
if (!normalized) {
delete this.values[name];
} else {
this.values[name] = safeStorage.encryptString(normalized).toString("base64");
}
await this.persist();
}
environment(): NodeJS.ProcessEnv {
const output: NodeJS.ProcessEnv = {};
for (const name of Object.keys(this.values)) {
const value = this.decrypt(name);
if (value) output[name] = value;
}
return output;
}
private decrypt(name: string): string {
try {
return safeStorage.decryptString(Buffer.from(this.values[name], "base64"));
} catch {
return "";
}
}
private assertAllowed(name: string): void {
if (!ENV_CREDENTIALS.has(name)) {
throw new Error("Unsupported credential key");
}
}
private async load(): Promise<void> {
try {
const parsed = JSON.parse(await fs.readFile(this.filePath, "utf8")) as CredentialFile;
if (parsed.version !== 1 || !parsed.values || typeof parsed.values !== "object") {
throw new Error("Unsupported desktop credential file");
}
this.values = Object.fromEntries(
Object.entries(parsed.values).filter(
([name, value]) => ENV_CREDENTIALS.has(name) && typeof value === "string",
),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
private async persist(): Promise<void> {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const temporary = `${this.filePath}.${process.pid}.tmp`;
await fs.writeFile(
temporary,
`${JSON.stringify({ version: 1, values: this.values }, null, 2)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
await fs.rename(temporary, this.filePath);
}
private async migrateDotenv(filePath: string): Promise<void> {
let text: string;
try {
text = await fs.readFile(filePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw error;
}
let changed = false;
const nextLines: string[] = [];
for (const line of text.split(/\r?\n/u)) {
const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/u);
const name = match?.[1] ?? "";
const value = unquote(match?.[2] ?? "");
if (ENV_CREDENTIALS.has(name) && !PLACEHOLDER_VALUES.has(value.toLowerCase())) {
if (!this.values[name]) {
this.values[name] = safeStorage.encryptString(value).toString("base64");
this.migrated.push(name);
}
nextLines.push(`# ${name} is stored by Vibe-Trading Desktop secure storage.`);
changed = true;
} else {
nextLines.push(line);
}
}
if (changed) {
await this.persist();
await atomicTextWrite(filePath, nextLines.join("\n"));
}
}
private async migrateJsonField(
filePath: string,
field: string,
credential: string,
): Promise<void> {
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as Record<string, unknown>;
const value = typeof parsed[field] === "string" ? parsed[field].trim() : "";
if (!value) return;
if (!this.values[credential]) {
this.values[credential] = safeStorage.encryptString(value).toString("base64");
this.migrated.push(credential);
await this.persist();
}
delete parsed[field];
await atomicTextWrite(filePath, `${JSON.stringify(parsed, null, 2)}\n`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) {
throw error;
}
}
}
}
function unquote(value: string): string {
const trimmed = value.trim();
if (
trimmed.length >= 2 &&
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'")))
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
async function atomicTextWrite(filePath: string, content: string): Promise<void> {
const temporary = `${filePath}.${process.pid}.tmp`;
await fs.writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
await fs.rename(temporary, filePath);
}