fix(desktop): enforce packaging security gates
Signed-off-by: QCYTSN <2558348385@qq.com>
This commit is contained in:
1 parent
f5cb88cf49
commit
13b8e5993a
11 files changed
+483
-29
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user