signed-updates.mjs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. /** Actual Electron download, checksum, Authenticode, and coordinator recovery; installer execution is forbidden. */
  2. import assert from 'node:assert/strict'
  3. import { copyFile, mkdir, readdir, writeFile } from 'node:fs/promises'
  4. import { join } from 'node:path'
  5. import { app } from 'electron'
  6. import updaterModule from 'electron-updater'
  7. import { DesktopUpdateCoordinator } from '../../lib/types/update-coordinator.js'
  8. import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
  9. import { resolveWindowsUpdatePublisher } from '../../scripts/windows-sign.mjs'
  10. import { artifactDigest, createArtifactUpdateServer } from './artifact-update-server.mjs'
  11. const root = process.env.DSH_SIGNED_UPDATE_TEST_ROOT
  12. assert.ok(root, 'Launcher must supply a private test root')
  13. app.setPath('userData', join(root, 'runtime', 'electron'))
  14. const { NsisUpdater } = updaterModule
  15. const report = { cases: [], installerExecuted: false, passed: false }
  16. async function main() {
  17. await app.whenReady()
  18. const publisher = resolveWindowsUpdatePublisher(process.env.DSH_SIGNED_UPDATE_CERTIFICATE)
  19. const server = await createArtifactUpdateServer({
  20. signed: process.env.DSH_SIGNED_UPDATE_SIGNED,
  21. unsigned: process.env.DSH_SIGNED_UPDATE_UNSIGNED,
  22. ...(process.env.DSH_SIGNED_UPDATE_OLD ? {
  23. old: process.env.DSH_SIGNED_UPDATE_OLD,
  24. oldBlockmap: `${process.env.DSH_SIGNED_UPDATE_OLD}.blockmap`,
  25. signedBlockmap: `${process.env.DSH_SIGNED_UPDATE_SIGNED}.blockmap`,
  26. } : {}),
  27. })
  28. report.inputs = server.artifacts
  29. report.publisherName = publisher
  30. report.simulatedInstalledVersion = '1.0.0'
  31. report.syntheticFeedVersion = '1.0.1-nightly.1'
  32. const fixtures = []
  33. let installed = 0
  34. async function fixture(expectedPublisher = publisher, differential = false, multipleRanges = true) {
  35. const directory = join(root, 'runtime', `case-${fixtures.length}`)
  36. await mkdir(directory)
  37. const config = join(directory, 'app-update.yml')
  38. await writeFile(config, JSON.stringify({ publisherName: [expectedPublisher], updaterCacheDirName: 'cache' }))
  39. const forbidden = () => { throw new Error('Unexpected application quit or relaunch') }
  40. const updater = new NsisUpdater(undefined, {
  41. version: '1.0.0', name: 'signed-download-qualification', isPackaged: true,
  42. appUpdateConfigPath: config, userDataPath: directory, baseCachePath: directory,
  43. whenReady: async () => {}, quit: forbidden, relaunch: forbidden, onQuit: forbidden,
  44. })
  45. const logs = []
  46. const errors = []
  47. const states = []
  48. updater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, value => logs.push(String(value))]))
  49. updater.httpExecutor = new DesktopUpdateHttpExecutor(60_000)
  50. updater.disableDifferentialDownload = !differential
  51. updater.disableWebInstaller = true
  52. updater.setFeedURL({ provider: 'generic', url: server.url, channel: 'nightly', useMultipleRangeRequest: multipleRanges })
  53. updater.on('error', error => errors.push(error.code))
  54. updater.quitAndInstall = () => { installed++ }
  55. let authorized = false
  56. const coordinator = new DesktopUpdateCoordinator(state => { states.push(state); return state },
  57. async () => authorized, updater, () => true, () => '1.0.0')
  58. const f = { coordinator, updater, logs, errors, states, directory, authorize: () => { authorized = true } }
  59. fixtures.push(f)
  60. return f
  61. }
  62. async function download(f) {
  63. const count = server.requests.length
  64. assert.equal((await f.coordinator.check(true)).phase, 'available')
  65. assert.equal(server.requests.length - count, 1, 'Checking must not download')
  66. assert.equal(server.requests.at(-1).path, '/nightly.yml')
  67. return f.coordinator.download('1.0.1-nightly.1')
  68. }
  69. async function rejected(f, code) {
  70. const state = await download(f)
  71. assert.equal(state.phase, 'error')
  72. assert.equal(state.failedOperation, 'download')
  73. assert.ok(f.errors.includes(code), `Expected updater rejection ${code}, received ${f.errors}`)
  74. assert.ok(!f.states.some(item => item.phase === 'ready'))
  75. await assert.rejects(f.coordinator.install('1.0.1-nightly.1'), /not ready/u)
  76. const cache = await readdir(join(f.directory, 'cache'), { recursive: true })
  77. assert.ok(!cache.some(file => file.endsWith('.exe')), 'Rejected executables must be removed from cache')
  78. const count = server.requests.length
  79. await f.coordinator.check()
  80. assert.equal(server.requests.length, count, 'Automatic checks must not retry rejected downloads')
  81. assert.equal(installed, 0)
  82. }
  83. try {
  84. server.select('signed')
  85. const wrong = await fixture('CN=Not the release publisher')
  86. await rejected(wrong, 'ERR_UPDATER_INVALID_SIGNATURE')
  87. report.cases.push('valid-hash-wrong-publisher-rejected-and-cleared')
  88. server.select('unsigned')
  89. const unsigned = await fixture()
  90. await rejected(unsigned, 'ERR_UPDATER_INVALID_SIGNATURE')
  91. report.cases.push('valid-hash-unsigned-rejected-and-cleared')
  92. server.select('signed', true)
  93. const corrupt = await fixture()
  94. await rejected(corrupt, 'ERR_CHECKSUM_MISMATCH')
  95. assert.ok(!corrupt.logs.some(line => line.startsWith('Verifying signature ')), 'Corrupt bytes must fail before signature verification')
  96. report.cases.push('corrupt-transfer-rejected-before-signature')
  97. server.select('signed')
  98. assert.equal((await download(unsigned)).phase, 'ready')
  99. assert.equal(await artifactDigest(unsigned.updater.installerPath), server.artifacts.signed.sha512)
  100. assert.ok(unsigned.states.some(state => state.phase === 'verifying'))
  101. assert.ok(unsigned.logs.some(line => line.startsWith('Verifying signature ')))
  102. const count = server.requests.length
  103. await unsigned.coordinator.check(true)
  104. await unsigned.coordinator.download('1.0.1-nightly.1')
  105. assert.equal(server.requests.length, count, 'Prepared targets must not download again')
  106. assert.equal(installed, 0)
  107. assert.equal((await unsigned.coordinator.install('1.0.1-nightly.1')).phase, 'ready')
  108. assert.equal(installed, 0)
  109. unsigned.authorize()
  110. assert.equal((await unsigned.coordinator.install('1.0.1-nightly.1')).phase, 'installing')
  111. assert.equal(installed, 1, 'Installation handoff requires separate approval')
  112. report.cases.push('explicit-retry-signed-ready-and-separate-install-handoff')
  113. if (server.artifacts.old) {
  114. report.differential = []
  115. for (const [name, multipleRanges, fault] of [
  116. ['multipart-range-reconstruction', true],
  117. ['single-range-reconstruction', false],
  118. ['missing-old-blockmap-full-fallback', true, 'missing-old-blockmap'],
  119. ['rejected-range-full-fallback', true, 'reject-ranges'],
  120. ]) {
  121. const f = await fixture(publisher, true, multipleRanges)
  122. await mkdir(join(f.directory, 'cache'))
  123. await copyFile(server.artifacts.old.file, join(f.directory, 'cache', 'installer.exe'))
  124. server.select('signed', false, fault)
  125. const start = server.requests.length
  126. assert.equal((await download(f)).phase, 'ready')
  127. assert.equal(await artifactDigest(f.updater.installerPath), server.artifacts.signed.sha512)
  128. assert.ok(f.logs.some(line => line.startsWith('Verifying signature ')))
  129. assert.equal(installed, 1, 'Download must not invoke another installer handoff')
  130. const requests = server.requests.slice(start)
  131. const payload = requests.filter(request => request.path.endsWith('.exe'))
  132. const fallback = f.logs.some(line => line.includes('fallback to full download'))
  133. assert.ok(requests.some(request => request.path.endsWith('1.0.0.exe.blockmap')))
  134. assert.ok(requests.some(request => request.path.endsWith('1.0.1-nightly.1.exe.blockmap')))
  135. if (!fault) {
  136. assert.equal(fallback, false, 'A full fallback cannot pass differential qualification')
  137. assert.ok(payload.length > 0 && payload.every(request => request.range))
  138. assert.equal(payload.some(request => request.range.includes(',')), multipleRanges)
  139. assert.ok(payload.reduce((bytes, request) => bytes + request.bytes, 0) < server.artifacts.signed.size,
  140. 'Reconstruction must reuse bytes from the cached installer')
  141. }
  142. else {
  143. assert.equal(fallback, true)
  144. assert.equal(payload.filter(request => !request.range).length, 1)
  145. assert.equal(payload.at(-1).bytes, server.artifacts.signed.size)
  146. if (fault === 'reject-ranges') assert.ok(payload.some(request => request.range))
  147. }
  148. report.differential.push({ name, requests, fallback,
  149. payloadBytes: payload.reduce((bytes, request) => bytes + request.bytes, 0),
  150. fullSize: server.artifacts.signed.size })
  151. report.cases.push(name)
  152. }
  153. }
  154. assert.ok(fixtures.every(f => !f.logs.some(line => line.includes('Ignoring signature validation'))))
  155. assert.deepEqual(server.failures, [])
  156. for (const artifact of Object.values(server.artifacts)) assert.equal(await artifactDigest(artifact.file), artifact.sha512)
  157. report.passed = true
  158. }
  159. finally {
  160. for (const f of fixtures) f.coordinator.dispose()
  161. await server.close()
  162. report.requests = server.requests
  163. report.fixtures = fixtures.map(f => ({ errors: f.errors, phases: f.states.map(state => state.phase) }))
  164. await writeFile(join(root, 'result.json'), `${JSON.stringify(report, null, 2)}\n`)
  165. }
  166. }
  167. main().then(() => app.exit(0), async (error) => {
  168. console.error(error)
  169. await writeFile(join(root, 'failure.txt'), `${error.stack ?? error}\n`)
  170. app.exit(1)
  171. })