fix(desktop): enforce packaging security gates

Signed-off-by: QCYTSN <2558348385@qq.com>
This commit is contained in:
QCYTSN committed 2026-08-08 19:55:12 +08:00
1 parent f5cb88cf49
commit 13b8e5993a
11 files changed
+483 -29

No files matched your search

+5 -3
View File
@@ -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
+46 -12
View File
@@ -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:<random-port>
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.
+41 -8
View File
@@ -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 = '<provided outside source control>'
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.
+3 -1
View File
@@ -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": {
@@ -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
@@ -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");
}
@@ -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");
}
@@ -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);
}
+17 -3
View File
@@ -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<string, string> = {};
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<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.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",
);