Skip to content

feat(desktop): sign Windows installers via Azure Trusted Signing - #559

Open
Weegy wants to merge 9 commits into
mainfrom
feat/windows-azure-trusted-signing
Open

feat(desktop): sign Windows installers via Azure Trusted Signing#559
Weegy wants to merge 9 commits into
mainfrom
feat/windows-azure-trusted-signing

Conversation

@Weegy

@Weegy Weegy commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Problem

Windows installers currently ship unsigned — SmartScreen shows an "unknown publisher" warning. Unlike the macOS defect in #558 the installer still runs, so this is a product-quality issue rather than an outage, but it hurts conversion on every download.

The existing path in the workflow expects a .p12 in WINDOWS_CSC_LINK_BASE64, and that is a dead end for any new certificate: since 2023-06-01 the CA/Browser Forum requires every code-signing signing key — OV and EV — to be generated and held in a FIPS 140-2 Level 2 / EAL4+ HSM. No CA will issue an exportable file any more, so that secret can only ever be filled from a pre-2023 certificate.

Approach: Azure Trusted Signing

Azure Trusted Signing (rebranded Azure Artifact Signing in 2026) keeps the signing material in Microsoft's HSM and signs over an API, so it needs no hardware token and works on GitHub-hosted runners. ~$9.99/month, available to verified EU businesses.

Everything below was verified against the pinned app-builder-lib@25.1.8, not assumed:

Checked Result
win.azureSignOptions in 25.1.8 schema present — no electron-builder upgrade needed
Required fields endpoint, codeSigningAccountName, certificateProfileName
PowerShell module electron-builder installs it itself (Install-Module -Name TrustedSigning -RequiredVersion 0.4.1)
Credentials Azure EnvironmentCredential → the three AZURE_* names are fixed by Azure, not chosen here

Changes

File Change
.github/workflows/desktop-apps.yml Azure signing step building --config.win.azureSignOptions.* flags; fails fast if AZURE_CLIENT_ID is set but any sibling is empty
.github/workflows/desktop-apps.yml Always-on Windows verification gate (see below)
.github/workflows/desktop-apps.yml Legacy .p12 path kept, but only used when the Azure secrets are absent; header documents why it is legacy
desktop/README.md Documents both paths and the HSM requirement

The three account coordinates are passed on the CLI rather than written into electron-builder.yml, so the committed config stays generic — a fork without these secrets builds unsigned instead of failing against byte5's Azure account.

Verification gate

Mirrors the macOS gate added in #558. It runs whenever signing was configured, so "secrets are present but the installer came out unsigned" fails the build instead of going green and reaching users. It also requires a trusted timestamp — without a countersignature the signature stops validating once the certificate expires. Uses Get-AuthenticodeSignature rather than signtool.exe, so there is no Windows SDK path to locate.

That failure mode is not hypothetical: it is exactly how v0.56.0 and v0.57.0 shipped a macOS app that could not be opened at all.

Verified so far

  • Schema and runtime requirements confirmed against the actual pinned package
  • Flag construction tested locally: produces exactly the 3 expected argv entries, and the missing-variable guard triggers correctly
  • actionlint clean (the two info-level SC2086 hits are the intentional word-splitting on ${EB_NOTARIZE:-} / ${EB_WIN_SIGN:-})

Not yet validated end-to-end

This cannot be tested until the Azure Trusted Signing account exists. Required setup on the byte5 Azure tenant:

  1. Trusted Signing Account + Certificate Profile, with identity validation (takes a few days)
  2. App Registration + client secret, granted the Trusted Signing Certificate Profile Signer role
  3. Six repo secrets: AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SIGN_ENDPOINT, AZURE_SIGN_ACCOUNT, AZURE_SIGN_CERT_PROFILE

Until then this branch is a no-op for Windows: no Azure secrets → no EB_WIN_SIGN flags → unsigned build, exactly as today. Merging it early is safe; it simply activates once the secrets land.

Test plan

  • azureSignOptions support confirmed in the pinned electron-builder
  • Flag construction + guard tested
  • Azure Trusted Signing account provisioned and secrets added
  • Desktop apps dispatched; Windows job signs and the gate passes
  • Downloaded .exe shows a valid, timestamped signature with the byte5 publisher
  • Confirm SmartScreen behaviour on a clean Windows machine (reputation builds over downloads; only EV certificates grant it instantly)

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Weegy added 2 commits July 31, 2026 13:58
The existing Windows path expects a .p12 in WINDOWS_CSC_LINK_BASE64, which is
a dead end for any new certificate: since 2023-06-01 the CA/Browser Forum
requires every code-signing private key (OV *and* EV) to be generated and kept
in a FIPS 140-2 L2 / EAL4+ HSM, so no CA will issue an exportable file. As a
result Windows installers currently ship unsigned and SmartScreen shows an
'unknown publisher' warning.

Azure Trusted Signing (rebranded Azure Artifact Signing) solves this without a
hardware token, so it works on GitHub-hosted runners. Verified against the
pinned app-builder-lib@25.1.8 schema rather than assumed:

  - win.azureSignOptions already exists in 25.1.8 — no electron-builder
    upgrade needed
  - required fields are endpoint, codeSigningAccountName, certificateProfileName
  - electron-builder installs the TrustedSigning PowerShell module itself
    (Install-Module -Name TrustedSigning -RequiredVersion 0.4.1) and reads
    credentials via Azure EnvironmentCredential, which is why the three
    AZURE_* credential names are fixed by Azure rather than chosen here

The three account coordinates are passed on the CLI instead of being written
into electron-builder.yml, so the committed config stays generic — a fork
without these secrets builds unsigned rather than failing against someone
else's Azure account.

Also adds an always-on Windows verification gate, mirroring the macOS one: it
runs whenever signing was configured, so 'secrets present but the installer
came out unsigned' fails the build instead of reaching users. It additionally
requires a trusted timestamp, without which the signature would expire with
the certificate. Uses Get-AuthenticodeSignature rather than signtool.exe to
avoid depending on a Windows SDK path.

The legacy .p12 path is kept for pre-2023 certificates but is now only used
when the Azure secrets are absent.
Weegy added 7 commits August 28, 2026 15:29
…ent-install race

electron-builder signs every unpacked Windows binary in parallel. Each
parallel Invoke-TrustedSigning call independently checks whether its NuGet
dependencies (Microsoft.Trusted.Signing.Client,
Microsoft.Windows.SDK.BuildTools) are installed, and on the first run none
are cached yet — so N concurrent installs race for the same package file
and lose with "the process cannot access the file because it is being used
by another process". Not an auth/config problem, a bootstrap race.

Sign one throwaway file (a copy of notepad.exe) single-threaded right after
Azure signing is configured, so the one-time package install finishes
before electron-builder's parallel batch starts.
…signing

electron-builder's default NSIS artifact name is
"${productName} Setup ${version}.${ext}" — the space in that filename
breaks Azure Trusted Signing: electron-builder invokes
`Invoke-TrustedSigning -Files <path>` without quoting the path, so
signtool truncates it at the first space and fails with
"SignTool Error: File not found".

Override nsis.artifactName to drop the space entirely rather than patch
electron-builder's quoting internals.
Test-only — will be reverted before merge. Needed so this workflow_dispatch
run actually exercises the electron-builder.yml fixes that only exist on
this branch.
electron-builder's WindowsSignAzureManager spreads any extra
win.azureSignOptions keys straight through to Invoke-TrustedSigning as
-<Key> <value>. We only ever passed endpoint/account/cert-profile, so the
signature was valid but uncountersigned — Get-AuthenticodeSignature reports
no TimeStamperCertificate, which the verification gate correctly rejects:
an untimestamped signature stops validating the moment the cert expires.

Add timestampRfc3161 (Microsoft's public Trusted/Artifact Signing timestamp
authority) and timestampDigest.
@Weegy

Weegy commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Verified end-to-end — three real bugs found + fixed on this branch

Azure Trusted Signing account (byte5gmbh, region weu) + App Registration + RBAC role are now provisioned and the 6 AZURE_* repo secrets are set. Testing against the real v0.141.0 tag surfaced three independent bugs in the signing path, all now fixed:

  1. NuGet install race — electron-builder signs every unpacked binary in parallel; each parallel Invoke-TrustedSigning call independently tries to install its NuGet dependencies on first use, and concurrent installs collide (the process cannot access the file). Fixed by pre-warming the tooling (signing one throwaway file single-threaded) before the parallel batch starts.
  2. Installer filename has a space — electron-builder's default NSIS artifact name (${productName} Setup ${version}.exe) breaks Invoke-TrustedSigning's unquoted path argument, causing SignTool Error: File not found. Fixed via nsis.artifactName override with no space.
  3. No RFC3161 timestampwin.azureSignOptions only had endpoint/codeSigningAccountName/certificateProfileName wired up; timestampRfc3161/timestampDigest were never passed through, so signatures validated today but would die the moment the cert expires. The repo's own verification gate correctly caught this (no trusted timestamp). Fixed by adding both flags.

Final verification run produced a Windows installer with Get-AuthenticodeSignature reporting status=Valid signer=CN=byte5 GmbH, O=byte5 GmbH, L=Frankfurt, S=Hessen, C=DE and a valid timestamp — the acceptance gate passed.

⚠️ Known side effect needing manual cleanup (not done by me, per Marcel's call): the verification runs used a temporary checkout-override to exercise these branch-only fixes against the v0.141.0 tag, which caused every dispatch to rebuild all platforms and --clobber-upload to the real, already-published v0.141.0 release (mac/Linux content is byte-identical since v0.141.0 points at the same commit as main at the time, but two Windows installer variants with the wrong version number 0.1.0omadia-Setup-0.1.0.exe/.blockmap and the earlier omadia.Setup.0.1.0.exe/.blockmap — plus a latest.yml reflecting that wrong version are now attached to that release and should be cleaned up or replaced with a correct 0.141.0-versioned rebuild).

Branch is up to date with main (e1db6267) and CI is green.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant