feat(desktop): protect packaged credentials with safeStorage
Signed-off-by: QCYTSN <2558348385@qq.com>
This commit is contained in:
1 parent
46465ac3cd
commit
2b1b499b42
15 files changed
+396
-10
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user