From 38f5b0bed9f414267f8ad411058cdf3249d900ed Mon Sep 17 00:00:00 2001 From: NeuroKoder3 Date: Sat, 1 Aug 2026 20:24:40 -0500 Subject: [PATCH] feat(release): add Azure Artifact Signing as a signer mode Roughly $10/month against several hundred a year for a CA certificate, with no hardware token and no annual re-issue. signtool loads Azure.CodeSigning.Dlib.dll, which authenticates to Azure and has the signature produced server-side, so no private key is ever on the build machine -- a compromised build host can request signatures while its access lasts but cannot take the key. Two behaviours are handled rather than left to be discovered: Artifact Signing certificates are valid for three days. A signature outlives them only because a timestamp proves it was made while the certificate was live, so the signer always timestamps against Microsoft's authority. Skip it and the installer verifies for three days and then begins failing on customer machines with nothing about the file having changed. DefaultAzureCredential walks a chain of credential sources, one of which opens a browser -- on a headless build that hangs rather than fails. With a service principal in the environment the signer narrows the chain to it; with federated identity it keeps the chain but excludes the browser. The two common Azure failures both surface from signtool as a generic SignerSign() error, so each gets a hint: a 403 is almost always a region mismatch or a missing signer role assignment, and a dlib load failure is almost always a missing .NET 8 runtime or an x64/x86 mismatch. The release workflow's preflight now detects the mode with the same precedence as the signer's own auto-detect, so the two cannot disagree, and installs the client tools on the runner. The release gate reports the mode without requiring AZURE_SIGNING_DLIB, which is a path on the Windows machine that signed and cannot exist on the Linux host running the gate. Co-authored-by: Cursor --- .github/workflows/release.yml | 62 +++++- CHANGELOG.md | 21 ++ docs/CODE_SIGNING.md | 135 +++++++++++-- .../SOFTWARE_DESIGN_SPECIFICATION.md | 16 ++ docs/legal/COMMERCIALIZATION_CHECKLIST.md | 22 ++- scripts/release-readiness-check.mjs | 13 ++ scripts/sign-win.cjs | 179 +++++++++++++++++- tests/signWin.test.cjs | 129 +++++++++++++ 8 files changed, 538 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b013885..be05f1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,14 +11,19 @@ name: Release (commercial) # enforces "no unsigned binary is ever published to customers". # # Required repository secrets: -# Windows (one of two modes): -# a) SSL.com eSigner (preferred — cloud HSM, no local cert): +# Windows (one of three modes, auto-detected by the preflight job): +# a) Azure Artifact Signing (preferred — no certificate to buy, no token): +# AZURE_SIGNING_ENDPOINT region URI, e.g. https://eus.codesigning.azure.net +# AZURE_SIGNING_ACCOUNT Artifact Signing account name +# AZURE_SIGNING_PROFILE certificate profile name +# AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET +# b) SSL.com eSigner (cloud HSM, for a traditional CA certificate): # ESIGNER_USERNAME # ESIGNER_PASSWORD # ESIGNER_CREDENTIAL_ID # ESIGNER_TOTP_SECRET -# and TRANSTRACK_SIGN_MODE=ssl_esigner in env -# b) Legacy PFX file: +# plus the ESIGNER_TOOL_URL variable so CodeSignTool can be installed +# c) PFX file: # CSC_LINK base64 .pfx # CSC_KEY_PASSWORD pfx password # macOS: @@ -91,13 +96,18 @@ jobs: env: CSC_LINK: ${{ secrets.CSC_LINK }} ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} run: | + # Precedence matches the signer's own auto-detect, so the mode chosen + # here and the mode the signer would pick can never disagree. if [ -n "$ESIGNER_USERNAME" ]; then echo "windows_mode=ssl_esigner" >> $GITHUB_OUTPUT + elif [ -n "$AZURE_SIGNING_ACCOUNT" ]; then + echo "windows_mode=azure" >> $GITHUB_OUTPUT elif [ -n "$CSC_LINK" ]; then echo "windows_mode=pfx" >> $GITHUB_OUTPUT else - echo "::error::No Windows code-signing credentials present. Set ESIGNER_* or CSC_LINK secrets before tagging a release." + echo "::error::No Windows code-signing credentials present. Set AZURE_SIGNING_*, ESIGNER_* or CSC_LINK secrets before tagging a release." exit 1 fi @@ -135,6 +145,15 @@ jobs: # CodeSignTool is not present on the runner image; the step below installs # it here. Overridable for self-hosted runners that pre-provision it. ESIGNER_TOOL_PATH: ${{ vars.ESIGNER_TOOL_PATH || 'D:\CodeSignTool\CodeSignTool.bat' }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ secrets.AZURE_SIGNING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + # The client tools installer puts the dlib here. Overridable for + # self-hosted runners that provision it elsewhere. + AZURE_SIGNING_DLIB: ${{ vars.AZURE_SIGNING_DLIB || 'C:\Program Files\Microsoft\Azure Artifact Signing Client Tools\bin\x64\Azure.CodeSigning.Dlib.dll' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} steps: @@ -174,6 +193,27 @@ jobs: env: ESIGNER_TOOL_URL: ${{ vars.ESIGNER_TOOL_URL }} + # The dlib, the .NET 8 runtime and a new-enough signtool all have to be + # present before signtool can talk to Azure, and none of them are on the + # runner image. The client tools MSI installs all three together. + - name: Install Azure Artifact Signing client tools + if: needs.preflight.outputs.windows_mode == 'azure' + shell: pwsh + run: | + if (Test-Path $env:AZURE_SIGNING_DLIB) { + Write-Host "Artifact Signing dlib already present at $env:AZURE_SIGNING_DLIB" + exit 0 + } + winget install -e --id Microsoft.Azure.ArtifactSigningClientTools ` + --accept-source-agreements --accept-package-agreements --disable-interactivity + if (-not (Test-Path $env:AZURE_SIGNING_DLIB)) { + Write-Host "::error::Client tools installed but $env:AZURE_SIGNING_DLIB does not exist." + Write-Host "::error::Set the AZURE_SIGNING_DLIB repository variable to the actual path of x64\Azure.CodeSigning.Dlib.dll." + Get-ChildItem -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' 'C:\Program Files' -ErrorAction SilentlyContinue | + ForEach-Object { Write-Host $_.FullName } + exit 1 + } + - name: Install npm dependencies run: npm ci @@ -253,19 +293,25 @@ jobs: gate: name: Commercial release gate (--for-sale) - needs: [build-windows, build-macos] + needs: [preflight, build-windows, build-macos] runs-on: ubuntu-latest env: TRANSTRACK_RELEASE_CHANNEL: public # The signing-mode env vars are set so the gate's environment check # reports them as configured even though we don't actually re-sign - # here — the artifacts were signed in the matrix jobs above. - TRANSTRACK_SIGN_MODE: ssl_esigner + # here — the artifacts were signed in the matrix jobs above. The mode is + # taken from preflight rather than hardcoded, so the gate reports on the + # mode that actually signed the artifacts. + TRANSTRACK_SIGN_MODE: ${{ needs.preflight.outputs.windows_mode }} ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }} ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }} ESIGNER_CREDENTIAL_ID: ${{ secrets.ESIGNER_CREDENTIAL_ID }} ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }} ESIGNER_TOOL_PATH: '/usr/local/bin/codesigntool' + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ secrets.AZURE_SIGNING_PROFILE }} + AZURE_SIGNING_DLIB: '/usr/local/lib/Azure.CodeSigning.Dlib.dll' APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc0c3c..a1f1aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added — Azure Artifact Signing + +- **`TRANSTRACK_SIGN_MODE=azure`.** Microsoft's cloud signing service, roughly + $10/month, with no certificate to buy, no hardware token, and no annual + re-issue. `signtool` loads `Azure.CodeSigning.Dlib.dll`, which authenticates + to Azure and has the signature produced server-side, so no private key is + ever on the build machine. The release workflow detects the mode from the + secrets present and installs the client tools on the runner. +- Two behaviours worth knowing rather than discovering. Artifact Signing + certificates are valid for **three days**, so the signer always timestamps + against Microsoft's authority — an untimestamped installer verifies for three + days and then starts failing on customer machines with nothing about the file + having changed. And `DefaultAzureCredential`'s chain includes a browser + prompt that would hang a headless build, so the signer narrows the chain to + the service principal when one is present, and otherwise excludes only the + browser so federated identity still works. +- The two common Azure failures both surface from `signtool` as a generic + `SignerSign()` error. A 403 now says it is probably a region mismatch or a + missing signer role; a dlib load failure now says it is probably a missing + .NET 8 runtime or an x64/x86 mismatch. + ### Fixed — release signing - **A release build can no longer emit an unsigned artifact.** Both diff --git a/docs/CODE_SIGNING.md b/docs/CODE_SIGNING.md index 1eb58f2..bc31224 100644 --- a/docs/CODE_SIGNING.md +++ b/docs/CODE_SIGNING.md @@ -73,7 +73,7 @@ not an engineering one. | Option | Indicative cost | Notes | |---|---|---| -| **Azure Artifact Signing** (formerly Trusted Signing) | ~$10/month | Microsoft's recommended route for non-Store distribution. No hardware token, CI-native. Organisations in US/Canada/EU/UK; individuals US/Canada only. **Not yet implemented in `sign-win.cjs`** — needs a new mode. | +| **Azure Artifact Signing** (formerly Trusted Signing) | ~$10/month | Microsoft's recommended route for non-Store distribution. No hardware token, no annual re-issue, CI-native. Organisations in US/Canada/EU/UK; individuals US/Canada only. Implemented as `azure` mode. | | **OV certificate** (Sectigo, DigiCert, Certum, SSL.com) | ~$150–300/yr | Same SmartScreen behaviour as EV. Works with `pfx` mode, or with a cloud HSM via `ssl_esigner`. | | **EV certificate** | ~$400–700/yr | Choose only if a customer's procurement process demands it. | | Apple Developer Program (Organization) | $99/yr | Required for notarization; no alternative. | @@ -97,19 +97,98 @@ Two constraints worth knowing before you commit: ### Modes supported `scripts/sign-win.cjs` is the electron-builder hook that signs every -Windows artifact. It supports three modes selected by the +Windows artifact. It supports four modes selected by the `TRANSTRACK_SIGN_MODE` environment variable: | Mode | Use case | Required env vars | |----------------|------------------------------------------------------------------|-------------------| -| `ssl_esigner` | Recommended for CI/CD. SSL.com eSigner cloud HSM (no USB token). | `ESIGNER_USERNAME`, `ESIGNER_PASSWORD`, `ESIGNER_CREDENTIAL_ID`, `ESIGNER_TOTP_SECRET`, `ESIGNER_TOOL_PATH` | -| `pfx` | Local builds with a software-protected `.pfx` file. | `CSC_LINK` (path **or** base64 content), `CSC_KEY_PASSWORD` | +| `azure` | Recommended. Azure Artifact Signing — no certificate to buy. | `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_PROFILE`, `AZURE_SIGNING_DLIB`, plus `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` | +| `ssl_esigner` | A traditional CA certificate held in SSL.com's cloud HSM. | `ESIGNER_USERNAME`, `ESIGNER_PASSWORD`, `ESIGNER_CREDENTIAL_ID`, `ESIGNER_TOTP_SECRET`, `ESIGNER_TOOL_PATH` | +| `pfx` | A `.pfx` you already hold; internal and test builds. | `CSC_LINK` (path **or** base64 content), `CSC_KEY_PASSWORD` | | `skip` | Unsigned development builds. Never use for release. | (none) | If `TRANSTRACK_SIGN_MODE` is **unset**, the script auto-detects in the -order `ssl_esigner` → `pfx` → `skip`. When a mode is named explicitly but its -variables are incomplete, the signer fails immediately and names the missing -variable rather than falling through to `skip`. +order `ssl_esigner` → `azure` → `pfx` → `skip`. When a mode is named explicitly +but its variables are incomplete, the signer fails immediately and names the +missing variable rather than falling through to `skip`. + +### Azure Artifact Signing (recommended) + +Microsoft signs on your behalf against a certificate you never possess. There +is no `.pfx`, no USB token, no yearly re-issue, and no private key on the build +machine — `signtool` loads a library that authenticates to Azure and the +signature is produced server-side. + +Eligibility is the one thing to check before committing: your organisation must +have been verifiable for **three years or more**. Newer organisations, and +individuals, can enrol but the certificate subject shows an unverified identity. + +**1. Set up the Azure resources.** In the portal, create an Artifact Signing +account, complete identity validation, and create a certificate profile of type +*Public Trust*. Note the region — the endpoint URI must match it, and a mismatch +surfaces as an opaque 403 during signing. + +| Region | Endpoint | +|---|---| +| East US | `https://eus.codesigning.azure.net` | +| West US 2 | `https://wus2.codesigning.azure.net` | +| West US 3 | `https://wus3.codesigning.azure.net` | +| West Central US | `https://wcus.codesigning.azure.net` | +| North Europe | `https://neu.codesigning.azure.net` | +| West Europe | `https://weu.codesigning.azure.net` | + +(Full list in [Microsoft's integration guide](https://learn.microsoft.com/en-us/azure/artifact-signing/how-to-signing-integrations).) + +**2. Create a service principal for CI.** Register an application in Entra ID, +create a client secret, and grant it the **Trusted Signing Certificate Profile +Signer** role on the Artifact Signing account. Without that role assignment +signing fails with a 403 that looks identical to a wrong endpoint. + +**3. Install the client tools on the signing machine.** One MSI supplies the +dlib, the .NET 8 runtime, and a new-enough `signtool`: + +```powershell +winget install -e --id Microsoft.Azure.ArtifactSigningClientTools +``` + +The release workflow does this automatically when `azure` mode is selected. + +**4. Set the environment:** + +```text +TRANSTRACK_SIGN_MODE=azure +AZURE_SIGNING_ENDPOINT=https://eus.codesigning.azure.net +AZURE_SIGNING_ACCOUNT= +AZURE_SIGNING_PROFILE= +AZURE_SIGNING_DLIB=C:\Program Files\Microsoft\Azure Artifact Signing Client Tools\bin\x64\Azure.CodeSigning.Dlib.dll +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +``` + +Two things worth understanding about how this mode behaves. + +**Timestamping is not optional.** Artifact Signing certificates are valid for +**three days**. A signature survives past that only because a timestamp proves +it was made while the certificate was live. The signer always timestamps against +Microsoft's authority (`http://timestamp.acs.microsoft.com`). Override +`SIGN_TIMESTAMP_URL` only if you know why; an installer signed without a +timestamp verifies for three days and then begins failing on customer machines +with nothing about the file having changed. + +**The credential chain is narrowed deliberately.** `DefaultAzureCredential` +tries a series of credential sources in order, one of which opens a browser. On +a headless build that hangs rather than fails. When a service principal is +present in the environment the signer excludes every other source; with +federated identity (GitHub OIDC, managed identity) it keeps the chain but still +excludes the browser. + +If signing fails, the two common causes are both reported as a generic +`SignerSign()` error by `signtool`, so the signer adds a hint: a **403** is +almost always a region mismatch or a missing role assignment, and a **dlib load +failure** is almost always a missing .NET 8 runtime or an x64/x86 mismatch +between `signtool` and the dlib. Note that the 20348 Windows SDK does not work +with this dlib; you need 10.0.2261.755 or newer. ### Cloud HSM via SSL.com eSigner @@ -283,19 +362,36 @@ accepted) and `notepad.exe` (catalog-signed, must be rejected). ## CI -`.github/workflows/release.yml` builds and signs on tag push. The relevant -environment for the Windows job: +`.github/workflows/release.yml` builds and signs on tag push. A `preflight` job +picks the mode from whichever secrets are present, using the same precedence as +the signer's own auto-detect, and fails the release if none are. For Azure the +repository secrets are: + +```text +AZURE_SIGNING_ENDPOINT +AZURE_SIGNING_ACCOUNT +AZURE_SIGNING_PROFILE +AZURE_TENANT_ID +AZURE_CLIENT_ID +AZURE_CLIENT_SECRET +``` + +The workflow installs the client tools on the runner and sets +`AZURE_SIGNING_DLIB` to the default install path; override it with an +`AZURE_SIGNING_DLIB` repository *variable* if you use a self-hosted runner that +provisions it elsewhere. + +For eSigner instead: ```yaml env: TRANSTRACK_RELEASE_CHANNEL: public # makes signing mandatory - TRANSTRACK_SIGN_MODE: ${{ vars.TRANSTRACK_SIGN_MODE || 'ssl_esigner' }} ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }} ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }} ESIGNER_CREDENTIAL_ID: ${{ secrets.ESIGNER_CREDENTIAL_ID }} ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }} - ESIGNER_TOOL_URL: ${{ secrets.ESIGNER_TOOL_URL }} - ESIGNER_TOOL_PATH: ${{ vars.ESIGNER_TOOL_PATH || 'C:\CodeSignTool\CodeSignTool.bat' }} + ESIGNER_TOOL_URL: ${{ vars.ESIGNER_TOOL_URL }} + ESIGNER_TOOL_PATH: ${{ vars.ESIGNER_TOOL_PATH || 'D:\CodeSignTool\CodeSignTool.bat' }} ``` and for macOS: @@ -319,15 +415,14 @@ un-notarized artifact fails there even if the hooks somehow did not. ## What to do first -If you have not bought anything yet, the shortest path to a signed Windows -installer is **Azure Artifact Signing** at roughly $10/month, with no hardware -token and no annual re-issue. It needs a new mode in `sign-win.cjs` (the Azure -signing tool has a different invocation than CodeSignTool) — that is a small, -self-contained piece of work, not a blocker. +Use **Azure Artifact Signing**. It is roughly $10/month, needs no certificate +purchase, no hardware token, and no annual re-issue, and it is implemented as +`azure` mode. The setup is four steps and is written out above; the only thing +to verify before committing is the three-year organisation age requirement. -If you want to ship with what is already implemented, buy an **OV certificate -with cloud HSM signing** (SSL.com eSigner is what `ssl_esigner` mode targets) -and skip EV unless a customer asks for it in writing. +Fall back to an **OV certificate with cloud HSM signing** (`ssl_esigner`) if you +are not eligible for Artifact Signing. Skip EV unless a customer asks for it in +writing. macOS has no equivalent decision: Apple Developer Program Organization enrolment at $99/year, and the D-U-N-S number takes about two weeks, so start diff --git a/docs/compliance/SOFTWARE_DESIGN_SPECIFICATION.md b/docs/compliance/SOFTWARE_DESIGN_SPECIFICATION.md index a49d825..fb3ae4d 100644 --- a/docs/compliance/SOFTWARE_DESIGN_SPECIFICATION.md +++ b/docs/compliance/SOFTWARE_DESIGN_SPECIFICATION.md @@ -359,6 +359,22 @@ A receiving site's only means of confirming that an installer came from the vendor and arrived unmodified is its code signature. Two controls protect that property. +**Signing modes.** `scripts/sign-win.cjs` supports Azure Artifact Signing +(`azure`), a cloud HSM holding a CA-issued certificate (`ssl_esigner`), a +PKCS#12 file (`pfx`), and an explicit unsigned developer build (`skip`). The +first two keep the private key off the build machine entirely: the signature is +produced by the service, and the build host holds only a credential authorising +it to request one. This is the property that matters for a vendor of regulated +software, because it bounds what an attacker gains by compromising a build +machine — they can request signatures while their access lasts, but they cannot +take the key. + +Azure Artifact Signing certificates are valid for three days, so the signature +outlives the certificate only by virtue of a trusted timestamp. The signer +always timestamps, and the timestamp authority is not left to a default that +could drift, because an untimestamped artifact verifies for three days and then +begins failing at receiving sites with nothing about it having changed. + **Fail closed on a designated release.** `scripts/sign-win.cjs` and `scripts/notarize.cjs` both distinguish a developer build, where a missing certificate is a warning, from a distribution build, where it is a build diff --git a/docs/legal/COMMERCIALIZATION_CHECKLIST.md b/docs/legal/COMMERCIALIZATION_CHECKLIST.md index 5baa164..8c2b2c9 100644 --- a/docs/legal/COMMERCIALIZATION_CHECKLIST.md +++ b/docs/legal/COMMERCIALIZATION_CHECKLIST.md @@ -86,7 +86,7 @@ the four required GitHub Actions secrets. | Cert | Vendor | Cost | Mode | | -------------------------------------- | --------------------------------------------- | ------------- | ---------------------------------- | -| Windows — Azure Artifact Signing | Microsoft | ~$10/mo | *not yet implemented in the signer* | +| Windows — Azure Artifact Signing | Microsoft | ~$10/mo | `TRANSTRACK_SIGN_MODE=azure` | | Windows OV Code Signing (cloud HSM) | SSL.com eSigner / DigiCert KeyLocker | ~$150–300/yr | `TRANSTRACK_SIGN_MODE=ssl_esigner` | | Windows OV/EV Code Signing (USB token) | DigiCert / Sectigo / SSL.com (hardware token) | ~$300–$700/yr | `TRANSTRACK_SIGN_MODE=pfx` | | Apple Developer Program (Organization) | Apple | $99/yr | `APPLE_*` secrets | @@ -96,11 +96,12 @@ Microsoft has since removed that behaviour — EV and OV now give the same first-download experience, and reputation accrues per file hash either way. Buy EV only if a customer's procurement process names it. -**Recommendation:** Azure Artifact Signing is the cheapest and least -operationally painful route, but needs a new mode in `scripts/sign-win.cjs` -(small, self-contained). To ship with what exists today, buy an OV certificate -with cloud-HSM signing — `ssl_esigner` mode works out of the box with the -existing CI workflow and avoids the lost-USB-token failure mode. +**Recommendation:** Azure Artifact Signing. Cheapest, no certificate purchase, +no hardware token, no annual re-issue, and implemented as `azure` mode with the +release workflow installing the client tools automatically. Check one thing +before committing: the organisation must have been verifiable for three years +or more. If it is not, buy an OV certificate with cloud-HSM signing — +`ssl_esigner` mode — which avoids the lost-USB-token failure mode. Note that since June 2023 the CA/Browser Forum requires *all* code signing private keys, OV included, to live in hardware. A copyable `.pfx` is no longer @@ -110,6 +111,15 @@ signing. ### GitHub Actions secrets to set (settings → secrets and variables → actions) ``` +Azure Artifact Signing (recommended): +AZURE_SIGNING_ENDPOINT (region URI — must match the account's region) +AZURE_SIGNING_ACCOUNT +AZURE_SIGNING_PROFILE +AZURE_TENANT_ID +AZURE_CLIENT_ID +AZURE_CLIENT_SECRET + +or SSL.com eSigner: ESIGNER_USERNAME ESIGNER_PASSWORD ESIGNER_CREDENTIAL_ID diff --git a/scripts/release-readiness-check.mjs b/scripts/release-readiness-check.mjs index a1ea5bf..b92726b 100644 --- a/scripts/release-readiness-check.mjs +++ b/scripts/release-readiness-check.mjs @@ -322,6 +322,15 @@ await runStep('Windows code-signing configured (any supported mode)', signingSev } return 'ssl_esigner mode'; } + if (mode === 'azure') { + // AZURE_SIGNING_DLIB is deliberately not required here. It is a path on the + // Windows machine that did the signing, and this gate runs on Linux, where + // demanding it would fail a correctly configured release. + for (const k of ['AZURE_SIGNING_ENDPOINT', 'AZURE_SIGNING_ACCOUNT', 'AZURE_SIGNING_PROFILE']) { + if (!process.env[k]) throw new Error(`${k} not set`); + } + return 'azure mode (Artifact Signing)'; + } if (mode === 'pfx' || (process.env.CSC_LINK && process.env.CSC_KEY_PASSWORD)) { if (!process.env.CSC_LINK || !process.env.CSC_KEY_PASSWORD) { throw new Error('CSC_LINK / CSC_KEY_PASSWORD missing'); @@ -333,6 +342,10 @@ await runStep('Windows code-signing configured (any supported mode)', signingSev process.env.ESIGNER_TOOL_PATH) { return 'ssl_esigner mode (auto-detected)'; } + if (process.env.AZURE_SIGNING_ENDPOINT && process.env.AZURE_SIGNING_ACCOUNT && + process.env.AZURE_SIGNING_PROFILE) { + return 'azure mode (auto-detected)'; + } throw new Error('no code-signing credentials in environment'); }); diff --git a/scripts/sign-win.cjs b/scripts/sign-win.cjs index 84bb416..a2e3f2e 100644 --- a/scripts/sign-win.cjs +++ b/scripts/sign-win.cjs @@ -15,13 +15,26 @@ * ESIGNER_TOTP_SECRET - the BASE32 TOTP secret (NOT the 6-digit code) * ESIGNER_TOOL_PATH - absolute path to CodeSignTool.bat (or .sh on linux/mac) * - * MODE 2 TRANSTRACK_SIGN_MODE=pfx + * MODE 2 TRANSTRACK_SIGN_MODE=azure (cheapest for a new vendor) + * Azure Artifact Signing, formerly Trusted Signing. Microsoft's cloud + * signing service: no certificate to buy, no hardware token, no + * annual re-issue. signtool loads Azure.CodeSigning.Dlib.dll, which + * authenticates to Azure and performs the signature server-side, so + * no private key is ever on the build machine. Required env vars: + * AZURE_SIGNING_ENDPOINT - region URI, e.g. https://eus.codesigning.azure.net + * AZURE_SIGNING_ACCOUNT - Artifact Signing account name + * AZURE_SIGNING_PROFILE - certificate profile name + * AZURE_SIGNING_DLIB - path to x64\\Azure.CodeSigning.Dlib.dll + * plus credentials for DefaultAzureCredential, normally: + * AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET + * + * MODE 3 TRANSTRACK_SIGN_MODE=pfx * A PKCS#12 certificate held as a file. Required env vars: * CSC_LINK - path to the .pfx, OR its base64 contents * (CI secrets carry the bytes, not a path) * CSC_KEY_PASSWORD - PFX export password * - * MODE 3 TRANSTRACK_SIGN_MODE=skip + * MODE 4 TRANSTRACK_SIGN_MODE=skip * No-op. Used for unsigned local development builds. The artifact is * still produced but arrives unverifiable on any other machine, so * Windows warns the user before it will run. Never use for release — @@ -30,8 +43,8 @@ * * Auto-detect: when TRANSTRACK_SIGN_MODE is unset, the script picks the * first mode for which all required env vars are present, in the order - * ssl_esigner -> pfx -> skip. A mode named *explicitly* whose variables are - * incomplete is an error, not a reason to fall through to skip. + * ssl_esigner -> azure -> pfx -> skip. A mode named *explicitly* whose + * variables are incomplete is an error, not a reason to fall through to skip. * * The script accepts the file-to-sign path as the first argv after node / * the script itself, OR as `process.env.SIGNTOOL_PATH` (electron-builder @@ -62,6 +75,14 @@ function _autoDetectMode() { ) { return 'ssl_esigner'; } + if ( + process.env.AZURE_SIGNING_ENDPOINT && + process.env.AZURE_SIGNING_ACCOUNT && + process.env.AZURE_SIGNING_PROFILE && + process.env.AZURE_SIGNING_DLIB + ) { + return 'azure'; + } if (process.env.CSC_LINK && process.env.CSC_KEY_PASSWORD) { return 'pfx'; } @@ -99,6 +120,12 @@ const MODE_REQUIREMENTS = Object.freeze({ 'ESIGNER_TOTP_SECRET', 'ESIGNER_TOOL_PATH', ], + azure: [ + 'AZURE_SIGNING_ENDPOINT', + 'AZURE_SIGNING_ACCOUNT', + 'AZURE_SIGNING_PROFILE', + 'AZURE_SIGNING_DLIB', + ], pfx: ['CSC_LINK', 'CSC_KEY_PASSWORD'], skip: [], }); @@ -207,6 +234,143 @@ function _runSslEsigner(filePath) { _log(`Signed (eSigner): ${path.basename(filePath)}`); } +/** + * Timestamp authority for Azure Artifact Signing. + * + * Not optional and not interchangeable. Artifact Signing certificates are valid + * for three days; the signature outlives them only because a timestamp proves + * it was made while the certificate was live. Sign without one and the + * installer verifies for three days and then starts failing on customer + * machines — with nothing about the artifact having changed. + */ +const AZURE_TIMESTAMP_URL = 'http://timestamp.acs.microsoft.com'; + +/** + * Build the metadata document the Azure dlib reads. + * + * Exposed for tests: the contents decide which Azure account signs, and an + * endpoint that does not match the account's region fails as a 403 from inside + * signtool, which is a poor place to learn about a typo. + * + * @param {NodeJS.ProcessEnv} env + */ +function _buildAzureMetadata(env) { + const metadata = { + Endpoint: env.AZURE_SIGNING_ENDPOINT, + CodeSigningAccountName: env.AZURE_SIGNING_ACCOUNT, + CertificateProfileName: env.AZURE_SIGNING_PROFILE, + }; + + // Correlates a signature with the build that produced it in the Azure audit + // log. GitHub Actions supplies a run id; anything stable will do. + const correlation = env.AZURE_SIGNING_CORRELATION_ID || env.GITHUB_RUN_ID; + if (correlation) metadata.CorrelationId = String(correlation); + + // DefaultAzureCredential tries a chain of credential sources. On a build + // machine most of them cannot succeed, and one of them — the interactive + // browser — can hang a headless run waiting for a login nobody is there to + // perform. With a service principal in the environment there is exactly one + // credential worth attempting, so say so. + if (env.AZURE_TENANT_ID && env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) { + metadata.ExcludeCredentials = [ + 'ManagedIdentityCredential', + 'WorkloadIdentityCredential', + 'SharedTokenCacheCredential', + 'VisualStudioCredential', + 'VisualStudioCodeCredential', + 'AzureCliCredential', + 'AzurePowerShellCredential', + 'AzureDeveloperCliCredential', + 'InteractiveBrowserCredential', + ]; + } else { + // Federated identity (GitHub OIDC, managed identity) is legitimate and + // needs the rest of the chain — but never the browser. + metadata.ExcludeCredentials = ['InteractiveBrowserCredential']; + } + + return metadata; +} + +function _runAzureSign(filePath) { + const dlib = process.env.AZURE_SIGNING_DLIB; + if (!fs.existsSync(dlib)) { + throw new Error( + `AZURE_SIGNING_DLIB not found: ${dlib}. Install the Artifact Signing client ` + + `tools (winget install -e --id Microsoft.Azure.ArtifactSigningClientTools) ` + + `and point this at x64\\Azure.CodeSigning.Dlib.dll.`, + ); + } + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-azsign-')); + const metadataFile = path.join(dir, 'metadata.json'); + try { + fs.writeFileSync(metadataFile, JSON.stringify(_buildAzureMetadata(process.env), null, 2)); + + const args = [ + 'sign', + '/v', + '/fd', 'SHA256', + '/tr', process.env.SIGN_TIMESTAMP_URL || AZURE_TIMESTAMP_URL, + '/td', 'SHA256', + '/dlib', dlib, + '/dmdf', metadataFile, + filePath, + ]; + + _log(`Signing via Azure Artifact Signing: ${path.basename(filePath)}`); + const result = child_process.spawnSync( + process.env.SIGNTOOL_EXE || 'signtool', + args, + { stdio: ['ignore', 'pipe', 'pipe'], shell: true }, + ); + + if (result.status !== 0) { + const stderr = result.stderr?.toString() || ''; + const stdout = result.stdout?.toString() || ''; + process.stderr.write(stderr); + process.stdout.write(stdout); + throw new Error( + `signtool with the Azure dlib failed (exit ${result.status}). ` + + _azureHint(`${stdout}\n${stderr}`), + ); + } + + process.stdout.write(result.stdout?.toString() || ''); + _log(`Signed (Azure): ${path.basename(filePath)}`); + } finally { + try { fs.rmSync(dir, { recursive: true, force: true }); } + catch (e) { _warn(`could not remove temporary metadata directory: ${e.message}`); } + } +} + +/** + * Translate the two Azure failures that are common and opaque. + * + * Both surface from inside signtool as a generic SignerSign() error, which + * tells the operator nothing about which of the several things they configured + * is wrong. + */ +function _azureHint(output) { + if (/403|forbidden/i.test(output)) { + return ( + 'A 403 here is usually one of two things: AZURE_SIGNING_ENDPOINT names a ' + + 'different region than the one holding the account and certificate ' + + 'profile, or the service principal lacks the "Trusted Signing Certificate ' + + 'Profile Signer" role on the account.' + ); + } + if (/dlib|0x80070002|not found|could not load/i.test(output)) { + return ( + 'Check that the .NET 8 runtime is installed and that signtool and the dlib ' + + 'are the same architecture — an x64 signtool cannot load the x86 dlib. ' + + 'The Windows SDK must be 10.0.2261.755 or newer; the 20348 SDK does not ' + + 'work with this dlib.' + ); + } + return 'See docs/CODE_SIGNING.md for the Azure Artifact Signing setup.'; +} + function _runPfxSign(filePath) { const cert = _materializeCertificate(process.env.CSC_LINK); const pfxPwd = process.env.CSC_KEY_PASSWORD; @@ -305,7 +469,7 @@ async function sign(configuration) { _warn( `TRANSTRACK_SIGN_MODE=skip (auto-detected: no signing credentials in environment). ` + `Artifact "${path.basename(filePath)}" will be UNSIGNED. ` + - `Set ESIGNER_* or CSC_LINK/CSC_KEY_PASSWORD before producing a release.` + `Set AZURE_SIGNING_*, ESIGNER_* or CSC_LINK/CSC_KEY_PASSWORD before producing a release.` ); return; } @@ -313,6 +477,7 @@ async function sign(configuration) { _assertModeConfigured(MODE); if (MODE === 'ssl_esigner') return _runSslEsigner(filePath); + if (MODE === 'azure') return _runAzureSign(filePath); if (MODE === 'pfx') return _runPfxSign(filePath); throw new Error(`Unknown TRANSTRACK_SIGN_MODE: ${MODE}`); } @@ -329,5 +494,9 @@ module.exports.__testing__ = { _signingRequired, _assertModeConfigured, _materializeCertificate, + _buildAzureMetadata, + _runAzureSign, + _azureHint, + AZURE_TIMESTAMP_URL, MODE_REQUIREMENTS, }; diff --git a/tests/signWin.test.cjs b/tests/signWin.test.cjs index 08a8a9d..f9af589 100644 --- a/tests/signWin.test.cjs +++ b/tests/signWin.test.cjs @@ -39,6 +39,9 @@ function test(name, fn) { const SIGNING_ENV_KEYS = (k) => k.startsWith('ESIGNER_') || k.startsWith('CSC_') || + k.startsWith('AZURE_') || + k === 'GITHUB_RUN_ID' || + k === 'SIGNTOOL_EXE' || k === 'TRANSTRACK_SIGN_MODE' || k === 'TRANSTRACK_RELEASE_CHANNEL' || k === 'TRANSTRACK_REQUIRE_SIGNING' || @@ -182,6 +185,132 @@ test('pfx mode names both missing variables at once', async () => { }); }); +test('azure mode names every missing variable at once', async () => { + await withSigner({ TRANSTRACK_SIGN_MODE: 'azure' }, async (sign) => { + await assert.rejects(() => sign('C:/tmp/file.exe'), (e) => { + for (const k of ['AZURE_SIGNING_ENDPOINT', 'AZURE_SIGNING_ACCOUNT', + 'AZURE_SIGNING_PROFILE', 'AZURE_SIGNING_DLIB']) { + assert.match(e.message, new RegExp(k)); + } + return true; + }); + }); +}); + +console.log('\n=== Azure Artifact Signing ==='); + +const AZURE_ENV = Object.freeze({ + TRANSTRACK_SIGN_MODE: 'azure', + AZURE_SIGNING_ENDPOINT: 'https://eus.codesigning.azure.net', + AZURE_SIGNING_ACCOUNT: 'transtrack-signing', + AZURE_SIGNING_PROFILE: 'transtrack-public', + AZURE_TENANT_ID: 'tenant-1', + AZURE_CLIENT_ID: 'client-1', + AZURE_CLIENT_SECRET: 'secret-1', +}); + +test('auto-detect selects azure when its variables are present', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-dlib-')); + const dlib = path.join(dir, 'Azure.CodeSigning.Dlib.dll'); + fs.writeFileSync(dlib, 'not a real dll'); + try { + const env = { ...AZURE_ENV, AZURE_SIGNING_DLIB: dlib }; + delete env.TRANSTRACK_SIGN_MODE; + await withSigner(env, async (mod) => { + assert.strictEqual(mod.__testing__._autoDetectMode(), 'azure'); + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('esigner still wins auto-detect when both are configured', async () => { + // Ordering matters for anyone migrating: a half-removed eSigner + // configuration should not silently take precedence by accident, so the + // precedence is asserted rather than left to reading the function. + await withSigner( + { + ESIGNER_USERNAME: 'u', ESIGNER_PASSWORD: 'p', ESIGNER_CREDENTIAL_ID: 'c', + ESIGNER_TOTP_SECRET: 'JBSWY3DPEHPK3PXP', ESIGNER_TOOL_PATH: 'C:/tool.bat', + AZURE_SIGNING_ENDPOINT: 'https://eus.codesigning.azure.net', + AZURE_SIGNING_ACCOUNT: 'a', AZURE_SIGNING_PROFILE: 'p', + AZURE_SIGNING_DLIB: 'C:/dlib.dll', + }, + async (mod) => { + assert.strictEqual(mod.__testing__._autoDetectMode(), 'ssl_esigner'); + }, + ); +}); + +test('a missing dlib is reported with the command that installs it', async () => { + await withSigner( + { ...AZURE_ENV, AZURE_SIGNING_DLIB: 'C:/definitely/not/here/Azure.CodeSigning.Dlib.dll' }, + async (sign) => { + await assert.rejects(() => sign('C:/tmp/file.exe'), (e) => { + assert.match(e.message, /AZURE_SIGNING_DLIB not found/); + assert.match(e.message, /winget install/); + return true; + }); + }, + ); +}); + +test('metadata names the account, profile and endpoint the dlib will use', async () => { + const m = exposed._buildAzureMetadata({ ...AZURE_ENV }); + assert.strictEqual(m.Endpoint, 'https://eus.codesigning.azure.net'); + assert.strictEqual(m.CodeSigningAccountName, 'transtrack-signing'); + assert.strictEqual(m.CertificateProfileName, 'transtrack-public'); +}); + +test('a service principal narrows the credential chain to exactly one', async () => { + // DefaultAzureCredential walks a chain. On a build machine the others cannot + // succeed, and the interactive browser can hang a headless run outright. + const m = exposed._buildAzureMetadata({ ...AZURE_ENV }); + assert.ok(Array.isArray(m.ExcludeCredentials)); + assert.ok(m.ExcludeCredentials.includes('InteractiveBrowserCredential')); + assert.ok(m.ExcludeCredentials.includes('ManagedIdentityCredential')); + assert.ok(m.ExcludeCredentials.includes('AzureCliCredential')); +}); + +test('federated identity keeps its chain but never the browser', async () => { + // GitHub OIDC and managed identity are legitimate and need the rest of the + // chain; excluding everything would break them. + const env = { ...AZURE_ENV }; + delete env.AZURE_CLIENT_SECRET; + const m = exposed._buildAzureMetadata(env); + assert.deepStrictEqual(m.ExcludeCredentials, ['InteractiveBrowserCredential']); +}); + +test('a correlation id is carried through when the build supplies one', async () => { + const explicit = exposed._buildAzureMetadata({ ...AZURE_ENV, AZURE_SIGNING_CORRELATION_ID: 'run-9' }); + assert.strictEqual(explicit.CorrelationId, 'run-9'); + + const fromCi = exposed._buildAzureMetadata({ ...AZURE_ENV, GITHUB_RUN_ID: '30726011240' }); + assert.strictEqual(fromCi.CorrelationId, '30726011240'); + + assert.ok(!('CorrelationId' in exposed._buildAzureMetadata({ ...AZURE_ENV }))); +}); + +test('the Azure timestamp authority is the Microsoft one', async () => { + // Artifact Signing certificates live three days. Without a timestamp the + // installer verifies for three days and then starts failing on customer + // machines, with nothing about the file having changed — so the default must + // never quietly become a generic CA's timestamp server. + assert.strictEqual(exposed.AZURE_TIMESTAMP_URL, 'http://timestamp.acs.microsoft.com'); +}); + +test('a 403 is explained as a region or role problem', async () => { + const hint = exposed._azureHint('SignerSign() failed. (403) Forbidden'); + assert.match(hint, /region/); + assert.match(hint, /Signer.*role|role/); +}); + +test('a dlib load failure is explained as runtime or architecture', async () => { + const hint = exposed._azureHint('could not load Azure.CodeSigning.Dlib.dll'); + assert.match(hint, /\.NET 8/); + assert.match(hint, /x64|architecture/); +}); + console.log('\n=== signing-required detection ==='); const exposed = require('../scripts/sign-win.cjs').__testing__;