verify-macos-signature.mjs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. /** Sign runtime code and verify that packaged macOS artifacts carry the company release identity. */
  2. import { spawn, spawnSync } from 'node:child_process'
  3. import { resolve } from 'node:path'
  4. import { resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs'
  5. /**
  6. * Reject signature metadata that does not name the company release authority and team.
  7. * @param {string} details - Output from `codesign --display --verbose=4`.
  8. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  9. * @returns {void}
  10. */
  11. export function assertMacOSSignatureDetails(details, expected) {
  12. const fields = new Set(details.split(/\r?\n/u).map(line => line.trim()))
  13. const expectedAuthority = `Authority=Developer ID Application: ${expected.signingIdentity}`
  14. const expectedTeam = `TeamIdentifier=${expected.teamId}`
  15. const missing = [expectedAuthority, expectedTeam].filter(field => !fields.has(field))
  16. if (missing.length > 0) {
  17. throw new Error(`desktop macOS signing: signature does not match the release identity; missing ${missing.join(', ')}`)
  18. }
  19. }
  20. /**
  21. * Require the signature properties Apple validates for executable runtime content.
  22. * @param {string} details - Output from `codesign --display --verbose=4`.
  23. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  24. * @returns {void}
  25. */
  26. export function assertMacOSRuntimeSignatureDetails(details, expected) {
  27. assertMacOSSignatureDetails(details, expected)
  28. const fields = details.split(/\r?\n/u).map(line => line.trim())
  29. if (!fields.some(line => /^Timestamp=.+/u.test(line))) {
  30. throw new Error('desktop macOS signing: runtime signature has no secure timestamp')
  31. }
  32. if (!fields.some(line => /\bflags=0x[0-9a-f]+\(runtime\)(?:\s|$)/iu.test(line))) {
  33. throw new Error('desktop macOS signing: runtime signature does not enable hardened runtime')
  34. }
  35. }
  36. /**
  37. * Execute one Apple release tool and return its diagnostic streams.
  38. * @param {string} command - Absolute executable path.
  39. * @param {readonly string[]} args - Tool arguments.
  40. * @param {string} label - Stable diagnostic name.
  41. * @returns {string} Combined stdout and stderr.
  42. */
  43. function runAppleCommand(command, args, label) {
  44. const result = spawnSync(command, args, { encoding: 'utf8' })
  45. if (result.error !== undefined) {
  46. throw new Error(`desktop macOS signing: could not execute ${label}: ${result.error.message}`)
  47. }
  48. if (result.signal !== null) {
  49. throw new Error(`desktop macOS signing: ${label} was terminated by ${result.signal}`)
  50. }
  51. if (result.status !== 0) {
  52. const diagnostic = `${result.stdout}${result.stderr}`.trim()
  53. throw new Error(`desktop macOS signing: ${label} exited with ${String(result.status)}${diagnostic === '' ? '' : `: ${diagnostic}`}`)
  54. }
  55. return `${result.stdout}${result.stderr}`
  56. }
  57. /**
  58. * Execute one Apple release tool without blocking other independent runtime signers.
  59. * @param {string} command - Absolute executable path.
  60. * @param {readonly string[]} args - Tool arguments.
  61. * @param {string} label - Stable diagnostic name.
  62. * @returns {Promise<string>} Combined stdout and stderr after process exit.
  63. */
  64. function runAppleCommandAsync(command, args, label) {
  65. return new Promise((resolvePromise, reject) => {
  66. const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
  67. let stdout = ''
  68. let stderr = ''
  69. let spawnError
  70. child.stdout.setEncoding('utf8')
  71. child.stderr.setEncoding('utf8')
  72. child.stdout.on('data', chunk => { stdout += chunk })
  73. child.stderr.on('data', chunk => { stderr += chunk })
  74. child.once('error', error => { spawnError = error })
  75. child.once('close', (code, signal) => {
  76. if (spawnError !== undefined) {
  77. reject(new Error(`desktop macOS signing: could not execute ${label}: ${spawnError.message}`))
  78. return
  79. }
  80. if (signal !== null) {
  81. reject(new Error(`desktop macOS signing: ${label} was terminated by ${signal}`))
  82. return
  83. }
  84. if (code !== 0) {
  85. const diagnostic = `${stdout}${stderr}`.trim()
  86. reject(new Error(`desktop macOS signing: ${label} exited with ${String(code)}${diagnostic === '' ? '' : `: ${diagnostic}`}`))
  87. return
  88. }
  89. resolvePromise(`${stdout}${stderr}`)
  90. })
  91. })
  92. }
  93. /**
  94. * Execute Apple's code-signing tool and return its diagnostic streams.
  95. * @param {readonly string[]} args - Arguments passed to `/usr/bin/codesign`.
  96. * @returns {string} Combined stdout and stderr.
  97. */
  98. function runCodeSign(args) {
  99. return runAppleCommand('/usr/bin/codesign', args, 'codesign')
  100. }
  101. /**
  102. * Sign one Mach-O file embedded in the runtime tree.
  103. * @param {string} path - Writable standalone Mach-O file.
  104. * @param {string} identifier - Stable code-signing identifier derived from the release app ID and CAS digest.
  105. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  106. * @returns {Promise<void>} Resolves after codesign exits successfully.
  107. */
  108. export async function signMacOSRuntimeCode(path, identifier, expected) {
  109. await runAppleCommandAsync('/usr/bin/codesign', [
  110. '--force',
  111. '--sign', expected.signingIdentity,
  112. '--identifier', identifier,
  113. '--timestamp',
  114. '--options', 'runtime',
  115. path,
  116. ], 'codesign')
  117. }
  118. /**
  119. * Verify one Mach-O file embedded in the runtime tree.
  120. * @param {string} path - Mach-O file to inspect.
  121. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  122. * @returns {void}
  123. */
  124. export function verifyMacOSRuntimeCode(path, expected) {
  125. runCodeSign(['--verify', '--strict', '--verbose=2', path])
  126. const details = runCodeSign(['--display', '--verbose=4', path])
  127. assertMacOSRuntimeSignatureDetails(details, expected)
  128. }
  129. /**
  130. * Verify the full application signature and its release owner.
  131. * @param {string} appPath - Path to the packaged `.app` directory.
  132. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  133. * @returns {void}
  134. */
  135. export function verifyMacOSSignature(appPath, expected) {
  136. runCodeSign(['--verify', '--deep', '--strict', '--verbose=2', appPath])
  137. const details = runCodeSign(['--display', '--verbose=4', appPath])
  138. assertMacOSSignatureDetails(details, expected)
  139. }
  140. /**
  141. * Verify an independently distributed application's signature, ticket, and Gatekeeper acceptance.
  142. * @param {string} appPath - Path to the stapled `.app` directory.
  143. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  144. * @returns {void}
  145. */
  146. export function verifyMacOSNotarizedApplication(appPath, expected) {
  147. verifyMacOSSignature(appPath, expected)
  148. runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', appPath], 'stapler validate')
  149. runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath], 'spctl')
  150. }
  151. /**
  152. * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image.
  153. * @param {string} diskImagePath - Path to the packaged `.dmg` file.
  154. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  155. * @returns {void}
  156. */
  157. export function verifyMacOSDiskImage(diskImagePath, expected) {
  158. runCodeSign(['--verify', '--strict', '--verbose=2', diskImagePath])
  159. const details = runCodeSign(['--display', '--verbose=4', diskImagePath])
  160. assertMacOSSignatureDetails(details, expected)
  161. runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', diskImagePath], 'stapler validate')
  162. runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'install', '--verbose=4', diskImagePath], 'spctl')
  163. }
  164. /**
  165. * Verify the macOS application produced by electron-builder's signing phase.
  166. * @param {{ electronPlatformName: string, appOutDir: string, packager: { appInfo: { productFilename: string } } }} context - electron-builder hook context.
  167. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  168. * @returns {void}
  169. */
  170. export function verifyMacOSSignatureAfterSign(context, expected) {
  171. if (context.electronPlatformName !== 'darwin') return
  172. const appPath = resolve(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
  173. verifyMacOSSignature(appPath, expected)
  174. process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`)
  175. }
  176. if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
  177. const cliArgs = process.argv[2] === '--' ? process.argv.slice(3) : process.argv.slice(2)
  178. const appPath = cliArgs[0]
  179. if (appPath === undefined || cliArgs.length !== 1) {
  180. throw new Error('usage: node scripts/verify-macos-signature.mjs <path-to-app>')
  181. }
  182. const expected = resolveMacOSSigningEnvironment(process.env)
  183. verifyMacOSSignature(resolve(appPath), expected)
  184. process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`)
  185. }