windows-sign.spec.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join, resolve } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import {
  6. buildWindowsSigningEnvironment,
  7. createRedactedWindowsSigningError,
  8. createWindowsTokenSigner,
  9. installWindowsNsisBootstrapSigner,
  10. repairDanglingAuthenticodeDirectory,
  11. scrubWindowsSigningEnvironment,
  12. } from '../scripts/windows-sign.mjs'
  13. vi.mock('node:crypto', () => ({
  14. X509Certificate: class {
  15. readonly ca = false
  16. readonly keyUsage = ['1.3.6.1.5.5.7.3.3']
  17. constructor(contents: Buffer) {
  18. if (contents.toString('utf8') !== 'code-signing-certificate-fixture') {
  19. throw new Error('invalid test certificate')
  20. }
  21. }
  22. },
  23. }))
  24. const CERTIFICATE_FILE = 'C:\\release\\server.cer'
  25. const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd')
  26. describe('Windows token signing', () => {
  27. it('passes only the validated BAT fields to the signing command interpreter', () => {
  28. expect(buildWindowsSigningEnvironment({
  29. SystemRoot: 'C:\\Windows',
  30. DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'inherited-token-secret',
  31. DEEPSEEK_API_KEY: 'api-secret',
  32. BUILD_PASSWORD: 'build-secret',
  33. }, {
  34. certificateFile: CERTIFICATE_FILE,
  35. signTool: 'C:\\tools\\signtool.exe',
  36. path: 'C:\\release\\DeepSeek Harness.exe',
  37. isNest: false,
  38. tokenPin: 'token-secret!',
  39. keyContainer: 'te-container',
  40. })).toEqual({
  41. SystemRoot: 'C:\\Windows',
  42. DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe',
  43. DSH_DESKTOP_WINDOWS_CER_FILE: CERTIFICATE_FILE,
  44. DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret!',
  45. DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'te-container',
  46. DSH_DESKTOP_WINDOWS_SIGN_TARGET: 'C:\\release\\DeepSeek Harness.exe',
  47. DSH_DESKTOP_WINDOWS_SIGN_APPEND: '',
  48. })
  49. })
  50. it('requests an appended signature only for an electron-builder nested task', () => {
  51. expect(buildWindowsSigningEnvironment({}, {
  52. certificateFile: CERTIFICATE_FILE,
  53. signTool: 'C:\\tools\\signtool.exe',
  54. path: 'C:\\release\\setup.exe',
  55. isNest: true,
  56. tokenPin: 'token-secret!',
  57. keyContainer: 'te-container',
  58. }).DSH_DESKTOP_WINDOWS_SIGN_APPEND).toBe('1')
  59. })
  60. it('keeps the verified SafeNet command in an ASCII CRLF CMD file', async () => {
  61. const contents = await readFile(SIGN_SCRIPT)
  62. const text = contents.toString('ascii')
  63. expect(contents.every(byte => byte <= 0x7F)).toBe(true)
  64. expect(text).toContain('\r\n')
  65. expect(text.replaceAll('\r\n', '')).not.toContain('\n')
  66. expect(text).toContain('setlocal DisableDelayedExpansion\r\n')
  67. expect(text).toContain('set "DSH_DESKTOP_WINDOWS_CER_FILE="\r\n')
  68. expect(text).toContain('set "DSH_DESKTOP_WINDOWS_TOKEN_PIN="\r\n')
  69. 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')
  70. })
  71. it('rejects incomplete signing identities and non-SHA-256 signing tasks', async () => {
  72. const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-tool-'))
  73. const certificateFile = join(directory, 'server.cer')
  74. const signTool = join(directory, 'signtool.exe')
  75. await writeFile(certificateFile, 'code-signing-certificate-fixture')
  76. await writeFile(signTool, 'fixture')
  77. expect(() => createWindowsTokenSigner({
  78. certificateFile: undefined,
  79. signTool,
  80. tokenPin: 'token-secret!',
  81. keyContainer: 'te-container',
  82. })).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u)
  83. expect(() => createWindowsTokenSigner({
  84. certificateFile,
  85. signTool: undefined,
  86. tokenPin: 'token-secret!',
  87. keyContainer: 'te-container',
  88. })).toThrow(/DSH_DESKTOP_WINDOWS_SIGNTOOL/u)
  89. const signer = createWindowsTokenSigner({
  90. certificateFile,
  91. signTool,
  92. tokenPin: 'token-secret!',
  93. keyContainer: 'te-container',
  94. })
  95. try {
  96. expect(() => createWindowsTokenSigner({
  97. certificateFile,
  98. signTool,
  99. tokenPin: 'token-secret!',
  100. })).toThrow(/DSH_DESKTOP_WINDOWS_KEY_CONTAINER/u)
  101. expect(() => createWindowsTokenSigner({
  102. certificateFile,
  103. signTool,
  104. tokenPin: '',
  105. keyContainer: 'te-container',
  106. })).toThrow(/DSH_DESKTOP_WINDOWS_TOKEN_PIN/u)
  107. expect(() => createWindowsTokenSigner({
  108. certificateFile,
  109. signTool,
  110. tokenPin: 'token]secret',
  111. keyContainer: 'te-container',
  112. })).toThrow(/cannot contain/u)
  113. await expect(signer({
  114. path: 'C:\\release\\setup.exe',
  115. hash: 'sha1',
  116. isNest: false,
  117. })).rejects.toThrow(/requires SHA-256/u)
  118. }
  119. finally {
  120. await rm(directory, { recursive: true })
  121. }
  122. })
  123. it('removes inherited credentials and redacts SignTool process failures', () => {
  124. expect(scrubWindowsSigningEnvironment({
  125. SystemRoot: 'C:\\Windows',
  126. DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer',
  127. DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe',
  128. DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret',
  129. DEEPSEEK_API_KEY: 'api-secret',
  130. BUILD_PASSWORD: 'build-secret',
  131. })).toEqual({ SystemRoot: 'C:\\Windows' })
  132. const processError = Object.assign(new Error('failed'), {
  133. code: 1,
  134. cmd: 'signtool /kc [{{token-secret}}]=te-container',
  135. stderr: 'provider rejected token-secret',
  136. })
  137. const failure = createRedactedWindowsSigningError(
  138. processError,
  139. 'C:\\release\\setup.exe',
  140. ['token-secret'],
  141. )
  142. expect(failure.message).toBe('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected <redacted>')
  143. expect(failure.message).not.toContain('token-secret')
  144. expect(failure).not.toHaveProperty('cause')
  145. expect(failure).not.toHaveProperty('cmd')
  146. })
  147. it('signs the temporary NSIS executable before enterprise policy evaluates it', async () => {
  148. const events: string[] = []
  149. let receivedEnvironment: NodeJS.ProcessEnv | undefined
  150. class FakeWineVmManager {
  151. async exec(
  152. file: string,
  153. _args: string[],
  154. options?: { env?: NodeJS.ProcessEnv },
  155. ): Promise<string> {
  156. events.push(`exec:${file}`)
  157. receivedEnvironment = options?.env
  158. return 'executed'
  159. }
  160. }
  161. installWindowsNsisBootstrapSigner({
  162. sign: async (configuration) => {
  163. events.push(`sign:${configuration.path}:${configuration.hash}:${String(configuration.isNest)}`)
  164. },
  165. wineVmManager: FakeWineVmManager,
  166. platform: 'win32',
  167. environment: {
  168. SystemRoot: 'C:\\Windows',
  169. DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret',
  170. },
  171. })
  172. const result = await new FakeWineVmManager().exec('C:\\release\\setup.exe', [], {
  173. env: {
  174. __COMPAT_LAYER: 'RunAsInvoker',
  175. BUILD_PASSWORD: 'build-secret',
  176. },
  177. })
  178. expect(result).toBe('executed')
  179. expect(events).toEqual([
  180. 'sign:C:\\release\\setup.exe:sha256:false',
  181. 'exec:C:\\release\\setup.exe',
  182. ])
  183. expect(receivedEnvironment).toEqual({
  184. SystemRoot: 'C:\\Windows',
  185. __COMPAT_LAYER: 'RunAsInvoker',
  186. })
  187. })
  188. it('clears a certificate table inherited beyond the generated uninstaller', async () => {
  189. const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-'))
  190. const path = join(directory, 'uninstaller.exe')
  191. const executable = Buffer.alloc(512)
  192. const peOffset = 216
  193. const optionalHeaderOffset = peOffset + 24
  194. const certificateDirectoryOffset = optionalHeaderOffset + 96 + (4 * 8)
  195. executable.write('MZ', 0, 'ascii')
  196. executable.writeUInt32LE(peOffset, 60)
  197. executable.write('PE\0\0', peOffset, 'ascii')
  198. executable.writeUInt16LE(0x10B, optionalHeaderOffset)
  199. executable.writeUInt32LE(600, certificateDirectoryOffset)
  200. executable.writeUInt32LE(100, certificateDirectoryOffset + 4)
  201. await writeFile(path, executable)
  202. try {
  203. await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(true)
  204. const repaired = await readFile(path)
  205. expect(repaired.readUInt32LE(certificateDirectoryOffset)).toBe(0)
  206. expect(repaired.readUInt32LE(certificateDirectoryOffset + 4)).toBe(0)
  207. await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(false)
  208. }
  209. finally {
  210. await rm(directory, { recursive: true })
  211. }
  212. })
  213. })