feat: add safe Gitea sync and signed updates
This commit is contained in:
+124
-24
@@ -5,7 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -14,10 +14,36 @@ const require = createRequire(import.meta.url);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const {
|
||||
UpdateService,
|
||||
verifyReleaseManifest,
|
||||
waitForUpdaterStarted,
|
||||
windowsUpdaterSpawnOptions,
|
||||
} = require("../src/main/update-service.cjs");
|
||||
|
||||
function createSignedReleaseFixture({
|
||||
version,
|
||||
remoteSha,
|
||||
assetName,
|
||||
binary,
|
||||
}) {
|
||||
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
||||
const sha256 = createHash("sha256").update(binary).digest("hex");
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
product: "ForgeFlow",
|
||||
version,
|
||||
tag: `v${version}`,
|
||||
commit: remoteSha,
|
||||
buildId: "test-build",
|
||||
signature: { algorithm: "Ed25519", keyId: "SHA256:test" },
|
||||
artifacts: [{ name: assetName, bytes: binary.length, sha256 }],
|
||||
};
|
||||
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const signatureBytes = Buffer.from(
|
||||
`${sign(null, manifestBytes, privateKey).toString("base64")}\n`,
|
||||
);
|
||||
return { publicKey, sha256, manifestBytes, signatureBytes };
|
||||
}
|
||||
|
||||
test("Windows updater uses a hidden non-detached PowerShell child", () => {
|
||||
assert.deepEqual(windowsUpdaterSpawnOptions("C:\\updates"), {
|
||||
detached: false,
|
||||
@@ -324,6 +350,8 @@ test("packaged updater passes Gitea browser download URLs to the asset downloade
|
||||
);
|
||||
assert.match(source, /downloadUrl: asset\.browser_download_url/);
|
||||
assert.match(source, /downloadUrl: checksumAsset\.browser_download_url/);
|
||||
assert.match(source, /downloadUrl: manifestAsset\.browser_download_url/);
|
||||
assert.match(source, /downloadUrl: signatureAsset\.browser_download_url/);
|
||||
assert.match(source, /RELEASE_ASSET_METADATA_RECEIVED/);
|
||||
});
|
||||
test("PowerShell helper replaces an existing launching status with a Windows-safe file API", async () => {
|
||||
@@ -480,15 +508,22 @@ test("updater handshake rejects a stale status from another update request", asy
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("packaged updater downloads only a published checksum-matched Windows asset", async () => {
|
||||
test("packaged updater downloads only a publisher-signed Windows asset", async () => {
|
||||
const temp = await mkdtemp(
|
||||
path.join(os.tmpdir(), "forgeflow-binary-update-"),
|
||||
);
|
||||
const binary = Buffer.alloc(1_100_000, 0x5a);
|
||||
binary[0] = 0x4d;
|
||||
binary[1] = 0x5a;
|
||||
const sha256 = createHash("sha256").update(binary).digest("hex");
|
||||
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
|
||||
const remoteSha = "a".repeat(40);
|
||||
const signed = createSignedReleaseFixture({
|
||||
version: "0.8.2",
|
||||
remoteSha,
|
||||
assetName,
|
||||
binary,
|
||||
});
|
||||
const manifestName = "ForgeFlow-0.8.2-release-manifest.json";
|
||||
const gitea = {
|
||||
async getReleaseByTag(_owner, _repo, tag) {
|
||||
if (tag !== "v0.8.2") return null;
|
||||
@@ -508,18 +543,32 @@ test("packaged updater downloads only a published checksum-matched Windows asset
|
||||
id: 42,
|
||||
browser_download_url: "http://wrong-origin.test/checksum",
|
||||
},
|
||||
{
|
||||
name: manifestName,
|
||||
id: 43,
|
||||
browser_download_url: "http://wrong-origin.test/manifest",
|
||||
},
|
||||
{
|
||||
name: `${manifestName}.sig`,
|
||||
id: 44,
|
||||
browser_download_url: "http://wrong-origin.test/signature",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async downloadReleaseAsset(_owner, _repo, releaseId, assetId, options) {
|
||||
assert.equal(releaseId, 82);
|
||||
assert.equal(
|
||||
options.downloadUrl,
|
||||
assetId === 42
|
||||
? "http://wrong-origin.test/checksum"
|
||||
: "http://wrong-origin.test/setup",
|
||||
);
|
||||
return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary;
|
||||
const downloads = {
|
||||
41: ["http://wrong-origin.test/setup", binary],
|
||||
42: [
|
||||
"http://wrong-origin.test/checksum",
|
||||
Buffer.from(`${signed.sha256} ${assetName}\n`),
|
||||
],
|
||||
43: ["http://wrong-origin.test/manifest", signed.manifestBytes],
|
||||
44: ["http://wrong-origin.test/signature", signed.signatureBytes],
|
||||
};
|
||||
assert.equal(options.downloadUrl, downloads[assetId][0]);
|
||||
return downloads[assetId][1];
|
||||
},
|
||||
};
|
||||
const service = new UpdateService({
|
||||
@@ -537,14 +586,17 @@ test("packaged updater downloads only a published checksum-matched Windows asset
|
||||
sourcePath: temp,
|
||||
userDataPath: temp,
|
||||
platform: "win32",
|
||||
updatePublicKey: signed.publicKey,
|
||||
});
|
||||
const result = await service.downloadPackaged({
|
||||
owner: "Jens",
|
||||
repo: "ForgeFlow",
|
||||
remoteVersion: "0.8.2",
|
||||
remoteSha,
|
||||
});
|
||||
assert.equal(result.downloaded, true);
|
||||
assert.equal(result.sha256, sha256);
|
||||
assert.equal(result.sha256, signed.sha256);
|
||||
assert.equal(result.publisherKeyId, "SHA256:test");
|
||||
assert.equal(result.portable, false);
|
||||
assert.equal((await readFile(result.binaryPath)).length, binary.length);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
@@ -558,6 +610,14 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
binary[0] = 0x4d;
|
||||
binary[1] = 0x5a;
|
||||
const assetName = "ForgeFlow-Portable-0.8.2-win-x64.exe";
|
||||
const remoteSha = "b".repeat(40);
|
||||
const signed = createSignedReleaseFixture({
|
||||
version: "0.8.2",
|
||||
remoteSha,
|
||||
assetName,
|
||||
binary,
|
||||
});
|
||||
const manifestName = "ForgeFlow-0.8.2-release-manifest.json";
|
||||
const service = new UpdateService({
|
||||
store: { data: { gitea: {} }, save: async () => {} },
|
||||
gitea: {
|
||||
@@ -576,14 +636,18 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
name: `${assetName}.sha256`,
|
||||
browser_download_url: "http://wrong-origin.test/checksum",
|
||||
},
|
||||
{ id: 53, name: manifestName },
|
||||
{ id: 54, name: `${manifestName}.sig` },
|
||||
],
|
||||
};
|
||||
},
|
||||
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
|
||||
assert.equal(releaseId, 83);
|
||||
return assetId === 52
|
||||
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
|
||||
: binary;
|
||||
if (assetId === 51) return binary;
|
||||
if (assetId === 52)
|
||||
return Buffer.from(`${"0".repeat(64)} ${assetName}`);
|
||||
if (assetId === 53) return signed.manifestBytes;
|
||||
return signed.signatureBytes;
|
||||
},
|
||||
},
|
||||
diagnostics: null,
|
||||
@@ -595,6 +659,7 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
sourcePath: temp,
|
||||
userDataPath: temp,
|
||||
platform: "win32",
|
||||
updatePublicKey: signed.publicKey,
|
||||
});
|
||||
await assert.rejects(
|
||||
() =>
|
||||
@@ -602,17 +667,45 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
owner: "Jens",
|
||||
repo: "ForgeFlow",
|
||||
remoteVersion: "0.8.2",
|
||||
remoteSha,
|
||||
}),
|
||||
/SHA-256 verification/,
|
||||
/does not match the signed publisher manifest/,
|
||||
);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("Windows release pipeline preserves optional signing checks and emits provenance plus SBOM", async () => {
|
||||
const [pkgSource, signatureSource, checksumSource] = await Promise.all([
|
||||
test("release manifest verification rejects a different publisher key", () => {
|
||||
const binary = Buffer.alloc(1_100_000, 0x5a);
|
||||
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
|
||||
const fixture = createSignedReleaseFixture({
|
||||
version: "0.8.2",
|
||||
remoteSha: "c".repeat(40),
|
||||
assetName,
|
||||
binary,
|
||||
});
|
||||
const otherKey = generateKeyPairSync("ed25519").publicKey;
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyReleaseManifest({
|
||||
manifestBytes: fixture.manifestBytes,
|
||||
signatureBytes: fixture.signatureBytes,
|
||||
publicKey: otherKey,
|
||||
update: {
|
||||
remoteVersion: "0.8.2",
|
||||
remoteSha: "c".repeat(40),
|
||||
},
|
||||
assetName,
|
||||
}),
|
||||
(error) => error.code === "RELEASE_SIGNATURE_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
test("Windows release pipeline emits signed provenance, manifest and SBOM evidence", async () => {
|
||||
const [pkgSource, signatureSource, checksumSource, manifestSigner] = await Promise.all([
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(pkgSource, /verify-release-signatures\.mjs/);
|
||||
assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/);
|
||||
@@ -623,6 +716,10 @@ test("Windows release pipeline preserves optional signing checks and emits prove
|
||||
assert.match(checksumSource, /provenance\.json/);
|
||||
assert.match(checksumSource, /sbom\.cdx\.json/);
|
||||
assert.match(checksumSource, /CycloneDX/);
|
||||
assert.match(checksumSource, /publisherManifestSignature/);
|
||||
assert.match(manifestSigner, /Ed25519/);
|
||||
assert.match(manifestSigner, /release-manifest\.json/);
|
||||
assert.match(pkgSource, /sign-release-manifest\.mjs/);
|
||||
const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");
|
||||
assert.match(publisher, /draft: true/);
|
||||
assert.match(publisher, /requiredAssets/);
|
||||
@@ -630,17 +727,20 @@ test("Windows release pipeline preserves optional signing checks and emits prove
|
||||
assert.match(publisher, /sbom\.cdx\.json/);
|
||||
});
|
||||
|
||||
test("the supported Windows build is free, checksum-protected and updater-compatible", async () => {
|
||||
const [pkg, signatureCheck, checksumWriter] = await Promise.all([
|
||||
test("the supported Windows build uses free offline Ed25519 publisher signing", async () => {
|
||||
const [pkg, keySetup, manifestSigner, publicKey] = await Promise.all([
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../scripts/setup-update-signing-key.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../build/update-signing-public.pem", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.doesNotMatch(pkg, /dist:win:signed/);
|
||||
assert.match(pkg, /dist:win/);
|
||||
assert.match(pkg, /write-release-checksums\.mjs/);
|
||||
assert.match(signatureCheck, /checksum-protected unsigned artifact/);
|
||||
assert.match(checksumWriter, /sha256/);
|
||||
assert.match(pkg, /signing:setup/);
|
||||
assert.match(keySetup, /release-signing-private\.pem/);
|
||||
assert.match(manifestSigner, /sign\(null, manifestBytes, privateKey\)/);
|
||||
assert.match(publicKey, /BEGIN PUBLIC KEY/);
|
||||
assert.doesNotMatch(publicKey, /PRIVATE KEY/);
|
||||
});
|
||||
|
||||
test("binary update helper verifies, waits, applies and records restart state", async () => {
|
||||
|
||||
Reference in New Issue
Block a user