windows-sign.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import { execFile } from 'node:child_process'
  2. import { X509Certificate } from 'node:crypto'
  3. import { readFileSync, realpathSync, statSync } from 'node:fs'
  4. import { open } from 'node:fs/promises'
  5. import { dirname, join } from 'node:path'
  6. import { fileURLToPath } from 'node:url'
  7. import { promisify } from 'node:util'
  8. import wineVmModule from 'app-builder-lib/out/vm/WineVm.js'
  9. import { beginWindowsSigningAttempt } from './windows-signing-state.mjs'
  10. const execFileAsync = promisify(execFile)
  11. const { WineVmManager } = wineVmModule
  12. const CODE_SIGNING_EKU = '1.3.6.1.5.5.7.3.3'
  13. const NSIS_RUN_AS_INVOKER = 'RunAsInvoker'
  14. const NSIS_BOOTSTRAP_PATCH = Symbol.for('@deepseek-ai/dsh-desktop/nsis-bootstrap-signing')
  15. const WINDOWS_SIGN_SCRIPT = 'windows-sign.cmd'
  16. const WINDOWS_SIGN_SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url))
  17. const PE_HEADER_READ_SIZE = 4096
  18. const PE32_MAGIC = 0x10B
  19. const PE32_PLUS_MAGIC = 0x20B
  20. const SENSITIVE_ENVIRONMENT_NAME = /(?:KEY|SECRET|TOKEN|PASSWORD)/iu
  21. const WINDOWS_SIGNING_ENVIRONMENT_PREFIX = 'DSH_DESKTOP_WINDOWS_'
  22. /**
  23. * Remove inherited credentials before starting a signing-related subprocess.
  24. *
  25. * @param {NodeJS.ProcessEnv} environment Parent environment.
  26. * @returns {NodeJS.ProcessEnv} Environment without credential-shaped names.
  27. */
  28. export function scrubWindowsSigningEnvironment(environment) {
  29. return Object.fromEntries(Object.entries(environment)
  30. .filter(([name]) => !SENSITIVE_ENVIRONMENT_NAME.test(name)
  31. && !name.startsWith(WINDOWS_SIGNING_ENVIRONMENT_PREFIX)))
  32. }
  33. function resolveTokenIdentity(input) {
  34. const keyContainer = input.keyContainer?.trim()
  35. if (!keyContainer) {
  36. throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER must contain the SafeNet private-key container name')
  37. }
  38. if (/["\r\n]/u.test(keyContainer)) {
  39. throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER cannot contain quotes or line breaks')
  40. }
  41. const tokenPin = input.tokenPin
  42. if (tokenPin === undefined || tokenPin.length === 0) {
  43. throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN must contain the SafeNet Token Password')
  44. }
  45. if (/[\]"\r\n]/u.test(tokenPin)) {
  46. throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN cannot contain "]", quotes, or line breaks because the SafeNet key-container syntax uses them as delimiters')
  47. }
  48. return { keyContainer, tokenPin }
  49. }
  50. function resolveCertificateFile(value) {
  51. const candidate = value?.trim()
  52. if (!candidate) {
  53. throw new Error('DSH_DESKTOP_WINDOWS_CER_FILE must identify the public X.509 leaf certificate file')
  54. }
  55. let path
  56. let certificate
  57. try {
  58. path = realpathSync(candidate)
  59. certificate = new X509Certificate(readFileSync(path))
  60. }
  61. catch {
  62. throw new Error(`Windows code-signing certificate file is missing or invalid: ${candidate}`)
  63. }
  64. if (certificate.ca || !certificate.keyUsage?.includes(CODE_SIGNING_EKU)) {
  65. throw new Error(`Windows code-signing certificate file must contain a non-CA Code Signing certificate: ${path}`)
  66. }
  67. return { path, certificate }
  68. }
  69. /**
  70. * Pin updater verification to the release certificate's organization, country, and common name.
  71. *
  72. * @param {string | undefined} certificateFile Public Windows Code Signing certificate file.
  73. * @returns {string} Distinguished-name attributes consumed by electron-updater.
  74. */
  75. export function resolveWindowsUpdatePublisher(certificateFile) {
  76. const { certificate } = resolveCertificateFile(certificateFile)
  77. const subject = certificate.toLegacyObject().subject
  78. // These attributes have identical names in OpenSSL and Windows certificate subjects.
  79. return ['CN', 'O', 'C'].map((key) => {
  80. const value = subject[key]
  81. if (typeof value !== 'string' || value.trim() === '') {
  82. throw new Error(`Windows update publisher requires one nonempty ${key} certificate attribute`)
  83. }
  84. const escaped = value.replace(/[\\",;+\x00-\x20]/gu,
  85. character => `\\${character.charCodeAt(0).toString(16).padStart(2, '0')}`)
  86. return `${key}=${escaped}`
  87. }).join(',')
  88. }
  89. function resolveSignTool(value) {
  90. const candidate = value?.trim()
  91. if (!candidate) {
  92. throw new Error('DSH_DESKTOP_WINDOWS_SIGNTOOL must identify the SafeNet-compatible SignTool executable')
  93. }
  94. let path
  95. try {
  96. path = realpathSync(candidate)
  97. if (!statSync(path).isFile() || !path.toLowerCase().endsWith('.exe')) throw new Error('not an executable file')
  98. }
  99. catch {
  100. throw new Error(`DSH_DESKTOP_WINDOWS_SIGNTOOL is missing or is not an executable file: ${candidate}`)
  101. }
  102. return path
  103. }
  104. function redactedSigningOutput(value, secrets) {
  105. let output = Buffer.isBuffer(value) ? value.toString('utf8') : typeof value === 'string' ? value : ''
  106. for (const secret of secrets) {
  107. if (secret !== '') output = output.replaceAll(secret, '<redacted>')
  108. }
  109. return output
  110. }
  111. /**
  112. * Replace a SignTool failure with a diagnostic that cannot retain its command line.
  113. *
  114. * @param {unknown} error SignTool process failure.
  115. * @param {string} path Artifact that failed signing.
  116. * @param {readonly string[]} secrets Values that must not appear in the diagnostic.
  117. * @returns {Error} Sanitized signing failure without the original error as its cause.
  118. */
  119. export function createRedactedWindowsSigningError(error, path, secrets) {
  120. const record = error !== null && typeof error === 'object' ? error : undefined
  121. const code = record !== undefined && 'code' in record
  122. && (typeof record.code === 'number' || typeof record.code === 'string')
  123. ? ` (exit ${String(record.code)})`
  124. : ''
  125. const stderr = record !== undefined && 'stderr' in record
  126. ? redactedSigningOutput(record.stderr, secrets).trim()
  127. : ''
  128. return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}`)
  129. }
  130. /**
  131. * Build the minimal CMD environment for one Electron artifact.
  132. *
  133. * @param {NodeJS.ProcessEnv} environment Parent environment.
  134. * @param {{ certificateFile: string, signTool: string, path: string, isNest: boolean, tokenPin: string, keyContainer: string }} input Validated signing identity and task.
  135. * @returns {NodeJS.ProcessEnv} Scrubbed environment plus fields consumed and cleared by the signing CMD.
  136. */
  137. export function buildWindowsSigningEnvironment(environment, input) {
  138. return {
  139. ...scrubWindowsSigningEnvironment(environment),
  140. DSH_DESKTOP_WINDOWS_SIGNTOOL: input.signTool,
  141. DSH_DESKTOP_WINDOWS_CER_FILE: input.certificateFile,
  142. DSH_DESKTOP_WINDOWS_TOKEN_PIN: input.tokenPin,
  143. DSH_DESKTOP_WINDOWS_KEY_CONTAINER: input.keyContainer,
  144. DSH_DESKTOP_WINDOWS_SIGN_TARGET: input.path,
  145. DSH_DESKTOP_WINDOWS_SIGN_APPEND: input.isNest ? '1' : '',
  146. }
  147. }
  148. /**
  149. * Serialize SafeNet signing and stop all queued tasks after the first failure.
  150. *
  151. * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string, runDirectory?: string, stateDirectory?: string, preserveSignature?: (path: string) => Promise<boolean> }} options Release identity, supervised run, verified-copy preservation, and test-only isolated interlock directory.
  152. * @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise<void>} The signing hook.
  153. */
  154. export function createWindowsTokenSigner(options) {
  155. const { path: certificateFile } = resolveCertificateFile(options.certificateFile)
  156. const signTool = resolveSignTool(options.signTool)
  157. const { keyContainer, tokenPin } = resolveTokenIdentity(options)
  158. const commandInterpreter = options.commandInterpreter
  159. ?? process.env.ComSpec
  160. ?? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe')
  161. let pending = Promise.resolve()
  162. return (configuration) => {
  163. pending = pending.then(async () => {
  164. if (configuration.hash !== 'sha256') {
  165. throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`)
  166. }
  167. if (await options.preserveSignature?.(configuration.path)) return
  168. await repairDanglingAuthenticodeDirectory(configuration.path)
  169. const secrets = [tokenPin]
  170. const attempt = beginWindowsSigningAttempt({ runDirectory: options.runDirectory,
  171. stateDirectory: options.stateDirectory, target: configuration.path })
  172. let result
  173. try {
  174. const operation = execFileAsync(commandInterpreter, [
  175. '/d',
  176. '/v:off',
  177. '/c',
  178. WINDOWS_SIGN_SCRIPT,
  179. ], {
  180. cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY,
  181. env: buildWindowsSigningEnvironment(process.env, {
  182. certificateFile,
  183. signTool,
  184. path: configuration.path,
  185. isNest: configuration.isNest,
  186. tokenPin,
  187. keyContainer,
  188. }),
  189. windowsHide: true,
  190. })
  191. attempt.started(operation.child?.pid ?? null)
  192. result = await operation
  193. }
  194. catch (error) {
  195. const failure = createRedactedWindowsSigningError(error, configuration.path, secrets)
  196. attempt.failure(typeof error.code === 'number' || typeof error.code === 'string' ? error.code : null, failure.message)
  197. throw failure
  198. }
  199. attempt.success()
  200. const stdout = redactedSigningOutput(result.stdout, secrets)
  201. const stderr = redactedSigningOutput(result.stderr, secrets)
  202. if (stdout !== '') process.stdout.write(stdout)
  203. if (stderr !== '') process.stderr.write(stderr)
  204. })
  205. return pending
  206. }
  207. }
  208. /**
  209. * Clear a certificate-table entry that points beyond the end of a generated executable.
  210. *
  211. * @param {string} path Executable to inspect.
  212. * @returns {Promise<boolean>} Whether an invalid certificate-table entry was cleared.
  213. */
  214. export async function repairDanglingAuthenticodeDirectory(path) {
  215. const file = await open(path, 'r+')
  216. try {
  217. const { size } = await file.stat()
  218. const header = Buffer.alloc(Math.min(PE_HEADER_READ_SIZE, size))
  219. await file.read(header, 0, header.length, 0)
  220. const directoryOffset = findDanglingAuthenticodeDirectory(header, size)
  221. if (directoryOffset === undefined) return false
  222. await file.write(Buffer.alloc(8), 0, 8, directoryOffset)
  223. return true
  224. }
  225. finally {
  226. await file.close()
  227. }
  228. }
  229. /**
  230. * Locate an Authenticode certificate-table entry whose declared bytes are outside the file.
  231. *
  232. * @param {Buffer} header Initial executable bytes.
  233. * @param {number} fileSize Complete file size.
  234. * @returns {number | undefined} File offset of the invalid data-directory entry.
  235. */
  236. function findDanglingAuthenticodeDirectory(header, fileSize) {
  237. if (header.length < 64 || header.toString('ascii', 0, 2) !== 'MZ') return undefined
  238. const peOffset = header.readUInt32LE(60)
  239. const optionalHeaderOffset = peOffset + 24
  240. if (optionalHeaderOffset + 2 > header.length
  241. || header.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') return undefined
  242. const magic = header.readUInt16LE(optionalHeaderOffset)
  243. const dataDirectoryOffset = magic === PE32_MAGIC
  244. ? optionalHeaderOffset + 96
  245. : magic === PE32_PLUS_MAGIC
  246. ? optionalHeaderOffset + 112
  247. : undefined
  248. if (dataDirectoryOffset === undefined) return undefined
  249. const certificateDirectoryOffset = dataDirectoryOffset + (4 * 8)
  250. if (certificateDirectoryOffset + 8 > header.length) return undefined
  251. const certificateOffset = header.readUInt32LE(certificateDirectoryOffset)
  252. const certificateSize = header.readUInt32LE(certificateDirectoryOffset + 4)
  253. if (certificateOffset === 0 && certificateSize === 0) return undefined
  254. return certificateOffset > 0
  255. && certificateSize > 0
  256. && certificateOffset + certificateSize <= fileSize
  257. ? undefined
  258. : certificateDirectoryOffset
  259. }
  260. /**
  261. * Sign electron-builder's temporary NSIS executable before enterprise code integrity evaluates it.
  262. *
  263. * @param {{ sign: (configuration: { path: string, hash: string, isNest: boolean }) => Promise<void>, wineVmManager?: typeof WineVmManager, platform?: NodeJS.Platform, environment?: NodeJS.ProcessEnv }} options Signing hook and injectable host values.
  264. * @returns {void}
  265. */
  266. export function installWindowsNsisBootstrapSigner(options) {
  267. if ((options.platform ?? process.platform) !== 'win32') return
  268. const prototype = (options.wineVmManager ?? WineVmManager).prototype
  269. if (prototype[NSIS_BOOTSTRAP_PATCH] === true) return
  270. const originalExec = prototype.exec
  271. prototype.exec = async function (file, args, execOptions, isLogOutIfDebug) {
  272. const isNsisBootstrap = file.toLowerCase().endsWith('.exe')
  273. && execOptions?.env?.__COMPAT_LAYER === NSIS_RUN_AS_INVOKER
  274. if (!isNsisBootstrap) {
  275. return originalExec.call(this, file, args, execOptions, isLogOutIfDebug)
  276. }
  277. await options.sign({ path: file, hash: 'sha256', isNest: false })
  278. return originalExec.call(this, file, args, {
  279. ...execOptions,
  280. env: scrubWindowsSigningEnvironment({
  281. ...(options.environment ?? process.env),
  282. ...execOptions.env,
  283. }),
  284. }, isLogOutIfDebug)
  285. }
  286. Object.defineProperty(prototype, NSIS_BOOTSTRAP_PATCH, { value: true })
  287. }