Files
deepseek-harness/apps/desktop/scripts/windows-sign.mjs
T
2026-09-01 16:19:43 +08:00

267 lines
11 KiB
JavaScript

import { execFile } from 'node:child_process'
import { X509Certificate } from 'node:crypto'
import { readFileSync, realpathSync, statSync } from 'node:fs'
import { open } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import wineVmModule from 'app-builder-lib/out/vm/WineVm.js'
const execFileAsync = promisify(execFile)
const { WineVmManager } = wineVmModule
const CODE_SIGNING_EKU = '1.3.6.1.5.5.7.3.3'
const NSIS_RUN_AS_INVOKER = 'RunAsInvoker'
const NSIS_BOOTSTRAP_PATCH = Symbol.for('@deepseek-ai/dsh-desktop/nsis-bootstrap-signing')
const WINDOWS_SIGN_SCRIPT = 'windows-sign.cmd'
const WINDOWS_SIGN_SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url))
const PE_HEADER_READ_SIZE = 4096
const PE32_MAGIC = 0x10B
const PE32_PLUS_MAGIC = 0x20B
const SENSITIVE_ENVIRONMENT_NAME = /(?:KEY|SECRET|TOKEN|PASSWORD)/iu
const WINDOWS_SIGNING_ENVIRONMENT_PREFIX = 'DSH_DESKTOP_WINDOWS_'
/**
* Remove inherited credentials before starting a signing-related subprocess.
*
* @param {NodeJS.ProcessEnv} environment Parent environment.
* @returns {NodeJS.ProcessEnv} Environment without credential-shaped names.
*/
export function scrubWindowsSigningEnvironment(environment) {
return Object.fromEntries(Object.entries(environment)
.filter(([name]) => !SENSITIVE_ENVIRONMENT_NAME.test(name)
&& !name.startsWith(WINDOWS_SIGNING_ENVIRONMENT_PREFIX)))
}
function resolveTokenIdentity(input) {
const keyContainer = input.keyContainer?.trim()
if (!keyContainer) {
throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER must contain the SafeNet private-key container name')
}
if (/["\r\n]/u.test(keyContainer)) {
throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER cannot contain quotes or line breaks')
}
const tokenPin = input.tokenPin
if (tokenPin === undefined || tokenPin.length === 0) {
throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN must contain the SafeNet Token Password')
}
if (/[\]"\r\n]/u.test(tokenPin)) {
throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN cannot contain "]", quotes, or line breaks because the SafeNet key-container syntax uses them as delimiters')
}
return { keyContainer, tokenPin }
}
function resolveCertificateFile(value) {
const candidate = value?.trim()
if (!candidate) {
throw new Error('DSH_DESKTOP_WINDOWS_CER_FILE must identify the public X.509 leaf certificate file')
}
let path
let certificate
try {
path = realpathSync(candidate)
certificate = new X509Certificate(readFileSync(path))
}
catch {
throw new Error(`Windows code-signing certificate file is missing or invalid: ${candidate}`)
}
if (certificate.ca || !certificate.keyUsage?.includes(CODE_SIGNING_EKU)) {
throw new Error(`Windows code-signing certificate file must contain a non-CA Code Signing certificate: ${path}`)
}
return path
}
function resolveSignTool(value) {
const candidate = value?.trim()
if (!candidate) {
throw new Error('DSH_DESKTOP_WINDOWS_SIGNTOOL must identify the SafeNet-compatible SignTool executable')
}
let path
try {
path = realpathSync(candidate)
if (!statSync(path).isFile() || !path.toLowerCase().endsWith('.exe')) throw new Error('not an executable file')
}
catch {
throw new Error(`DSH_DESKTOP_WINDOWS_SIGNTOOL is missing or is not an executable file: ${candidate}`)
}
return path
}
function redactedSigningOutput(value, secrets) {
let output = Buffer.isBuffer(value) ? value.toString('utf8') : typeof value === 'string' ? value : ''
for (const secret of secrets) {
if (secret !== '') output = output.replaceAll(secret, '<redacted>')
}
return output
}
/**
* Replace a SignTool failure with a diagnostic that cannot retain its command line.
*
* @param {unknown} error SignTool process failure.
* @param {string} path Artifact that failed signing.
* @param {readonly string[]} secrets Values that must not appear in the diagnostic.
* @returns {Error} Sanitized signing failure without the original error as its cause.
*/
export function createRedactedWindowsSigningError(error, path, secrets) {
const record = error !== null && typeof error === 'object' ? error : undefined
const code = record !== undefined && 'code' in record
&& (typeof record.code === 'number' || typeof record.code === 'string')
? ` (exit ${String(record.code)})`
: ''
const stderr = record !== undefined && 'stderr' in record
? redactedSigningOutput(record.stderr, secrets).trim()
: ''
return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}`)
}
/**
* Build the minimal CMD environment for one Electron artifact.
*
* @param {NodeJS.ProcessEnv} environment Parent environment.
* @param {{ certificateFile: string, signTool: string, path: string, isNest: boolean, tokenPin: string, keyContainer: string }} input Validated signing identity and task.
* @returns {NodeJS.ProcessEnv} Scrubbed environment plus fields consumed and cleared by the signing CMD.
*/
export function buildWindowsSigningEnvironment(environment, input) {
return {
...scrubWindowsSigningEnvironment(environment),
DSH_DESKTOP_WINDOWS_SIGNTOOL: input.signTool,
DSH_DESKTOP_WINDOWS_CER_FILE: input.certificateFile,
DSH_DESKTOP_WINDOWS_TOKEN_PIN: input.tokenPin,
DSH_DESKTOP_WINDOWS_KEY_CONTAINER: input.keyContainer,
DSH_DESKTOP_WINDOWS_SIGN_TARGET: input.path,
DSH_DESKTOP_WINDOWS_SIGN_APPEND: input.isNest ? '1' : '',
}
}
/**
* Create the electron-builder hook for a SafeNet-backed Windows code-signing certificate.
*
* @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string }} options Release signing configuration.
* @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise<void>} The signing hook.
*/
export function createWindowsTokenSigner(options) {
const certificateFile = resolveCertificateFile(options.certificateFile)
const signTool = resolveSignTool(options.signTool)
const { keyContainer, tokenPin } = resolveTokenIdentity(options)
const commandInterpreter = options.commandInterpreter
?? process.env.ComSpec
?? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe')
return async (configuration) => {
if (configuration.hash !== 'sha256') {
throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`)
}
await repairDanglingAuthenticodeDirectory(configuration.path)
const secrets = [tokenPin]
let result
try {
result = await execFileAsync(commandInterpreter, [
'/d',
'/v:off',
'/c',
WINDOWS_SIGN_SCRIPT,
], {
cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY,
env: buildWindowsSigningEnvironment(process.env, {
certificateFile,
signTool,
path: configuration.path,
isNest: configuration.isNest,
tokenPin,
keyContainer,
}),
windowsHide: false,
})
}
catch (error) {
throw createRedactedWindowsSigningError(error, configuration.path, secrets)
}
const stdout = redactedSigningOutput(result.stdout, secrets)
const stderr = redactedSigningOutput(result.stderr, secrets)
if (stdout !== '') process.stdout.write(stdout)
if (stderr !== '') process.stderr.write(stderr)
}
}
/**
* Clear a certificate-table entry that points beyond the end of a generated executable.
*
* @param {string} path Executable to inspect.
* @returns {Promise<boolean>} Whether an invalid certificate-table entry was cleared.
*/
export async function repairDanglingAuthenticodeDirectory(path) {
const file = await open(path, 'r+')
try {
const { size } = await file.stat()
const header = Buffer.alloc(Math.min(PE_HEADER_READ_SIZE, size))
await file.read(header, 0, header.length, 0)
const directoryOffset = findDanglingAuthenticodeDirectory(header, size)
if (directoryOffset === undefined) return false
await file.write(Buffer.alloc(8), 0, 8, directoryOffset)
return true
}
finally {
await file.close()
}
}
/**
* Locate an Authenticode certificate-table entry whose declared bytes are outside the file.
*
* @param {Buffer} header Initial executable bytes.
* @param {number} fileSize Complete file size.
* @returns {number | undefined} File offset of the invalid data-directory entry.
*/
function findDanglingAuthenticodeDirectory(header, fileSize) {
if (header.length < 64 || header.toString('ascii', 0, 2) !== 'MZ') return undefined
const peOffset = header.readUInt32LE(60)
const optionalHeaderOffset = peOffset + 24
if (optionalHeaderOffset + 2 > header.length
|| header.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') return undefined
const magic = header.readUInt16LE(optionalHeaderOffset)
const dataDirectoryOffset = magic === PE32_MAGIC
? optionalHeaderOffset + 96
: magic === PE32_PLUS_MAGIC
? optionalHeaderOffset + 112
: undefined
if (dataDirectoryOffset === undefined) return undefined
const certificateDirectoryOffset = dataDirectoryOffset + (4 * 8)
if (certificateDirectoryOffset + 8 > header.length) return undefined
const certificateOffset = header.readUInt32LE(certificateDirectoryOffset)
const certificateSize = header.readUInt32LE(certificateDirectoryOffset + 4)
if (certificateOffset === 0 && certificateSize === 0) return undefined
return certificateOffset > 0
&& certificateSize > 0
&& certificateOffset + certificateSize <= fileSize
? undefined
: certificateDirectoryOffset
}
/**
* Sign electron-builder's temporary NSIS executable before enterprise code integrity evaluates it.
*
* @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.
* @returns {void}
*/
export function installWindowsNsisBootstrapSigner(options) {
if ((options.platform ?? process.platform) !== 'win32') return
const prototype = (options.wineVmManager ?? WineVmManager).prototype
if (prototype[NSIS_BOOTSTRAP_PATCH] === true) return
const originalExec = prototype.exec
prototype.exec = async function (file, args, execOptions, isLogOutIfDebug) {
const isNsisBootstrap = file.toLowerCase().endsWith('.exe')
&& execOptions?.env?.__COMPAT_LAYER === NSIS_RUN_AS_INVOKER
if (!isNsisBootstrap) {
return originalExec.call(this, file, args, execOptions, isLogOutIfDebug)
}
await options.sign({ path: file, hash: 'sha256', isNest: false })
return originalExec.call(this, file, args, {
...execOptions,
env: scrubWindowsSigningEnvironment({
...(options.environment ?? process.env),
...execOptions.env,
}),
}, isLogOutIfDebug)
}
Object.defineProperty(prototype, NSIS_BOOTSTRAP_PATCH, { value: true })
}