verify-installed-update-package.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. /** Verify signed installer bytes and extract their payload without running any installer or signing operation. */
  2. import { execFile } from 'node:child_process'
  3. import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
  4. import { join, resolve } from 'node:path'
  5. import { promisify } from 'node:util'
  6. import { readInstalledUpdateRun } from './installed-update-qualification.ts'
  7. import { planInstalledUpdateDistribution } from './installed-update-distribution.ts'
  8. import { installedUpdateFileHash, verifyInstalledUpdateSignature } from './installed-update-signature.mjs'
  9. import { resolveWindowsUpdatePublisher } from './windows-sign.mjs'
  10. import { verifyInstalledUpdatePackageContent } from './installed-update-package-content.ts'
  11. import { recordPackagingEvent } from './packaging-run.mjs'
  12. /**
  13. * Reject nonrelative archive names and link entries before extraction into a new directory.
  14. * @param listing The pinned 7-Zip's UTF-8 technical listing, with archive headers suppressed.
  15. * @returns Number of relative archive entries; rejects empty or unsafe listings.
  16. */
  17. export function validateInstalledUpdateArchivePaths(listing: string): number {
  18. const paths = [...listing.matchAll(/^Path = (.+)\r?$/gmu)].map(match => match[1]!.replace(/\r$/u, '').replaceAll('\\', '/'))
  19. if (paths.length === 0 || /^(?:Symbolic Link|Hard Link|Reparse Point) = .+/mu.test(listing)
  20. || /^Attributes = .*\blrwx/mu.test(listing)) throw new Error('installed update: archive links or missing entries are not accepted')
  21. const seen = new Set<string>()
  22. for (const path of paths) {
  23. if (seen.has(path.toLowerCase())) throw new Error('installed update: archive contains duplicate Windows paths')
  24. seen.add(path.toLowerCase())
  25. if (/[\x00-\x1f:*?"<>|]/u.test(path) || path.split('/').some(part => part === '' || part === '.' || part === '..'
  26. || /[. ]$/u.test(part) || /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/iu.test(part))) {
  27. throw new Error('installed update: archive contains an unsafe path')
  28. }
  29. }
  30. return paths.length
  31. }
  32. /**
  33. * Produce retained signature, extraction, and content evidence for one final installer.
  34. * @param manifest Original test run.
  35. * @param version One exact run version.
  36. * @param certificate Trusted public certificate; no .env or PIN is read.
  37. * @param archiveTool Reviewed local 7-Zip executable, never an executable extracted from this installer.
  38. * @returns Result path; failure retains partial records and never reports a passed package.
  39. */
  40. export async function verifyInstalledUpdatePackage(
  41. manifest: string, version: string, certificate: string, archiveTool: string,
  42. ): Promise<string> {
  43. const run = await readInstalledUpdateRun(manifest)
  44. if (!run.versions.includes(version)) throw new Error('installed update: verification version is outside the run')
  45. const parent = join(run.root, version, 'verification')
  46. await mkdir(parent, { recursive: true })
  47. const record = await mkdtemp(join(parent, 'check-'))
  48. const result: Record<string, unknown> = { schemaVersion: 1, runId: run.id, version, passed: false,
  49. installerExecuted: false, published: false, manualChecks: ['installer-registration', 'startup', 'upgrade', 'data-retention'] }
  50. const stage = (name: string): void => { result.stage = name; recordPackagingEvent(record, { type: 'verification-stage', stage: name }) }
  51. console.log(`INSTALLED_UPDATE_VERIFICATION_RECORD ${record}`)
  52. try {
  53. stage('local-file-plan')
  54. const plan = await planInstalledUpdateDistribution(manifest, version)
  55. const installer = plan.binaries[0]!.path
  56. const publisher = resolveWindowsUpdatePublisher(certificate)
  57. const toolHash = await installedUpdateFileHash(archiveTool)
  58. const certificateSha512 = await installedUpdateFileHash(certificate)
  59. const manifestSha512 = await installedUpdateFileHash(manifest)
  60. await writeFile(join(record, 'inputs.json'), `${JSON.stringify({ manifestSha512, distribution: plan,
  61. certificate, certificateSha512, archiveTool, toolHash })}\n`, { flag: 'wx', flush: true })
  62. await mkdir(join(record, 'installer-signature'))
  63. stage('installer-signature')
  64. result.installerSignature = await verifyInstalledUpdateSignature(installer, publisher, join(record, 'installer-signature'))
  65. const environment = Object.fromEntries(Object.entries(process.env)
  66. .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$/iu.test(name)))
  67. const execute = (args: string[]) => promisify(execFile)(archiveTool, args, {
  68. env: environment, cwd: record, windowsHide: true, encoding: 'utf8' as const, timeout: 120_000, maxBuffer: 16 * 1024 * 1024,
  69. })
  70. stage('archive-paths')
  71. const listing = await execute(['l', '-slt', '-ba', '-sccUTF-8', '--', installer])
  72. await writeFile(join(record, 'archive-list.txt'), listing.stdout, { flag: 'wx', flush: true })
  73. result.archiveEntries = validateInstalledUpdateArchivePaths(listing.stdout)
  74. const payload = join(record, 'payload')
  75. await mkdir(payload)
  76. stage('extraction')
  77. const extraction = await execute(['x', '-y', '-bd', '-bso0', '-bsp0', `-o${payload}`, '--', installer])
  78. await writeFile(join(record, 'extraction.log'), `${extraction.stdout}\n${extraction.stderr}`, { flag: 'wx', flush: true })
  79. stage('payload-content')
  80. const contents = await verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)
  81. result.contents = contents
  82. await mkdir(join(record, 'application-signature'))
  83. stage('application-signature')
  84. result.applicationSignature = await verifyInstalledUpdateSignature(join(payload, `${run.productName}.exe`),
  85. publisher, join(record, 'application-signature'))
  86. const runtimeSignatures: object[] = []
  87. result.runtimeSignatures = runtimeSignatures
  88. for (const [index, path] of contents.resignedExecutables.entries()) {
  89. stage(`runtime-signature-${index}`)
  90. const directory = join(record, `runtime-signature-${index}`)
  91. await mkdir(directory)
  92. runtimeSignatures.push({ path, ...await verifyInstalledUpdateSignature(path, publisher, directory) })
  93. }
  94. stage('unchanged-inputs')
  95. if (await installedUpdateFileHash(installer) !== plan.binaries[0]!.sha512
  96. || await installedUpdateFileHash(archiveTool) !== toolHash || await installedUpdateFileHash(certificate) !== certificateSha512
  97. || await installedUpdateFileHash(manifest) !== manifestSha512
  98. || JSON.stringify(await planInstalledUpdateDistribution(manifest, version)) !== JSON.stringify(plan)) {
  99. throw new Error('installed update: verification input changed')
  100. }
  101. stage('complete')
  102. result.passed = true
  103. return join(record, 'result.json')
  104. } catch (error) {
  105. result.failure = error instanceof Error ? error.message : 'verification failed'
  106. throw error
  107. } finally {
  108. await writeFile(join(record, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx', flush: true })
  109. }
  110. }
  111. if (process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(import.meta.filename)) {
  112. const [manifest, version, certificate, archiveTool, ...extra] = process.argv.slice(2)
  113. if (!manifest || !version || !certificate || !archiveTool || extra.length !== 0) {
  114. console.error('usage: verify-installed-update-package.ts <run.json> <version> <public.cer> <reviewed-7za.exe>')
  115. process.exitCode = 1
  116. } else {
  117. verifyInstalledUpdatePackage(manifest, version, certificate, archiveTool).then(path => console.log(path)).catch(() => {
  118. console.error('installed update: package verification failed; inspect the retained record. No installer was executed.')
  119. process.exitCode = 1
  120. })
  121. }
  122. }