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');