Files
hermes-agent/apps/desktop/scripts/batch-sign-binaries.mjs

432 lines
18 KiB
JavaScript

// 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 (`<productName>.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<void>} rejects with the child's error on non-zero exit.
*/
function execFileAsync(cmd, args, options) {
return new Promise((resolve, reject) => {
execFile(cmd, args, options, (error) => {
if (error) reject(error)
else resolve()
})
})
}
/**
* 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<void>} 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<void>} 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
]
const run = async () => {
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<typeof createPayloadSignCache> }} [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 }
}
// 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
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.
await runPool(batches, concurrency, (batch) =>
signChunk(batch, { signtool, dlib, metadataPath, exec: opts.exec, execOptions })
)
// Pass 2: RFC3161 timestamp — concurrent, no Azure/dlib, retried.
await runPool(batches, concurrency, (batch) =>
timestampChunk(batch, {
signtool,
timestampUrl: opts.timestampUrl,
exec: opts.exec,
execOptions,
timestampAttempts: opts.timestampAttempts,
timestampRetryDelayMs: opts.timestampRetryDelayMs
})
)
if (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<typeof batchSignBinaries>[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))
const binaries = getBinaries(appOutDir, {
skip: (file) =>
inCache(file) || (productExe ? path.resolve(file) === productExe : false)
})
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<Arch, string[]>} */ (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<void>, azureSignFile?: (file: string, packager: import('app-builder-lib').WinPackager) => Promise<void> }} [deps]
* @returns {Promise<boolean>} 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 <dir>')
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