feat: add safe Gitea sync and signed updates
This commit is contained in:
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { app, safeStorage } = require("electron");
|
||||
const { normalizeBaseUrl } = require("../src/shared/validation.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const configuredUserData =
|
||||
@@ -59,10 +60,7 @@ app.whenReady().then(async () => {
|
||||
const token = safeStorage.decryptString(
|
||||
Buffer.from(config.gitea.encryptedToken, "base64"),
|
||||
);
|
||||
const baseUrl = String(config.gitea.baseUrl || "").replace(/\/+$/, "");
|
||||
if (!/^https?:\/\//i.test(baseUrl)) {
|
||||
throw new Error("The configured Gitea base URL is invalid.");
|
||||
}
|
||||
const baseUrl = normalizeBaseUrl(config.gitea.baseUrl);
|
||||
const owner = safeRepositoryPart(
|
||||
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
|
||||
"Release repository owner",
|
||||
@@ -181,6 +179,8 @@ app.whenReady().then(async () => {
|
||||
for (const [name, type] of [
|
||||
[`ForgeFlow-${version}-provenance.json`, "application/json"],
|
||||
[`ForgeFlow-${version}-sbom.cdx.json`, "application/vnd.cyclonedx+json"],
|
||||
[`ForgeFlow-${version}-release-manifest.json`, "application/json"],
|
||||
[`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"],
|
||||
]) {
|
||||
const bytes = await fs.readFile(path.join(root, "dist", name));
|
||||
const existing = (release.assets || []).find((asset) => asset.name === name);
|
||||
@@ -194,6 +194,8 @@ app.whenReady().then(async () => {
|
||||
...binaries.flatMap((binaryPath) => [path.basename(binaryPath), `${path.basename(binaryPath)}.sha256`]),
|
||||
`ForgeFlow-${version}-provenance.json`,
|
||||
`ForgeFlow-${version}-sbom.cdx.json`,
|
||||
`ForgeFlow-${version}-release-manifest.json`,
|
||||
`ForgeFlow-${version}-release-manifest.json.sig`,
|
||||
];
|
||||
const missingAssets = requiredAssets.filter((name) => !(release.assets || []).some((asset) => asset.name === name));
|
||||
if (missingAssets.length) throw new Error(`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const defaultPrivatePath = path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
||||
"forgeflow",
|
||||
"release-signing-private.pem",
|
||||
);
|
||||
const privatePath = path.resolve(process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY || defaultPrivatePath);
|
||||
const publicPath = path.join(root, "build", "update-signing-public.pem");
|
||||
|
||||
let privateKey;
|
||||
try {
|
||||
privateKey = createPrivateKey(await readFile(privatePath));
|
||||
if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("The existing key is not Ed25519.");
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
privateKey = generateKeyPairSync("ed25519").privateKey;
|
||||
await mkdir(path.dirname(privatePath), { recursive: true, mode: 0o700 });
|
||||
await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600, flag: "wx" });
|
||||
}
|
||||
|
||||
const publicKey = createPublicKey(privateKey);
|
||||
const publicPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
await mkdir(path.dirname(publicPath), { recursive: true });
|
||||
await writeFile(publicPath, publicPem, { mode: 0o644 });
|
||||
const fingerprint = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
|
||||
console.log(`ForgeFlow Ed25519 update key ready. Public key fingerprint: SHA256:${fingerprint}`);
|
||||
console.log(`Private key: ${privatePath}`);
|
||||
console.log(`Public key: ${publicPath}`);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
||||
const privatePath = path.resolve(
|
||||
process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY ||
|
||||
path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "forgeflow", "release-signing-private.pem"),
|
||||
);
|
||||
const publicPath = path.join(root, "build", "update-signing-public.pem");
|
||||
const privateKey = createPrivateKey(await readFile(privatePath).catch((error) => {
|
||||
if (error.code === "ENOENT") throw new Error(`ForgeFlow update signing key is missing. Run npm run signing:setup once. Expected: ${privatePath}`);
|
||||
throw error;
|
||||
}));
|
||||
const publicKey = createPublicKey(await readFile(publicPath));
|
||||
if (!publicKey.equals(createPublicKey(privateKey))) throw new Error("The release private key does not match the public key embedded in ForgeFlow.");
|
||||
|
||||
const provenance = JSON.parse(await readFile(path.join(root, "dist", `ForgeFlow-${pkg.version}-provenance.json`), "utf8"));
|
||||
const artifacts = [];
|
||||
for (const kind of ["Setup", "Portable"]) {
|
||||
const name = `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`;
|
||||
const filePath = path.join(root, "dist", name);
|
||||
const bytes = await readFile(filePath);
|
||||
artifacts.push({ name, bytes: (await stat(filePath)).size, sha256: createHash("sha256").update(bytes).digest("hex") });
|
||||
}
|
||||
const keyId = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
product: "ForgeFlow",
|
||||
version: pkg.version,
|
||||
tag: `v${pkg.version}`,
|
||||
commit: provenance.commit,
|
||||
buildId: provenance.buildId,
|
||||
signature: { algorithm: "Ed25519", keyId: `SHA256:${keyId}` },
|
||||
artifacts,
|
||||
};
|
||||
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
const signature = sign(null, manifestBytes, privateKey);
|
||||
if (!verify(null, manifestBytes, publicKey, signature)) throw new Error("The generated release signature did not verify.");
|
||||
const manifestName = `ForgeFlow-${pkg.version}-release-manifest.json`;
|
||||
await writeFile(path.join(root, "dist", manifestName), manifestBytes, { mode: 0o644 });
|
||||
await writeFile(path.join(root, "dist", `${manifestName}.sig`), `${signature.toString("base64")}\n`, { mode: 0o644 });
|
||||
console.log(`${manifestName}: signed with SHA256:${keyId}`);
|
||||
+10
-2
@@ -47,6 +47,8 @@ const required = [
|
||||
"scripts/validate-installed-connections.cjs",
|
||||
"scripts/publish-binary-release.cjs",
|
||||
"scripts/write-release-checksums.mjs",
|
||||
"scripts/setup-update-signing-key.mjs",
|
||||
"scripts/sign-release-manifest.mjs",
|
||||
"scripts/prune-dist.mjs",
|
||||
"scripts/generate-source-manifest.mjs",
|
||||
"setup-windows.ps1",
|
||||
@@ -92,6 +94,7 @@ const required = [
|
||||
"docs/RELEASE_NOTES_0.10.10.md",
|
||||
"docs/RELEASE_NOTES_0.10.11.md",
|
||||
"docs/RELEASE_NOTES_0.10.12.md",
|
||||
"docs/RELEASE_NOTES_0.10.13.md",
|
||||
"docs/UPDATING.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/DEPLOYMENT_SETUP.md",
|
||||
@@ -123,6 +126,7 @@ const required = [
|
||||
"examples/server/status-example.json",
|
||||
"build/icon.png",
|
||||
"build/icon.ico",
|
||||
"build/update-signing-public.pem",
|
||||
];
|
||||
|
||||
for (const file of required) await access(path.join(root, file));
|
||||
@@ -130,9 +134,9 @@ for (const file of required) await access(path.join(root, file));
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (packageJson.version !== "0.10.12")
|
||||
if (packageJson.version !== "0.10.13")
|
||||
throw new Error(
|
||||
`Expected package version 0.10.12, got ${packageJson.version}.`,
|
||||
`Expected package version 0.10.13, got ${packageJson.version}.`,
|
||||
);
|
||||
const sourceManifest = await readFile(
|
||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||
@@ -494,6 +498,10 @@ const release01012 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.12.
|
||||
for (const phrase of ["coalesced", "exact Gitea commit parity", "batched Docker inspect", "bounded worker pools", "stopped container"]) {
|
||||
if (!release01012.includes(phrase)) throw new Error(`0.10.12 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const release01013 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.13.md"), "utf8");
|
||||
for (const phrase of ["Gitea workspace sync", "recovery branch", "Stale deployment links", "Ed25519-signed release manifest", "Git-toolsgrid"]) {
|
||||
if (!release01013.includes(phrase)) throw new Error(`0.10.13 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
|
||||
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
|
||||
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
|
||||
|
||||
@@ -25,7 +25,19 @@ for (const kind of ["Setup", "Portable"]) {
|
||||
}
|
||||
const commit = String(process.env.FORGEFLOW_BUILD_COMMIT || (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })).stdout).trim();
|
||||
const buildId = String(process.env.FORGEFLOW_BUILD_ID || `${manifest.version}-${commit.slice(0, 12)}`);
|
||||
const provenance = { schemaVersion: 1, product: "ForgeFlow", version: manifest.version, commit, buildId, createdAt: new Date().toISOString(), signedRelease: process.env.FORGEFLOW_SIGNED_RELEASE === "1", expectedPublisher: process.env.FORGEFLOW_EXPECTED_PUBLISHER || null, artifacts };
|
||||
const provenance = {
|
||||
schemaVersion: 1,
|
||||
product: "ForgeFlow",
|
||||
version: manifest.version,
|
||||
commit,
|
||||
buildId,
|
||||
createdAt: new Date().toISOString(),
|
||||
publisherManifestSignature: "Ed25519",
|
||||
authenticodeSigned: process.env.FORGEFLOW_SIGNED_RELEASE === "1",
|
||||
expectedAuthenticodePublisher:
|
||||
process.env.FORGEFLOW_EXPECTED_PUBLISHER || null,
|
||||
artifacts,
|
||||
};
|
||||
await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-provenance.json`), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
|
||||
const lock = JSON.parse(await readFile(path.join(root, "package-lock.json"), "utf8"));
|
||||
const components = Object.entries(lock.packages || {}).filter(([name]) => name.startsWith("node_modules/")).map(([name, value]) => ({ type: "library", name: name.slice(13), version: value.version || "unknown", licenses: value.license ? [{ license: { id: value.license } }] : undefined })).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
Reference in New Issue
Block a user