feat(desktop): add dormant update safety boundary
Signed-off-by: QCYTSN <2558348385@qq.com>
This commit is contained in:
1 parent
5e39e42908
commit
d8723223e9
13 files changed
+998
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user