installed-update-signature.mjs 4.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
  1. /** Verify supplied executable files without invoking SignTool, private keys, or installer entry points. */
  2. import { execFile } from 'node:child_process'
  3. import { createHash } from 'node:crypto'
  4. import { createReadStream } from 'node:fs'
  5. import { writeFile } from 'node:fs/promises'
  6. import { createRequire } from 'node:module'
  7. import { join, resolve } from 'node:path'
  8. import { promisify } from 'node:util'
  9. const require = createRequire(import.meta.url)
  10. /**
  11. * Hash a file without loading its complete contents into memory.
  12. * @param {string} path Local file.
  13. * @returns {Promise<string>} Base64 SHA-512.
  14. */
  15. export async function installedUpdateFileHash(path) {
  16. const hash = createHash('sha512')
  17. for await (const bytes of createReadStream(path)) hash.update(bytes)
  18. return hash.digest('base64')
  19. }
  20. /**
  21. * Require the real updater verifier, a valid Authenticode signature, and a timestamp.
  22. * @param {string} file Executable inspected as data, never executed.
  23. * @param {string} publisher Expected DN from the trusted public release certificate, not downloaded YAML.
  24. * @param {string} directory New private evidence directory owned by this signature check.
  25. * @returns {Promise<object>} Public signature attributes and unchanged-file SHA-512; no installation claim.
  26. */
  27. export async function verifyInstalledUpdateSignature(file, publisher, directory) {
  28. if (process.platform !== 'win32') throw new Error('installed update: signature verification requires Windows')
  29. const before = await installedUpdateFileHash(file)
  30. const config = join(directory, 'signature-config.json')
  31. await writeFile(config, `${JSON.stringify({ publisherName: [publisher] })}\n`, { flag: 'wx', flush: true })
  32. const environment = Object.fromEntries(Object.entries(process.env)
  33. .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$|^PSModulePath$/iu.test(name)))
  34. const options = { env: environment, encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 64 * 1024 }
  35. const verification = await promisify(execFile)(process.execPath, [import.meta.filename, '--updater', config, file], options)
  36. if (JSON.parse(verification.stdout).updaterVerificationInvoked !== true) {
  37. throw new Error('installed update: updater signature verification was not confirmed')
  38. }
  39. const { stdout, stderr } = await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
  40. '$ErrorActionPreference="Stop"; $s=Get-AuthenticodeSignature -LiteralPath $env:DSH_VERIFY_FILE; [pscustomobject]@{valid=($s.Status -eq "Valid");timestamped=($null -ne $s.TimeStamperCertificate);signer=$s.SignerCertificate.Thumbprint;timestamp=$s.TimeStamperCertificate.Thumbprint}|ConvertTo-Json -Compress'],
  41. { ...options, env: { ...environment, DSH_VERIFY_FILE: file } })
  42. const details = JSON.parse(stdout)
  43. if (stderr || details.valid !== true || details.timestamped !== true || !/^[A-Fa-f0-9]{40}$/u.test(details.signer)
  44. || !/^[A-Fa-f0-9]{40}$/u.test(details.timestamp)) throw new Error('installed update: valid timestamped signature is required')
  45. if (await installedUpdateFileHash(file) !== before) throw new Error('installed update: file changed during signature verification')
  46. return { sha512: before, valid: true, timestamped: true, signerThumbprint: details.signer,
  47. timestampThumbprint: details.timestamp, updaterVerificationInvoked: true }
  48. }
  49. async function verifyWithUpdater(config, file) {
  50. const { NsisUpdater } = require('electron-updater')
  51. const updater = new NsisUpdater(null, { version: '0.0.0', isPackaged: true })
  52. updater.autoInstallOnAppQuit = false
  53. updater.updateConfigPath = config
  54. const logs = []
  55. updater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, value => logs.push(String(value))]))
  56. try {
  57. const result = await updater.verifySignature(file)
  58. if (result !== null || !logs.some(line => line.startsWith('Verifying signature '))
  59. || logs.some(line => line.includes('Ignoring signature validation'))) {
  60. throw new Error('installed update: updater signature verification rejected the file or was skipped')
  61. }
  62. return { updaterVerificationInvoked: true }
  63. } finally { updater.removeAllListeners() }
  64. }
  65. if (process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(import.meta.filename)) {
  66. const [mode, config, file, ...extra] = process.argv.slice(2)
  67. if (mode !== '--updater' || !config || !file || extra.length) process.exitCode = 1
  68. else verifyWithUpdater(config, file).then(result => console.log(JSON.stringify(result))).catch(() => {
  69. console.error('installed update: updater signature verification failed')
  70. process.exitCode = 1
  71. })
  72. }