From 65954e7f9e3ace750125e150740c0f9a85594d13 Mon Sep 17 00:00:00 2001 From: NeuroKoder3 Date: Sat, 1 Aug 2026 21:23:45 -0500 Subject: [PATCH] Fix the eSigner signing path and remove Azure Artifact Signing Azure Artifact Signing requires an organisation verifiable for three years or more, which TransTrack Medical Software does not meet, so the mode could not have been used. Leaving it in would also have left the validation package describing a signing path that cannot be demonstrated on request. That makes SSL.com eSigner with an OV certificate the production route, so it was audited against SSL.com's CodeSignTool command guide. It would have failed on the first real attempt for three independent reasons: - -totp_secret takes the secret from the dashboard, not a code. CodeSignTool derives the six-digit code itself. The signer derived one and passed that, which would have surfaced as "invalid otp" and looked like broken 2FA. - -overwrite is not a CodeSignTool option, so every invocation would have been rejected. Signing now writes to a temporary directory and moves the result over the original, which also avoids the interactive overwrite confirmation that would hang CI outright. - Arguments were passed as "-name value"; SSL.com documents "-name=value". Two further problems in the same path: a password containing shell metacharacters was silently corrupted, because CodeSignTool is a batch file and its arguments are re-parsed by cmd.exe (SSL.com's own example password is P!@^^ssword12). Values are now quoted, and the two characters that cannot survive quoting are refused with an explanation rather than sent wrong. And a zero exit status was treated as proof of signing, which CodeSignTool does not guarantee; every mode now re-reads the artifact and fails unless it carries an embedded signature. The release gate now also rejects an unrecognised TRANSTRACK_SIGN_MODE instead of silently falling through to auto-detect, so a stale azure setting fails loudly. Co-authored-by: Cursor --- .github/workflows/release.yml | 52 +-- CHANGELOG.md | 69 ++-- docs/CODE_SIGNING.md | 199 ++++----- .../SOFTWARE_DESIGN_SPECIFICATION.md | 32 +- docs/legal/COMMERCIALIZATION_CHECKLIST.md | 28 +- scripts/release-readiness-check.mjs | 26 +- scripts/sign-win.cjs | 380 ++++++++---------- tests/signWin.test.cjs | 212 +++++----- 8 files changed, 426 insertions(+), 572 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be05f1f..f108cb0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,19 +11,16 @@ name: Release (commercial) # enforces "no unsigned binary is ever published to customers". # # Required repository secrets: -# 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): +# Windows (one of two modes, auto-detected by the preflight job): +# a) SSL.com eSigner with an OV code signing certificate (the production +# route — cloud HSM, no USB token, works unattended in CI): # ESIGNER_USERNAME # ESIGNER_PASSWORD # ESIGNER_CREDENTIAL_ID # ESIGNER_TOTP_SECRET # plus the ESIGNER_TOOL_URL variable so CodeSignTool can be installed -# c) PFX file: +# b) PFX file (only for a certificate already held — no CA issues these +# for code signing any more): # CSC_LINK base64 .pfx # CSC_KEY_PASSWORD pfx password # macOS: @@ -96,18 +93,15 @@ 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 AZURE_SIGNING_*, ESIGNER_* or CSC_LINK secrets before tagging a release." + echo "::error::No Windows code-signing credentials present. Set the ESIGNER_* secrets (or CSC_LINK) before tagging a release." exit 1 fi @@ -145,15 +139,6 @@ 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: @@ -193,27 +178,6 @@ 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 @@ -308,10 +272,6 @@ jobs: 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 a1f1aba..2192b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,26 +7,49 @@ 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 — SSL.com eSigner signing (`ssl_esigner` mode) + +The eSigner path had never been exercised against a real certificate. Audited +against SSL.com's CodeSignTool command guide, it would have failed on the first +attempt for three independent reasons, each of which presented as something +other than what it was. + +- **The TOTP secret was being consumed as if it were a code generator seed the + signer owned.** `-totp_secret` takes the secret from the SSL.com dashboard; + CodeSignTool derives the six-digit code itself. The signer was deriving a code + and passing *that*, so CodeSignTool tried to derive an OTP from six digits. + The failure would have read `Error: invalid otp` and looked like a broken 2FA + enrolment. The now-unused RFC 6238 helpers have been removed rather than left + as an attractive nuisance. +- **`-overwrite` is not a CodeSignTool option**, so every invocation would have + been rejected for an unknown argument. The signer now writes to a temporary + output directory and moves the result over the original, which also avoids + the interactive overwrite confirmation that would hang a CI build outright. +- **Arguments were passed as `-name value`.** SSL.com documents `-name=value` + throughout; the space-separated form is not documented to work. + +Two further problems in the same path: + +- **A password containing shell metacharacters was silently corrupted.** + CodeSignTool is a batch file, so its arguments are re-parsed by `cmd.exe` — + and SSL.com's own documentation uses `P!@^^ssword12` as its example password. + Values are now quoted, and the two characters that cannot survive quoting + (`"` and `%`) are refused up front with an explanation, rather than sending a + different password than the one configured and surfacing as an authentication + failure. The PFX path no longer uses a shell at all, so Node quotes for it. +- **A zero exit status was treated as proof of signing.** CodeSignTool has been + observed to print a failure and exit 0. Every mode now re-reads the artifact + afterwards and fails the build unless it actually carries an embedded + signature. + +### Removed — Azure Artifact Signing + +- **`TRANSTRACK_SIGN_MODE=azure` and its workflow, gate and documentation + support have been removed.** Artifact Signing requires an organisation + verifiable for three years or more, which TransTrack Medical Software does not + meet, so the mode could not have been used. Keeping it would have left the + validation package describing a signing path that cannot be demonstrated on + request. Documented in `docs/CODE_SIGNING.md` as worth revisiting at renewal. ### Fixed — release signing @@ -71,9 +94,9 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **EV is no longer recommended by default.** The guidance to buy EV rested on it granting immediate SmartScreen reputation; Microsoft removed that behaviour, and OV now gives the same first-download experience. The docs now - recommend Azure Artifact Signing (~$10/month) or an OV certificate with cloud - HSM signing, and note that since June 2023 all code signing keys — OV - included — must live in hardware, so a copyable `.pfx` is no longer issuable. + recommend an OV certificate with cloud HSM signing, and note that since June + 2023 all code signing keys — OV included — must live in hardware, so a + copyable `.pfx` is no longer issuable. ### Fixed diff --git a/docs/CODE_SIGNING.md b/docs/CODE_SIGNING.md index bc31224..6a777f2 100644 --- a/docs/CODE_SIGNING.md +++ b/docs/CODE_SIGNING.md @@ -73,9 +73,9 @@ 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, 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. | +| **OV certificate + SSL.com eSigner** | ~$150–300/yr | **This is what TransTrack uses.** Same SmartScreen behaviour as EV, and the cloud HSM makes it work unattended in CI. Implemented as `ssl_esigner` mode. | +| **EV certificate** | ~$400–700/yr | Choose only if a customer's procurement process demands it. Same `ssl_esigner` mode — only the certificate differs. | +| Azure Artifact Signing | ~$10/month | Cheaper, but requires an organisation verifiable for **three years or more**. Not available to TransTrack Medical Software yet; revisit when the company clears that bar. | | Apple Developer Program (Organization) | $99/yr | Required for notarization; no alternative. | | D-U-N-S registration | Free | Needed for Apple organisation enrolment and for EV vetting. | @@ -97,119 +97,60 @@ 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 four modes selected by the +Windows artifact. It supports three modes selected by the `TRANSTRACK_SIGN_MODE` environment variable: | Mode | Use case | Required env vars | |----------------|------------------------------------------------------------------|-------------------| -| `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` | +| `ssl_esigner` | **The production route.** An OV (or EV) 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` → `azure` → `pfx` → `skip`. When a mode is named explicitly +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`. -### Azure Artifact Signing (recommended) +Whichever mode runs, the signer re-reads the artifact afterwards and fails the +build unless it now carries an embedded signature. A zero exit status is not +accepted as evidence on its own — CodeSignTool in particular has been observed +to report a failure and exit 0, and the point of the release gate is that no +unsigned artifact reaches a customer. -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. +### OV certificate via SSL.com eSigner (the production route) -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. +Since the June 2023 CA/Browser Forum rules, the private key for a code signing +certificate has to live in certified hardware. That leaves two shapes: a USB +token somebody physically plugs into the build machine, or a cloud HSM. Only the +second works in unattended CI, so this is the route TransTrack uses. -**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. +Only a *hash* of the artifact is sent to SSL.com. The installer itself never +leaves the build machine, which is the answer to the question a hospital +security reviewer will eventually ask. -| 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` | +**1. Buy the certificate.** An **SSL.com Code Signing Certificate**, OV unless a +customer's procurement demands EV, with **eSigner Cloud Signing** included. +DigiCert KeyLocker and Certum SimplySign are the same shape if you prefer them, +but the tooling below is SSL.com-specific. -(Full list in [Microsoft's integration guide](https://learn.microsoft.com/en-us/azure/artifact-signing/how-to-signing-integrations).) +**2. Complete vetting.** For OV this is organisation verification: SSL.com +confirms the legal entity exists and that you are authorised to request on its +behalf. Expect to supply incorporation documents and to take a verification +phone call at a number they establish independently. Budget a few business days. -**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. Enrol the certificate in eSigner** and set up TOTP two-factor +authentication, following SSL.com's +[automation guide](https://www.ssl.com/how-to/automate-esigner-ev-code-signing/). +When it shows you the QR code, also reveal and copy the **secret string** behind +it — that is what CI needs, and it is shown only at setup time. -**3. Install the client tools on the signing machine.** One MSI supplies the -dlib, the .NET 8 runtime, and a new-enough `signtool`: +**4. Download CodeSignTool** from the SSL.com dashboard. It ships as a `.bat` +(Windows) or `.sh` (Linux/macOS) wrapper around a Java jar. The Windows download +bundles a Java runtime; the Linux/macOS one requires Java to be installed. -```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 - -A cloud HSM is preferable to a physical USB token because it works in -unattended CI without anyone present to insert the token. Since the 2023 -hardware-key requirement this is effectively the only workable CI option for a -traditional CA certificate. - -Procurement steps: - -1. Purchase an **SSL.com Code Signing Certificate** — OV unless a customer - requires EV — with **eSigner Cloud Signing** (or DigiCert KeyLocker / - Certum SimplySign — same shape). -2. Complete the SSL.com vetting process (D-U-N-S number required for EV). -3. Download **CodeSignTool** from the SSL.com dashboard. The tool ships - as a `.bat` (Windows) or `.sh` (Linux/macOS) wrapper around a Java jar. -4. From the SSL.com dashboard, copy: - - your account username and password, - - the **Credential ID** (a UUID identifying the certificate slot), - - the **TOTP secret** (a base32 string — this is the seed, not the - 6-digit code). +**5. Collect four values** from the dashboard: account username, account +password, the **Credential ID** (a UUID naming the certificate slot), and the +**TOTP secret** from step 3. CI environment variables (e.g., GitHub Actions): @@ -218,7 +159,7 @@ TRANSTRACK_SIGN_MODE=ssl_esigner ESIGNER_USERNAME= ESIGNER_PASSWORD= ESIGNER_CREDENTIAL_ID= -ESIGNER_TOTP_SECRET= +ESIGNER_TOTP_SECRET= ESIGNER_TOOL_PATH=C:\CodeSignTool\CodeSignTool.bat ESIGNER_TOOL_URL= ``` @@ -229,8 +170,28 @@ current one from your dashboard and store it as a repository secret. If the mode is active and the URL is missing, the workflow fails rather than building an unsigned installer. -The signer derives a one-time TOTP code at sign time using the seed -(RFC 6238, SHA1, 30-second step, 6 digits). +Three details that cause most first-attempt failures: + +**`ESIGNER_TOTP_SECRET` is the secret, not a code.** CodeSignTool computes the +six-digit code itself from the secret, which is why it can run unattended. The +secret is a long base64-looking string +(`ii5gVvZ9G+WkxB3FauAnoL/z14AXSMistcE0jZMWWNSjQDlql2kt2D6Z+l8=`), not the six +digits your authenticator app shows. Storing the digits produces `Error: invalid +otp`, which reads like a 2FA problem and is not. + +**The account password cannot contain `"` or `%`.** CodeSignTool is a batch +file, so its arguments are re-parsed by the Windows command interpreter. Most +special characters survive being quoted; those two cannot — a double quote ends +the quoted run and a percent sign triggers variable expansion even inside +quotes. The signer refuses such a password up front with a clear message rather +than sending a corrupted one and letting it look like an authentication failure. +Every other special character is fine. + +**A zero exit status is not proof.** CodeSignTool sometimes prints a failure and +exits 0. The signer therefore signs into a temporary directory, confirms a +signed file was actually produced, moves it over the original, and then re-reads +the artifact to confirm it carries a signature. Any of those failing fails the +build. ### Alternate: PFX file @@ -364,25 +325,9 @@ accepted) and `notepad.exe` (catalog-signed, must be rejected). `.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 +the signer's own auto-detect, and fails the release if none are. For Windows 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 @@ -415,14 +360,20 @@ un-notarized artifact fails there even if the hooks somehow did not. ## What to do first -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. +Buy an **SSL.com OV Code Signing Certificate with eSigner Cloud Signing**, +roughly $150–300/year. Skip EV unless a customer asks for it in writing — it +costs two to three times as much and, since Microsoft stopped granting it +automatic SmartScreen trust, buys nothing technical. If procurement later +insists on EV, the certificate changes and the pipeline does not: same +`ssl_esigner` mode, same four secrets. + +The long pole is organisation vetting, not anything in this repository. Start +that before you need the release. -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. +Azure Artifact Signing would be cheaper at about $10/month, but it requires an +organisation verifiable for three years or more, which TransTrack Medical +Software does not yet meet. Worth revisiting at renewal once the company clears +that bar. 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 fb3ae4d..16fb189 100644 --- a/docs/compliance/SOFTWARE_DESIGN_SPECIFICATION.md +++ b/docs/compliance/SOFTWARE_DESIGN_SPECIFICATION.md @@ -359,21 +359,23 @@ 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. +**Signing modes.** `scripts/sign-win.cjs` supports a cloud HSM holding a +CA-issued certificate (`ssl_esigner`), a PKCS#12 file (`pfx`), and an explicit +unsigned developer build (`skip`). Production releases use `ssl_esigner`, which +keeps the private key off the build machine entirely: the signature is produced +by the certificate authority's HSM, and the build host holds only a credential +authorising it to request one, while the artifact itself never leaves the host — +only its hash is transmitted. This bounds what an attacker gains by compromising +a build machine: they can request signatures while their access lasts, but they +cannot take the key. The signer always applies a trusted timestamp, so a +signature remains verifiable after the certificate expires. + +**Post-condition verification.** A signing tool's exit status reports whether +the tool ran, not whether the artifact changed; CodeSignTool has been observed +to report a failure and exit 0. The signer therefore writes signed output to a +temporary directory, confirms a file was produced, moves it over the original, +and re-reads the artifact to confirm a certificate table is present before +allowing the build to continue. **Fail closed on a designated release.** `scripts/sign-win.cjs` and `scripts/notarize.cjs` both distinguish a developer build, where a missing diff --git a/docs/legal/COMMERCIALIZATION_CHECKLIST.md b/docs/legal/COMMERCIALIZATION_CHECKLIST.md index 8c2b2c9..ea410e9 100644 --- a/docs/legal/COMMERCIALIZATION_CHECKLIST.md +++ b/docs/legal/COMMERCIALIZATION_CHECKLIST.md @@ -86,8 +86,7 @@ the four required GitHub Actions secrets. | Cert | Vendor | Cost | Mode | | -------------------------------------- | --------------------------------------------- | ------------- | ---------------------------------- | -| 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 Code Signing (cloud HSM)**| **SSL.com eSigner** | ~$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,12 +95,15 @@ 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. 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. +**Recommendation:** an SSL.com OV certificate with eSigner cloud signing — +`ssl_esigner` mode. The key lives in SSL.com's HSM rather than on a USB token, +so releases can be built unattended and there is no token to lose. Azure +Artifact Signing is cheaper at ~$10/month but requires an organisation +verifiable for three years or more, which TransTrack Medical Software does not +yet meet; revisit at renewal. + +The long pole is organisation vetting at the CA, not anything in this +repository. Start it well before the release you need it for. 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 @@ -111,15 +113,7 @@ 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: +SSL.com eSigner (the production route): ESIGNER_USERNAME ESIGNER_PASSWORD ESIGNER_CREDENTIAL_ID diff --git a/scripts/release-readiness-check.mjs b/scripts/release-readiness-check.mjs index b92726b..4265ae1 100644 --- a/scripts/release-readiness-check.mjs +++ b/scripts/release-readiness-check.mjs @@ -315,6 +315,19 @@ await runStep('Code-signed Windows installer present (release/enterprise)', sign await runStep('Windows code-signing configured (any supported mode)', signingSeverity, () => { const mode = (process.env.TRANSTRACK_SIGN_MODE || '').toLowerCase(); + + // An explicitly named mode that the signer does not implement must fail here + // rather than fall through to auto-detect. Otherwise a stale + // TRANSTRACK_SIGN_MODE=azure left over from the removed Artifact Signing + // support would report a pass for a mode nobody asked for. + const KNOWN = ['ssl_esigner', 'pfx', 'skip']; + if (mode && !KNOWN.includes(mode)) { + throw new Error( + `TRANSTRACK_SIGN_MODE=${mode} is not a supported mode (${KNOWN.join(', ')})`, + ); + } + if (mode === 'skip') throw new Error('TRANSTRACK_SIGN_MODE=skip produces an unsigned artifact'); + if (mode === 'ssl_esigner') { for (const k of ['ESIGNER_USERNAME', 'ESIGNER_PASSWORD', 'ESIGNER_CREDENTIAL_ID', 'ESIGNER_TOTP_SECRET', 'ESIGNER_TOOL_PATH']) { @@ -322,15 +335,6 @@ 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'); @@ -342,10 +346,6 @@ 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 a2e3f2e..0b721b2 100644 --- a/scripts/sign-win.cjs +++ b/scripts/sign-win.cjs @@ -6,35 +6,29 @@ * script supports three signing modes, selected by environment variable, in * priority order: * - * MODE 1 TRANSTRACK_SIGN_MODE=ssl_esigner (recommended for CI/CD) - * SSL.com eSigner CodeSignTool — cloud HSM, no physical USB token, - * CI-friendly. Required env vars: + * MODE 1 TRANSTRACK_SIGN_MODE=ssl_esigner (the production route) + * SSL.com eSigner CodeSignTool — the certificate's private key lives + * in SSL.com's cloud HSM, so there is no USB token to plug in and no + * key on the build machine. Only a hash of the artifact is sent for + * signing; the artifact itself never leaves. Required env vars: * ESIGNER_USERNAME - SSL.com account username * ESIGNER_PASSWORD - SSL.com account password * ESIGNER_CREDENTIAL_ID - certificate slot id from SSL.com dashboard - * ESIGNER_TOTP_SECRET - the BASE32 TOTP secret (NOT the 6-digit code) + * ESIGNER_TOTP_SECRET - the TOTP *secret* from the dashboard, not + * a 6-digit code: CodeSignTool derives the + * code itself and the secret is base64 * ESIGNER_TOOL_PATH - absolute path to CodeSignTool.bat (or .sh on linux/mac) * - * 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: + * MODE 2 TRANSTRACK_SIGN_MODE=pfx + * A PKCS#12 certificate held as a file. Since June 2023 no CA can + * issue a code signing key that is exportable to a file, so this mode + * is for a certificate already held, internal builds, and test + * signing — not for a new purchase. 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 4 TRANSTRACK_SIGN_MODE=skip + * MODE 3 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 — @@ -43,8 +37,12 @@ * * 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 -> azure -> pfx -> skip. A mode named *explicitly* whose - * variables are incomplete is an error, not a reason to fall through to skip. + * ssl_esigner -> pfx -> skip. A mode named *explicitly* whose variables are + * incomplete is an error, not a reason to fall through to skip. + * + * Whichever mode runs, the artifact is re-read afterwards and must actually + * carry an embedded signature. A zero exit status is not sufficient evidence: + * CodeSignTool in particular has been observed to print a failure and exit 0. * * 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 @@ -75,14 +73,6 @@ 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'; } @@ -120,12 +110,6 @@ 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: [], }); @@ -204,171 +188,123 @@ function _resolveFilePath(input) { return null; } -function _runSslEsigner(filePath) { - const tool = process.env.ESIGNER_TOOL_PATH; - if (!fs.existsSync(tool)) { - throw new Error(`ESIGNER_TOOL_PATH not found: ${tool}`); - } - // Use TOTP secret to derive a one-time code at sign time - const totp = _generateTotp(process.env.ESIGNER_TOTP_SECRET); - const args = [ - 'sign', - '-username', process.env.ESIGNER_USERNAME, - '-password', process.env.ESIGNER_PASSWORD, - '-credential_id', process.env.ESIGNER_CREDENTIAL_ID, - '-totp_secret', totp, - '-input_file_path', filePath, - '-output_dir_path', path.dirname(filePath), - '-overwrite', - ]; - _log(`Signing via SSL.com eSigner: ${path.basename(filePath)}`); - const result = child_process.spawnSync(tool, args, { - stdio: ['ignore', 'pipe', 'pipe'], - shell: true, - }); - if (result.status !== 0) { - process.stderr.write(result.stderr?.toString() || ''); - throw new Error(`SSL.com CodeSignTool failed (exit ${result.status})`); - } - process.stdout.write(result.stdout?.toString() || ''); - _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. + * Quote a value so cmd.exe passes it through unchanged. * - * 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. + * CodeSignTool ships as a .bat, and Node cannot spawn a batch file without a + * shell, so the arguments are re-parsed by cmd.exe. Inside double quotes cmd + * stops treating `&`, `|`, `<`, `>`, `^` and `(` `)` as syntax, which matters + * because SSL.com passwords routinely contain them — their own documentation + * uses `P!@^^ssword12` as the example. * - * @param {NodeJS.ProcessEnv} env + * Two characters cannot be rescued this way, so they are refused rather than + * silently mangled into a wrong password and an "invalid credentials" error + * that looks like an account problem: + * `"` terminates the quoting, and cmd has no in-quote escape for it. + * `%` triggers environment expansion even inside quotes. */ -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']; +function _quoteForCmd(name, value) { + const text = String(value); + const offender = /["%]/.exec(text); + if (offender) { + throw new Error( + `${name} contains a ${offender[0] === '"' ? 'double quote' : 'percent sign'}, ` + + `which cannot be passed through the Windows command interpreter to ` + + `CodeSignTool without corrupting it. Change the value in your SSL.com ` + + `account to avoid " and %, then update the secret.`, + ); } + return `"${text}"`; +} - return metadata; +/** Keep secrets out of anything we echo from the tool. */ +function _redact(text, secrets) { + let out = String(text); + for (const s of secrets) { + if (s) out = out.split(s).join('***'); + } + return out; } -function _runAzureSign(filePath) { - const dlib = process.env.AZURE_SIGNING_DLIB; - if (!fs.existsSync(dlib)) { +function _runSslEsigner(filePath) { + const tool = process.env.ESIGNER_TOOL_PATH; + if (!fs.existsSync(tool)) { 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.`, + `ESIGNER_TOOL_PATH not found: ${tool}. Download CodeSignTool from your ` + + `SSL.com dashboard and point this at CodeSignTool.bat.`, ); } - 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 secrets = [process.env.ESIGNER_PASSWORD, process.env.ESIGNER_TOTP_SECRET]; + // Sign into a sibling directory rather than over the input. CodeSignTool + // prompts for confirmation before overwriting its input file, and a prompt in + // CI is a hung build, not a failed one. A sibling keeps the (large) installer + // on the same volume so the move back is a rename rather than a copy. + const outDir = fs.mkdtempSync(path.join(path.dirname(filePath), '.tt-signed-')); + try { const args = [ 'sign', - '/v', - '/fd', 'SHA256', - '/tr', process.env.SIGN_TIMESTAMP_URL || AZURE_TIMESTAMP_URL, - '/td', 'SHA256', - '/dlib', dlib, - '/dmdf', metadataFile, - filePath, + // CodeSignTool is picocli-based and every SSL.com example uses the + // name=value form; the space-separated form is not documented to work. + `-username=${_quoteForCmd('ESIGNER_USERNAME', process.env.ESIGNER_USERNAME)}`, + `-password=${_quoteForCmd('ESIGNER_PASSWORD', process.env.ESIGNER_PASSWORD)}`, + `-credential_id=${_quoteForCmd('ESIGNER_CREDENTIAL_ID', process.env.ESIGNER_CREDENTIAL_ID)}`, + // The *secret*, not a generated code. CodeSignTool derives the six-digit + // OTP itself; handing it a code makes it try to derive an OTP from that + // code, which fails as "invalid otp" and looks like a 2FA problem. + `-totp_secret=${_quoteForCmd('ESIGNER_TOTP_SECRET', process.env.ESIGNER_TOTP_SECRET)}`, + `-input_file_path="${filePath}"`, + `-output_dir_path="${outDir}"`, ]; - _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 }, - ); + _log(`Signing via SSL.com eSigner: ${path.basename(filePath)}`); + const result = child_process.spawnSync(tool, args, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + // CodeSignTool resolves conf/ and logs/ relative to its own directory. + cwd: path.dirname(tool), + }); + + const stdout = _redact(result.stdout?.toString() || '', secrets); + const stderr = _redact(result.stderr?.toString() || '', secrets); + process.stdout.write(stdout); 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(`SSL.com CodeSignTool failed (exit ${result.status})`); + } + + // CodeSignTool has been observed to report a failure and still exit 0, so + // its exit status alone is not evidence that anything was signed. + const signed = path.join(outDir, path.basename(filePath)); + if (!fs.existsSync(signed)) { + process.stderr.write(stderr); throw new Error( - `signtool with the Azure dlib failed (exit ${result.status}). ` + - _azureHint(`${stdout}\n${stderr}`), + `CodeSignTool exited 0 but produced no signed file in ${outDir}. ` + + `The output above is the tool's own report of what went wrong; ` + + `"invalid otp" here usually means ESIGNER_TOTP_SECRET holds a 6-digit ` + + `code rather than the secret from the dashboard.`, ); } - process.stdout.write(result.stdout?.toString() || ''); - _log(`Signed (Azure): ${path.basename(filePath)}`); + _replaceFile(signed, filePath); + _log(`Signed (eSigner): ${path.basename(filePath)}`); } finally { - try { fs.rmSync(dir, { recursive: true, force: true }); } - catch (e) { _warn(`could not remove temporary metadata directory: ${e.message}`); } + try { fs.rmSync(outDir, { recursive: true, force: true }); } + catch (e) { _warn(`could not remove temporary signing 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.' - ); +/** Move `from` onto `to`, falling back to a copy across volumes. */ +function _replaceFile(from, to) { + try { + fs.rmSync(to, { force: true }); + fs.renameSync(from, to); + } catch (e) { + if (e.code !== 'EXDEV') throw e; + fs.copyFileSync(from, to); } - return 'See docs/CODE_SIGNING.md for the Azure Artifact Signing setup.'; } function _runPfxSign(filePath) { @@ -387,14 +323,22 @@ function _runPfxSign(filePath) { filePath, ]; _log(`Signing via signtool/PFX: ${path.basename(filePath)}`); + // No shell here. signtool is an .exe, so Node can quote the arguments + // itself — which it does correctly, unlike cmd.exe, for a PFX password + // containing the special characters export passwords tend to contain. const result = child_process.spawnSync('signtool', args, { stdio: ['ignore', 'pipe', 'pipe'], - shell: true, }); + if (result.error) { + throw new Error( + `could not run signtool: ${result.error.message}. It ships with the ` + + `Windows 10/11 SDK and must be on PATH.`, + ); + } if (result.status !== 0) { // signtool echoes the /p value in some diagnostics; the password is the // one thing that must not reach a build log. - const stderr = (result.stderr?.toString() || '').split(pfxPwd).join('***'); + const stderr = _redact(result.stderr?.toString() || '', [pfxPwd]); process.stderr.write(stderr); throw new Error(`signtool failed (exit ${result.status})`); } @@ -405,39 +349,44 @@ function _runPfxSign(filePath) { } } -function _generateTotp(base32Secret) { - // Standard RFC 6238 TOTP: SHA1, 30s step, 6 digits. - const crypto = require('crypto'); - const key = _base32Decode(base32Secret.replace(/\s+/g, '').toUpperCase()); - const counter = Math.floor(Date.now() / 30000); - const buf = Buffer.alloc(8); - buf.writeUInt32BE(0, 0); - buf.writeUInt32BE(counter, 4); - const hmac = crypto.createHmac('sha1', key).update(buf).digest(); - const offset = hmac[hmac.length - 1] & 0x0f; - const binary = - ((hmac[offset] & 0x7f) << 24) | - ((hmac[offset + 1] & 0xff) << 16) | - ((hmac[offset + 2] & 0xff) << 8) | - ( hmac[offset + 3] & 0xff); - return String(binary % 1000000).padStart(6, '0'); -} +/** + * Confirm the artifact actually gained a signature. + * + * A signing tool's exit status reports whether the tool ran, not whether the + * file on disk changed. CodeSignTool has been observed to print a failure and + * exit 0, and the whole point of the release gate is that no unsigned artifact + * reaches a customer — so the file is re-read rather than trusted. + * + * This is a post-condition, not a validity check: it establishes that a + * certificate table is present. Trust evaluation happens in the release gate, + * which has the artifact and the tooling to ask the operating system. + */ +async function _assertSignatureEmbedded(filePath) { + let readEmbeddedSignature; + try { + ({ readEmbeddedSignature } = await import('./verify-artifact-signature.mjs')); + } catch (e) { + _warn(`could not load the signature verifier, skipping post-sign check: ${e.message}`); + return; + } -function _base32Decode(input) { - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; - let bits = 0, value = 0; - const out = []; - for (const ch of input.replace(/=+$/, '')) { - const idx = alphabet.indexOf(ch); - if (idx < 0) throw new Error(`Invalid base32 char: ${ch}`); - value = (value << 5) | idx; - bits += 5; - if (bits >= 8) { - bits -= 8; - out.push((value >>> bits) & 0xff); - } + let result; + try { + result = readEmbeddedSignature(filePath); + } catch (e) { + // electron-builder signs PE files, but if it ever hands us something else + // the inability to parse it is not evidence that signing failed. + _warn(`post-sign check skipped for ${path.basename(filePath)}: ${e.message}`); + return; + } + + if (!result.present) { + throw new Error( + `${path.basename(filePath)} still carries no embedded signature after the ` + + `signing tool reported success. Treat the tool's output above as the real ` + + `error; the exit status was misleading.`, + ); } - return Buffer.from(out); } async function sign(configuration) { @@ -469,17 +418,18 @@ async function sign(configuration) { _warn( `TRANSTRACK_SIGN_MODE=skip (auto-detected: no signing credentials in environment). ` + `Artifact "${path.basename(filePath)}" will be UNSIGNED. ` + - `Set AZURE_SIGNING_*, ESIGNER_* or CSC_LINK/CSC_KEY_PASSWORD before producing a release.` + `Set ESIGNER_* or CSC_LINK/CSC_KEY_PASSWORD before producing a release.` ); return; } _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}`); + if (MODE === 'ssl_esigner') _runSslEsigner(filePath); + else if (MODE === 'pfx') _runPfxSign(filePath); + else throw new Error(`Unknown TRANSTRACK_SIGN_MODE: ${MODE}`); + + await _assertSignatureEmbedded(filePath); } module.exports = sign; @@ -488,15 +438,13 @@ module.exports.default = sign; // production code. module.exports.__testing__ = { _autoDetectMode, - _generateTotp, - _base32Decode, _resolveFilePath, _signingRequired, _assertModeConfigured, _materializeCertificate, - _buildAzureMetadata, - _runAzureSign, - _azureHint, - AZURE_TIMESTAMP_URL, + _quoteForCmd, + _redact, + _replaceFile, + _assertSignatureEmbedded, MODE_REQUIREMENTS, }; diff --git a/tests/signWin.test.cjs b/tests/signWin.test.cjs index f9af589..48da884 100644 --- a/tests/signWin.test.cjs +++ b/tests/signWin.test.cjs @@ -2,8 +2,9 @@ * TransTrack — sign-win.cjs unit tests. * * Validates the parts that don't need a real Authenticode certificate: mode - * auto-detection, the fail-closed behaviour on release builds, base32/TOTP, - * certificate materialisation from base64, and the path resolver. + * auto-detection, the fail-closed behaviour on release builds, the CodeSignTool + * argument contract, certificate materialisation from base64, the post-sign + * verification, and the path resolver. * * The harness awaits its tests. It previously did not: `test()` called an * `async` function and incremented the pass counter on the next line, so every @@ -39,9 +40,6 @@ 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' || @@ -185,131 +183,125 @@ 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=== CodeSignTool argument contract ==='); -console.log('\n=== Azure Artifact Signing ==='); +// These assertions encode SSL.com's documented CLI so that a future edit which +// drifts from it fails here rather than at 2am against a real certificate. +// Reference: https://www.ssl.com/guide/esigner-codesigntool-command-guide/ -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('a password with shell metacharacters survives cmd.exe intact', async () => { + // CodeSignTool is a .bat, so Node cannot spawn it without a shell and the + // arguments get re-parsed by cmd. SSL.com's own documentation uses + // `P!@^^ssword12` as its example password, so this is the normal case, not an + // exotic one. Unquoted, cmd would eat the carets and treat & as a separator. + const quoted = exposed._quoteForCmd('ESIGNER_PASSWORD', 'P!@^^ss&word|12'); + assert.strictEqual(quoted, '"P!@^^ss&word|12"'); }); -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('a password containing a quote or percent is refused, not corrupted', async () => { + // Neither can be escaped inside cmd's quoting: `"` ends the quoted run and + // `%` expands even inside quotes. Passing them anyway would send a different + // password than the one configured and surface as an authentication failure + // that looks like an account problem. + for (const bad of ['pass"word', 'pass%PATH%word']) { + assert.throws( + () => exposed._quoteForCmd('ESIGNER_PASSWORD', bad), + /cannot be passed through the Windows command interpreter/, + ); } }); -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('secrets are stripped from anything echoed out of the tool', async () => { + const out = exposed._redact( + 'authenticating as bob with hunter2 and totp seedvalue==', + ['hunter2', 'seedvalue=='], ); + assert.ok(!out.includes('hunter2')); + assert.ok(!out.includes('seedvalue==')); + assert.match(out, /authenticating as bob/); }); -test('a missing dlib is reported with the command that installs it', async () => { +test('a missing CodeSignTool says where to get it', async () => { await withSigner( - { ...AZURE_ENV, AZURE_SIGNING_DLIB: 'C:/definitely/not/here/Azure.CodeSigning.Dlib.dll' }, + { + TRANSTRACK_SIGN_MODE: 'ssl_esigner', + ESIGNER_USERNAME: 'u', ESIGNER_PASSWORD: 'p', ESIGNER_CREDENTIAL_ID: 'c', + ESIGNER_TOTP_SECRET: 's', ESIGNER_TOOL_PATH: 'C:/nope/CodeSignTool.bat', + }, 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/); + assert.match(e.message, /ESIGNER_TOOL_PATH not found/); + assert.match(e.message, /SSL\.com dashboard/); 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')); -}); +console.log('\n=== post-sign verification ==='); -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 tool that reports success without signing anything is caught', async () => { + // CodeSignTool has been observed to print a failure and exit 0. An exit + // status describes whether the tool ran, not whether the file changed, so + // the artifact is re-read rather than trusted. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-postsign-')); + try { + const exe = path.join(dir, 'unsigned.exe'); + fs.writeFileSync(exe, makeUnsignedPe()); + await assert.rejects( + () => exposed._assertSignatureEmbedded(exe), + /still carries no embedded signature/, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } }); -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('a genuinely signed artifact passes the post-sign check', async () => { + if (process.platform !== 'win32') { + console.log(' (skipped off Windows — no signed PE to hand)'); + return; + } + await exposed._assertSignatureEmbedded(process.execPath); }); -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 file that is not a PE image is skipped rather than failed', async () => { + // The hook signs PE files, but an unparseable input is not evidence that + // signing failed, and turning it into a build failure would be wrong. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-postsign-')); + try { + const txt = path.join(dir, 'notpe.txt'); + fs.writeFileSync(txt, 'hello'); + await exposed._assertSignatureEmbedded(txt); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } }); -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('the signed output replaces the original even across volumes', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-replace-')); + try { + const from = path.join(dir, 'signed.exe'); + const to = path.join(dir, 'original.exe'); + fs.writeFileSync(from, 'SIGNED'); + fs.writeFileSync(to, 'ORIGINAL'); + exposed._replaceFile(from, to); + assert.strictEqual(fs.readFileSync(to, 'utf8'), 'SIGNED'); + assert.ok(!fs.existsSync(from), 'the temporary copy must not be left behind'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } }); -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/); -}); +/** Smallest structurally valid PE32+ image with an empty certificate table. */ +function makeUnsignedPe() { + const buf = Buffer.alloc(0x400); + buf.writeUInt16LE(0x5a4d, 0); + buf.writeUInt32LE(0x80, 0x3c); + buf.writeUInt32LE(0x00004550, 0x80); + buf.writeUInt16LE(0x20b, 0x98); + return buf; +} console.log('\n=== signing-required detection ==='); @@ -387,22 +379,6 @@ test('base64 of something that is not a PKCS#12 is rejected', async () => { assert.throws(() => exposed._materializeCertificate(notACert), /PKCS#12/); }); -console.log('\n=== TOTP RFC 6238 vectors (via base32 decoder) ==='); - -test('base32 decode of known vector: "JBSWY3DPEHPK3PXP"', async () => { - const buf = exposed._base32Decode('JBSWY3DPEHPK3PXP'); - // "Hello!" then DE AD BE EF - assert.deepStrictEqual( - Array.from(buf), - [0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x21, 0xde, 0xad, 0xbe, 0xef], - ); -}); - -test('TOTP digits are 6, all numeric', async () => { - const code = exposed._generateTotp('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'); - assert.match(code, /^\d{6}$/); -}); - test('_resolveFilePath: handles string and {path} shapes', async () => { assert.strictEqual(exposed._resolveFilePath('C:/x/y.exe'), 'C:/x/y.exe'); assert.strictEqual(exposed._resolveFilePath({ path: 'C:/x/y.exe' }), 'C:/x/y.exe');