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__;