windows-signing-preflight.ts 5.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. /** Check the production signing path with one private probe before preparing release artifacts. */
  2. import { execFile } from 'node:child_process'
  3. import { X509Certificate, createHash } from 'node:crypto'
  4. import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
  5. import { homedir } from 'node:os'
  6. import { join, resolve } from 'node:path'
  7. import { promisify } from 'node:util'
  8. import { createWindowsTokenSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs'
  9. import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs'
  10. import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
  11. const PROBE_SOURCE = 'internal static class SigningProbe { private static int Main() { return 0; } }\n'
  12. interface SigningPreflightOptions {
  13. runDirectory: string
  14. environment: NodeJS.ProcessEnv
  15. stateDirectory?: string
  16. compile?: (compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv) => Promise<void>
  17. sign?: ReturnType<typeof createWindowsTokenSigner>
  18. inspect?: (path: string) => Promise<WindowsRuntimeSignature>
  19. }
  20. async function compileProbe(compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv): Promise<void> {
  21. const { NODE_OPTIONS: _options, NODE_PATH: _path, ...safeEnvironment } = scrubWindowsSigningEnvironment(environment)
  22. await promisify(execFile)(compiler, ['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, source], {
  23. env: safeEnvironment, windowsHide: true, timeout: 30_000,
  24. })
  25. }
  26. /**
  27. * Sign one newly compiled, never-executed probe and verify its timestamp and configured certificate.
  28. * @param options Supervised evidence directory, file-owned settings and isolated test adapters.
  29. * @returns Resolves after one signature and verification; failures retain evidence and never retry or unlock a token.
  30. */
  31. export async function preflightWindowsSigning(options: SigningPreflightOptions): Promise<void> {
  32. const { environment, runDirectory } = options
  33. const record = (event: object): void => { recordPackagingEvent(runDirectory, event) }
  34. const signer = createWindowsTokenSigner({
  35. certificateFile: environment.DSH_DESKTOP_WINDOWS_CER_FILE,
  36. signTool: environment.DSH_DESKTOP_WINDOWS_SIGNTOOL,
  37. keyContainer: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
  38. tokenPin: environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
  39. runDirectory,
  40. stateDirectory: options.stateDirectory,
  41. })
  42. const state = options.stateDirectory ?? join(homedir(), '.dsh-desktop-signing')
  43. const lock = join(state, 'attempt.json')
  44. const compiler = join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe')
  45. await access(join(runDirectory, 'run.json'))
  46. for (const path of [join(runDirectory, 'fatal.json'), lock]) {
  47. try { await lstat(path) }
  48. catch (error) {
  49. if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
  50. throw error
  51. }
  52. throw new Error(`Windows signing preflight refused: inspect retained failure or interlock at ${path}; no automatic recovery`)
  53. }
  54. if (!(await lstat(compiler)).isFile()) throw new Error('Windows signing preflight requires the local .NET Framework C# compiler')
  55. const certificate = await readFile(environment.DSH_DESKTOP_WINDOWS_CER_FILE!)
  56. const leaf = new X509Certificate(certificate)
  57. if (Date.now() < Date.parse(leaf.validFrom) || Date.now() > Date.parse(leaf.validTo)) {
  58. throw new Error('Windows signing preflight requires a currently valid public certificate')
  59. }
  60. const thumbprint = leaf.fingerprint.replaceAll(':', '')
  61. const directory = join(runDirectory, 'signing-preflight')
  62. await mkdir(directory)
  63. const source = join(directory, 'probe.cs')
  64. const output = join(directory, 'probe.exe')
  65. await writeFile(source, PROBE_SOURCE, { flag: 'wx', flush: true })
  66. record({ type: 'signing-preflight-static', compiler,
  67. compilerSha256: createHash('sha256').update(await readFile(compiler)).digest('hex'),
  68. signToolSha256: createHash('sha256').update(await readFile(environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!)).digest('hex'),
  69. certificateSha256: createHash('sha256').update(certificate).digest('hex') })
  70. await (options.compile ?? compileProbe)(compiler, source, output, environment)
  71. const inspect = options.inspect ?? inspectWindowsRuntimeSignature
  72. if ((await inspect(output)).status !== 'NotSigned') throw new Error('Windows signing preflight requires a new unsigned probe')
  73. record({ type: 'signing-preflight-probe', path: output, sha256: createHash('sha256').update(await readFile(output)).digest('hex') })
  74. await (options.sign ?? signer)({ path: output, hash: 'sha256', isNest: false })
  75. const signature = await inspect(output)
  76. if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) {
  77. throw new Error('Windows signing preflight verification failed: expected the configured certificate and a valid timestamp')
  78. }
  79. record({ type: 'signing-preflight-success', ...signature,
  80. sha256: createHash('sha256').update(await readFile(output)).digest('hex'), probeExecuted: false })
  81. }
  82. async function main(): Promise<void> {
  83. if (process.platform !== 'win32') throw new Error('Windows signing preflight requires Windows')
  84. const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
  85. if (!runDirectory) throw new Error('Windows signing preflight requires a supervised packaging run')
  86. try { await preflightWindowsSigning({ runDirectory, environment: process.env }) }
  87. catch (error) {
  88. failPackagingRun(runDirectory, 'windows-signing-preflight-failed')
  89. throw error
  90. }
  91. }
  92. if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) await main()