// batch-sign-binaries.mjs — Authenticode-sign every standalone binary inside // the packed Windows app tree (Hermes.exe's sibling DLLs and everything under // resources/agent-payload/tools/: node.exe, ffmpeg.exe, chromium, the minted // CLI launcher exes, pm tool binaries, …). // // Why a batch: Windows validates only the MSIX package signature // (AppxSignature.p7x over AppxBlockMap.xml), so inner binaries have never been // signed. But an MSIX whose payload carries unsigned PEs trips SmartScreen / // enterprise WDAC policies that scan inner files, and signature-verification // tooling reports the app as mixed signed/unsigned. Task 0 (pm-clean fix // plan): sign the whole tree, once, in chunked signtool invocations. // // Ordering (electron-builder win32 pipeline, pinned by app-builder-lib source): // beforePack → pack → afterPack (this module's caller) → electron fuses // → signAndEditResources (rcedit on the product exe) → per-file sign hook. // Consequences: // - The batch runs in afterPack AFTER sanitize-pe-signatures.mjs (a dangling // certificate table makes signtool fail 0x800700C1) and AFTER the rcedit // identity stamp, so neither can invalidate what we sign. // - The product exe (`.exe`) is EXCLUDED from the batch: // rcedit edits its resources (and the electron fuses flip) after // afterPack, which invalidates any signature. It is signed per-file by the // customSign hook AFTER rcedit, on the exact Azure mechanism sign-msix.mjs // uses for the package itself. // - Extra-resource copies invoke customSign BEFORE afterPack; payload files // are deferred to the sanitized batch. Later per-file calls are no-ops, // so electron-builder never re-signs those binaries one-by-one. // // Sign-nested-chromium.mjs stays as-is: it is macOS-only (codesign --deep over // .app bundles inside the payload for Apple notarization) and is invoked from // the darwin branch of after-pack.mjs. There is no overlap with this Windows // Authenticode batch. // // Gating matches the rest of the pipeline: this module only signs when the // Azure Trusted Signing variables are present (the same AZURE_SIGN_* set the // release-signing workflow arms provide). Without them — local builds, forks, // unsigned canary lanes — it is a no-op with a loud warning, exactly like // stage-msixbundle.mjs. The shared Windows tool provisioner supplies the // matching SDK, ATS dlib, and .NET runtime even when the cache is empty. import { execFile } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { Arch } from 'app-builder-lib' import { computeArchToTargetNamesMap } from 'app-builder-lib/internal' import { isMain } from './utils.mjs' import { createPayloadSignCache } from './payload-sign-cache.mjs' import { ensureWindowsBundleTools } from './windows-bundle-tools.mjs' /** @typedef {(cmd: string, args: string[], options?: import('node:child_process').ExecFileOptions) => unknown} SignExecutor */ export const CHUNK_SIZE = 100 // How many signtool children may run at once. Azure Trusted Signing and the // timestamp server are both network round-trips per file, so N concurrent // children multiply throughput ~Nx. Keep it modest — the timestamp server // rate-limits aggressive bursters. export const DEFAULT_CONCURRENCY = 4 // Separate timestamp pass URL. `timestamp.acs.microsoft.com` intermittently // fails with "Invalid Time Stamp Request Length:-1" (documented widely); // digicert's RFC3161 endpoint has been reliable in the release pipeline. const TIMESTAMP_URL = 'http://timestamp.digicert.com' /** * execFile as a promise, so chunks can run concurrently. * @returns {Promise} rejects with the child's error on non-zero exit. */ function execFileAsync(cmd, args, options) { return new Promise((resolve, reject) => { const child = execFile(cmd, args, options, (error) => { if (error) reject(error) else resolve() }) // execFile captures output even when passed stdio: 'inherit'. child.stdout?.pipe(process.stdout, { end: false }) child.stderr?.pipe(process.stderr, { end: false }) }) } /** * Retry a fallible op (bounded). Used for the timestamp pass, where the * external server flakes intermittently — a retried timestamp beats a whole * rebuild. Exponential backoff (1s, 2s, ...) between attempts. * * @param {() => Promise} fn * @param {{ attempts?: number, baseDelayMs?: number }} [opts] */ export async function withRetry(fn, opts = {}) { const attempts = opts.attempts ?? 3 const baseDelayMs = opts.baseDelayMs ?? 1000 let lastError for (let attempt = 1; attempt <= attempts; attempt += 1) { try { return await fn() } catch (error) { lastError = error if (attempt < attempts) { await new Promise((resolve) => setTimeout(resolve, baseDelayMs * attempt)) } } } throw lastError } /** * Run up to `concurrency` async workers over items. Each worker pulls the * next item as it frees up, so unevenly-sized work balances itself. * * @param {T[]} items * @param {number} concurrency * @param {(item: T, index: number) => Promise} worker * @template T */ export async function runPool(items, concurrency, worker) { let index = 0 const next = async () => { while (index < items.length) { const i = index index += 1 await worker(items[i], i) } } const workers = Array.from( { length: Math.min(Math.max(1, concurrency), items.length) }, () => next() ) await Promise.all(workers) } /** * Recursively collect every .exe/.dll under dir. Symbolic links are skipped * (the payload's materialized-link layout can contain them; the target is * collected on its own walk). Sorted for deterministic chunking. * * @param {string} dir * @param {{ skip?: (file: string) => boolean }} [opts] * @returns {string[]} */ export function getBinaries(dir, opts = {}) { const out = [] const walk = (current) => { let entries try { entries = fs.readdirSync(current, { withFileTypes: true }) } catch { return } for (const entry of entries) { if (entry.isSymbolicLink()) continue const full = path.join(current, entry.name) if (entry.isDirectory()) { walk(full) continue } if (!entry.isFile()) continue const lower = entry.name.toLowerCase() if (lower.endsWith('.exe') || lower.endsWith('.dll')) { if (opts.skip && opts.skip(full)) continue out.push(full) } } } walk(dir) return out.sort() } /** Split a file list into signtool-sized batches. */ export function chunk(items, size = CHUNK_SIZE) { const chunks = [] for (let i = 0; i < items.length; i += size) { chunks.push(items.slice(i, i + size)) } return chunks } /** True when the Azure Trusted Signing variables are all present. */ export function azureSigningConfigured(env = process.env) { return Boolean(env.AZURE_SIGN_ENDPOINT && env.AZURE_SIGN_ACCOUNT && env.AZURE_SIGN_PROFILE) } /** * Sign one chunk of binaries with a single signtool invocation (argv array, * never a shell string — the joined list can be long and must not interpolate * through a shell). Azure-only: NO timestamp here (see timestampChunk — the * sign pass would otherwise hold each file hostage to the flaky timestamp * server, and a timestamp failure would force a full re-sign). * * @param {string[]} files * @param {{ signtool: string, dlib: string, metadataPath: string, exec?: SignExecutor, execOptions?: import('node:child_process').ExecFileOptions }} opts */ export async function signChunk(files, opts) { const args = [ 'sign', '/fd', 'SHA256', '/dlib', opts.dlib, '/dmdf', opts.metadataPath, ...files ] if (opts.exec) { await opts.exec(opts.signtool, args, opts.execOptions) return } await execFileAsync(opts.signtool, args, opts.execOptions ?? { stdio: 'inherit' }) } /** * RFC3161-timestamp one chunk of ALREADY-SIGNED files. No /dlib, no /dmdf — * this is pure timestamping, so it neither re-auths against Azure nor * re-initializes the .NET dlib. The timestamp server is the flaky external * dependency, so this pass retries per chunk (a retried timestamp beats a * whole re-sign) and runs concurrently like the sign pass. * * @param {string[]} files * @param {{ signtool: string, timestampUrl?: string, exec?: SignExecutor, execOptions?: import('node:child_process').ExecFileOptions, timestampAttempts?: number, timestampRetryDelayMs?: number }} opts */ export async function timestampChunk(files, opts) { const args = [ 'timestamp', '/tr', opts.timestampUrl ?? TIMESTAMP_URL, '/td', 'SHA256', ...files ] let attempt = 0 const run = async () => { attempt += 1 console.log(`[batch-sign] timestamp attempt ${attempt}/${opts.timestampAttempts ?? 3} (${files.length} binaries)`) if (opts.exec) { await opts.exec(opts.signtool, args, opts.execOptions) return } await execFileAsync(opts.signtool, args, opts.execOptions ?? { stdio: 'inherit' }) } await withRetry(run, { attempts: opts.timestampAttempts, baseDelayMs: opts.timestampRetryDelayMs }) } /** * Batch-sign every binary under a tree. * * Two passes: (1) Azure Authenticode sign — concurrent signtool children, * no timestamp; (2) RFC3161 timestamp — concurrent, no Azure/dlib, retried * per chunk. A verified content cache removes unchanged inputs from both * passes. Identical cacheable inputs share one signing operation. * * @param {string[]} binaries file list from getBinaries * @param {{ env?: NodeJS.ProcessEnv, exec?: SignExecutor, chunkSize?: number, concurrency?: number, mkdtemp?: typeof fs.mkdtempSync, signtool?: string, dlib?: string, dotnetRoot?: string, config?: import('app-builder-lib').Configuration, resourcesDir?: string, timestampUrl?: string, timestampAttempts?: number, timestampRetryDelayMs?: number, cache?: ReturnType }} [opts] * @returns {Promise<{ signed: number, chunks: number, skipped: boolean }>} * skipped=true when Azure signing is not configured (caller warns). */ export async function batchSignBinaries(binaries, opts = {}) { const env = opts.env ?? process.env if (!azureSigningConfigured(env)) { return { signed: 0, chunks: 0, skipped: true } } if (binaries.length === 0) { return { signed: 0, chunks: 0, skipped: false } } console.log('[batch-sign] preparing Windows signing tools') // afterPack runs before the per-file Azure signer downloads its tools. const { signtool, dlib, dotnetRoot } = opts.signtool && opts.dlib ? opts : await ensureWindowsBundleTools({ signing: true, config: opts.config, resourcesDir: opts.resourcesDir }) const started = performance.now() const cache = opts.cache === undefined ? createPayloadSignCache({ root: env.ELECTRON_BUILDER_CACHE ? `${env.ELECTRON_BUILDER_CACHE}-payload-signatures` : null, env, signtool, dlib, timestampUrl: opts.timestampUrl ?? TIMESTAMP_URL }) : opts.cache console.log(`[batch-sign] checking signature cache for ${binaries.length} binaries${cache ? '' : ' (cache disabled)'}`) const plan = cache ? await cache.prepare(binaries) : null const toSign = plan?.files ?? binaries console.log(`[batch-sign] ${plan?.restored ?? 0} cache hits, ${toSign.length} to sign, ${plan?.duplicates ?? 0} duplicate copies`) // Ijwhost.dll needs the runtime paired with the provisioned ATS dlib. const signEnv = { ...env } if (dotnetRoot) signEnv.DOTNET_ROOT = dotnetRoot const mkdtemp = opts.mkdtemp ?? fs.mkdtempSync const tmpDir = mkdtemp(path.join(env.TEMP || env.TMP || '.', 'batch-sign-')) const metadataPath = path.join(tmpDir, 'batch-sign.json') fs.writeFileSync(metadataPath, JSON.stringify({ Endpoint: env.AZURE_SIGN_ENDPOINT, CodeSigningAccountName: env.AZURE_SIGN_ACCOUNT, CertificateProfileName: env.AZURE_SIGN_PROFILE })) const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY const batches = chunk(toSign, opts.chunkSize ?? CHUNK_SIZE) const execOptions = { stdio: 'inherit', env: signEnv } try { // Pass 1: Azure Authenticode sign — concurrent, no timestamp. console.log(`[batch-sign] Azure signing: ${batches.length} batches, up to ${concurrency} concurrent`) await runPool(batches, concurrency, async (batch, index) => { const start = performance.now() console.log(`[batch-sign] signing batch ${index + 1}/${batches.length} (${batch.length} binaries)`) await signChunk(batch, { signtool, dlib, metadataPath, exec: opts.exec, execOptions }) console.log(`[batch-sign] signed batch ${index + 1}/${batches.length} in ${((performance.now() - start) / 1000).toFixed(1)}s`) }) // Pass 2: RFC3161 timestamp — concurrent, no Azure/dlib, retried. console.log(`[batch-sign] timestamping: ${batches.length} batches`) await runPool(batches, concurrency, async (batch, index) => { const start = performance.now() console.log(`[batch-sign] timestamping batch ${index + 1}/${batches.length} (${batch.length} binaries)`) await timestampChunk(batch, { signtool, timestampUrl: opts.timestampUrl, exec: opts.exec, execOptions, timestampAttempts: opts.timestampAttempts, timestampRetryDelayMs: opts.timestampRetryDelayMs }) console.log(`[batch-sign] timestamped batch ${index + 1}/${batches.length} in ${((performance.now() - start) / 1000).toFixed(1)}s`) }) if (cache) { console.log('[batch-sign] verifying signed binaries and publishing signature cache') await cache.publish(plan) } console.log(`[batch-sign] completed in ${((performance.now() - started) / 1000).toFixed(1)}s`) return { signed: toSign.length, chunks: batches.length, skipped: false } } finally { fs.rmSync(tmpDir, { recursive: true, force: true }) } } /** * afterPack-side entry: batch-sign the packed tree, excluding the product exe * (it is rcedit-ed and signed per-file after this hook — see the header). * Callers must have run sanitize-pe-signatures.mjs first. * * @param {string} appOutDir * @param {string} productExePath absolute path of the main product exe * @param {Parameters[1]} [opts] */ export async function batchSignAppTree(appOutDir, productExePath, opts = {}) { const env = opts.env ?? process.env if (!azureSigningConfigured(env)) { console.warn( '[batch-sign] AZURE_SIGN_* not set — payload binaries will be UNSIGNED ' + '(package block-map still covers them; release lanes must set the signing env)' ) return { signed: 0, chunks: 0, skipped: true } } const productExe = productExePath ? path.resolve(productExePath) : null // uv-cache/ is the pm bundle's deliberately-shipped build cache (inert // sdist/archive artifacts, never loaded at runtime — the arch audit // exempts it for the same reason). Signing it wastes Azure round-trips // on dead weight and can FAIL: locally the cache may hold files that // were removed between pm bundle staging and the afterPack walk. const inCache = (file) => /(^|[\\/])uv-cache[\\/]/.test(path.resolve(file)) console.log(`[batch-sign] collecting signable binaries in ${appOutDir} (excluding product exe and uv-cache)`) const binaries = getBinaries(appOutDir, { skip: (file) => inCache(file) || (productExe ? path.resolve(file) === productExe : false) }) console.log(`[batch-sign] collected ${binaries.length} binaries`) if (binaries.length === 0) return { signed: 0, chunks: 0, skipped: false } const result = await batchSignBinaries(binaries, opts) console.log( `[batch-sign] signed ${result.signed} payload binaries in ${result.chunks} signtool batch(es)` + ` (product exe excluded — signed per-file after rcedit)` ) return result } // ── electron-builder custom win.sign hook ─────────────────────────────────── // // Per app-builder-lib's signtoolBaseSignManager, the custom `sign` hook is // invoked once per signable file (the product exe after rcedit, top-level // exes, asar.unpacked natives, extraResource exes). The hook's return value is // ignored by the manager, but per the Task 0 contract it resolves true for // files the afterPack batch already signed — a no-op — and delegates the two // artifacts that genuinely need per-file signing to the sign-msix.mjs Azure // machinery: the .msix/.msixbundle package and the product exe. const SIGNABLE_PACKAGE_EXTENSIONS = ['.msix', '.msixbundle'] const STORE_ARTIFACT_PREFIX = 'Store-' /** * Match the paths WinPackager.signApp signs after editing the root executable. * The per-file hook has no arch/appOutDir: use the same target normalization, * output macros and computeAppOutDir as Packager.doBuild/PlatformPackager.pack. * @param {string} file * @param {import('app-builder-lib').WinPackager} packager * @returns {boolean} */ function isProductExe(file, packager) { // Packager initializes targets and validateConfig supplies directories.output // before doBuild can invoke this hook (the public input types are optional). const targets = /** @type {Map} */ (packager.packagerOptions.targets?.get(packager.platform)) const output = /** @type {string} */ (packager.config.directories?.output) const arches = computeArchToTargetNamesMap(targets, packager, packager.platform) const resolved = path.resolve(file).toLowerCase() for (const arch of arches.keys()) { const outDir = path.resolve(packager.projectDir, packager.expandMacro(output, Arch[arch])) // Protected in the declarations, but this is the actual pack() resolver; // bracket access avoids inventing a parallel output-directory convention. const appOutDir = packager['computeAppOutDir'](outDir, arch) const productExe = path.resolve(appOutDir, `${packager.appInfo.productFilename}.exe`) if (resolved === productExe.toLowerCase()) return true } return false } /** * The electron-builder custom win.sign hook. * * @param {{ path: string }} configuration * @param {import('app-builder-lib').WinPackager} packager * @param {{ signMsix?: (configuration: { path: string }, packager: import('app-builder-lib').WinPackager) => Promise, azureSignFile?: (file: string, packager: import('app-builder-lib').WinPackager) => Promise }} [deps] * @returns {Promise} true when this hook handled the file * (batch-signed: nothing to do) — electron-builder must not re-sign it. */ export async function customSign(configuration, packager, deps = {}) { const file = configuration.path const base = path.basename(file) // Store-submission packages are Partner Center's to sign (see sign-msix.mjs). if (base.startsWith(STORE_ARTIFACT_PREFIX)) return true const lower = file.toLowerCase() if (SIGNABLE_PACKAGE_EXTENSIONS.some(ext => lower.endsWith(ext))) { const { default: signMsix } = await import('./sign-msix.mjs') await (deps.signMsix ?? signMsix)(configuration, packager) return true } // The product exe was rcedit-ed after the batch ran, so it is signed here, // after its resources are final, on the same Azure manager sign-msix uses. const productName = packager.appInfo.productFilename if (base.toLowerCase() === `${productName.toLowerCase()}.exe` && isProductExe(file, packager)) { const { azureSignFile } = await import('./sign-msix.mjs') await (deps.azureSignFile ?? azureSignFile)(file, packager) return true } // Payload copies arrive before afterPack; defer them to its sanitized batch. // Calls after afterPack are no-ops for binaries that batch already signed. return true } async function main() { const root = process.argv[2] if (!root) { console.error('usage: batch-sign-binaries.mjs ') process.exit(2) } const result = await batchSignAppTree(root, process.env.HERMES_PRODUCT_EXE || path.join(root, 'Hermes.exe')) if (result.skipped) process.exit(0) } if (isMain(import.meta.url)) { main() } // electron-builder's resolveFunction prefers a named export matching the hook // name ("sign") and falls back to the module default — provide both so the // config's `sign: './scripts/batch-sign-binaries.mjs'` binds to customSign. export { customSign as sign } export default customSign