windows-sign.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import { execFile } from 'node:child_process'
  2. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join, resolve } from 'node:path'
  5. import { describe, expect, it, vi } from 'vitest'
  6. import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
  7. import { createPackagingRun } from '../scripts/packaging-run.mjs'
  8. import {
  9. buildWindowsSigningEnvironment,
  10. createRedactedWindowsSigningError,
  11. createWindowsTokenSigner,
  12. installWindowsNsisBootstrapSigner,
  13. repairDanglingAuthenticodeDirectory,
  14. scrubWindowsSigningEnvironment,
  15. } from '../scripts/windows-sign.mjs'
  16. vi.mock('node:child_process', async (importOriginal) => {
  17. const actual = await importOriginal<typeof import('node:child_process')>()
  18. return { ...actual, execFile: vi.fn() }
  19. })
  20. vi.mock('node:crypto', async importOriginal => ({
  21. ...await importOriginal<typeof import('node:crypto')>(),
  22. X509Certificate: class {
  23. readonly ca = false
  24. readonly keyUsage = ['1.3.6.1.5.5.7.3.3']
  25. constructor(contents: Buffer) {
  26. if (contents.toString('utf8') !== 'code-signing-certificate-fixture') {
  27. throw new Error('invalid test certificate')
  28. }
  29. }
  30. },
  31. }))
  32. const CERTIFICATE_FILE = 'C:\\release\\server.cer'
  33. const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd')
  34. describe('Windows token signing', () => {
  35. it('preserves verified copies without hardware and rejects the entire queue after preservation failure', async () => {
  36. const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-copy-signature-'))
  37. try {
  38. const certificateFile = join(directory, 'server.cer')
  39. const signTool = join(directory, 'signtool.exe')
  40. await writeFile(certificateFile, 'code-signing-certificate-fixture')
  41. await writeFile(signTool, 'fixture')
  42. const preserveSignature = vi.fn(async () => true)
  43. const sign = createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin',
  44. keyContainer: 'fixture-container', preserveSignature })
  45. const task = { path: join(directory, 'copy.exe'), hash: 'sha256', isNest: false }
  46. await sign(task)
  47. expect(execFile).not.toHaveBeenCalled()
  48. preserveSignature.mockRejectedValueOnce(new Error('copy changed'))
  49. const results = await Promise.allSettled([sign(task), sign(task), sign(task)])
  50. expect(results.map(result => result.status)).toEqual(['rejected', 'rejected', 'rejected'])
  51. expect(preserveSignature).toHaveBeenCalledTimes(2)
  52. expect(execFile).not.toHaveBeenCalled()
  53. } finally {
  54. await rm(directory, { recursive: true, force: true })
  55. }
  56. })
  57. it('stops concurrent and subsequent signing tasks after a PIN failure', async () => {
  58. const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-pin-failure-'))
  59. try {
  60. const certificateFile = join(directory, 'server.cer')
  61. const signTool = join(directory, 'signtool.exe')
  62. const path = join(directory, 'application.exe')
  63. await writeFile(certificateFile, 'code-signing-certificate-fixture')
  64. await writeFile(signTool, 'fixture')
  65. await writeFile(path, 'fixture')
  66. validateDesktopPackageEnvironment({
  67. DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com',
  68. DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
  69. DSH_DESKTOP_WINDOWS_CER_FILE: certificateFile, DSH_DESKTOP_WINDOWS_SIGNTOOL: signTool,
  70. DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'fixture-container',
  71. }, { platform: 'win32', arch: 'x64' })
  72. expect(execFile).not.toHaveBeenCalled()
  73. vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
  74. const callback = args.at(-1) as (error: Error) => void
  75. callback(Object.assign(new Error('signing failed'), { stderr: 'SignTool Error: No private key is available.', code: 1 }))
  76. return undefined as unknown as ReturnType<typeof execFile>
  77. })
  78. const run = createPackagingRun(join(directory, 'records'), {})
  79. const options = { certificateFile, signTool, tokenPin: 'fixture-pin', keyContainer: 'fixture-container',
  80. runDirectory: run.directory, stateDirectory: join(directory, 'state') }
  81. const sign = createWindowsTokenSigner(options)
  82. const task = { path, hash: 'sha256', isNest: false }
  83. const results = await Promise.allSettled([sign(task), sign(task), sign(task)])
  84. expect(results.map(result => result.status)).toEqual(['rejected', 'rejected', 'rejected'])
  85. await expect(sign(task)).rejects.toThrow('No private key is available.')
  86. const laterRun = createPackagingRun(join(directory, 'records'), {})
  87. await expect(createWindowsTokenSigner({ ...options, runDirectory: laterRun.directory })(task)).rejects.toThrow('interlock unavailable')
  88. expect(execFile).toHaveBeenCalledTimes(1)
  89. const records = await readFile(join(run.directory, 'events.jsonl'), 'utf8')
  90. expect(records).toContain('sign-start')
  91. expect(records).toContain('sign-failure')
  92. expect(records).not.toContain('fixture-pin')
  93. expect(await readFile(join(options.stateDirectory, 'attempt.json'), 'utf8')).toContain(run.directory.replaceAll('\\', '\\\\'))
  94. } finally {
  95. vi.mocked(execFile).mockReset()
  96. await rm(directory, { recursive: true, force: true })
  97. }
  98. })
  99. it('passes only the validated BAT fields to the signing command interpreter', () => {
  100. expect(buildWindowsSigningEnvironment({
  101. SystemRoot: 'C:\\Windows',
  102. DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'inherited-token-secret',
  103. DEEPSEEK_API_KEY: 'api-secret',
  104. BUILD_PASSWORD: 'build-secret',
  105. }, {
  106. certificateFile: CERTIFICATE_FILE,
  107. signTool: 'C:\\tools\\signtool.exe',
  108. path: 'C:\\release\\DeepSeek Harness.exe',
  109. isNest: false,
  110. tokenPin: 'token-secret!',
  111. keyContainer: 'te-container',
  112. })).toEqual({
  113. SystemRoot: 'C:\\Windows',
  114. DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe',
  115. DSH_DESKTOP_WINDOWS_CER_FILE: CERTIFICATE_FILE,
  116. DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret!',
  117. DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'te-container',
  118. DSH_DESKTOP_WINDOWS_SIGN_TARGET: 'C:\\release\\DeepSeek Harness.exe',
  119. DSH_DESKTOP_WINDOWS_SIGN_APPEND: '',
  120. })
  121. })
  122. it('requests an appended signature only for an electron-builder nested task', () => {
  123. expect(buildWindowsSigningEnvironment({}, {
  124. certificateFile: CERTIFICATE_FILE,
  125. signTool: 'C:\\tools\\signtool.exe',
  126. path: 'C:\\release\\setup.exe',
  127. isNest: true,
  128. tokenPin: 'token-secret!',
  129. keyContainer: 'te-container',
  130. }).DSH_DESKTOP_WINDOWS_SIGN_APPEND).toBe('1')
  131. })
  132. it('keeps the verified SafeNet command in an ASCII CRLF CMD file', async () => {
  133. const contents = await readFile(SIGN_SCRIPT)
  134. const text = contents.toString('ascii')
  135. expect(contents.every(byte => byte <= 0x7F)).toBe(true)
  136. expect(text).toContain('\r\n')
  137. expect(text.replaceAll('\r\n', '')).not.toContain('\n')
  138. expect(text).toContain('setlocal DisableDelayedExpansion\r\n')
  139. expect(text).toContain('set "DSH_DESKTOP_WINDOWS_CER_FILE="\r\n')
  140. expect(text).toContain('set "DSH_DESKTOP_WINDOWS_TOKEN_PIN="\r\n')
  141. expect(text).toContain('"%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%"\r\n')
  142. })
  143. it('rejects incomplete signing identities and non-SHA-256 signing tasks', async () => {
  144. const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-tool-'))
  145. const certificateFile = join(directory, 'server.cer')
  146. const signTool = join(directory, 'signtool.exe')
  147. await writeFile(certificateFile, 'code-signing-certificate-fixture')
  148. await writeFile(signTool, 'fixture')
  149. expect(() => createWindowsTokenSigner({
  150. certificateFile: undefined,
  151. signTool,
  152. tokenPin: 'token-secret!',
  153. keyContainer: 'te-container',
  154. })).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u)
  155. expect(() => createWindowsTokenSigner({
  156. certificateFile,
  157. signTool: undefined,
  158. tokenPin: 'token-secret!',
  159. keyContainer: 'te-container',
  160. })).toThrow(/DSH_DESKTOP_WINDOWS_SIGNTOOL/u)
  161. const signer = createWindowsTokenSigner({
  162. certificateFile,
  163. signTool,
  164. tokenPin: 'token-secret!',
  165. keyContainer: 'te-container',
  166. })
  167. try {
  168. expect(() => createWindowsTokenSigner({
  169. certificateFile,
  170. signTool,
  171. tokenPin: 'token-secret!',
  172. })).toThrow(/DSH_DESKTOP_WINDOWS_KEY_CONTAINER/u)
  173. expect(() => createWindowsTokenSigner({
  174. certificateFile,
  175. signTool,
  176. tokenPin: '',
  177. keyContainer: 'te-container',
  178. })).toThrow(/DSH_DESKTOP_WINDOWS_TOKEN_PIN/u)
  179. expect(() => createWindowsTokenSigner({
  180. certificateFile,
  181. signTool,
  182. tokenPin: 'token]secret',
  183. keyContainer: 'te-container',
  184. })).toThrow(/cannot contain/u)
  185. await expect(signer({
  186. path: 'C:\\release\\setup.exe',
  187. hash: 'sha1',
  188. isNest: false,
  189. })).rejects.toThrow(/requires SHA-256/u)
  190. }
  191. finally {
  192. await rm(directory, { recursive: true })
  193. }
  194. })
  195. it('removes inherited credentials and redacts SignTool process failures', () => {
  196. expect(scrubWindowsSigningEnvironment({
  197. SystemRoot: 'C:\\Windows',
  198. DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer',
  199. DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe',
  200. DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret',
  201. DEEPSEEK_API_KEY: 'api-secret',
  202. BUILD_PASSWORD: 'build-secret',
  203. })).toEqual({ SystemRoot: 'C:\\Windows' })
  204. const processError = Object.assign(new Error('failed'), {
  205. code: 1,
  206. cmd: 'signtool /kc [{{token-secret}}]=te-container',
  207. stderr: 'provider rejected token-secret',
  208. })
  209. const failure = createRedactedWindowsSigningError(
  210. processError,
  211. 'C:\\release\\setup.exe',
  212. ['token-secret'],
  213. )
  214. expect(failure.message).toBe('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected <redacted>')
  215. expect(failure.message).not.toContain('token-secret')
  216. expect(failure).not.toHaveProperty('cause')
  217. expect(failure).not.toHaveProperty('cmd')
  218. })
  219. it('signs the temporary NSIS executable before enterprise policy evaluates it', async () => {
  220. const events: string[] = []
  221. let receivedEnvironment: NodeJS.ProcessEnv | undefined
  222. class FakeWineVmManager {
  223. async exec(
  224. file: string,
  225. _args: string[],
  226. options?: { env?: NodeJS.ProcessEnv },
  227. ): Promise<string> {
  228. events.push(`exec:${file}`)
  229. receivedEnvironment = options?.env
  230. return 'executed'
  231. }
  232. }
  233. installWindowsNsisBootstrapSigner({
  234. sign: async (configuration) => {
  235. events.push(`sign:${configuration.path}:${configuration.hash}:${String(configuration.isNest)}`)
  236. },
  237. wineVmManager: FakeWineVmManager,
  238. platform: 'win32',
  239. environment: {
  240. SystemRoot: 'C:\\Windows',
  241. DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret',
  242. },
  243. })
  244. const result = await new FakeWineVmManager().exec('C:\\release\\setup.exe', [], {
  245. env: {
  246. __COMPAT_LAYER: 'RunAsInvoker',
  247. BUILD_PASSWORD: 'build-secret',
  248. },
  249. })
  250. expect(result).toBe('executed')
  251. expect(events).toEqual([
  252. 'sign:C:\\release\\setup.exe:sha256:false',
  253. 'exec:C:\\release\\setup.exe',
  254. ])
  255. expect(receivedEnvironment).toEqual({
  256. SystemRoot: 'C:\\Windows',
  257. __COMPAT_LAYER: 'RunAsInvoker',
  258. })
  259. })
  260. it('clears a certificate table inherited beyond the generated uninstaller', async () => {
  261. const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-'))
  262. const path = join(directory, 'uninstaller.exe')
  263. const executable = Buffer.alloc(512)
  264. const peOffset = 216
  265. const optionalHeaderOffset = peOffset + 24
  266. const certificateDirectoryOffset = optionalHeaderOffset + 96 + (4 * 8)
  267. executable.write('MZ', 0, 'ascii')
  268. executable.writeUInt32LE(peOffset, 60)
  269. executable.write('PE\0\0', peOffset, 'ascii')
  270. executable.writeUInt16LE(0x10B, optionalHeaderOffset)
  271. executable.writeUInt32LE(600, certificateDirectoryOffset)
  272. executable.writeUInt32LE(100, certificateDirectoryOffset + 4)
  273. await writeFile(path, executable)
  274. try {
  275. await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(true)
  276. const repaired = await readFile(path)
  277. expect(repaired.readUInt32LE(certificateDirectoryOffset)).toBe(0)
  278. expect(repaired.readUInt32LE(certificateDirectoryOffset + 4)).toBe(0)
  279. await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(false)
  280. }
  281. finally {
  282. await rm(directory, { recursive: true })
  283. }
  284. })
  285. })