windows-sign.mjs 11 KB

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