feat(desktop): add dormant update safety boundary

Signed-off-by: QCYTSN <2558348385@qq.com>
This commit is contained in:
QCYTSN committed 2026-08-14 22:45:18 +08:00
1 parent 5e39e42908
commit d8723223e9
13 files changed
+998 -13

No files matched your search

+13 -1
View File
@@ -8,6 +8,12 @@ credential storage. This layer does not contain an updater, optional IM
adapters, personal WeChat pairing, or changes to the agent loop and provider
behavior.
It also contains dormant, fail-closed building blocks for a future signed
updater: Authenticode/publisher/version verification, an interruption-recovery
journal, and a strict backend shutdown result. They do not check a feed,
download an artifact, launch an installer, or enable updates. See
[UPDATE_SAFETY.md](UPDATE_SAFETY.md).
## What the shell does
- Enforces a single desktop application instance.
@@ -22,6 +28,8 @@ behavior.
- Reports startup failures and exposes retry and log-folder actions.
- Requests authenticated graceful shutdown before asking the watchdog to
terminate the owned Windows process tree as a fallback.
- Exposes a strict update-handoff shutdown call that succeeds only after the
owned backend PID, watchdog PID, and loopback listener are all gone.
- Terminates the Python process tree if the Electron main process is killed
without running JavaScript shutdown handlers.
- Localizes desktop-owned loading, status, error, dialog, and menu text in
@@ -55,11 +63,15 @@ The Windows lifecycle suite used by CI can be run from the same directory:
```powershell
npm run smoke:lifecycle
npm run test:update-safety
```
It verifies localized message parity, graceful shutdown, authentication, and
the parent-death path by force-terminating only the Electron main PID before
checking that the Python process and loopback listener are gone.
checking that the Python process and loopback listener are gone. Both graceful
and parent-death tests keep a separate Python sentinel alive throughout the
owned-process cleanup. The update-safety test exercises the rejection matrix
and every journal recovery phase without enabling an updater.
Backend resolution is intentionally narrow. It checks an explicit
`VIBE_TRADING_EXECUTABLE` override first, then exact application/resource
+43
View File
@@ -9,6 +9,11 @@ injection into that owned Python process. Auto-update, optional messaging
adapters, personal WeChat pairing, broker configuration, and release ownership
remain outside this change.
Dormant update-safety primitives are in scope only as a reviewable boundary:
they inspect a caller-supplied local artifact, apply a fail-closed policy, and
record/recover a staged attempt. No release feed, downloader, installer launch,
or default-enabled updater exists.
## Assets
- The per-launch API authentication secret.
@@ -18,6 +23,10 @@ remain outside this change.
- 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.
- Future update publisher policy, release digest metadata, and the integrity of
a staged installer.
- The pending-update journal used to distinguish completed and interrupted
attempts.
## Trust boundaries
@@ -111,6 +120,34 @@ therefore security-significant.
- The automated parent-death smoke test terminates only the Electron main PID,
then independently verifies that both the Python PID and loopback listener
are gone.
- Graceful update handoff returns structured evidence for the exact owned
backend PID, watchdog PID, and listener. It fails closed if any remains.
- Graceful and parent-death tests each start an unrelated Python sentinel and
verify that it remains alive; termination commands are always PID-scoped and
never use an image name such as `python.exe`.
### Dormant update verification and recovery
- Candidate release and installed versions must be valid semantic versions,
and the candidate must be strictly newer.
- The artifact SHA-256 must match caller-supplied release metadata before its
signature is considered.
- Windows Authenticode status must be `Valid`; unsigned and invalid signatures
are rejected with stable codes.
- Publisher identity uses an explicit allowlist of SHA-256 hashes of signer
certificate raw bytes. Subject display names alone are not trusted.
- No publisher is currently configured. An empty allowlist is itself a policy
error, so these primitives cannot silently trust the first certificate seen.
- A pending-attempt journal is written through a same-directory temporary file
and rename. It records only a simple staged `.exe` file name, not an
arbitrary deletion path.
- On the next launch, an attempt that still reports the old version discards
the staged candidate and requires a fresh download and verification. A
successful target version clears the journal. Any third version or malformed
journal is left untouched for explicit operator intervention.
- The allowed phase order is `verified` -> `backend-stopped` ->
`installer-launched`. A future updater must call the strict backend shutdown
path before recording `backend-stopped`.
### Credential storage
@@ -155,6 +192,12 @@ therefore security-significant.
credentials required by the backend.
- Forceful tree termination can interrupt in-progress local work after the
graceful shutdown timeout.
- The verification policy receives its expected digest and certificate
allowlist from a future trusted release configuration. Feed ownership and
metadata authenticity are unresolved, so no runtime caller is wired today.
- Recovery proves safe journal and staged-file handling. It does not claim to
roll back an installer that has already partially modified application files;
the signed end-to-end NSIS test remains required.
- The review installer is deliberately unsigned and unpublished. This change
does not provide a signing identity, installer reputation, update
authenticity, or an official HKUDS release channel.
+113
View File
@@ -0,0 +1,113 @@
# Dormant signed-update safety boundary
## Status
The desktop application still has **no updater**. There is no release feed,
download client, update UI, background check, installer launch, or configured
publisher identity. Updates remain disabled until issue #1016's signed
`0.3.0 -> 0.3.1` clean-Windows validation is completed.
This code makes two parts of that future review executable now:
1. `update-verification.ts` verifies a local candidate against explicit release
and publisher policy.
2. `update-recovery.ts` records the narrow handoff phases and resolves an
interrupted attempt safely on the next launch.
`BackendManager.stopForUpdate()` is the required handoff boundary. It returns
only after the exact owned backend PID, watchdog PID, and loopback listener are
gone. It never terminates `python.exe` by image name.
## Verification order
The caller must supply the installed version, candidate version, expected
SHA-256 digest, and an allowlist containing at least one publisher certificate
SHA-256 fingerprint. The policy fails closed when the allowlist is empty.
Checks run in this order:
1. policy and release metadata are well formed;
2. the candidate semantic version is strictly newer;
3. the local file digest matches the expected SHA-256;
4. Windows reports a `Valid` Authenticode signature;
5. the SHA-256 hash of the signer certificate's raw bytes is allowlisted.
The expected digest and publisher allowlist must eventually come from a trusted
release configuration owned by the maintainers. That source is deliberately
not invented here.
## Rejection matrix
| Scenario | Verification evidence | Stable result code | Required behavior |
| --- | --- | --- | --- |
| Tampered artifact | Local SHA-256 differs from release metadata | `artifact-hash-mismatch` | Do not launch; discard/quarantine the staged file |
| Unsigned artifact | Authenticode status is `NotSigned` | `artifact-unsigned` | Do not launch, even when the digest matches |
| Invalid or damaged signature | Authenticode status is not `Valid` or `NotSigned` | `artifact-signature-invalid` | Do not launch; record the Windows status for diagnostics |
| Wrong publisher | Signature is valid but certificate SHA-256 is not allowlisted | `publisher-not-allowed` | Do not launch; never trust the subject name alone |
| Equal or downgraded version | Candidate is not strictly newer | `version-not-newer` | Do not download or launch |
| Missing publisher policy | Certificate allowlist is empty | `invalid-verification-policy` | Keep the updater disabled |
| Malformed version or digest | Release metadata is not valid | `invalid-release-metadata` | Reject before signature inspection |
| File or Authenticode inspection failure | Artifact cannot be read or Windows inspection fails | `artifact-inspection-failed` | Reject without launching |
The unsigned automated test uses injected Authenticode observations so every
branch is deterministic. The later signed end-to-end run must repeat the first
five rows with real Authenticode artifacts and the agreed publisher identity.
## Recovery journal
The future updater may advance only through these durable phases:
```text
verified -> backend-stopped -> installer-launched
```
- `verified`: the staged candidate passed the complete verification policy.
- `backend-stopped`: `stopForUpdate()` confirmed no owned PID or listener.
- `installer-launched`: Windows acknowledged the installer process launch.
On the next application start, the journal is reconciled against the running
application version:
| Installed version | Last phase | Recovery result |
| --- | --- | --- |
| Target version | Any | Mark complete; remove journal and staged candidate |
| Original version | `verified` | Discard staged candidate; no shutdown occurred |
| Original version | `backend-stopped` | Treat as interrupted before installer; discard and require fresh verification |
| Original version | `installer-launched` | Treat as failed/interrupted installer; discard and require fresh verification |
| Any third version | Any | Leave journal and artifact untouched; require manual intervention |
| Malformed journal | Unknown | Leave it untouched; require manual intervention |
The journal stores a simple `.exe` file name inside its dedicated staging
directory. Malformed or traversal paths are never followed for deletion. Writes
use a temporary file and same-directory rename.
This recovery path restores the application to a clean retry state when the
old or new version can launch. It does **not** claim rollback of a partially
modified installation. That remains part of the real signed NSIS interruption
test required by #1016.
## Automated checks
From `desktop/electron`:
```powershell
npm run test:update-safety
npm run smoke:lifecycle
npm run smoke:parent-death
```
The lifecycle tests start an unrelated Python sentinel before stopping the
desktop-owned backend. The sentinel must still be alive after graceful and
forced-parent-death cleanup, while the owned Python PID and listener must be
gone.
## Remaining enablement gates
- maintainer-owned Authenticode identity and certificate lifecycle;
- maintainer-owned release feed and authenticated metadata policy;
- real signed `0.3.0 -> 0.3.1` clean-Windows upgrade;
- preserved settings and `safeStorage` credential validation;
- real interrupted NSIS recovery validation;
- the rejection matrix repeated with real signed and tampered artifacts.
Until every gate passes, no updater call site or release feed should be added.
+5
View File
@@ -20,6 +20,11 @@ This layer is stacked on the desktop lifecycle shell and adds only:
Users can install an optional adapter later from a source/developer environment.
The desktop runtime does not expose a package installer UI in this change.
The source tree contains dormant verification and recovery primitives for the
future signed-updater review. They remain disconnected from application startup
and cannot download or launch anything. Their rejection and recovery contract
is documented in [UPDATE_SAFETY.md](UPDATE_SAFETY.md).
## Build
From a complete checkout of the current upstream source:
+1
View File
@@ -11,6 +11,7 @@
"start": "npm run build && electron .",
"test:locales": "npm run build && node scripts/test-locales.mjs",
"test:backend-resolution": "npm run build && node scripts/test-backend-resolution.mjs",
"test:update-safety": "npm run build && node scripts/test-update-safety.mjs",
"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",
@@ -0,0 +1,50 @@
import { spawn } from "node:child_process";
export async function startUnrelatedPythonSentinel() {
const executable = process.env.VIBE_TRADING_DESKTOP_TEST_PYTHON || "python.exe";
const child = spawn(
executable,
["-c", "import time; time.sleep(600)"],
{ windowsHide: true, stdio: "ignore" },
);
await new Promise((resolve, reject) => {
child.once("spawn", resolve);
child.once("error", reject);
});
if (!child.pid) throw new Error("Unrelated Python sentinel started without a PID");
return {
pid: child.pid,
isAlive: () => isProcessAlive(child.pid),
stop: () => terminateExactProcessTree(child.pid),
};
}
function isProcessAlive(pid) {
if (!pid) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function terminateExactProcessTree(pid) {
if (!pid || !isProcessAlive(pid)) return Promise.resolve();
if (process.platform !== "win32") {
try {
process.kill(pid, "SIGKILL");
} catch {
// The sentinel already exited.
}
return Promise.resolve();
}
return new Promise((resolve) => {
const killer = spawn("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
windowsHide: true,
stdio: "ignore",
});
killer.once("error", () => resolve());
killer.once("exit", () => resolve());
});
}
+14 -10
View File
@@ -1,3 +1,4 @@
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { mkdtemp } from "node:fs/promises";
import os from "node:os";
@@ -6,12 +7,14 @@ import { fileURLToPath } from "node:url";
import { BackendManager } from "../dist/backend-manager.js";
import { getDesktopMessages } from "../dist/locales.js";
import { startUnrelatedPythonSentinel } from "./process-sentinel.mjs";
const electronRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const logDirectory = await mkdtemp(path.join(os.tmpdir(), "vibe-trading-desktop-shell-"));
const statuses = [];
let unexpectedExit;
const apiAuthKey = randomBytes(32).toString("base64url");
const unrelatedPython = await startUnrelatedPythonSentinel();
const backend = new BackendManager({
appPath: electronRoot,
@@ -26,6 +29,7 @@ const backend = new BackendManager({
});
let url;
let shutdownEvidence;
try {
url = await backend.start();
const parsed = new URL(url);
@@ -48,23 +52,23 @@ try {
}
if (unexpectedExit) throw new Error(unexpectedExit);
} finally {
await backend.stop();
}
let stopped = false;
if (url) {
try {
await fetch(new URL("health", url), { signal: AbortSignal.timeout(1_000) });
} catch {
stopped = true;
shutdownEvidence = await backend.stopForUpdate();
assert.equal(unrelatedPython.isAlive(), true, "Shutdown terminated an unrelated Python process");
} finally {
await unrelatedPython.stop();
}
}
if (!stopped) throw new Error("Backend still accepted connections after shutdown");
assert.equal(shutdownEvidence.backendExited, true);
assert.equal(shutdownEvidence.watchdogExited, true);
assert.equal(shutdownEvidence.listenerClosed, true);
console.log(JSON.stringify({
backendOrigin: new URL(url).origin,
statuses,
logDirectory,
authBoundaryVerified: true,
shutdownVerified: stopped,
shutdownVerified: true,
unrelatedPythonPid: unrelatedPython.pid,
unrelatedPythonSurvived: true,
}, null, 2));
@@ -4,6 +4,7 @@ import { mkdtemp, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { startUnrelatedPythonSentinel } from "./process-sentinel.mjs";
if (process.platform !== "win32") {
console.log("Parent-death smoke is Windows-specific; skipped on this platform.");
@@ -18,6 +19,7 @@ const testDirectory = await mkdtemp(path.join(os.tmpdir(), "vibe-desktop-parent-
const readyFile = path.join(testDirectory, "ready.json");
const userData = path.join(testDirectory, "user-data");
const output = [];
const unrelatedPython = await startUnrelatedPythonSentinel();
const electronMain = spawn(electronPath, [electronRoot, "--disable-gpu"], {
cwd: electronRoot,
windowsHide: true,
@@ -52,6 +54,11 @@ try {
20_000,
`Backend listener ${ready.backendOrigin} survived Electron main termination`,
);
assert.equal(
unrelatedPython.isAlive(),
true,
"Parent-death cleanup terminated an unrelated Python process",
);
console.log(JSON.stringify({
killedPid: ready.mainPid,
@@ -60,6 +67,8 @@ try {
backendOrigin: ready.backendOrigin,
backendExited: true,
listenerClosed: true,
unrelatedPythonPid: unrelatedPython.pid,
unrelatedPythonSurvived: true,
}, null, 2));
} finally {
if (electronMain.pid && isProcessAlive(electronMain.pid)) {
@@ -68,6 +77,7 @@ try {
if (ready?.backendPid && isProcessAlive(ready.backendPid)) {
await killProcessTree(ready.backendPid);
}
await unrelatedPython.stop();
}
async function waitForReadyFile(filePath, timeoutMs, child, capturedOutput) {
@@ -0,0 +1,191 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { UpdateRecoveryJournal } from "../dist/update-recovery.js";
import {
compareSemanticVersions,
inspectAuthenticodeSignature,
verifyWindowsUpdateCandidate,
} from "../dist/update-verification.js";
const fixture = Buffer.from("vibe-trading-update-safety-fixture", "utf8");
const fixtureHash = createHash("sha256").update(fixture).digest("hex");
const publisher = "ab".repeat(32);
const otherPublisher = "cd".repeat(32);
const testRoot = await mkdtemp(path.join(os.tmpdir(), "vibe-update-safety-"));
const artifactPath = path.join(testRoot, "candidate.exe");
await writeFile(artifactPath, fixture);
const candidate = { artifactPath, version: "0.3.1", expectedSha256: fixtureHash };
const policy = {
currentVersion: "0.3.0",
allowedPublisherCertificateSha256: [publisher],
};
const validSignature = () => ({
status: "Valid",
signerSubject: "CN=Vibe-Trading Test Publisher",
certificateSha256: publisher,
});
const accepted = await verifyWindowsUpdateCandidate(candidate, policy, validSignature);
assert.equal(accepted.accepted, true);
await expectRejection(
{ ...candidate, expectedSha256: "00".repeat(32) },
policy,
validSignature,
"artifact-hash-mismatch",
);
await expectRejection(candidate, policy, () => ({ status: "NotSigned" }), "artifact-unsigned");
await expectRejection(
candidate,
policy,
() => ({ status: "HashMismatch", certificateSha256: publisher }),
"artifact-signature-invalid",
);
await expectRejection(
candidate,
policy,
() => ({ status: "Valid", certificateSha256: otherPublisher }),
"publisher-not-allowed",
);
await expectRejection(
{ ...candidate, version: "0.2.9" },
policy,
validSignature,
"version-not-newer",
);
await expectRejection(
candidate,
{ ...policy, allowedPublisherCertificateSha256: [] },
validSignature,
"invalid-verification-policy",
);
await expectRejection(
candidate,
{ ...policy, allowedPublisherCertificateSha256: [publisher, "malformed"] },
validSignature,
"invalid-verification-policy",
);
await expectRejection(
{ ...candidate, artifactPath: path.join(testRoot, "missing.exe") },
policy,
validSignature,
"artifact-inspection-failed",
);
assert.equal(compareSemanticVersions("0.3.1", "0.3.0"), 1);
assert.equal(compareSemanticVersions("0.3.1-beta.2", "0.3.1-beta.1"), 1);
assert.equal(compareSemanticVersions("0.3.1-beta.1", "0.3.1"), -1);
assert.equal(compareSemanticVersions("0.3.0+build.2", "0.3.0+build.1"), 0);
assert.throws(() => compareSemanticVersions("0.3.1-01", "0.3.0"));
let authenticodeAdapterVerified = false;
if (process.platform === "win32") {
if (!process.env.SystemRoot) throw new Error("SystemRoot is required for the Authenticode adapter test");
const signedSystemBinary = path.join(
process.env.SystemRoot,
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
);
const inspection = inspectAuthenticodeSignature(signedSystemBinary);
assert.equal(inspection.status, "Valid");
assert.match(inspection.certificateSha256 || "", /^[a-f0-9]{64}$/u);
authenticodeAdapterVerified = true;
}
for (const scenario of [
{ phase: "verified", disposition: "discarded-before-shutdown" },
{ phase: "backend-stopped", disposition: "interrupted-before-installer" },
{ phase: "installer-launched", disposition: "installer-failed-or-interrupted" },
]) {
const directory = await mkdtemp(path.join(testRoot, "recovery-"));
const journal = new UpdateRecoveryJournal(directory);
const stagedArtifact = path.join(directory, "update.exe");
await writeFile(stagedArtifact, fixture);
let attempt = await journal.begin(newAttempt("update.exe"));
if (scenario.phase === "backend-stopped" || scenario.phase === "installer-launched") {
attempt = await journal.advance(attempt.attemptId, "backend-stopped");
}
if (scenario.phase === "installer-launched") {
attempt = await journal.advance(attempt.attemptId, "installer-launched");
}
const recovery = await journal.recover("0.3.0");
assert.equal(recovery.disposition, scenario.disposition);
assert.equal(await exists(stagedArtifact), false);
assert.equal(await exists(journal.journalPath), false);
}
const completedDirectory = await mkdtemp(path.join(testRoot, "completed-"));
const completedJournal = new UpdateRecoveryJournal(completedDirectory);
await writeFile(path.join(completedDirectory, "update.exe"), fixture);
await completedJournal.begin(newAttempt("update.exe"));
assert.equal((await completedJournal.recover("0.3.1")).disposition, "completed");
const mismatchDirectory = await mkdtemp(path.join(testRoot, "mismatch-"));
const mismatchJournal = new UpdateRecoveryJournal(mismatchDirectory);
const mismatchArtifact = path.join(mismatchDirectory, "update.exe");
await writeFile(mismatchArtifact, fixture);
await mismatchJournal.begin(newAttempt("update.exe"));
await assert.rejects(() => mismatchJournal.begin(newAttempt("other.exe")), /already exists/u);
assert.equal(
(await mismatchJournal.recover("0.4.0")).disposition,
"manual-intervention-required",
);
assert.equal(await exists(mismatchArtifact), true);
assert.equal(await exists(mismatchJournal.journalPath), true);
const traversalDirectory = await mkdtemp(path.join(testRoot, "traversal-"));
const traversalJournal = new UpdateRecoveryJournal(traversalDirectory);
const protectedFile = path.join(testRoot, "protected.exe");
await writeFile(protectedFile, fixture);
await writeFile(traversalJournal.journalPath, JSON.stringify({
...newAttempt("../protected.exe"),
schemaVersion: 1,
attemptId: "ef".repeat(16),
phase: "verified",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
assert.equal(
(await traversalJournal.recover("0.3.0")).disposition,
"manual-intervention-required",
);
assert.equal((await readFile(protectedFile)).equals(fixture), true);
console.log(JSON.stringify({
verificationCases: ["accepted", "tampered", "unsigned", "invalid-signature", "wrong-publisher", "downgraded"],
recoveryPhases: ["verified", "backend-stopped", "installer-launched", "completed", "version-mismatch", "path-traversal"],
authenticodeAdapterVerified,
updaterEnabled: false,
}, null, 2));
async function expectRejection(candidateValue, policyValue, inspector, expectedCode) {
const result = await verifyWindowsUpdateCandidate(candidateValue, policyValue, inspector);
assert.equal(result.accepted, false);
assert.equal(result.code, expectedCode);
}
function newAttempt(artifactFileName) {
return {
fromVersion: "0.3.0",
toVersion: "0.3.1",
artifactFileName,
artifactSha256: fixtureHash,
publisherCertificateSha256: publisher,
};
}
async function exists(file) {
try {
await access(file);
return true;
} catch {
return false;
}
}
+86 -2
View File
@@ -31,6 +31,14 @@ export type ResolvedBackend = {
includeServeCommand: boolean;
};
export type BackendShutdownEvidence = {
backendPid?: number;
watchdogPid?: number;
backendExited: boolean;
watchdogExited: boolean;
listenerClosed: boolean;
};
export type BackendResolutionOptions = {
appPath: string;
resourcesPath: string;
@@ -145,9 +153,14 @@ export class BackendManager {
return this.baseUrl;
}
async stop(): Promise<void> {
async stop(): Promise<BackendShutdownEvidence> {
const watchdog = this.watchdog;
if (!watchdog) return;
const backendPid = this.backendPid;
const watchdogPid = watchdog?.pid;
const baseUrl = this.baseUrl;
if (!watchdog) {
return shutdownEvidence(backendPid, watchdogPid, baseUrl);
}
this.stopping = true;
if (isRunning(watchdog) && this.baseUrl) {
try {
@@ -178,12 +191,31 @@ export class BackendManager {
await runTaskkill(watchdog.pid);
}
const evidence = await waitForShutdownEvidence(
backendPid,
watchdogPid,
baseUrl,
5_000,
);
if (!evidence.backendExited || !evidence.watchdogExited || !evidence.listenerClosed) {
this.writeLog(`[SHUTDOWN] incomplete evidence ${JSON.stringify(evidence)}`);
}
this.watchdog = undefined;
this.watchdogError = undefined;
this.backendPid = undefined;
this.baseUrl = undefined;
await new Promise<void>((resolve) => this.logStream?.end(resolve) ?? resolve());
this.logStream = undefined;
return evidence;
}
async stopForUpdate(): Promise<BackendShutdownEvidence> {
const evidence = await this.stop();
if (!evidence.backendExited || !evidence.watchdogExited || !evidence.listenerClosed) {
throw new Error(`Owned backend shutdown could not be verified: ${JSON.stringify(evidence)}`);
}
return evidence;
}
get url(): string | undefined {
@@ -401,6 +433,58 @@ function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function waitForShutdownEvidence(
backendPid: number | undefined,
watchdogPid: number | undefined,
baseUrl: string | undefined,
timeoutMilliseconds: number,
): Promise<BackendShutdownEvidence> {
const deadline = Date.now() + timeoutMilliseconds;
let evidence = await shutdownEvidence(backendPid, watchdogPid, baseUrl);
while (
Date.now() < deadline &&
(!evidence.backendExited || !evidence.watchdogExited || !evidence.listenerClosed)
) {
await delay(100);
evidence = await shutdownEvidence(backendPid, watchdogPid, baseUrl);
}
return evidence;
}
async function shutdownEvidence(
backendPid: number | undefined,
watchdogPid: number | undefined,
baseUrl: string | undefined,
): Promise<BackendShutdownEvidence> {
return {
backendPid,
watchdogPid,
backendExited: !backendPid || !isProcessAlive(backendPid),
watchdogExited: !watchdogPid || !isProcessAlive(watchdogPid),
listenerClosed: !baseUrl || !(await listenerAcceptsConnections(baseUrl)),
};
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function listenerAcceptsConnections(baseUrl: string): Promise<boolean> {
try {
await fetch(new URL("health", baseUrl), {
signal: AbortSignal.timeout(500),
});
return true;
} catch {
return false;
}
}
function runTaskkill(pid: number): Promise<void> {
if (process.platform !== "win32") {
try {
+215
View File
@@ -0,0 +1,215 @@
import { randomBytes } from "node:crypto";
import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { compareSemanticVersions } from "./update-verification";
export type UpdateAttemptPhase = "verified" | "backend-stopped" | "installer-launched";
export type UpdateAttempt = {
schemaVersion: 1;
attemptId: string;
fromVersion: string;
toVersion: string;
artifactFileName: string;
artifactSha256: string;
publisherCertificateSha256: string;
phase: UpdateAttemptPhase;
createdAt: string;
updatedAt: string;
};
export type UpdateRecoveryDisposition =
| "none"
| "completed"
| "discarded-before-shutdown"
| "interrupted-before-installer"
| "installer-failed-or-interrupted"
| "manual-intervention-required";
export type UpdateRecoveryResult = {
disposition: UpdateRecoveryDisposition;
attempt?: UpdateAttempt;
detail?: string;
};
const journalFileName = "pending-update.json";
export class UpdateRecoveryJournal {
readonly stagingDirectory: string;
readonly journalPath: string;
constructor(rootDirectory: string) {
this.stagingDirectory = path.resolve(rootDirectory);
this.journalPath = path.join(this.stagingDirectory, journalFileName);
}
async begin(
attempt: Omit<UpdateAttempt, "schemaVersion" | "attemptId" | "phase" | "createdAt" | "updatedAt">,
): Promise<UpdateAttempt> {
assertArtifactFileName(attempt.artifactFileName);
assertSha256(attempt.artifactSha256, "artifact SHA-256");
assertSha256(attempt.publisherCertificateSha256, "publisher certificate SHA-256");
if (compareSemanticVersions(attempt.toVersion, attempt.fromVersion) <= 0) {
throw new Error("An update attempt must target a newer semantic version");
}
await mkdir(this.stagingDirectory, { recursive: true });
try {
await readFile(this.journalPath, "utf8");
throw new Error("A pending update attempt already exists");
} catch (error) {
if (errorCode(error) !== "ENOENT") throw error;
}
const now = new Date().toISOString();
const record: UpdateAttempt = {
...attempt,
schemaVersion: 1,
attemptId: randomBytes(16).toString("hex"),
phase: "verified",
createdAt: now,
updatedAt: now,
};
await this.write(record);
return record;
}
async advance(attemptId: string, nextPhase: Exclude<UpdateAttemptPhase, "verified">): Promise<UpdateAttempt> {
const current = await this.readRequired();
if (current.attemptId !== attemptId) throw new Error("Update attempt identifier does not match");
const allowed = current.phase === "verified"
? "backend-stopped"
: current.phase === "backend-stopped"
? "installer-launched"
: undefined;
if (nextPhase !== allowed) {
throw new Error(`Invalid update phase transition: ${current.phase} -> ${nextPhase}`);
}
const updated = { ...current, phase: nextPhase, updatedAt: new Date().toISOString() };
await this.write(updated);
return updated;
}
async recover(currentVersion: string): Promise<UpdateRecoveryResult> {
let attempt: UpdateAttempt;
try {
const raw = await readFile(this.journalPath, "utf8");
const parsed: unknown = JSON.parse(raw);
if (!isUpdateAttempt(parsed)) {
return {
disposition: "manual-intervention-required",
detail: "The pending update journal is malformed and was left untouched.",
};
}
attempt = parsed;
} catch (error) {
if (errorCode(error) === "ENOENT") return { disposition: "none" };
return {
disposition: "manual-intervention-required",
detail: `The pending update journal could not be read: ${errorText(error)}`,
};
}
if (currentVersion === attempt.toVersion) {
await this.clearSafeAttempt(attempt);
return { disposition: "completed", attempt };
}
if (currentVersion !== attempt.fromVersion) {
return {
disposition: "manual-intervention-required",
attempt,
detail: `Installed version ${currentVersion} matches neither ${attempt.fromVersion} nor ${attempt.toVersion}.`,
};
}
const disposition: UpdateRecoveryDisposition = attempt.phase === "verified"
? "discarded-before-shutdown"
: attempt.phase === "backend-stopped"
? "interrupted-before-installer"
: "installer-failed-or-interrupted";
await this.clearSafeAttempt(attempt);
return { disposition, attempt };
}
private async readRequired(): Promise<UpdateAttempt> {
const parsed: unknown = JSON.parse(await readFile(this.journalPath, "utf8"));
if (!isUpdateAttempt(parsed)) throw new Error("Pending update journal is malformed");
return parsed;
}
private async write(attempt: UpdateAttempt): Promise<void> {
const temporaryPath = path.join(
this.stagingDirectory,
`${journalFileName}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`,
);
try {
await writeFile(temporaryPath, `${JSON.stringify(attempt, null, 2)}\n`, {
encoding: "utf8",
flag: "wx",
});
await rename(temporaryPath, this.journalPath);
} finally {
await rm(temporaryPath, { force: true });
}
}
private async clearSafeAttempt(attempt: UpdateAttempt): Promise<void> {
const artifactPath = resolveSafeArtifactPath(this.stagingDirectory, attempt.artifactFileName);
await rm(artifactPath, { force: true });
await unlink(this.journalPath);
}
}
function isUpdateAttempt(value: unknown): value is UpdateAttempt {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
try {
assertArtifactFileName(candidate.artifactFileName);
assertSha256(candidate.artifactSha256, "artifact SHA-256");
assertSha256(candidate.publisherCertificateSha256, "publisher certificate SHA-256");
if (
typeof candidate.fromVersion !== "string"
|| typeof candidate.toVersion !== "string"
|| compareSemanticVersions(candidate.toVersion, candidate.fromVersion) <= 0
) {
return false;
}
} catch {
return false;
}
return candidate.schemaVersion === 1
&& typeof candidate.attemptId === "string"
&& /^[a-f0-9]{32}$/u.test(candidate.attemptId)
&& ["verified", "backend-stopped", "installer-launched"].includes(String(candidate.phase))
&& typeof candidate.createdAt === "string"
&& typeof candidate.updatedAt === "string";
}
function resolveSafeArtifactPath(rootDirectory: string, fileName: string): string {
assertArtifactFileName(fileName);
const resolved = path.resolve(rootDirectory, fileName);
if (path.dirname(resolved) !== path.resolve(rootDirectory)) {
throw new Error("Update artifact path escaped the staging directory");
}
return resolved;
}
function assertArtifactFileName(value: unknown): asserts value is string {
if (typeof value !== "string" || value !== path.basename(value) || !/^[A-Za-z0-9._-]+\.exe$/u.test(value)) {
throw new Error("Update artifact must be a simple .exe file name");
}
}
function assertSha256(value: unknown, label: string): asserts value is string {
if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) {
throw new Error(`Invalid ${label}`);
}
}
function errorCode(error: unknown): string | undefined {
return error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+253
View File
@@ -0,0 +1,253 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { createReadStream } from "node:fs";
import path from "node:path";
export type AuthenticodeInspection = {
status: string;
signerSubject?: string;
certificateSha256?: string;
};
export type UpdateCandidate = {
artifactPath: string;
version: string;
expectedSha256: string;
};
export type UpdateVerificationPolicy = {
currentVersion: string;
allowedPublisherCertificateSha256: readonly string[];
};
export type UpdateRejectionCode =
| "invalid-verification-policy"
| "invalid-release-metadata"
| "version-not-newer"
| "artifact-inspection-failed"
| "artifact-hash-mismatch"
| "artifact-unsigned"
| "artifact-signature-invalid"
| "publisher-not-allowed";
export type UpdateVerificationResult =
| {
accepted: true;
version: string;
sha256: string;
signerSubject?: string;
certificateSha256: string;
}
| {
accepted: false;
code: UpdateRejectionCode;
detail: string;
};
type SignatureInspector = (artifactPath: string) => AuthenticodeInspection;
export async function verifyWindowsUpdateCandidate(
candidate: UpdateCandidate,
policy: UpdateVerificationPolicy,
inspectSignature: SignatureInspector = inspectAuthenticodeSignature,
): Promise<UpdateVerificationResult> {
const expectedHash = normalizeSha256(candidate.expectedSha256);
const normalizedPublishers = policy.allowedPublisherCertificateSha256.map(normalizeSha256);
if (normalizedPublishers.length === 0 || normalizedPublishers.some((value) => !value)) {
return reject(
"invalid-verification-policy",
"Every publisher entry must be a complete SHA-256 certificate fingerprint.",
);
}
const allowedPublishers = normalizedPublishers as string[];
if (!expectedHash || !parseSemanticVersion(candidate.version)) {
return reject(
"invalid-release-metadata",
"The candidate version or SHA-256 digest is malformed.",
);
}
if (!parseSemanticVersion(policy.currentVersion)) {
return reject("invalid-verification-policy", "The installed version is malformed.");
}
if (compareSemanticVersions(candidate.version, policy.currentVersion) <= 0) {
return reject(
"version-not-newer",
`Candidate ${candidate.version} is not newer than ${policy.currentVersion}.`,
);
}
let actualHash: string;
try {
actualHash = await sha256(candidate.artifactPath);
} catch (error) {
return reject("artifact-inspection-failed", `The artifact could not be read: ${errorText(error)}`);
}
if (actualHash !== expectedHash) {
return reject("artifact-hash-mismatch", "The artifact SHA-256 digest does not match release metadata.");
}
let signature: AuthenticodeInspection;
try {
signature = inspectSignature(candidate.artifactPath);
} catch (error) {
return reject(
"artifact-inspection-failed",
`Authenticode inspection failed: ${errorText(error)}`,
);
}
if (signature.status === "NotSigned") {
return reject("artifact-unsigned", "The update artifact has no Authenticode signature.");
}
if (signature.status !== "Valid") {
return reject(
"artifact-signature-invalid",
`Authenticode reported status '${signature.status || "Unknown"}'.`,
);
}
const certificateSha256 = normalizeSha256(signature.certificateSha256);
if (!certificateSha256 || !allowedPublishers.includes(certificateSha256)) {
return reject(
"publisher-not-allowed",
"The signer certificate is not in the configured publisher allowlist.",
);
}
return {
accepted: true,
version: candidate.version,
sha256: actualHash,
signerSubject: signature.signerSubject,
certificateSha256,
};
}
export function inspectAuthenticodeSignature(artifactPath: string): AuthenticodeInspection {
if (process.platform !== "win32") {
return { status: "UnsupportedPlatform" };
}
const result = spawnSync(
resolveSignaturePowerShell(),
[
"-NoProfile",
"-NonInteractive",
"-Command",
[
"$signature = Get-AuthenticodeSignature -LiteralPath $env:VIBE_UPDATE_ARTIFACT_PATH",
"$fingerprint = $null",
"if ($signature.SignerCertificate) {",
" $sha256 = [System.Security.Cryptography.SHA256]::Create()",
" try {",
" $bytes = $sha256.ComputeHash($signature.SignerCertificate.RawData)",
" $fingerprint = ([System.BitConverter]::ToString($bytes)).Replace('-', '').ToLowerInvariant()",
" } finally { $sha256.Dispose() }",
"}",
"@{ status = $signature.Status.ToString(); signerSubject = $signature.SignerCertificate.Subject; certificateSha256 = $fingerprint } | ConvertTo-Json -Compress",
].join("; "),
],
{
env: { ...process.env, VIBE_UPDATE_ARTIFACT_PATH: path.resolve(artifactPath) },
encoding: "utf8",
windowsHide: true,
},
);
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || "Authenticode inspection failed");
}
const parsed: unknown = JSON.parse(result.stdout.trim());
if (!parsed || typeof parsed !== "object" || typeof (parsed as Record<string, unknown>).status !== "string") {
throw new Error("Authenticode inspection returned an invalid payload");
}
const payload = parsed as Record<string, unknown>;
return {
status: payload.status as string,
signerSubject: typeof payload.signerSubject === "string" ? payload.signerSubject : undefined,
certificateSha256: typeof payload.certificateSha256 === "string"
? payload.certificateSha256
: undefined,
};
}
export function compareSemanticVersions(left: string, right: string): number {
const leftVersion = parseSemanticVersion(left);
const rightVersion = parseSemanticVersion(right);
if (!leftVersion || !rightVersion) throw new Error("Cannot compare malformed semantic versions");
for (let index = 0; index < 3; index += 1) {
const difference = leftVersion.core[index] - rightVersion.core[index];
if (difference !== 0) return Math.sign(difference);
}
if (!leftVersion.prerelease.length && !rightVersion.prerelease.length) return 0;
if (!leftVersion.prerelease.length) return 1;
if (!rightVersion.prerelease.length) return -1;
const length = Math.max(leftVersion.prerelease.length, rightVersion.prerelease.length);
for (let index = 0; index < length; index += 1) {
const leftPart = leftVersion.prerelease[index];
const rightPart = rightVersion.prerelease[index];
if (leftPart === undefined) return -1;
if (rightPart === undefined) return 1;
if (leftPart === rightPart) continue;
const leftNumeric = /^\d+$/u.test(leftPart);
const rightNumeric = /^\d+$/u.test(rightPart);
if (leftNumeric && rightNumeric) {
if (leftPart.length !== rightPart.length) return leftPart.length < rightPart.length ? -1 : 1;
return leftPart < rightPart ? -1 : 1;
}
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
return leftPart < rightPart ? -1 : 1;
}
return 0;
}
function parseSemanticVersion(value: string): { core: [number, number, number]; prerelease: string[] } | undefined {
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u.exec(value);
if (!match) return undefined;
const core = [Number(match[1]), Number(match[2]), Number(match[3])] as [number, number, number];
if (!core.every(Number.isSafeInteger)) return undefined;
const prerelease = match[4]?.split(".") ?? [];
if (prerelease.some((part) => /^\d+$/u.test(part) && part.length > 1 && part.startsWith("0"))) {
return undefined;
}
return {
core,
prerelease,
};
}
function normalizeSha256(value: string | undefined): string | undefined {
const normalized = value?.replaceAll(":", "").trim().toLowerCase();
return normalized && /^[a-f0-9]{64}$/u.test(normalized) ? normalized : undefined;
}
function resolveSignaturePowerShell(): string {
const candidates = [
process.env.ProgramFiles
? path.join(process.env.ProgramFiles, "PowerShell", "7", "pwsh.exe")
: undefined,
"pwsh.exe",
"powershell.exe",
].filter((candidate): candidate is string => Boolean(candidate));
for (const candidate of new Set(candidates)) {
const probe = spawnSync(
candidate,
["-NoProfile", "-NonInteractive", "-Command", "Get-Command Get-AuthenticodeSignature -ErrorAction Stop | Out-Null"],
{ encoding: "utf8", windowsHide: true },
);
if (!probe.error && probe.status === 0) return candidate;
}
throw new Error("No PowerShell host can inspect Authenticode signatures");
}
async function sha256(file: string): Promise<string> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(file)) hash.update(chunk);
return hash.digest("hex");
}
function reject(code: UpdateRejectionCode, detail: string): UpdateVerificationResult {
return { accepted: false, code, detail };
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}