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
@@ -44,6 +44,10 @@ jobs:
|
|||||||
working-directory: desktop/electron
|
working-directory: desktop/electron
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Verify encrypted credential storage
|
||||||
|
working-directory: desktop/electron
|
||||||
|
run: npm run smoke:credentials
|
||||||
|
|
||||||
- name: Assemble minimal Python runtime
|
- name: Assemble minimal Python runtime
|
||||||
working-directory: desktop/electron
|
working-directory: desktop/electron
|
||||||
shell: powershell
|
shell: powershell
|
||||||
@@ -57,7 +61,7 @@ jobs:
|
|||||||
working-directory: desktop/electron
|
working-directory: desktop/electron
|
||||||
env:
|
env:
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
run: npm run installer:win
|
run: npm run installer:win:review
|
||||||
|
|
||||||
- name: Verify installer and record checksum
|
- name: Verify installer and record checksum
|
||||||
working-directory: desktop/electron
|
working-directory: desktop/electron
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
This directory contains an Electron host for the existing Vibe-Trading FastAPI
|
This directory contains an Electron host for the existing Vibe-Trading FastAPI
|
||||||
and React application. The Windows packaging layer can assemble an unsigned
|
and React application. The Windows packaging layer can assemble an unsigned
|
||||||
NSIS installer with an isolated Python runtime and encrypted credential
|
NSIS review artifact or require and verify Authenticode signing for a
|
||||||
storage. It does not contain an updater, optional IM adapters, personal
|
publishable installer. Both use an isolated Python runtime and encrypted
|
||||||
WeChat pairing, or changes to the agent loop and provider behavior.
|
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
|
## What the shell does
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This document covers the Electron main process, its sandboxed renderer, and the
|
This document covers the Electron main process, its sandboxed renderer, and the
|
||||||
single Vibe-Trading Python process started by the shell. Packaging, credential
|
single Vibe-Trading Python process started by the shell. The Windows packaging
|
||||||
storage, auto-update, optional messaging adapters, broker configuration, and
|
layer additionally covers local credential encryption, migration, and
|
||||||
code signing are outside this change.
|
injection into that owned Python process. Auto-update, optional messaging
|
||||||
|
adapters, personal WeChat pairing, broker configuration, and release ownership
|
||||||
|
remain outside this change.
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ code signing are outside this change.
|
|||||||
- Local Vibe-Trading sessions, reports, configuration, and research data.
|
- Local Vibe-Trading sessions, reports, configuration, and research data.
|
||||||
- Any credentials already present in the environment inherited by the Python
|
- Any credentials already present in the environment inherited by the Python
|
||||||
process.
|
process.
|
||||||
|
- LLM, Tushare, and QVeris credentials managed by the desktop host.
|
||||||
- The integrity of the executable selected as the backend.
|
- The integrity of the executable selected as the backend.
|
||||||
- The ability to invoke authenticated local API routes.
|
- The ability to invoke authenticated local API routes.
|
||||||
|
|
||||||
@@ -21,20 +24,23 @@ code signing are outside this change.
|
|||||||
```text
|
```text
|
||||||
Electron main process
|
Electron main process
|
||||||
|-- owns random API secret
|
|-- owns random API secret
|
||||||
|
|-- owns safeStorage encryption/decryption
|
||||||
|-- selects backend executable and starts watchdog
|
|-- selects backend executable and starts watchdog
|
||||||
|-- injects Authorization on one loopback origin
|
|-- injects Authorization on one loopback origin
|
||||||
|
|
|
|
||||||
+--> sandboxed renderer
|
+--> sandboxed renderer
|
||||||
| no Node.js, isolated context, deny-by-default permissions
|
| no Node.js, isolated context, deny-by-default permissions
|
||||||
| can use the authenticated local API through normal page requests
|
| can use the authenticated local API through normal page requests
|
||||||
|
| can set or clear allowlisted credentials, but cannot read them
|
||||||
|
|
|
|
||||||
+--> backend parent-death watchdog
|
+--> 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
|
|-- exits without spawning Python if the parent is already gone
|
||||||
|-- monitors Electron IPC disconnect and parent PID liveness
|
|-- monitors Electron IPC disconnect and parent PID liveness
|
||||||
+--> Python backend
|
+--> Python backend
|
||||||
binds 127.0.0.1:<random-port>
|
binds 127.0.0.1:<random-port>
|
||||||
receives API_AUTH_KEY through its child environment
|
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,
|
The renderer is trusted to perform the same application actions as the web UI,
|
||||||
@@ -62,8 +68,9 @@ therefore security-significant.
|
|||||||
|
|
||||||
- `nodeIntegration` is disabled.
|
- `nodeIntegration` is disabled.
|
||||||
- `contextIsolation` and Chromium sandboxing are enabled.
|
- `contextIsolation` and Chromium sandboxing are enabled.
|
||||||
- The preload exposes only status, error, retry, log-folder, and backend-restart
|
- The preload exposes status, error, retry, log-folder, backend-restart, and
|
||||||
operations.
|
allowlisted credential-write operations. Credential values are never
|
||||||
|
returned to the renderer.
|
||||||
- Browser permission checks and requests are denied by default.
|
- Browser permission checks and requests are denied by default.
|
||||||
- New windows are denied; safe HTTP(S) links are opened in the system browser.
|
- 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.
|
- 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
|
then independently verifies that both the Python PID and loopback listener
|
||||||
are gone.
|
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
|
## Residual risks
|
||||||
|
|
||||||
- Renderer script injection can exercise the authenticated API even though it
|
- 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
|
- The `PATH` fallback trusts the desktop process environment and normal Windows
|
||||||
executable-path integrity. A process able to alter that environment can
|
executable-path integrity. A process able to alter that environment can
|
||||||
select the backend that receives the launch secret.
|
select the backend that receives the launch secret.
|
||||||
- The child inherits the desktop process environment. Secure credential
|
- A compromised renderer can overwrite or clear an allowlisted credential even
|
||||||
isolation is deferred to the packaging/credential-storage review.
|
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
|
- Forceful tree termination can interrupt in-progress local work after the
|
||||||
graceful shutdown timeout.
|
graceful shutdown timeout.
|
||||||
- This source-only change does not provide code signing, installer reputation,
|
- This source-only change does not provide a signing identity, installer
|
||||||
update authenticity, or an official HKUDS release channel.
|
reputation, update authenticity, or an official HKUDS release channel.
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
- Protecting against a fully compromised Windows account or administrator.
|
- Protecting against a fully compromised Windows account or administrator.
|
||||||
- Enabling remote API access.
|
- Enabling remote API access.
|
||||||
- Managing broker, exchange, IM, or model-provider credentials.
|
- Managing broker, exchange, or IM credentials.
|
||||||
- Installing or updating the application.
|
- 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
|
cd ..\desktop\electron
|
||||||
npm ci
|
npm ci
|
||||||
|
npm run smoke:credentials
|
||||||
npm run runtime:win -- -Clean
|
npm run runtime:win -- -Clean
|
||||||
npm run smoke:lifecycle
|
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 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
|
Python dependencies are installed from
|
||||||
`desktop/electron/requirements-windows-lock.txt` with `--require-hashes`; the
|
`desktop/electron/requirements-windows-lock.txt` with `--require-hashes`; the
|
||||||
checked-out Vibe-Trading package is then installed with `--no-deps` so
|
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
|
when they are launched. Code signing and release ownership remain with the
|
||||||
community publisher unless HKUDS explicitly takes them over later.
|
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
|
## Dependency-audit note
|
||||||
|
|
||||||
`npm audit --omit=dev` reports zero production dependency vulnerabilities.
|
`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
|
## 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
|
- [x] all 183 installed third-party Python distributions exactly match the
|
||||||
committed Windows lock; the checked-out `vibe-trading-ai` package is the only
|
committed Windows lock; the checked-out `vibe-trading-ai` package is the only
|
||||||
additional distribution;
|
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] embedded Python reports version 3.12.10;
|
||||||
- [x] backend, CLI, and WeasyPrint PDF imports succeed;
|
- [x] backend, CLI, and WeasyPrint PDF imports succeed;
|
||||||
- [x] DingTalk, Discord, Telegram, Neonize/WeChat, and QR-code modules are
|
- [x] DingTalk, Discord, Telegram, Neonize/WeChat, and QR-code modules are
|
||||||
absent;
|
absent;
|
||||||
- [x] authenticated random-port startup and graceful shutdown succeed with no
|
- [x] authenticated random-port startup and graceful shutdown succeed with no
|
||||||
residual embedded Python process;
|
residual embedded Python process;
|
||||||
- [x] the packaged application loads the frontend and backend from an isolated
|
- [x] safeStorage migration, plaintext removal, allowlisting, replacement, and
|
||||||
empty Windows user profile and closes its owned process tree cleanly;
|
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
|
- [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
|
The assembled backend is 801.1 MiB before installer compression. An earlier
|
||||||
Windows VM re-run remains a release/review gate; this host record is not a
|
prototype passed the isolated-profile startup/shutdown check, but that result
|
||||||
substitute for that independent environment check.
|
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",
|
"smoke:parent-death": "npm run build && node scripts/smoke-parent-death.mjs",
|
||||||
"prepare:electron": "node scripts/prepare-electron.mjs",
|
"prepare:electron": "node scripts/prepare-electron.mjs",
|
||||||
"runtime:win": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-backend.ps1",
|
"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",
|
"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",
|
"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"
|
"sbom": "npm sbom --sbom-format cyclonedx"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -254,6 +254,11 @@ Copy-Item `
|
|||||||
-Force
|
-Force
|
||||||
|
|
||||||
$runtimePython = Join-Path $runtimeRoot 'python.exe'
|
$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'
|
$gtkBin = Join-Path $runtimeRoot 'gtk\bin'
|
||||||
$previousPath = $env:PATH
|
$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[];
|
migrated: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SecureCredentialStoreOptions = {
|
||||||
|
userDataDirectory?: string;
|
||||||
|
homeDirectory?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class SecureCredentialStore {
|
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 values: Record<string, string> = {};
|
||||||
private migrated: 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> {
|
async initialize(): Promise<void> {
|
||||||
if (!safeStorage.isEncryptionAvailable()) {
|
if (!safeStorage.isEncryptionAvailable()) {
|
||||||
throw new Error("Windows credential encryption is unavailable for this user session.");
|
throw new Error("Windows credential encryption is unavailable for this user session.");
|
||||||
}
|
}
|
||||||
await this.load();
|
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(
|
await this.migrateJsonField(
|
||||||
path.join(os.homedir(), ".vibe-trading", "qveris.json"),
|
path.join(this.homeDirectory, ".vibe-trading", "qveris.json"),
|
||||||
"api_key",
|
"api_key",
|
||||||
"QVERIS_API_KEY",
|
"QVERIS_API_KEY",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export function QVerisSettings() {
|
|||||||
setForm(formFromConfig(nextConfig));
|
setForm(formFromConfig(nextConfig));
|
||||||
toast.success(t("qveris.saved"));
|
toast.success(t("qveris.saved"));
|
||||||
if (trimmedApiKey && window.vibeDesktop) {
|
if (trimmedApiKey && window.vibeDesktop) {
|
||||||
toast.info("Credential encrypted. Restarting the local service...");
|
toast.info(t("settings.desktopCredentialRestarting"));
|
||||||
await window.vibeDesktop.restartBackend();
|
await window.vibeDesktop.restartBackend();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user