verify-macos-signature.mjs 9.3 KB

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