Published source
Download source ZIP
CH-J Server Manager
Browse directories and files for a specific application release.
Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.
1
"use strict";3
const test = require("node:test");4
const assert = require("node:assert/strict");5
const fs = require("node:fs");6
const os = require("node:os");7
const path = require("node:path");8
const openpgp = require("openpgp");9
const {10
OpenPgpVerifier,11
TRUSTED_PRIMARY_FINGERPRINT,12
normalizeFingerprint13
} = require("../src/main/updates/openPgpVerifier");14
const { UpdateService } = require("../src/main/updates/updateService");16
let fixturePromise;18
async 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;47
}49
async 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
});58
}60
function 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 };70
}72
function verifierFor(files, trustedPrimaryFingerprint, options = {}) {73
return new OpenPgpVerifier({74
publicKeyPath: files.publicKeyPath,75
trustedPrimaryFingerprint,76
...options77
});78
}80
test("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
});91
test("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
});101
test("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
});111
test("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
});120
test("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
});134
test("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
});145
test("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
});156
test("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
});166
test("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
});183
test("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
});210
test("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
});226
test("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
});239
test("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
});251
test("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: 20261
}).verifyFile(files.artifactPath, files.signaturePath),262
(error) => error.code === "UPDATE_SIGNATURE_TIMEOUT"263
);264
});266
test("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
});278
test("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: () => provider304
});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
});311
test("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: () => provider352
});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
});362
test("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