CH-J Server Managerserver management over SSH
Menu
Published source

CH-J Server Manager

Browse directories and files for a specific application release.

Download source ZIP
CH-J Proprietary Software License 1.14

Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.

16,3 KB · 369 linesDownload file
1"use strict";
3const test = require("node:test");
4const assert = require("node:assert/strict");
5const fs = require("node:fs");
6const os = require("node:os");
7const path = require("node:path");
8const openpgp = require("openpgp");
9const {
10 OpenPgpVerifier,
11 TRUSTED_PRIMARY_FINGERPRINT,
12 normalizeFingerprint
13} = require("../src/main/updates/openPgpVerifier");
14const { UpdateService } = require("../src/main/updates/updateService");
16let fixturePromise;
18async function fixtures() {
19 if (!fixturePromise) {
20 fixturePromise = (async () => {
21 const trusted = await openpgp.generateKey({
22 type: "ecc",
23 curve: "ed25519Legacy",
24 userIDs: [{ name: "CH-J updater test key" }],
25 subkeys: [{ sign: true }, { sign: true }],
26 format: "object"
27 });
28 const foreign = await openpgp.generateKey({
29 type: "ecc",
30 curve: "ed25519Legacy",
31 userIDs: [{ name: "Foreign updater test key" }],
32 subkeys: [{ sign: true }],
33 format: "object"
34 });
35 return {
36 trustedPrivateArmor: trusted.privateKey.armor(),
37 trustedPublicArmor: trusted.publicKey.armor(),
38 trustedFingerprint: normalizeFingerprint(trusted.publicKey.getFingerprint()),
39 trustedSigningFingerprints: trusted.publicKey.getSubkeys().map((key) => normalizeFingerprint(key.getFingerprint())),
40 foreignPrivateArmor: foreign.privateKey.armor(),
41 foreignPublicArmor: foreign.publicKey.armor(),
42 foreignFingerprint: normalizeFingerprint(foreign.publicKey.getFingerprint())
43 };
44 })();
45 }
46 return fixturePromise;
49async function detachedSignature(privateArmor, bytes, subkeyIndex = 0) {
50 const privateKey = await openpgp.readPrivateKey({ armoredKey: privateArmor });
51 return openpgp.sign({
52 message: await openpgp.createMessage({ binary: bytes }),
53 signingKeys: privateKey,
54 signingKeyIDs: privateKey.getSubkeys()[subkeyIndex].getKeyID(),
55 detached: true,
56 format: "armored"
57 });
60function verificationFiles(t, publicArmor, bytes, signature) {
61 const root = fs.mkdtempSync(path.join(os.tmpdir(), "chj-openpgp-test-"));
62 t.after(() => fs.rmSync(root, { recursive: true, force: true }));
63 const publicKeyPath = path.join(root, "trusted.asc");
64 const artifactPath = path.join(root, "update.bin");
65 const signaturePath = path.join(root, "update.bin.asc");
66 fs.writeFileSync(publicKeyPath, publicArmor, { mode: 0o600 });
67 fs.writeFileSync(artifactPath, bytes, { mode: 0o600 });
68 if (signature !== null) fs.writeFileSync(signaturePath, signature, { mode: 0o600 });
69 return { publicKeyPath, artifactPath, signaturePath };
72function verifierFor(files, trustedPrimaryFingerprint, options = {}) {
73 return new OpenPgpVerifier({
74 publicKeyPath: files.publicKeyPath,
75 trustedPrimaryFingerprint,
76 ...options
77 });
80test("bundled production certificate has the hardcoded trusted primary fingerprint", async () => {
81 const armoredKey = fs.readFileSync(path.join(__dirname, "..", "ch-j-signing-public.asc"), "utf8");
82 const key = await openpgp.readKey({ armoredKey });
83 assert.equal(normalizeFingerprint(key.getFingerprint()), TRUSTED_PRIMARY_FINGERPRINT);
84 await key.verifyPrimaryKey(new Date());
85 const signingSubkey = key.getSubkeys()[0];
86 assert.ok(signingSubkey, "production signing subkey is missing");
87 const binding = await signingSubkey.verify(new Date());
88 assert.ok([...binding.keyFlags].some((flags) => (flags & openpgp.enums.keyFlags.signData) !== 0));
89});
91test("valid file and detached signature by a trusted signing subkey pass", async (t) => {
92 const fixture = await fixtures();
93 const bytes = Buffer.from("authentic update artifact");
94 const signature = await detachedSignature(fixture.trustedPrivateArmor, bytes, 0);
95 const files = verificationFiles(t, fixture.trustedPublicArmor, bytes, signature);
96 const result = await verifierFor(files, fixture.trustedFingerprint).verifyFile(files.artifactPath, files.signaturePath);
97 assert.equal(result.valid, true);
98 assert.deepEqual(result.signingFingerprints, [fixture.trustedSigningFingerprints[0]]);
99});
101test("an artifact changed after signing is rejected", async (t) => {
102 const fixture = await fixtures();
103 const signature = await detachedSignature(fixture.trustedPrivateArmor, Buffer.from("original"));
104 const files = verificationFiles(t, fixture.trustedPublicArmor, Buffer.from("modified"), signature);
105 await assert.rejects(
106 () => verifierFor(files, fixture.trustedFingerprint).verifyFile(files.artifactPath, files.signaturePath),
107 (error) => error.code === "UPDATE_SIGNATURE_INVALID"
108 );
109});
111test("a missing signature is rejected", async (t) => {
112 const fixture = await fixtures();
113 const files = verificationFiles(t, fixture.trustedPublicArmor, Buffer.from("update"), null);
114 await assert.rejects(
115 () => verifierFor(files, fixture.trustedFingerprint).verifyFile(files.artifactPath, files.signaturePath),
116 (error) => error.code === "UPDATE_SIGNATURE_FILE_MISSING"
117 );
118});
120test("an empty or malformed signature is rejected", async (t) => {
121 const fixture = await fixtures();
122 const empty = verificationFiles(t, fixture.trustedPublicArmor, Buffer.from("update"), "");
123 await assert.rejects(
124 () => verifierFor(empty, fixture.trustedFingerprint).verifyFile(empty.artifactPath, empty.signaturePath),
125 (error) => error.code === "UPDATE_SIGNATURE_EMPTY"
126 );
127 const malformed = verificationFiles(t, fixture.trustedPublicArmor, Buffer.from("update"), "not an OpenPGP signature");
128 await assert.rejects(
129 () => verifierFor(malformed, fixture.trustedFingerprint).verifyFile(malformed.artifactPath, malformed.signaturePath),
130 (error) => error.code === "UPDATE_SIGNATURE_MALFORMED"
131 );
132});
134test("a valid signature from an unrelated keypair is rejected", async (t) => {
135 const fixture = await fixtures();
136 const bytes = Buffer.from("foreign update");
137 const signature = await detachedSignature(fixture.foreignPrivateArmor, bytes);
138 const files = verificationFiles(t, fixture.trustedPublicArmor, bytes, signature);
139 await assert.rejects(
140 () => verifierFor(files, fixture.trustedFingerprint).verifyFile(files.artifactPath, files.signaturePath),
141 (error) => error.code === "UPDATE_SIGNATURE_INVALID" || error.code === "UPDATE_SIGNATURE_UNTRUSTED_SIGNER"
142 );
143});
145test("a public certificate with a different primary fingerprint is rejected", async (t) => {
146 const fixture = await fixtures();
147 const bytes = Buffer.from("update");
148 const signature = await detachedSignature(fixture.foreignPrivateArmor, bytes);
149 const files = verificationFiles(t, fixture.foreignPublicArmor, bytes, signature);
150 await assert.rejects(
151 () => verifierFor(files, fixture.trustedFingerprint).verifyFile(files.artifactPath, files.signaturePath),
152 (error) => error.code === "UPDATE_PUBLIC_KEY_FINGERPRINT_MISMATCH"
153 );
154});
156test("a second future signing subkey bound to the same primary key passes", async (t) => {
157 const fixture = await fixtures();
158 const bytes = Buffer.from("future subkey update");
159 const signature = await detachedSignature(fixture.trustedPrivateArmor, bytes, 1);
160 const files = verificationFiles(t, fixture.trustedPublicArmor, bytes, signature);
161 const result = await verifierFor(files, fixture.trustedFingerprint).verifyFile(files.artifactPath, files.signaturePath);
162 assert.equal(result.valid, true);
163 assert.deepEqual(result.signingFingerprints, [fixture.trustedSigningFingerprints[1]]);
164});
166test("a revoked signing subkey is rejected", async (t) => {
167 const fixture = await fixtures();
168 const bytes = Buffer.from("revoked subkey update");
169 const signature = await detachedSignature(fixture.trustedPrivateArmor, bytes, 0);
170 const privateKey = await openpgp.readPrivateKey({ armoredKey: fixture.trustedPrivateArmor });
171 privateKey.subkeys[0] = await privateKey.subkeys[0].revoke(
172 privateKey.keyPacket,
173 { flag: openpgp.enums.reasonForRevocation.keyRetired, string: "Updater test revocation" },
174 new Date()
175 );
176 const files = verificationFiles(t, privateKey.toPublic().armor(), bytes, signature);
177 await assert.rejects(
178 () => verifierFor(files, fixture.trustedFingerprint).verifyFile(files.artifactPath, files.signaturePath),
179 (error) => ["UPDATE_SIGNATURE_INVALID", "UPDATE_SIGNATURE_UNTRUSTED_SIGNER", "UPDATE_SIGNING_SUBKEY_INVALID"].includes(error.code)
180 );
181});
183test("an expired signing subkey is rejected even when the signature was created before expiry", async (t) => {
184 const created = new Date(Date.now() - 120_000);
185 const generated = await openpgp.generateKey({
186 type: "ecc",
187 curve: "ed25519Legacy",
188 date: created,
189 userIDs: [{ name: "Expired updater test subkey" }],
190 subkeys: [{ sign: true, keyExpirationTime: 60 }],
191 format: "object"
192 });
193 const bytes = Buffer.from("expired subkey update");
194 const signature = await openpgp.sign({
195 message: await openpgp.createMessage({ binary: bytes }),
196 signingKeys: generated.privateKey,
197 signingKeyIDs: generated.privateKey.getSubkeys()[0].getKeyID(),
198 detached: true,
199 format: "armored",
200 date: new Date(created.getTime() + 30_000)
201 });
202 const fingerprint = normalizeFingerprint(generated.publicKey.getFingerprint());
203 const files = verificationFiles(t, generated.publicKey.armor(), bytes, signature);
204 await assert.rejects(
205 () => verifierFor(files, fingerprint).verifyFile(files.artifactPath, files.signaturePath),
206 (error) => ["UPDATE_SIGNATURE_INVALID", "UPDATE_SIGNATURE_UNTRUSTED_SIGNER", "UPDATE_SIGNING_SUBKEY_INVALID"].includes(error.code)
207 );
208});
210test("a revoked primary key is rejected before artifact verification", async (t) => {
211 const fixture = await fixtures();
212 const bytes = Buffer.from("revoked primary update");
213 const signature = await detachedSignature(fixture.trustedPrivateArmor, bytes);
214 const privateKey = await openpgp.readPrivateKey({ armoredKey: fixture.trustedPrivateArmor });
215 const revokedKey = await privateKey.revoke(
216 { flag: openpgp.enums.reasonForRevocation.keyRetired, string: "Updater test primary revocation" },
217 new Date()
218 );
219 const files = verificationFiles(t, revokedKey.toPublic().armor(), bytes, signature);
220 await assert.rejects(
221 () => verifierFor(files, fixture.trustedFingerprint).verifyFile(files.artifactPath, files.signaturePath),
222 (error) => error.code === "UPDATE_PUBLIC_KEY_INVALID"
223 );
224});
226test("a signing subkey with a corrupted binding signature is rejected", async (t) => {
227 const fixture = await fixtures();
228 const bytes = Buffer.from("invalid binding update");
229 const signature = await detachedSignature(fixture.trustedPrivateArmor, bytes, 0);
230 const publicKey = await openpgp.readKey({ armoredKey: fixture.trustedPublicArmor });
231 publicKey.subkeys[0].bindingSignatures[0].params.r[0] ^= 1;
232 const files = verificationFiles(t, publicKey.armor(), bytes, signature);
233 await assert.rejects(
234 () => verifierFor(files, fixture.trustedFingerprint).verifyFile(files.artifactPath, files.signaturePath),
235 (error) => ["UPDATE_SIGNATURE_INVALID", "UPDATE_SIGNATURE_UNTRUSTED_SIGNER", "UPDATE_SIGNING_SUBKEY_INVALID"].includes(error.code)
236 );
237});
239test("a verification exception fails closed", async (t) => {
240 const fixture = await fixtures();
241 const bytes = Buffer.from("exception update");
242 const signature = await detachedSignature(fixture.trustedPrivateArmor, bytes);
243 const files = verificationFiles(t, fixture.trustedPublicArmor, bytes, signature);
244 const openpgpImpl = { ...openpgp, verify: async () => { throw new Error("simulated verifier failure"); } };
245 await assert.rejects(
246 () => verifierFor(files, fixture.trustedFingerprint, { openpgpImpl }).verifyFile(files.artifactPath, files.signaturePath),
247 (error) => error.code === "UPDATE_SIGNATURE_INVALID"
248 );
249});
251test("a verification timeout fails closed", async (t) => {
252 const fixture = await fixtures();
253 const bytes = Buffer.from("timeout update");
254 const signature = await detachedSignature(fixture.trustedPrivateArmor, bytes);
255 const files = verificationFiles(t, fixture.trustedPublicArmor, bytes, signature);
256 const openpgpImpl = { ...openpgp, verify: () => new Promise(() => {}) };
257 await assert.rejects(
258 () => verifierFor(files, fixture.trustedFingerprint, {
259 openpgpImpl,
260 verificationTimeoutMs: 20
261 }).verifyFile(files.artifactPath, files.signaturePath),
262 (error) => error.code === "UPDATE_SIGNATURE_TIMEOUT"
263 );
264});
266test("a missing bundled public key fails closed", async (t) => {
267 const fixture = await fixtures();
268 const bytes = Buffer.from("missing key update");
269 const signature = await detachedSignature(fixture.trustedPrivateArmor, bytes);
270 const files = verificationFiles(t, fixture.trustedPublicArmor, bytes, signature);
271 fs.unlinkSync(files.publicKeyPath);
272 await assert.rejects(
273 () => verifierFor(files, fixture.trustedFingerprint).verifyFile(files.artifactPath, files.signaturePath),
274 (error) => error.code === "UPDATE_SIGNATURE_FILE_MISSING"
275 );
276});
278test("installer flow cannot start before main-process verification", async () => {
279 const selected = {
280 id: "release-1",
281 version: "0.0.2",
282 publishedAt: "2026-08-13T12:00:00Z",
283 filename: "update.exe",
284 size: 6,
285 sha512: "a".repeat(128)
286 };
287 const configStore = {
288 get: () => ({ updates: { channel: "alpha", lastLaunchedRelease: null } }),
289 recordLaunchedRelease() { throw new Error("must not record"); }
290 };
291 const provider = {
292 async listReleases() { return { releases: [selected], sourceBaseUrl: "https://updates.example/" }; },
293 async downloadAndVerify() {
294 return { path: "/tmp/update.exe", filename: "update.exe", size: 6, sha512: selected.sha512, signatureVerified: false };
295 },
296 async reverifyForInstall() { throw new Error("must not be called"); }
297 };
298 const service = new UpdateService({
299 appVersion: "0.0.1",
300 platform: "win32",
301 arch: "x64",
302 configStore,
303 providerFactory: () => provider
304 });
305 await service.check();
306 await assert.rejects(() => service.prepareInstallerLaunch(), /No verified update/);
307 await assert.rejects(() => service.download(), /mandatory OpenPGP verification/);
308 assert.throws(() => service.markInstallerLaunched(), /No verified update/);
309});
311test("installer launch is authorized only after the main process re-verifies the tracked artifact", async () => {
312 const selected = {
313 id: "release-2",
314 version: "0.0.2",
315 publishedAt: "2026-08-13T12:00:00Z",
316 filename: "update.exe",
317 size: 6,
318 sha512: "b".repeat(128)
319 };
320 let recorded = null;
321 let reverifyCalls = 0;
322 const provider = {
323 async listReleases() { return { releases: [selected], sourceBaseUrl: "https://updates.example/" }; },
324 async downloadAndVerify() {
325 return {
326 path: "/private/cache/update.exe",
327 signaturePath: "/private/cache/update.exe.asc",
328 filename: selected.filename,
329 size: selected.size,
330 sha512: selected.sha512,
331 signatureVerified: true,
332 primaryFingerprint: "0".repeat(40),
333 signingFingerprints: ["1".repeat(40)]
334 };
335 },
336 async reverifyForInstall(download, release) {
337 reverifyCalls += 1;
338 assert.equal(download.path, "/private/cache/update.exe");
339 assert.equal(release.id, selected.id);
340 return download.path;
341 }
342 };
343 const service = new UpdateService({
344 appVersion: "0.0.1",
345 platform: "win32",
346 arch: "x64",
347 configStore: {
348 get: () => ({ updates: { channel: "alpha", lastLaunchedRelease: null } }),
349 recordLaunchedRelease(value) { recorded = value; }
350 },
351 providerFactory: () => provider
352 });
353 await service.check();
354 await service.download();
355 assert.throws(() => service.markInstallerLaunched(), /No verified update/);
356 assert.equal(await service.prepareInstallerLaunch(), "/private/cache/update.exe");
357 service.markInstallerLaunched();
358 assert.equal(reverifyCalls, 1);
359 assert.equal(recorded.id, selected.id);
360});
362test("renderer API cannot provide paths, keys, fingerprints, or verified=true", () => {
363 const preload = fs.readFileSync(path.join(__dirname, "..", "src", "preload", "corePreload.js"), "utf8");
364 const ipc = fs.readFileSync(path.join(__dirname, "..", "src", "main", "ipc", "registerCoreIpc.js"), "utf8");
365 assert.match(preload, /downloadUpdate:\s*\(\)\s*=>\s*ipcRenderer\.invoke\("updates:download"\)/);
366 assert.match(preload, /installUpdate:\s*\(\)\s*=>\s*ipcRenderer\.invoke\("updates:install"\)/);
367 assert.match(ipc, /await updateService\.prepareInstallerLaunch\(\)/);
368 assert.doesNotMatch(preload, /verificationSuccess|signaturePath|publicKeyPath|trustedPrimaryFingerprint|verified\s*:/);
369});

SHA-256: ca639caa9c7272526a6e2802f212a45336a4fa7c80cca0a85d9d5633c2e63d96

Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0