Skip to content

Commit e5c3852

Browse files
authored
build(windows): add signing with DigiCert KeyLocker (#676)
* feat: implement Windows code signing with DigiCert KeyLocker - Add DigiCert KeyLocker integration using smctl for EV certificate signing - Configure forge.config.ts with windowsSign hook function for app and installer - Update CI/CD workflows to install and setup DigiCert KeyLocker tools - Add environment variable handling for DigiCert authentication - Update documentation with Windows signing requirements and secrets - Add @electron/windows-sign dependency for Windows signing support Eliminates SmartScreen warnings with EV code signing certificates. Tested locally with successful signing of all executables and DLLs. * refactor(ci): extract DigiCert KeyLocker setup into shared action - Create shared Windows code signing action at .github/actions/setup-windows-codesign - Replace duplicated DigiCert KeyLocker setup code in build-matrix and release workflows - Improve maintainability by centralizing Windows signing configuration - Follow existing pattern established by setup-macos-codesign action * refactor(windows): switch to Electron Forge built-in signing with DigiCert KSP - Replace direct smctl.exe calls with signWithParams using DigiCert KSP - Remove duplicate hookFunction from MakerSquirrel installer config - Use Electron Forge's built-in windowsSign with custom signtool parameters - Simplify configuration by eliminating code duplication Improves maintainability while keeping DigiCert KeyLocker integration. * chore: format * refactor: rollback to previous config * fix: add timeouts and error handling to Windows code signing setup - Switch from bash to PowerShell for better Windows MSI handling - Add explicit timeouts for installation, KSP registration, and cert sync - Add process management to kill hanging operations - Improve logging and error reporting - Use full paths for DigiCert tools * chore: format * feat: implement Windows code signing with DigiCert KeyLocker - Add DigiCert KeyLocker integration for EV certificate signing - Support signing both app executable and installer - Add shared signing function to eliminate duplication - Include signtool PATH setup for smctl compatibility - Add GitHub Actions workflow support with timeouts - Update documentation for required secrets * chore: format * refactor: adjust with postmake * refactor: use an external util for windows signature * chore: format * fix: address copilot feedback * chore: format
1 parent 792c254 commit e5c3852

File tree

6 files changed

+196
-2
lines changed

6 files changed

+196
-2
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
name: 'Setup Windows Code Signing'
2+
description: 'Sets up DigiCert KeyLocker for Windows code signing'
3+
4+
inputs:
5+
sm-host:
6+
description: 'DigiCert SigningManager host'
7+
required: true
8+
sm-api-key:
9+
description: 'DigiCert SigningManager API key'
10+
required: true
11+
sm-client-cert-file-b64:
12+
description: 'Base64 encoded client certificate file'
13+
required: true
14+
sm-client-cert-password:
15+
description: 'Client certificate password'
16+
required: true
17+
sm-code-signing-cert-sha1-hash:
18+
description: 'SHA1 hash of the code signing certificate'
19+
required: true
20+
21+
runs:
22+
using: 'composite'
23+
steps:
24+
- name: Setup DigiCert KeyLocker
25+
if: runner.os == 'Windows'
26+
shell: powershell
27+
env:
28+
SM_HOST: ${{ inputs.sm-host }}
29+
SM_API_KEY: ${{ inputs.sm-api-key }}
30+
SM_CLIENT_CERT_FILE_B64: ${{ inputs.sm-client-cert-file-b64 }}
31+
SM_CLIENT_CERT_PASSWORD: ${{ inputs.sm-client-cert-password }}
32+
SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ inputs.sm-code-signing-cert-sha1-hash }}
33+
run: |
34+
Write-Host "Setting up DigiCert KeyLocker..."
35+
36+
# Setup certificate
37+
Write-Host "Decoding certificate..."
38+
$certBytes = [Convert]::FromBase64String($env:SM_CLIENT_CERT_FILE_B64)
39+
$tempCertPath = Join-Path $env:TEMP "Certificate_pkcs12.p12"
40+
[IO.File]::WriteAllBytes($tempCertPath, $certBytes)
41+
42+
# Set environment variables
43+
Write-Host "Setting environment variables..."
44+
echo "SM_HOST=$env:SM_HOST" >> $env:GITHUB_ENV
45+
echo "SM_API_KEY=$env:SM_API_KEY" >> $env:GITHUB_ENV
46+
echo "SM_CLIENT_CERT_FILE=$tempCertPath" >> $env:GITHUB_ENV
47+
echo "SM_CLIENT_CERT_PASSWORD=$env:SM_CLIENT_CERT_PASSWORD" >> $env:GITHUB_ENV
48+
echo "SM_CODE_SIGNING_CERT_SHA1_HASH=$env:SM_CODE_SIGNING_CERT_SHA1_HASH" >> $env:GITHUB_ENV
49+
50+
# Add Windows SDK tools to PATH (for signtool)
51+
Write-Host "Adding Windows SDK tools to PATH..."
52+
if (Test-Path "C:\Program Files (x86)\Windows Kits\10\App Certification Kit") {
53+
echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $env:GITHUB_PATH
54+
}
55+
if (Test-Path "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools") {
56+
echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $env:GITHUB_PATH
57+
}
58+
59+
# Download DigiCert KeyLocker tools
60+
Write-Host "Downloading DigiCert KeyLocker tools..."
61+
$headers = @{"x-api-key" = $env:SM_API_KEY}
62+
Invoke-WebRequest -Uri "https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download" -Headers $headers -OutFile "Keylockertools-windows-x64.msi"
63+
64+
# Install with timeout
65+
Write-Host "Installing DigiCert KeyLocker tools..."
66+
$installTimeoutMs = 300000 # 5 minute timeout
67+
$process = Start-Process -FilePath "msiexec.exe" -ArgumentList "/i", "Keylockertools-windows-x64.msi", "/quiet", "/qn", "/L*v", "install.log" -PassThru
68+
if (-not $process.WaitForExit($installTimeoutMs)) {
69+
$process.Kill()
70+
throw "Installation timed out"
71+
}
72+
if ($process.ExitCode -ne 0) {
73+
Get-Content "install.log" -ErrorAction SilentlyContinue
74+
throw "Installation failed with exit code $($process.ExitCode)"
75+
}
76+
77+
# Add DigiCert tools to PATH
78+
Write-Host "Adding DigiCert tools to PATH..."
79+
echo "C:\Program Files\DigiCert\DigiCert Keylocker Tools" >> $env:GITHUB_PATH
80+
81+
# Register KSP with timeout
82+
Write-Host "Registering KSP..."
83+
$process = Start-Process -FilePath "C:\Program Files\DigiCert\DigiCert Keylocker Tools\smksp_registrar.exe" -ArgumentList "register" -PassThru -NoNewWindow
84+
$installTimeoutMs = 60000 # 1 minute timeout
85+
if (-not $process.WaitForExit($installTimeoutMs)) {
86+
$process.Kill()
87+
throw "KSP registration timed out"
88+
}
89+
90+
# Sync certificates with timeout
91+
Write-Host "Synchronizing certificates..."
92+
$process = Start-Process -FilePath "C:\Program Files\DigiCert\DigiCert Keylocker Tools\smksp_cert_sync.exe" -PassThru -NoNewWindow
93+
$installTimeoutMs = 60000 # 2 minute timeout
94+
if (-not $process.WaitForExit($installTimeoutMs)) {
95+
$process.Kill()
96+
throw "Certificate sync timed out"
97+
}
98+
99+
Write-Host "DigiCert KeyLocker setup completed successfully!"

.github/workflows/_build-matrix.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ jobs:
4141
apple-issuer-id: ${{ secrets.APPLE_ISSUER_ID }}
4242
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
4343

44+
- name: Setup Windows code signing
45+
uses: ./.github/actions/setup-windows-codesign
46+
if: runner.os == 'Windows'
47+
with:
48+
sm-host: ${{ secrets.SM_HOST }}
49+
sm-api-key: ${{ secrets.SM_API_KEY }}
50+
sm-client-cert-file-b64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
51+
sm-client-cert-password: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
52+
sm-code-signing-cert-sha1-hash: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}
53+
4454
- name: Install Flatpak tool-chain (Linux only)
4555
if: runner.os == 'Linux'
4656
run: |

.github/workflows/on-release.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ jobs:
7272
apple-issuer-id: ${{ secrets.APPLE_ISSUER_ID }}
7373
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
7474

75+
- name: Setup Windows code signing
76+
uses: ./.github/actions/setup-windows-codesign
77+
if: runner.os == 'Windows'
78+
with:
79+
sm-host: ${{ secrets.SM_HOST }}
80+
sm-api-key: ${{ secrets.SM_API_KEY }}
81+
sm-client-cert-file-b64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
82+
sm-client-cert-password: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
83+
sm-code-signing-cert-sha1-hash: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}
84+
7585
- name: Install Flatpak tool-chain (Linux only)
7686
if: runner.os == 'Linux'
7787
run: |

docs/README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,13 @@ it as `VITE_API_URL` in the `.env` file (locally) or in the CI environment.
104104

105105
## Code signing
106106

107-
> **Note:** Currently supports macOS only. Windows code signing is WIP.
107+
Supports both macOS and Windows code signing. macOS uses Apple certificates,
108+
Windows uses DigiCert KeyLocker for EV certificates.
108109

109110
### Local development
110111

112+
#### macOS
113+
111114
Optional: Set `MAC_DEVELOPER_IDENTITY` in `.env` to use a specific certificate:
112115

113116
```text
@@ -118,6 +121,8 @@ Local signing is not required for development.
118121

119122
### CI/CD
120123

124+
#### macOS Signing
125+
121126
Requires these GitHub secrets:
122127

123128
- `APPLE_CERTIFICATE` - Base64 encoded .p12 certificate
@@ -127,7 +132,19 @@ Requires these GitHub secrets:
127132
- `APPLE_ISSUER_ID` - Apple API Issuer ID
128133
- `APPLE_KEY_ID` - Apple API Key ID
129134

130-
CI auto-detects the certificate. Apps are signed and notarized automatically.
135+
#### Windows Signing (DigiCert KeyLocker)
136+
137+
Requires these GitHub secrets for EV certificate signing:
138+
139+
- `SM_HOST` - DigiCert KeyLocker host URL
140+
- `SM_API_KEY` - DigiCert KeyLocker API key
141+
- `SM_CLIENT_CERT_FILE_B64` - Base64 encoded client certificate (.p12)
142+
- `SM_CLIENT_CERT_PASSWORD` - Client certificate password
143+
- `SM_CODE_SIGNING_CERT_SHA1_HASH` - SHA1 fingerprint of the code signing
144+
certificate
145+
146+
CI auto-detects the certificates. Apps are signed automatically during the build
147+
process.
131148

132149
## ESLint configuration
133150

forge.config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ const config: ForgeConfig = {
4545
? { identity: process.env.MAC_DEVELOPER_IDENTITY }
4646
: {}, // Auto-detect certificates
4747

48+
// Windows Code Signing Configuration - DigiCert KeyLocker
49+
windowsSign:
50+
process.env.SM_HOST && process.env.SM_API_KEY
51+
? {
52+
hookModulePath: './utils/digicert-hook.js',
53+
}
54+
: undefined,
55+
4856
// MacOS Notarization Configuration
4957
osxNotarize: (() => {
5058
// Prefer Apple API Key method
@@ -94,6 +102,13 @@ const config: ForgeConfig = {
94102
authors: 'Stacklok',
95103
exe: 'ToolHive.exe',
96104
name: 'ToolHive',
105+
// Use DigiCert KeyLocker for signing the installer
106+
windowsSign:
107+
process.env.SM_HOST && process.env.SM_API_KEY
108+
? {
109+
hookModulePath: './utils/digicert-hook.js',
110+
}
111+
: undefined,
97112
}),
98113
new MakerDMGWithArch(
99114
{

utils/digicert-hook.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* DigiCert KeyLocker signing hook for @electron/windows-sign
3+
* This module exports a function that signs files using DigiCert smctl.exe
4+
*/
5+
6+
const { execSync } = require('child_process')
7+
8+
/**
9+
* Signs a file using DigiCert KeyLocker
10+
* @param {string} filePath - Absolute path to the file to sign
11+
* @returns {Promise<void>}
12+
*/
13+
async function signWithDigiCert(filePath) {
14+
console.log(`Signing ${filePath} using DigiCert KeyLocker...`)
15+
16+
const fingerprint = process.env.SM_CODE_SIGNING_CERT_SHA1_HASH
17+
if (!fingerprint) {
18+
throw new Error(
19+
'SM_CODE_SIGNING_CERT_SHA1_HASH environment variable is required'
20+
)
21+
}
22+
23+
const signCommand = [
24+
'"C:\\Program Files\\DigiCert\\DigiCert Keylocker Tools\\smctl.exe"',
25+
'sign',
26+
'--fingerprint',
27+
fingerprint,
28+
'--input',
29+
`"${filePath}"`,
30+
].join(' ')
31+
32+
console.log(`Executing: ${signCommand}`)
33+
34+
try {
35+
execSync(signCommand, { stdio: 'inherit' })
36+
console.log(`Successfully signed ${filePath}`)
37+
} catch (error) {
38+
console.error(`Failed to sign ${filePath}:`, error.message || error)
39+
throw error
40+
}
41+
}
42+
43+
module.exports = signWithDigiCert

0 commit comments

Comments
 (0)