From 13b8e5993a52937cd3102d6e99fa005a5314bdd4 Mon Sep 17 00:00:00 2001 From: QCYTSN <2558348385@qq.com> Date: Fri, 31 Jul 2026 12:44:23 +0800 Subject: [PATCH] fix(desktop): enforce packaging security gates Signed-off-by: QCYTSN <2558348385@qq.com> --- .github/workflows/desktop-windows.yml | 6 +- desktop/electron/README.md | 8 +- desktop/electron/THREAT_MODEL.md | 58 ++++++-- desktop/electron/WINDOWS_PACKAGING.md | 49 +++++-- desktop/electron/package.json | 4 +- desktop/electron/scripts/build-backend.ps1 | 5 + .../scripts/build-review-installer.mjs | 115 ++++++++++++++++ .../scripts/build-signed-installer.mjs | 126 ++++++++++++++++++ .../electron/scripts/smoke-credentials.cjs | 119 +++++++++++++++++ desktop/electron/src/secure-credentials.ts | 20 ++- .../components/settings/QVerisSettings.tsx | 2 +- 11 files changed, 483 insertions(+), 29 deletions(-) create mode 100644 desktop/electron/scripts/build-review-installer.mjs create mode 100644 desktop/electron/scripts/build-signed-installer.mjs create mode 100644 desktop/electron/scripts/smoke-credentials.cjs diff --git a/.github/workflows/desktop-windows.yml b/.github/workflows/desktop-windows.yml index fbb5f12e..5f6cb00f 100644 --- a/.github/workflows/desktop-windows.yml +++ b/.github/workflows/desktop-windows.yml @@ -44,6 +44,10 @@ jobs: working-directory: desktop/electron run: npm ci + - name: Verify encrypted credential storage + working-directory: desktop/electron + run: npm run smoke:credentials + - name: Assemble minimal Python runtime working-directory: desktop/electron shell: powershell @@ -57,7 +61,7 @@ jobs: working-directory: desktop/electron env: CSC_IDENTITY_AUTO_DISCOVERY: "false" - run: npm run installer:win + run: npm run installer:win:review - name: Verify installer and record checksum working-directory: desktop/electron diff --git a/desktop/electron/README.md b/desktop/electron/README.md index 4e3ccebc..d6eba088 100644 --- a/desktop/electron/README.md +++ b/desktop/electron/README.md @@ -2,9 +2,11 @@ This directory contains an Electron host for the existing Vibe-Trading FastAPI and React application. The Windows packaging layer can assemble an unsigned -NSIS installer with an isolated Python runtime and encrypted credential -storage. It does not contain an updater, optional IM adapters, personal -WeChat pairing, or changes to the agent loop and provider behavior. +NSIS review artifact or require and verify Authenticode signing for a +publishable installer. Both use an isolated Python runtime and encrypted +credential storage. This layer does not contain an updater, optional IM +adapters, personal WeChat pairing, or changes to the agent loop and provider +behavior. ## What the shell does diff --git a/desktop/electron/THREAT_MODEL.md b/desktop/electron/THREAT_MODEL.md index da9af672..41987223 100644 --- a/desktop/electron/THREAT_MODEL.md +++ b/desktop/electron/THREAT_MODEL.md @@ -3,9 +3,11 @@ ## Scope This document covers the Electron main process, its sandboxed renderer, and the -single Vibe-Trading Python process started by the shell. Packaging, credential -storage, auto-update, optional messaging adapters, broker configuration, and -code signing are outside this change. +single Vibe-Trading Python process started by the shell. The Windows packaging +layer additionally covers local credential encryption, migration, and +injection into that owned Python process. Auto-update, optional messaging +adapters, personal WeChat pairing, broker configuration, and release ownership +remain outside this change. ## Assets @@ -13,6 +15,7 @@ code signing are outside this change. - Local Vibe-Trading sessions, reports, configuration, and research data. - Any credentials already present in the environment inherited by the Python process. +- LLM, Tushare, and QVeris credentials managed by the desktop host. - The integrity of the executable selected as the backend. - The ability to invoke authenticated local API routes. @@ -21,20 +24,23 @@ code signing are outside this change. ```text Electron main process |-- owns random API secret + |-- owns safeStorage encryption/decryption |-- selects backend executable and starts watchdog |-- injects Authorization on one loopback origin | +--> sandboxed renderer | no Node.js, isolated context, deny-by-default permissions | can use the authenticated local API through normal page requests + | can set or clear allowlisted credentials, but cannot read them | +--> backend parent-death watchdog - |-- inherits the launch secret only to pass it to Python + |-- inherits the launch secret and decrypted credentials only to pass them to Python |-- exits without spawning Python if the parent is already gone |-- monitors Electron IPC disconnect and parent PID liveness +--> Python backend binds 127.0.0.1: receives API_AUTH_KEY through its child environment + receives decrypted credentials through its child environment ``` The renderer is trusted to perform the same application actions as the web UI, @@ -62,8 +68,9 @@ therefore security-significant. - `nodeIntegration` is disabled. - `contextIsolation` and Chromium sandboxing are enabled. -- The preload exposes only status, error, retry, log-folder, and backend-restart - operations. +- The preload exposes status, error, retry, log-folder, backend-restart, and + allowlisted credential-write operations. Credential values are never + returned to the renderer. - Browser permission checks and requests are denied by default. - New windows are denied; safe HTTP(S) links are opened in the system browser. - In-window navigation is restricted to the active local backend origin. @@ -105,6 +112,24 @@ therefore security-significant. then independently verifies that both the Python PID and loopback listener are gone. +### Credential storage + +- Credential names are checked against a fixed allowlist in the Electron main + process. +- Values are encrypted and decrypted only through Electron `safeStorage`. +- The on-disk JSON file contains only Base64-encoded ciphertext and the list of + configured names. +- Writes use a same-directory temporary file followed by rename. +- Existing supported values in `~/.vibe-trading/.env` and QVeris configuration + are migrated once; the corresponding plaintext field is removed after the + encrypted store has been persisted. +- Decrypted values are injected only into the owned backend child environment. +- Desktop secure mode prevents settings API writes from copying injected + secrets back into dotenv. +- The credential smoke test uses an isolated temporary profile and verifies + migration, persistence, plaintext removal, allowlisting, replacement, and + clearing. + ## Residual risks - Renderer script injection can exercise the authenticated API even though it @@ -119,16 +144,25 @@ therefore security-significant. - The `PATH` fallback trusts the desktop process environment and normal Windows executable-path integrity. A process able to alter that environment can select the backend that receives the launch secret. -- The child inherits the desktop process environment. Secure credential - isolation is deferred to the packaging/credential-storage review. +- A compromised renderer can overwrite or clear an allowlisted credential even + though it cannot retrieve the current value. +- `safeStorage` protects data at rest for the current Windows user; it does not + protect against malware, a debugger, or another process already executing + with equivalent user privileges. +- Migration cannot erase plaintext that may remain in backups, filesystem + history, crash dumps, or external logs. +- The child inherits both the desktop process environment and decrypted + credentials required by the backend. - Forceful tree termination can interrupt in-progress local work after the graceful shutdown timeout. -- This source-only change does not provide code signing, installer reputation, - update authenticity, or an official HKUDS release channel. +- This source-only change does not provide a signing identity, installer + reputation, update authenticity, or an official HKUDS release channel. ## Non-goals - Protecting against a fully compromised Windows account or administrator. - Enabling remote API access. -- Managing broker, exchange, IM, or model-provider credentials. -- Installing or updating the application. +- Managing broker, exchange, or IM credentials. +- Exposing credential values back to the renderer. +- Publishing releases, choosing a signing identity, or updating the + application. diff --git a/desktop/electron/WINDOWS_PACKAGING.md b/desktop/electron/WINDOWS_PACKAGING.md index cdb178c9..05e38f6f 100644 --- a/desktop/electron/WINDOWS_PACKAGING.md +++ b/desktop/electron/WINDOWS_PACKAGING.md @@ -31,12 +31,19 @@ npm run build cd ..\desktop\electron npm ci +npm run smoke:credentials npm run runtime:win -- -Clean npm run smoke:lifecycle -npm run installer:win +npm run installer:win:review ``` The installer and checksum are written under `desktop/electron/release/`. +The `installer:win:review` artifact is deliberately unsigned and must never be +published to users. The command strips all supported signing credentials, +disables certificate auto-discovery, and fails unless both the application +executable and installer report `NotSigned`. Both review and signed builds use +Electron Builder's supported compression-level override at level 7 to bound +build time without changing installed build-tool code. Python dependencies are installed from `desktop/electron/requirements-windows-lock.txt` with `--require-hashes`; the checked-out Vibe-Trading package is then installed with `--no-deps` so @@ -72,6 +79,24 @@ Local and pull-request artifacts are unsigned. Windows SmartScreen may warn when they are launched. Code signing and release ownership remain with the community publisher unless HKUDS explicitly takes them over later. +There is intentionally no generic `installer:win` command. A publishable build +must use: + +```powershell +$env:WIN_CSC_LINK = 'C:\secure-location\publisher-certificate.pfx' +$env:WIN_CSC_KEY_PASSWORD = '' +npm run installer:win:signed +``` + +The signed command fails closed when either signing input is missing and then +enables Electron Builder's `forceCodeSigning` gate. It then verifies that +Windows reports both the packaged application executable and the resulting +installer's Authenticode status as `Valid`. The certificate and password must +come from CI secrets or an equivalent external secret store; they must never +be committed. The generic `CSC_LINK` and `CSC_KEY_PASSWORD` names remain +supported as fallbacks. This PR does not add a release workflow or upload an +artifact. + ## Dependency-audit note `npm audit --omit=dev` reports zero production dependency vulnerabilities. @@ -83,22 +108,30 @@ be re-audited whenever Electron Builder publishes a repaired dependency tree. ## Local validation record -Windows host validation on 2026-07-28: +Windows host validation on 2026-07-31: - [x] all 183 installed third-party Python distributions exactly match the committed Windows lock; the checked-out `vibe-trading-ai` package is the only additional distribution; +- [x] `pip check` confirms that the locked runtime satisfies the checked-out + project dependency metadata; - [x] embedded Python reports version 3.12.10; - [x] backend, CLI, and WeasyPrint PDF imports succeed; - [x] DingTalk, Discord, Telegram, Neonize/WeChat, and QR-code modules are absent; - [x] authenticated random-port startup and graceful shutdown succeed with no residual embedded Python process; -- [x] the packaged application loads the frontend and backend from an isolated - empty Windows user profile and closes its owned process tree cleanly; +- [x] safeStorage migration, plaintext removal, allowlisting, replacement, and + clearing succeed against an isolated temporary Windows profile; +- [ ] the exact current branch's packaged application loads the frontend and + backend on a clean Windows VM and closes its owned process tree cleanly; - [x] the generated installer is reported as `NotSigned` by Windows - Authenticode inspection. + Authenticode inspection; +- [x] the unsigned review installer is 323.2 MiB and its generated + `SHA256SUMS.txt` matches an independent SHA-256 calculation. -The assembled backend is 801.4 MiB before installer compression. A clean -Windows VM re-run remains a release/review gate; this host record is not a -substitute for that independent environment check. +The assembled backend is 801.1 MiB before installer compression. An earlier +prototype passed the isolated-profile startup/shutdown check, but that result +predates the current-main rebuild and is deliberately not credited above. A +clean Windows VM re-run remains a release/review gate; this host record is not +a substitute for that independent environment check. diff --git a/desktop/electron/package.json b/desktop/electron/package.json index e15d9e20..229c86b7 100644 --- a/desktop/electron/package.json +++ b/desktop/electron/package.json @@ -14,9 +14,11 @@ "smoke:parent-death": "npm run build && node scripts/smoke-parent-death.mjs", "prepare:electron": "node scripts/prepare-electron.mjs", "runtime:win": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-backend.ps1", + "smoke:credentials": "npm run build && electron scripts/smoke-credentials.cjs", "smoke:lifecycle": "npm run build && node scripts/test-locales.mjs && node scripts/test-backend-resolution.mjs && node scripts/smoke-lifecycle.mjs && node scripts/smoke-parent-death.mjs", "pack:win": "npm run build && npm run prepare:electron && electron-builder --publish never --win dir", - "installer:win": "npm run build && npm run prepare:electron && electron-builder --publish never --win nsis", + "installer:win:review": "npm run build && npm run prepare:electron && node scripts/build-review-installer.mjs", + "installer:win:signed": "node scripts/build-signed-installer.mjs", "sbom": "npm sbom --sbom-format cyclonedx" }, "devDependencies": { diff --git a/desktop/electron/scripts/build-backend.ps1 b/desktop/electron/scripts/build-backend.ps1 index 49bbd083..28db00ee 100644 --- a/desktop/electron/scripts/build-backend.ps1 +++ b/desktop/electron/scripts/build-backend.ps1 @@ -254,6 +254,11 @@ Copy-Item ` -Force $runtimePython = Join-Path $runtimeRoot 'python.exe' +& $BuildPython -m pip --python $runtimePython check +if ($LASTEXITCODE -ne 0) { + throw 'The embedded runtime does not satisfy the checked-out project dependency metadata.' +} + & { $gtkBin = Join-Path $runtimeRoot 'gtk\bin' $previousPath = $env:PATH diff --git a/desktop/electron/scripts/build-review-installer.mjs b/desktop/electron/scripts/build-review-installer.mjs new file mode 100644 index 00000000..2dc5d9a9 --- /dev/null +++ b/desktop/electron/scripts/build-review-installer.mjs @@ -0,0 +1,115 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const electronRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const unsignedEnvironment = { ...process.env }; +for (const name of [ + "CSC_LINK", + "CSC_KEY_PASSWORD", + "WIN_CSC_LINK", + "WIN_CSC_KEY_PASSWORD", + "AZURE_TENANT_ID", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", +]) { + delete unsignedEnvironment[name]; +} +unsignedEnvironment.CSC_IDENTITY_AUTO_DISCOVERY = "false"; +unsignedEnvironment.ELECTRON_BUILDER_COMPRESSION_LEVEL = "7"; + +run( + process.execPath, + [ + path.join(electronRoot, "node_modules", "electron-builder", "cli.js"), + "--publish", + "never", + "--win", + "nsis", + ], + { env: unsignedEnvironment }, +); + +const releaseDirectory = path.join(electronRoot, "release"); +const installers = (await readdir(releaseDirectory, { withFileTypes: true })) + .filter( + (entry) => + entry.isFile() && + /^Vibe-Trading-Desktop-Unofficial-.*-x64\.exe$/u.test(entry.name), + ) + .map((entry) => path.join(releaseDirectory, entry.name)); +const unpackedDirectory = path.join(releaseDirectory, "win-unpacked"); +const applicationExecutables = (await readdir(unpackedDirectory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".exe")) + .map((entry) => path.join(unpackedDirectory, entry.name)); + +if (installers.length !== 1 || applicationExecutables.length !== 1) { + throw new Error( + `Expected one installer and one top-level application executable; found ` + + `${installers.length} and ${applicationExecutables.length}.`, + ); +} + +for (const artifact of [...applicationExecutables, ...installers]) { + const status = signatureStatus(artifact); + if (status !== "NotSigned") { + throw new Error( + `Review artifact unexpectedly has Authenticode status '${status}': ${artifact}`, + ); + } + console.log(`Unsigned review boundary verified: ${path.basename(artifact)}`); +} + +const installerHash = await sha256(installers[0]); +await writeFile( + path.join(releaseDirectory, "SHA256SUMS.txt"), + `${installerHash} ${path.basename(installers[0])}\n`, + "ascii", +); +console.log(`Unsigned review installer ready; do not publish: ${installers[0]}`); +console.log(`SHA-256: ${installerHash}`); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: electronRoot, + stdio: "inherit", + ...options, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${path.basename(command)} exited with code ${result.status}`); + } +} + +function signatureStatus(artifact) { + const result = spawnSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "(Get-AuthenticodeSignature -LiteralPath $env:VIBE_REVIEW_ARTIFACT_PATH).Status", + ], + { + cwd: electronRoot, + env: { + ...process.env, + VIBE_REVIEW_ARTIFACT_PATH: artifact, + }, + encoding: "utf8", + }, + ); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim()); + } + return result.stdout.trim(); +} + +async function sha256(file) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest("hex"); +} diff --git a/desktop/electron/scripts/build-signed-installer.mjs b/desktop/electron/scripts/build-signed-installer.mjs new file mode 100644 index 00000000..a81bdd03 --- /dev/null +++ b/desktop/electron/scripts/build-signed-installer.mjs @@ -0,0 +1,126 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const electronRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const certificate = (process.env.WIN_CSC_LINK ?? process.env.CSC_LINK)?.trim(); +const password = process.env.WIN_CSC_KEY_PASSWORD ?? process.env.CSC_KEY_PASSWORD; + +if (!certificate || password === undefined) { + throw new Error( + "Signed packaging requires WIN_CSC_LINK/WIN_CSC_KEY_PASSWORD " + + "(or their CSC_LINK/CSC_KEY_PASSWORD fallbacks). " + + "Use installer:win:review for a local, non-publishable unsigned artifact.", + ); +} + +const npmCli = process.env.npm_execpath; +if (!npmCli) { + throw new Error("Signed packaging must be launched through npm."); +} +run(process.execPath, [npmCli, "run", "build"]); +run(process.execPath, [npmCli, "run", "prepare:electron"]); +run( + process.execPath, + [ + path.join(electronRoot, "node_modules", "electron-builder", "cli.js"), + "--publish", + "never", + "--win", + "nsis", + "--config.win.forceCodeSigning=true", + ], + { + env: { + ...process.env, + CSC_IDENTITY_AUTO_DISCOVERY: "true", + ELECTRON_BUILDER_COMPRESSION_LEVEL: "7", + }, + }, +); + +const releaseDirectory = path.join(electronRoot, "release"); +const installers = (await readdir(releaseDirectory, { withFileTypes: true })) + .filter( + (entry) => + entry.isFile() && + /^Vibe-Trading-Desktop-Unofficial-.*-x64\.exe$/u.test(entry.name), + ) + .map((entry) => path.join(releaseDirectory, entry.name)); + +if (installers.length !== 1) { + throw new Error(`Expected one signed installer, found ${installers.length}.`); +} + +const unpackedDirectory = path.join(releaseDirectory, "win-unpacked"); +const applicationExecutables = (await readdir(unpackedDirectory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".exe")) + .map((entry) => path.join(unpackedDirectory, entry.name)); +if (applicationExecutables.length !== 1) { + throw new Error( + `Expected one top-level packaged application executable, found ${applicationExecutables.length}.`, + ); +} + +for (const artifact of [...applicationExecutables, ...installers]) { + console.log(`Authenticode signature verified: ${verifySignature(artifact)}`); +} +const installerHash = await sha256(installers[0]); +await writeFile( + path.join(releaseDirectory, "SHA256SUMS.txt"), + `${installerHash} ${path.basename(installers[0])}\n`, + "ascii", +); +console.log(`Signed installer ready: ${installers[0]}`); +console.log(`SHA-256: ${installerHash}`); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: electronRoot, + stdio: "inherit", + ...options, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${path.basename(command)} exited with code ${result.status}`); + } +} + +function verifySignature(artifact) { + const signatureCheck = spawnSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$signature = Get-AuthenticodeSignature -LiteralPath $env:VIBE_SIGNED_ARTIFACT_PATH", + "if ($signature.Status -ne 'Valid') {", + " throw \"Artifact signature is $($signature.Status): $($signature.StatusMessage)\"", + "}", + "$signature.SignerCertificate.Subject", + ].join("; "), + ], + { + cwd: electronRoot, + env: { + ...process.env, + VIBE_SIGNED_ARTIFACT_PATH: artifact, + }, + encoding: "utf8", + }, + ); + if (signatureCheck.status !== 0) { + throw new Error(signatureCheck.stderr.trim() || signatureCheck.stdout.trim()); + } + return `${path.basename(artifact)} — ${signatureCheck.stdout.trim()}`; +} + +async function sha256(file) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest("hex"); +} diff --git a/desktop/electron/scripts/smoke-credentials.cjs b/desktop/electron/scripts/smoke-credentials.cjs new file mode 100644 index 00000000..61e6fff5 --- /dev/null +++ b/desktop/electron/scripts/smoke-credentials.cjs @@ -0,0 +1,119 @@ +const assert = require("node:assert/strict"); +const { promises: fs } = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { app } = require("electron"); +const { SecureCredentialStore } = require("../dist/secure-credentials.js"); + +void app.whenReady().then(run).catch(async (error) => { + console.error(error); + app.exit(1); +}); + +async function run() { + const temporaryRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "vibe-trading-credentials-"), + ); + const homeDirectory = path.join(temporaryRoot, "home"); + const userDataDirectory = path.join(temporaryRoot, "user-data"); + const configDirectory = path.join(homeDirectory, ".vibe-trading"); + let exitCode = 0; + + try { + await fs.mkdir(configDirectory, { recursive: true }); + await fs.writeFile( + path.join(configDirectory, ".env"), + [ + "OPENAI_API_KEY=desktop-smoke-openai-secret", + "TUSHARE_TOKEN='desktop-smoke-tushare-secret'", + "ANTHROPIC_API_KEY=sk-xxx", + "NON_SECRET_SETTING=preserved", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(configDirectory, "qveris.json"), + `${JSON.stringify({ + api_key: "desktop-smoke-qveris-secret", + enabled: true, + }, null, 2)}\n`, + "utf8", + ); + + const store = new SecureCredentialStore({ homeDirectory, userDataDirectory }); + await store.initialize(); + + const initialEnvironment = store.environment(); + assert.equal(initialEnvironment.OPENAI_API_KEY, "desktop-smoke-openai-secret"); + assert.equal(initialEnvironment.TUSHARE_TOKEN, "desktop-smoke-tushare-secret"); + assert.equal(initialEnvironment.QVERIS_API_KEY, "desktop-smoke-qveris-secret"); + assert.equal(initialEnvironment.ANTHROPIC_API_KEY, undefined); + + const status = store.status(); + assert.deepEqual(status.configured, [ + "OPENAI_API_KEY", + "QVERIS_API_KEY", + "TUSHARE_TOKEN", + ]); + assert.deepEqual(status.migrated, [ + "OPENAI_API_KEY", + "TUSHARE_TOKEN", + "QVERIS_API_KEY", + ]); + + const encryptedFile = await fs.readFile( + path.join(userDataDirectory, "credentials.v1.json"), + "utf8", + ); + for (const secret of Object.values(initialEnvironment)) { + assert.ok(!encryptedFile.includes(secret), "encrypted file contains a plaintext secret"); + } + + const migratedDotenv = await fs.readFile(path.join(configDirectory, ".env"), "utf8"); + assert.match(migratedDotenv, /OPENAI_API_KEY is stored by Vibe-Trading Desktop/u); + assert.match(migratedDotenv, /TUSHARE_TOKEN is stored by Vibe-Trading Desktop/u); + assert.match(migratedDotenv, /ANTHROPIC_API_KEY=sk-xxx/u); + assert.match(migratedDotenv, /NON_SECRET_SETTING=preserved/u); + assert.ok(!migratedDotenv.includes("desktop-smoke-openai-secret")); + assert.ok(!migratedDotenv.includes("desktop-smoke-tushare-secret")); + + const migratedQveris = JSON.parse( + await fs.readFile(path.join(configDirectory, "qveris.json"), "utf8"), + ); + assert.deepEqual(migratedQveris, { enabled: true }); + + const reloadedStore = new SecureCredentialStore({ homeDirectory, userDataDirectory }); + await reloadedStore.initialize(); + assert.equal( + reloadedStore.environment().OPENAI_API_KEY, + "desktop-smoke-openai-secret", + ); + + await store.set("OPENAI_API_KEY", "desktop-smoke-replacement"); + assert.equal(store.environment().OPENAI_API_KEY, "desktop-smoke-replacement"); + await store.set("OPENAI_API_KEY", null); + assert.equal(store.environment().OPENAI_API_KEY, undefined); + await assert.rejects( + store.set("UNSUPPORTED_SECRET", "not-allowed"), + /Unsupported credential key/u, + ); + + console.log( + JSON.stringify({ + encryptionAvailable: status.available, + migrationVerified: true, + persistenceVerified: true, + plaintextRemovalVerified: true, + allowlistVerified: true, + clearVerified: true, + }, null, 2), + ); + } catch (error) { + console.error(error); + exitCode = 1; + } finally { + await fs.rm(temporaryRoot, { recursive: true, force: true }); + } + app.exit(exitCode); +} diff --git a/desktop/electron/src/secure-credentials.ts b/desktop/electron/src/secure-credentials.ts index 5d6cfd3f..907caba6 100644 --- a/desktop/electron/src/secure-credentials.ts +++ b/desktop/electron/src/secure-credentials.ts @@ -47,19 +47,33 @@ export type CredentialStatus = { migrated: string[]; }; +export type SecureCredentialStoreOptions = { + userDataDirectory?: string; + homeDirectory?: string; +}; + export class SecureCredentialStore { - private readonly filePath = path.join(app.getPath("userData"), "credentials.v1.json"); + private readonly filePath: string; + private readonly homeDirectory: string; private values: Record = {}; private migrated: string[] = []; + constructor(options: SecureCredentialStoreOptions = {}) { + this.filePath = path.join( + options.userDataDirectory ?? app.getPath("userData"), + "credentials.v1.json", + ); + this.homeDirectory = options.homeDirectory ?? os.homedir(); + } + async initialize(): Promise { 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.migrateDotenv(path.join(this.homeDirectory, ".vibe-trading", ".env")); await this.migrateJsonField( - path.join(os.homedir(), ".vibe-trading", "qveris.json"), + path.join(this.homeDirectory, ".vibe-trading", "qveris.json"), "api_key", "QVERIS_API_KEY", ); diff --git a/frontend/src/components/settings/QVerisSettings.tsx b/frontend/src/components/settings/QVerisSettings.tsx index ed79d8a6..f4e55c23 100644 --- a/frontend/src/components/settings/QVerisSettings.tsx +++ b/frontend/src/components/settings/QVerisSettings.tsx @@ -170,7 +170,7 @@ export function QVerisSettings() { setForm(formFromConfig(nextConfig)); toast.success(t("qveris.saved")); if (trimmedApiKey && window.vibeDesktop) { - toast.info("Credential encrypted. Restarting the local service..."); + toast.info(t("settings.desktopCredentialRestarting")); await window.vibeDesktop.restartBackend(); return; }