From c8f63d915e4904d930be8016c36bea519ca6a1c1 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Fri, 31 Jul 2026 13:58:18 +0200 Subject: [PATCH] feat(desktop): sign Windows installers via Azure Trusted Signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/desktop-apps.yml | 87 ++++++++++++++++++++++++++++-- desktop/README.md | 24 +++++++-- 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/.github/workflows/desktop-apps.yml b/.github/workflows/desktop-apps.yml index ecef9576..3d57cdc1 100644 --- a/.github/workflows/desktop-apps.yml +++ b/.github/workflows/desktop-apps.yml @@ -16,7 +16,13 @@ # APPLE_CERTIFICATE_P12_BASE64_HIGH5, APPLE_CERTIFICATE_PASSWORD_HIGH5, # APPLE_ASC_KEY_ID_HIGH5, APPLE_ASC_ISSUER_ID_HIGH5, # APPLE_ASC_KEY_P8_BASE64_HIGH5, APPLE_TEAM_ID_HIGH5 -# Windows (optional Authenticode p12 — ideally an EV cert to avoid SmartScreen): +# Windows — Azure Trusted Signing (preferred; no hardware token, works on +# GitHub-hosted runners). Since 2023-06-01 the CA/Browser Forum requires every +# code-signing key (OV *and* EV) to live in an HSM, so a plain .pfx in a secret +# is no longer obtainable for newly issued certificates: +# AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, +# AZURE_SIGN_ENDPOINT, AZURE_SIGN_ACCOUNT, AZURE_SIGN_CERT_PROFILE +# Windows — legacy Authenticode p12 (only certs issued before 2023-06-01): # WINDOWS_CSC_LINK_BASE64, WINDOWS_CSC_KEY_PASSWORD name: Desktop apps @@ -97,7 +103,24 @@ jobs: APPLE_ASC_ISSUER_ID: ${{ secrets.APPLE_ASC_ISSUER_ID_HIGH5 }} APPLE_ASC_KEY_P8_BASE64: ${{ secrets.APPLE_ASC_KEY_P8_BASE64_HIGH5 }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID_HIGH5 }} - # Windows signing (optional). + # Windows signing — PREFERRED path: Azure Trusted Signing (rebranded Azure + # Artifact Signing). electron-builder 25.1.8 supports it natively via + # `win.azureSignOptions` and installs the required TrustedSigning + # PowerShell module itself. Credentials are read by Azure's + # EnvironmentCredential, so the three AZURE_* names below are fixed by + # Azure, not chosen by us. + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + # Non-secret account coordinates, kept as secrets purely for consistency. + AZURE_SIGN_ENDPOINT: ${{ secrets.AZURE_SIGN_ENDPOINT }} + AZURE_SIGN_ACCOUNT: ${{ secrets.AZURE_SIGN_ACCOUNT }} + AZURE_SIGN_CERT_PROFILE: ${{ secrets.AZURE_SIGN_CERT_PROFILE }} + # Windows signing — LEGACY path: a .p12/.pfx in a secret. Only works with + # certificates issued BEFORE 2023-06-01. Since then the CA/Browser Forum + # requires every code-signing private key (OV *and* EV) to live in a + # FIPS 140-2 L2 / EAL4+ HSM, so no newly issued certificate can be exported + # to a file at all. Kept for existing certs; prefer Azure above. WINDOWS_CSC_LINK_BASE64: ${{ secrets.WINDOWS_CSC_LINK_BASE64 }} WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} defaults: @@ -305,13 +328,38 @@ jobs: if [ -n "$IDENT" ]; then echo "MAC_SIGN_IDENTITY=$IDENT" >> "$GITHUB_ENV"; fi # --- Windows Authenticode signing (optional) -------------------------- - - name: Prepare Windows signing (optional — needs WINDOWS_CSC_* secrets) - if: ${{ matrix.os == 'windows-latest' && env.WINDOWS_CSC_LINK_BASE64 != '' }} + # --- Windows signing: Azure Trusted Signing (preferred) ---------------- + # The three coordinates are passed on the CLI rather than hard-coded in + # electron-builder.yml so the config stays generic — a fork without these + # secrets simply builds unsigned instead of failing on someone else's + # Azure account. Presence of win.azureSignOptions is what switches + # electron-builder to the Azure path. + - name: Prepare Windows signing (Azure Trusted Signing) + if: ${{ matrix.os == 'windows-latest' && env.AZURE_CLIENT_ID != '' }} + run: | + for v in AZURE_TENANT_ID AZURE_CLIENT_SECRET AZURE_SIGN_ENDPOINT AZURE_SIGN_ACCOUNT AZURE_SIGN_CERT_PROFILE; do + if [ -z "${!v}" ]; then + echo "FAIL: AZURE_CLIENT_ID is set but $v is empty — Azure signing needs all six." >&2 + exit 1 + fi + done + { + printf 'EB_WIN_SIGN=--config.win.azureSignOptions.endpoint=%s' "$AZURE_SIGN_ENDPOINT" + printf ' --config.win.azureSignOptions.codeSigningAccountName=%s' "$AZURE_SIGN_ACCOUNT" + printf ' --config.win.azureSignOptions.certificateProfileName=%s\n' "$AZURE_SIGN_CERT_PROFILE" + echo "WIN_SIGN_EXPECTED=1" + } >> "$GITHUB_ENV" + echo "Azure Trusted Signing configured for account $AZURE_SIGN_ACCOUNT" + + # --- Windows signing: legacy .pfx (pre-2023 certificates only) --------- + - name: Prepare Windows signing (legacy CSC — pre-2023 certs only) + if: ${{ matrix.os == 'windows-latest' && env.AZURE_CLIENT_ID == '' && env.WINDOWS_CSC_LINK_BASE64 != '' }} run: | printf '%s' "$WINDOWS_CSC_LINK_BASE64" | base64 -d > "$RUNNER_TEMP/win-cert.p12" { echo "CSC_LINK=$RUNNER_TEMP/win-cert.p12" echo "CSC_KEY_PASSWORD=$WINDOWS_CSC_KEY_PASSWORD" + echo "WIN_SIGN_EXPECTED=1" } >> "$GITHUB_ENV" - name: Package installers @@ -332,7 +380,7 @@ jobs: # times and then reports only the LAST error, which masks the real # cause. Re-run this step with `DEBUG=electron-builder` to see the # actual command and its stderr. - npx electron-builder ${{ matrix.args }} ${EB_NOTARIZE:-} --publish never + npx electron-builder ${{ matrix.args }} ${EB_NOTARIZE:-} ${EB_WIN_SIGN:-} --publish never # ALWAYS runs on macOS — signed or not. Without it, a build whose signature # macOS considers corrupt sails through CI green and ships an app that @@ -366,6 +414,35 @@ jobs: done echo "✓ macOS bundle carries a structurally valid signature (nested code included)" + # Same lesson as macOS: without a gate, CI goes green while shipping an + # unsigned installer. Runs whenever signing was CONFIGURED, so "secrets are + # present but the installer came out unsigned" fails the build instead of + # reaching users as a SmartScreen warning. + # Uses Get-AuthenticodeSignature rather than signtool.exe — it is built into + # PowerShell, so there is no Windows SDK path to hunt for. + - name: Verify the Windows installer is signed + if: ${{ matrix.os == 'windows-latest' && env.WIN_SIGN_EXPECTED == '1' }} + working-directory: desktop + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $installers = @(Get-ChildItem -Path release -Filter *.exe -ErrorAction SilentlyContinue) + if ($installers.Count -eq 0) { + Write-Error "FAIL: no .exe produced under release/ — nothing to verify" + } + foreach ($exe in $installers) { + $sig = Get-AuthenticodeSignature $exe.FullName + Write-Host "$($exe.Name): status=$($sig.Status) signer=$($sig.SignerCertificate.Subject)" + if ($sig.Status -ne 'Valid') { + Write-Error "FAIL: $($exe.Name) is not validly signed (status=$($sig.Status)). Signing was configured, so this must not ship." + } + if (-not $sig.TimeStamperCertificate) { + # Without a countersignature the signature dies with the cert. + Write-Error "FAIL: $($exe.Name) has no trusted timestamp." + } + } + Write-Host "✓ Windows installer(s) carry a valid, timestamped Authenticode signature" + # The .app is notarized + stapled by electron-builder above; the DMG needs # its own ticket so `stapler validate` passes on the disk image itself. - name: Notarize + staple the DMGs diff --git a/desktop/README.md b/desktop/README.md index ec848060..709fb874 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -74,14 +74,30 @@ macOS): Developer ID cert, notarizes the `.app` via electron-builder, then `notarytool submit --wait` + `stapler staple` the DMG and verifies (rejects ad-hoc) — identical to the omadia-ui flow. -- **Windows** (new, optional Authenticode — ideally an EV cert to avoid - SmartScreen): `WINDOWS_CSC_LINK_BASE64` (base64 of the `.p12`), - `WINDOWS_CSC_KEY_PASSWORD`. +- **Windows — Azure Trusted Signing** (preferred): `AZURE_TENANT_ID`, + `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SIGN_ENDPOINT`, + `AZURE_SIGN_ACCOUNT`, `AZURE_SIGN_CERT_PROFILE`. electron-builder drives this + natively via `win.azureSignOptions` and installs the `TrustedSigning` + PowerShell module itself; the workflow passes the three account coordinates on + the CLI so no byte5-specific value is baked into the repo. No hardware token, + runs on GitHub-hosted runners. +- **Windows — legacy Authenticode `.p12`**: `WINDOWS_CSC_LINK_BASE64`, + `WINDOWS_CSC_KEY_PASSWORD`. Only usable with certificates issued **before + 2023-06-01**. Since then the CA/Browser Forum requires every code-signing + private key — OV *and* EV — to be generated and held in a FIPS 140-2 Level 2 / + EAL4+ HSM, so a newly issued certificate cannot be exported to a file at all. + Used only when the Azure secrets are absent. > Add the secrets under **byte5ai/omadia → Settings → Secrets → Actions**. The > Apple values are the same ones already in the omadia-ui repo (one Developer ID > per Apple account). Until the Windows secrets exist, Windows installers ship -> unsigned. +> unsigned — the installer still runs, but SmartScreen shows an "unknown +> publisher" warning. +> +> Both platforms have an **always-on verification gate**: if signing is +> configured but the artifact comes out unsigned, the build fails rather than +> going green and shipping it. That failure mode is not hypothetical — v0.56.0 +> and v0.57.0 shipped a macOS app that could not be opened at all (#558). **Not yet CI-validated:** the cross-platform middleware build + native rebuild on the Windows/macOS runners has only been exercised locally on macOS — the first