Created
December 17, 2025 23:38
-
-
Save abram/64aeb338cbf0f7abcd96e17e3bdfa8c5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "win": { | |
| "signtoolOptions": { | |
| "signingHashAlgorithms": ["sha256"], | |
| "sign": "scripts/electron-builder.win-sign-hook.js", | |
| }, | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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." |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 forelectron-builder.env.