test-windows-update-signature.mjs 4.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. /** Real NSIS publisher verification against supplied files; never signs or executes an installer. */
  2. import assert from 'node:assert/strict'
  3. import { createHash } from 'node:crypto'
  4. import { createReadStream } from 'node:fs'
  5. import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
  6. import { createRequire } from 'node:module'
  7. import { join } from 'node:path'
  8. import { fileURLToPath } from 'node:url'
  9. import { resolveWindowsUpdatePublisher } from './windows-sign.mjs'
  10. assert.equal(process.platform, 'win32', 'Authenticode qualification requires Windows')
  11. const [certificateFile, signedFile, unsignedFile] = process.argv.slice(2)
  12. assert.ok(certificateFile && signedFile && unsignedFile,
  13. 'Usage: node apps/desktop/scripts/test-windows-update-signature.mjs <public.cer> <signed.exe> <unsigned.exe>')
  14. const require = createRequire(import.meta.url)
  15. const { NsisUpdater } = require('electron-updater')
  16. const { WindowsSignToolManager } = require('app-builder-lib/out/codeSign/windowsSignToolManager.js')
  17. const { getAppUpdatePublishConfiguration } = require('app-builder-lib/out/publish/PublishManager.js')
  18. const { Platform } = require('app-builder-lib')
  19. const publisherName = resolveWindowsUpdatePublisher(certificateFile)
  20. const signed = await realpath(signedFile)
  21. const unsigned = await realpath(unsignedFile)
  22. assert.notEqual(signed, unsigned, 'Signed and unsigned controls must be distinct files')
  23. const qualification = fileURLToPath(new URL('../.desktop-build/qualification/', import.meta.url))
  24. await mkdir(qualification, { recursive: true })
  25. const root = await mkdtemp(join(qualification, 'windows-update-signature-'))
  26. const report = { publisherName, installerExecuted: false, cases: [], passed: false }
  27. async function digest(file) {
  28. const hash = createHash('sha512')
  29. for await (const chunk of createReadStream(file)) hash.update(chunk)
  30. return hash.digest('base64')
  31. }
  32. try {
  33. const before = { signed: await digest(signed), unsigned: await digest(unsigned) }
  34. const platformSpecificBuildOptions = { signtoolOptions: { publisherName } }
  35. const manager = new WindowsSignToolManager({ platformSpecificBuildOptions })
  36. // Keep builder/updater metadata code real; no packaging, key lookup, HTTP, or installer entry runs.
  37. const metadata = await getAppUpdatePublishConfiguration({
  38. platform: Platform.WINDOWS,
  39. platformSpecificBuildOptions,
  40. appInfo: { updaterCacheDirName: 'private-signature-qualification' },
  41. signingManager: { value: Promise.resolve(manager) },
  42. isForceCodeSigningVerification: true,
  43. info: {},
  44. config: { publish: [{ provider: 'generic', url: 'https://unused.invalid/', channel: 'nightly' }] },
  45. expandMacro: value => value,
  46. }, null, 1, true)
  47. assert.deepEqual(metadata.publisherName, [publisherName])
  48. for (const [name, file, config, accepted] of [
  49. ['missing-publisher-negative-control', signed, { ...metadata, publisherName: undefined }, true],
  50. ['matching-publisher', signed, metadata, true],
  51. ['wrong-publisher', signed, { ...metadata, publisherName: ['CN=Not the release publisher'] }, false],
  52. ['unsigned-file', unsigned, metadata, false],
  53. ]) {
  54. const configFile = join(root, `${name}.yml`)
  55. await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx' })
  56. const updater = new NsisUpdater(null, { version: '0.1.0', isPackaged: true })
  57. updater.autoInstallOnAppQuit = false
  58. updater.updateConfigPath = configFile
  59. const logs = []
  60. updater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, value => logs.push(String(value))]))
  61. const result = await updater.verifySignature(file)
  62. assert.equal(result === null, accepted, `${name}: unexpected signature result`)
  63. assert.ok(!logs.some(line => line.includes('Ignoring signature validation')), 'PowerShell verification must not be skipped')
  64. const verificationInvoked = logs.some(line => line.startsWith('Verifying signature '))
  65. assert.equal(verificationInvoked, name !== 'missing-publisher-negative-control')
  66. if (name === 'wrong-publisher') assert.match(result, /"Status": 0/u, 'Wrong-publisher control must have a valid signature')
  67. report.cases.push({ name, file, accepted, verificationInvoked, sha512: file === signed ? before.signed : before.unsigned })
  68. updater.removeAllListeners()
  69. }
  70. assert.equal(await digest(signed), before.signed, 'Signed input changed during verification')
  71. assert.equal(await digest(unsigned), before.unsigned, 'Unsigned input changed during verification')
  72. report.passed = true
  73. }
  74. finally {
  75. await writeFile(join(root, 'result.json'), `${JSON.stringify(report, null, 2)}\n`, { flag: 'wx' })
  76. console.log(`WINDOWS_UPDATE_SIGNATURE_RESULT ${join(root, 'result.json')}`)
  77. }