Skip to content

Instantly share code, notes, and snippets.

@abram
Created December 17, 2025 23:38
Show Gist options
  • Select an option

  • Save abram/64aeb338cbf0f7abcd96e17e3bdfa8c5 to your computer and use it in GitHub Desktop.

Select an option

Save abram/64aeb338cbf0f7abcd96e17e3bdfa8c5 to your computer and use it in GitHub Desktop.
"win": {
"signtoolOptions": {
"signingHashAlgorithms": ["sha256"],
"sign": "scripts/electron-builder.win-sign-hook.js",
},
}
const { spawn } = require("child_process")
const fs = require("fs")
const fsp = require("fs/promises")
const path = require("path")
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms))
}
async function acquireLock(lockPath, timeoutMs = 10 * 60 * 1000) {
const start = Date.now()
while (true) {
try {
const fd = fs.openSync(lockPath, "wx")
fs.closeSync(fd)
return
} catch (e) {
if (e && e.code !== "EEXIST") {
throw e
}
if (Date.now() - start > timeoutMs) {
throw new Error(`Timed out waiting for signing lock: ${lockPath}`)
}
await sleep(1000)
}
}
}
async function releaseLock(lockPath) {
try {
await fsp.unlink(lockPath)
} catch (e) {
if (e && e.code !== "ENOENT") {
throw e
}
}
}
function runPowershell(args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn("powershell", args, { cwd, stdio: "inherit" })
child.on("error", reject)
child.on("exit", (code) => {
if (code === 0) resolve()
else reject(new Error(`PowerShell signing failed with exit code ${code}`))
})
})
}
// electron-builder will call this hook for each file and (depending on config) each hash.
// We configured signingHashAlgorithms=["sha256"], but keep a defensive check.
exports.default = async function sign(config, packager) {
if (process.platform !== "win32") {
return
}
if (config && config.hash && config.hash !== "sha256") {
return
}
const filePath = config.path
const projectDir = packager.projectDir
const scriptPath = path.join(projectDir, "scripts", "win-sign.ps1")
const lockPath = path.join(projectDir, "out", ".trusted-signing.lock")
await fsp.mkdir(path.dirname(lockPath), { recursive: true })
await acquireLock(lockPath)
try {
await runPowershell(
[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
scriptPath,
"-Files",
filePath,
"-ResignAll",
],
projectDir
)
} finally {
await releaseLock(lockPath)
}
}
param(
[Parameter(Mandatory = $false)]
[string]$OutDir = "out",
[Parameter(Mandatory = $false)]
[string[]]$Files = @(),
[Parameter(Mandatory = $false)]
[string]$Endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT,
[Parameter(Mandatory = $false)]
[string]$CodeSigningAccountName = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME,
[Parameter(Mandatory = $false)]
[string]$CertificateProfileName = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME,
[Parameter(Mandatory = $false)]
[string]$TimestampRfc3161 = "http://timestamp.acs.microsoft.com",
[Parameter(Mandatory = $false)]
[ValidateSet("SHA1", "SHA256", "SHA384", "SHA512")]
[string]$TimestampDigest = "SHA256",
[Parameter(Mandatory = $false)]
[ValidateSet("SHA1", "SHA256", "SHA384", "SHA512")]
[string]$FileDigest = "SHA256",
[Parameter(Mandatory = $false)]
[switch]$ResignAll,
[Parameter(Mandatory = $false)]
[switch]$SkipModuleInstall
)
$ErrorActionPreference = "Stop"
function Import-EnvFile([string]$Path) {
if (-not (Test-Path -LiteralPath $Path)) { return }
Get-Content -LiteralPath $Path | ForEach-Object {
$line = $_.Trim()
if (-not $line) { return }
if ($line.StartsWith("#")) { return }
$idx = $line.IndexOf("=")
if ($idx -lt 1) { return }
$name = $line.Substring(0, $idx).Trim()
$value = $line.Substring($idx + 1)
if (-not $name) { return }
# Don't clobber already-set values
if (-not (Test-Path -Path ("env:" + $name))) {
Set-Item -Path ("env:" + $name) -Value $value
}
}
}
function Require-EnvVar([string]$Name) {
if (-not (Test-Path -Path ("env:" + $Name))) {
throw "Missing required environment variable: $Name"
}
}
function Assert-AzureAuthEnv() {
Require-EnvVar "AZURE_TENANT_ID"
Require-EnvVar "AZURE_CLIENT_ID"
if ($env:AZURE_CLIENT_SECRET) { return }
if ($env:AZURE_CLIENT_CERTIFICATE_PATH) { return }
if ($env:AZURE_USERNAME -and $env:AZURE_PASSWORD) { return }
throw "Missing Azure auth env vars. Provide one of: AZURE_CLIENT_SECRET OR AZURE_CLIENT_CERTIFICATE_PATH OR (AZURE_USERNAME + AZURE_PASSWORD)."
}
function Get-DotnetInfo([string]$DotnetExe) {
try {
$out = & $DotnetExe --info 2>$null
return ($out -join "`n")
} catch {
return $null
}
}
function Ensure-DotnetForTrustedSigning() {
# TrustedSigning's Dlib is x64 and targets net8.0; on Windows ARM64 you typically need the x64 .NET runtime too.
$dotnetCmd = Get-Command "dotnet" -ErrorAction SilentlyContinue
if (-not $dotnetCmd) {
# Be resilient to shells that don't have Program Files\dotnet on PATH (common in some IDE/task runners).
$arm64DotnetRoot = Join-Path $env:ProgramFiles "dotnet"
$arm64DotnetExe = Join-Path $arm64DotnetRoot "dotnet.exe"
$x64DotnetRoot = Join-Path $env:ProgramFiles "dotnet\\x64"
$x64DotnetExe = Join-Path $x64DotnetRoot "dotnet.exe"
if (Test-Path -LiteralPath $x64DotnetExe) {
$env:DOTNET_ROOT_X64 = $x64DotnetRoot
$env:Path = ($x64DotnetRoot + ";" + $env:Path)
} elseif (Test-Path -LiteralPath $arm64DotnetExe) {
$env:Path = ($arm64DotnetRoot + ";" + $env:Path)
}
$dotnetCmd = Get-Command "dotnet" -ErrorAction SilentlyContinue
}
if (-not $dotnetCmd) {
throw "dotnet is not available. The TrustedSigning module installs/uses the 'sign' CLI via 'dotnet tool install'. Install .NET and ensure dotnet.exe is discoverable (PATH or C:\\Program Files\\dotnet)."
}
# If we're on ARM64 and only have ARM64 dotnet installed, the x64 Dlib (net8.0) can fail to initialize.
if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
$x64DotnetExe = Join-Path $env:ProgramFiles "dotnet\\x64\\dotnet.exe"
$x64DotnetRoot = Join-Path $env:ProgramFiles "dotnet\\x64"
if (Test-Path -LiteralPath $x64DotnetExe) {
# Prefer x64 dotnet for any dotnet-based helper (sign CLI) and to provide the x64 hostfxr/runtime for the Dlib.
$env:DOTNET_ROOT_X64 = $x64DotnetRoot
$env:Path = ($x64DotnetRoot + ";" + $env:Path)
return
}
$info = Get-DotnetInfo $dotnetCmd.Source
if ($info -and ($info -match "Architecture:\\s*arm64")) {
throw "TrustedSigning uses an x64 .NET-based Dlib (net8.0) under signtool. You appear to have only ARM64 .NET installed. Install the x64 .NET runtime/SDK side-by-side (so dotnet exists at 'C:\\Program Files\\dotnet\\x64\\dotnet.exe'), then rerun."
}
}
}
function Ensure-TrustedSigningModule() {
if ($SkipModuleInstall) { return }
Ensure-DotnetForTrustedSigning
if (-not (Get-Command "Invoke-TrustedSigning" -ErrorAction SilentlyContinue)) {
Write-Host "Installing NuGet PackageProvider + TrustedSigning PowerShell module (CurrentUser)..."
try {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
} catch {
# Some environments already have NuGet; ignore if it fails.
Write-Host "NuGet PackageProvider install skipped/failed: $($_.Exception.Message)"
}
Install-Module -Name TrustedSigning -MinimumVersion 0.5.0 -Force -Repository PSGallery -Scope CurrentUser | Out-Null
}
if (-not (Get-Command "Invoke-TrustedSigning" -ErrorAction SilentlyContinue)) {
throw "Invoke-TrustedSigning not available even after install. Ensure the TrustedSigning module is available in this environment."
}
}
function Get-FilesToSign([string]$Root) {
$exts = @(".exe", ".dll", ".node", ".msi", ".appx")
if (-not (Test-Path -LiteralPath $Root)) {
throw "OutDir not found: $Root"
}
$all = Get-ChildItem -LiteralPath $Root -Recurse -File |
Where-Object { $exts -contains $_.Extension.ToLowerInvariant() } |
Sort-Object FullName
if ($ResignAll) {
return $all
}
# Skip already-valid signatures to speed things up on incremental builds.
$needs = @()
foreach ($f in $all) {
$sig = Get-AuthenticodeSignature -LiteralPath $f.FullName
if ($sig.Status -ne "Valid") {
$needs += $f
}
}
return $needs
}
function Clear-PeCertificateTable([string]$Path) {
# If a PE's certificate table entry is malformed (common cause of "File not valid"/0x800700C1 in signtool),
# zero it out so signing tools can attach a fresh signature.
$full = (Resolve-Path -LiteralPath $Path).Path
$bytes = [IO.File]::ReadAllBytes($full)
if ($bytes.Length -lt 0x100) {
throw "File too small to be a PE: $full"
}
$e_lfanew = [BitConverter]::ToInt32($bytes, 0x3c)
if ($e_lfanew -lt 0 -or $e_lfanew + 0x18 -ge $bytes.Length) {
throw "Invalid PE header offset (e_lfanew=$e_lfanew): $full"
}
$peSig = [Text.Encoding]::ASCII.GetString($bytes, $e_lfanew, 4)
if ($peSig -ne "PE`0`0") {
throw "Missing PE signature at e_lfanew=${e_lfanew}: $full"
}
$optHeaderOffset = $e_lfanew + 0x18
$magic = [BitConverter]::ToUInt16($bytes, $optHeaderOffset)
# Data directory base offset differs for PE32 vs PE32+
$dataDirBase = $null
if ($magic -eq 0x20b) {
$dataDirBase = $optHeaderOffset + 0x70
} elseif ($magic -eq 0x10b) {
$dataDirBase = $optHeaderOffset + 0x60
} else {
throw "Unknown optional header magic 0x$('{0:X4}' -f $magic): $full"
}
# IMAGE_DIRECTORY_ENTRY_SECURITY is index 4
$secEntry = $dataDirBase + (4 * 8)
if ($secEntry + 8 -gt $bytes.Length) {
throw "PE header too short to contain security directory: $full"
}
# Zero out VirtualAddress (file offset) and Size
[Array]::Clear($bytes, $secEntry, 8)
[IO.File]::WriteAllBytes($full, $bytes)
}
function Get-PeCertificateTable([string]$Path) {
$full = (Resolve-Path -LiteralPath $Path).Path
$bytes = [IO.File]::ReadAllBytes($full)
$e_lfanew = [BitConverter]::ToInt32($bytes, 0x3c)
$optHeaderOffset = $e_lfanew + 0x18
$magic = [BitConverter]::ToUInt16($bytes, $optHeaderOffset)
if ($magic -eq 0x20b) {
$dataDirBase = $optHeaderOffset + 0x70
} elseif ($magic -eq 0x10b) {
$dataDirBase = $optHeaderOffset + 0x60
} else {
throw "Unknown optional header magic 0x$('{0:X4}' -f $magic): $full"
}
$secEntry = $dataDirBase + (4 * 8)
$addr = [BitConverter]::ToUInt32($bytes, $secEntry)
$size = [BitConverter]::ToUInt32($bytes, $secEntry + 4)
return [PSCustomObject]@{
Path = $full
Magic = $magic
CertTableAddr = $addr
CertTableSize = $size
FileLen = $bytes.Length
}
}
# Make local builds easy: load ../electron-builder.env if present.
Import-EnvFile (Join-Path (Split-Path -Parent $PSScriptRoot) "electron-builder.env")
# If values were not passed explicitly, re-read them from the environment *after* loading electron-builder.env.
if (-not $PSBoundParameters.ContainsKey("Endpoint")) {
$Endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT
}
if (-not $PSBoundParameters.ContainsKey("CodeSigningAccountName")) {
$CodeSigningAccountName = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME
}
if (-not $PSBoundParameters.ContainsKey("CertificateProfileName")) {
$CertificateProfileName = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME
}
if (-not $Endpoint) { throw "Missing Endpoint. Set AZURE_TRUSTED_SIGNING_ENDPOINT or pass -Endpoint." }
if (-not $CodeSigningAccountName) { throw "Missing CodeSigningAccountName. Set AZURE_TRUSTED_SIGNING_ACCOUNT_NAME or pass -CodeSigningAccountName." }
if (-not $CertificateProfileName) { throw "Missing CertificateProfileName. Set AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME or pass -CertificateProfileName." }
Assert-AzureAuthEnv
Ensure-TrustedSigningModule
$toSign = @()
if ($Files -and $Files.Count -gt 0) {
foreach ($p in $Files) {
if (-not $p) { continue }
if (-not (Test-Path -LiteralPath $p)) {
throw "File to sign not found: $p"
}
$toSign += (Get-Item -LiteralPath $p)
}
} else {
$out = Resolve-Path -LiteralPath $OutDir
$toSign = Get-FilesToSign $out.Path
}
if ($toSign.Count -eq 0) {
if ($Files -and $Files.Count -gt 0) {
Write-Host "No files to sign (empty -Files list)."
} else {
Write-Host "No files to sign under $($out.Path)."
}
exit 0
}
Write-Host "Signing $($toSign.Count) file(s) with Azure Trusted Signing..."
foreach ($f in $toSign) {
Write-Host "Signing: $($f.FullName)"
# If an EXE claims to have a certificate table but Authenticode says it's not signed,
# the table is often malformed and will cause signtool/TrustedSigning to fail with "bad exe format".
# Clear it proactively before signing.
$currentSig = Get-AuthenticodeSignature -LiteralPath $f.FullName
if ($currentSig.Status -ne "Valid" -and $f.Extension.ToLowerInvariant() -eq ".exe") {
try {
$ct = Get-PeCertificateTable $f.FullName
if ($ct.CertTableSize -gt 0) {
Write-Host "Detected non-zero PE certificate table on unsigned EXE; clearing it before signing: $($f.FullName)"
Clear-PeCertificateTable $f.FullName
}
} catch {
# If we fail to parse the PE, let signing attempt surface the real error.
Write-Host "Warning: failed to inspect PE certificate table for $($f.FullName): $($_.Exception.Message)"
}
}
try {
Invoke-TrustedSigning `
-Endpoint $Endpoint `
-CertificateProfileName $CertificateProfileName `
-CodeSigningAccountName $CodeSigningAccountName `
-TimestampRfc3161 $TimestampRfc3161 `
-TimestampDigest $TimestampDigest `
-FileDigest $FileDigest `
-Files $f.FullName
} catch {
$msg = $_.Exception.Message
# TrustedSigning/signtool sometimes reports: SignedCode::Sign returned error: 0x800700C1 (BadExeFormat)
# when the PE's certificate table entry is malformed. Try sanitizing it and retry once.
if ($msg -match "0x800700C1" -or $msg -match "badexeformat" -or $msg -match "File not valid") {
Write-Host "Signing failed with a 'bad exe format/file not valid' style error. Clearing PE certificate table and retrying once..."
Clear-PeCertificateTable $f.FullName
Invoke-TrustedSigning `
-Endpoint $Endpoint `
-CertificateProfileName $CertificateProfileName `
-CodeSigningAccountName $CodeSigningAccountName `
-TimestampRfc3161 $TimestampRfc3161 `
-TimestampDigest $TimestampDigest `
-FileDigest $FileDigest `
-Files $f.FullName
} else {
throw
}
}
}
Write-Host "Verifying signatures..."
foreach ($f in $toSign) {
$sig = Get-AuthenticodeSignature -LiteralPath $f.FullName
if ($sig.Status -ne "Valid") {
throw "Signature verification failed for $($f.FullName): $($sig.Status) $($sig.StatusMessage)"
}
}
Write-Host "All signatures valid."
@abram
Copy link
Author

abram commented Dec 17, 2025

Manual setup for code signing using Azure Trusted Signing with electron-builder. The two scripts live under scripts/ in my setup, which is why win-sign.ps1 looks in its parent folder for electron-builder.env.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment