Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 54 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@ name: Release (commercial)
# enforces "no unsigned binary is ever published to customers".
#
# Required repository secrets:
# Windows (one of two modes):
# a) SSL.com eSigner (preferred — cloud HSM, no local cert):
# Windows (one of three modes, auto-detected by the preflight job):
# a) Azure Artifact Signing (preferred — no certificate to buy, no token):
# AZURE_SIGNING_ENDPOINT region URI, e.g. https://eus.codesigning.azure.net
# AZURE_SIGNING_ACCOUNT Artifact Signing account name
# AZURE_SIGNING_PROFILE certificate profile name
# AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET
# b) SSL.com eSigner (cloud HSM, for a traditional CA certificate):
# ESIGNER_USERNAME
# ESIGNER_PASSWORD
# ESIGNER_CREDENTIAL_ID
# ESIGNER_TOTP_SECRET
# and TRANSTRACK_SIGN_MODE=ssl_esigner in env
# b) Legacy PFX file:
# plus the ESIGNER_TOOL_URL variable so CodeSignTool can be installed
# c) PFX file:
# CSC_LINK base64 .pfx
# CSC_KEY_PASSWORD pfx password
# macOS:
Expand Down Expand Up @@ -91,13 +96,18 @@ jobs:
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
run: |
# Precedence matches the signer's own auto-detect, so the mode chosen
# here and the mode the signer would pick can never disagree.
if [ -n "$ESIGNER_USERNAME" ]; then
echo "windows_mode=ssl_esigner" >> $GITHUB_OUTPUT
elif [ -n "$AZURE_SIGNING_ACCOUNT" ]; then
echo "windows_mode=azure" >> $GITHUB_OUTPUT
elif [ -n "$CSC_LINK" ]; then
echo "windows_mode=pfx" >> $GITHUB_OUTPUT
else
echo "::error::No Windows code-signing credentials present. Set ESIGNER_* or CSC_LINK secrets before tagging a release."
echo "::error::No Windows code-signing credentials present. Set AZURE_SIGNING_*, ESIGNER_* or CSC_LINK secrets before tagging a release."
exit 1
fi

Expand Down Expand Up @@ -135,6 +145,15 @@ jobs:
# CodeSignTool is not present on the runner image; the step below installs
# it here. Overridable for self-hosted runners that pre-provision it.
ESIGNER_TOOL_PATH: ${{ vars.ESIGNER_TOOL_PATH || 'D:\CodeSignTool\CodeSignTool.bat' }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_SIGNING_PROFILE: ${{ secrets.AZURE_SIGNING_PROFILE }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
# The client tools installer puts the dlib here. Overridable for
# self-hosted runners that provision it elsewhere.
AZURE_SIGNING_DLIB: ${{ vars.AZURE_SIGNING_DLIB || 'C:\Program Files\Microsoft\Azure Artifact Signing Client Tools\bin\x64\Azure.CodeSigning.Dlib.dll' }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
steps:
Expand Down Expand Up @@ -174,6 +193,27 @@ jobs:
env:
ESIGNER_TOOL_URL: ${{ vars.ESIGNER_TOOL_URL }}

# The dlib, the .NET 8 runtime and a new-enough signtool all have to be
# present before signtool can talk to Azure, and none of them are on the
# runner image. The client tools MSI installs all three together.
- name: Install Azure Artifact Signing client tools
if: needs.preflight.outputs.windows_mode == 'azure'
shell: pwsh
run: |
if (Test-Path $env:AZURE_SIGNING_DLIB) {
Write-Host "Artifact Signing dlib already present at $env:AZURE_SIGNING_DLIB"
exit 0
}
winget install -e --id Microsoft.Azure.ArtifactSigningClientTools `
--accept-source-agreements --accept-package-agreements --disable-interactivity
if (-not (Test-Path $env:AZURE_SIGNING_DLIB)) {
Write-Host "::error::Client tools installed but $env:AZURE_SIGNING_DLIB does not exist."
Write-Host "::error::Set the AZURE_SIGNING_DLIB repository variable to the actual path of x64\Azure.CodeSigning.Dlib.dll."
Get-ChildItem -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' 'C:\Program Files' -ErrorAction SilentlyContinue |
ForEach-Object { Write-Host $_.FullName }
exit 1
}

- name: Install npm dependencies
run: npm ci

Expand Down Expand Up @@ -253,19 +293,25 @@ jobs:

gate:
name: Commercial release gate (--for-sale)
needs: [build-windows, build-macos]
needs: [preflight, build-windows, build-macos]
runs-on: ubuntu-latest
env:
TRANSTRACK_RELEASE_CHANNEL: public
# The signing-mode env vars are set so the gate's environment check
# reports them as configured even though we don't actually re-sign
# here — the artifacts were signed in the matrix jobs above.
TRANSTRACK_SIGN_MODE: ssl_esigner
# here — the artifacts were signed in the matrix jobs above. The mode is
# taken from preflight rather than hardcoded, so the gate reports on the
# mode that actually signed the artifacts.
TRANSTRACK_SIGN_MODE: ${{ needs.preflight.outputs.windows_mode }}
ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }}
ESIGNER_CREDENTIAL_ID: ${{ secrets.ESIGNER_CREDENTIAL_ID }}
ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
ESIGNER_TOOL_PATH: '/usr/local/bin/codesigntool'
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_SIGNING_PROFILE: ${{ secrets.AZURE_SIGNING_PROFILE }}
AZURE_SIGNING_DLIB: '/usr/local/lib/Azure.CodeSigning.Dlib.dll'
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added — Azure Artifact Signing

- **`TRANSTRACK_SIGN_MODE=azure`.** Microsoft's cloud signing service, roughly
$10/month, with no certificate to buy, no hardware token, and no annual
re-issue. `signtool` loads `Azure.CodeSigning.Dlib.dll`, which authenticates
to Azure and has the signature produced server-side, so no private key is
ever on the build machine. The release workflow detects the mode from the
secrets present and installs the client tools on the runner.
- Two behaviours worth knowing rather than discovering. Artifact Signing
certificates are valid for **three days**, so the signer always timestamps
against Microsoft's authority — an untimestamped installer verifies for three
days and then starts failing on customer machines with nothing about the file
having changed. And `DefaultAzureCredential`'s chain includes a browser
prompt that would hang a headless build, so the signer narrows the chain to
the service principal when one is present, and otherwise excludes only the
browser so federated identity still works.
- The two common Azure failures both surface from `signtool` as a generic
`SignerSign()` error. A 403 now says it is probably a region mismatch or a
missing signer role; a dlib load failure now says it is probably a missing
.NET 8 runtime or an x64/x86 mismatch.

### Fixed — release signing

- **A release build can no longer emit an unsigned artifact.** Both
Expand Down
135 changes: 115 additions & 20 deletions docs/CODE_SIGNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ not an engineering one.

| Option | Indicative cost | Notes |
|---|---|---|
| **Azure Artifact Signing** (formerly Trusted Signing) | ~$10/month | Microsoft's recommended route for non-Store distribution. No hardware token, CI-native. Organisations in US/Canada/EU/UK; individuals US/Canada only. **Not yet implemented in `sign-win.cjs`** — needs a new mode. |
| **Azure Artifact Signing** (formerly Trusted Signing) | ~$10/month | Microsoft's recommended route for non-Store distribution. No hardware token, no annual re-issue, CI-native. Organisations in US/Canada/EU/UK; individuals US/Canada only. Implemented as `azure` mode. |
| **OV certificate** (Sectigo, DigiCert, Certum, SSL.com) | ~$150–300/yr | Same SmartScreen behaviour as EV. Works with `pfx` mode, or with a cloud HSM via `ssl_esigner`. |
| **EV certificate** | ~$400–700/yr | Choose only if a customer's procurement process demands it. |
| Apple Developer Program (Organization) | $99/yr | Required for notarization; no alternative. |
Expand All @@ -97,19 +97,98 @@ Two constraints worth knowing before you commit:
### Modes supported

`scripts/sign-win.cjs` is the electron-builder hook that signs every
Windows artifact. It supports three modes selected by the
Windows artifact. It supports four modes selected by the
`TRANSTRACK_SIGN_MODE` environment variable:

| Mode | Use case | Required env vars |
|----------------|------------------------------------------------------------------|-------------------|
| `ssl_esigner` | Recommended for CI/CD. SSL.com eSigner cloud HSM (no USB token). | `ESIGNER_USERNAME`, `ESIGNER_PASSWORD`, `ESIGNER_CREDENTIAL_ID`, `ESIGNER_TOTP_SECRET`, `ESIGNER_TOOL_PATH` |
| `pfx` | Local builds with a software-protected `.pfx` file. | `CSC_LINK` (path **or** base64 content), `CSC_KEY_PASSWORD` |
| `azure` | Recommended. Azure Artifact Signing — no certificate to buy. | `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_PROFILE`, `AZURE_SIGNING_DLIB`, plus `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` |
| `ssl_esigner` | A traditional CA certificate held in SSL.com's cloud HSM. | `ESIGNER_USERNAME`, `ESIGNER_PASSWORD`, `ESIGNER_CREDENTIAL_ID`, `ESIGNER_TOTP_SECRET`, `ESIGNER_TOOL_PATH` |
| `pfx` | A `.pfx` you already hold; internal and test builds. | `CSC_LINK` (path **or** base64 content), `CSC_KEY_PASSWORD` |
| `skip` | Unsigned development builds. Never use for release. | (none) |

If `TRANSTRACK_SIGN_MODE` is **unset**, the script auto-detects in the
order `ssl_esigner` → `pfx` → `skip`. When a mode is named explicitly but its
variables are incomplete, the signer fails immediately and names the missing
variable rather than falling through to `skip`.
order `ssl_esigner` → `azure` → `pfx` → `skip`. When a mode is named explicitly
but its variables are incomplete, the signer fails immediately and names the
missing variable rather than falling through to `skip`.

### Azure Artifact Signing (recommended)

Microsoft signs on your behalf against a certificate you never possess. There
is no `.pfx`, no USB token, no yearly re-issue, and no private key on the build
machine — `signtool` loads a library that authenticates to Azure and the
signature is produced server-side.

Eligibility is the one thing to check before committing: your organisation must
have been verifiable for **three years or more**. Newer organisations, and
individuals, can enrol but the certificate subject shows an unverified identity.

**1. Set up the Azure resources.** In the portal, create an Artifact Signing
account, complete identity validation, and create a certificate profile of type
*Public Trust*. Note the region — the endpoint URI must match it, and a mismatch
surfaces as an opaque 403 during signing.

| Region | Endpoint |
|---|---|
| East US | `https://eus.codesigning.azure.net` |
| West US 2 | `https://wus2.codesigning.azure.net` |
| West US 3 | `https://wus3.codesigning.azure.net` |
| West Central US | `https://wcus.codesigning.azure.net` |
| North Europe | `https://neu.codesigning.azure.net` |
| West Europe | `https://weu.codesigning.azure.net` |

(Full list in [Microsoft's integration guide](https://learn.microsoft.com/en-us/azure/artifact-signing/how-to-signing-integrations).)

**2. Create a service principal for CI.** Register an application in Entra ID,
create a client secret, and grant it the **Trusted Signing Certificate Profile
Signer** role on the Artifact Signing account. Without that role assignment
signing fails with a 403 that looks identical to a wrong endpoint.

**3. Install the client tools on the signing machine.** One MSI supplies the
dlib, the .NET 8 runtime, and a new-enough `signtool`:

```powershell
winget install -e --id Microsoft.Azure.ArtifactSigningClientTools
```

The release workflow does this automatically when `azure` mode is selected.

**4. Set the environment:**

```text
TRANSTRACK_SIGN_MODE=azure
AZURE_SIGNING_ENDPOINT=https://eus.codesigning.azure.net
AZURE_SIGNING_ACCOUNT=<Artifact Signing account name>
AZURE_SIGNING_PROFILE=<certificate profile name>
AZURE_SIGNING_DLIB=C:\Program Files\Microsoft\Azure Artifact Signing Client Tools\bin\x64\Azure.CodeSigning.Dlib.dll
AZURE_TENANT_ID=<Entra tenant id>
AZURE_CLIENT_ID=<app registration client id>
AZURE_CLIENT_SECRET=<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

Expand Down Expand Up @@ -283,19 +362,36 @@ accepted) and `notepad.exe` (catalog-signed, must be rejected).

## CI

`.github/workflows/release.yml` builds and signs on tag push. The relevant
environment for the Windows job:
`.github/workflows/release.yml` builds and signs on tag push. A `preflight` job
picks the mode from whichever secrets are present, using the same precedence as
the signer's own auto-detect, and fails the release if none are. For Azure the
repository secrets are:

```text
AZURE_SIGNING_ENDPOINT
AZURE_SIGNING_ACCOUNT
AZURE_SIGNING_PROFILE
AZURE_TENANT_ID
AZURE_CLIENT_ID
AZURE_CLIENT_SECRET
```

The workflow installs the client tools on the runner and sets
`AZURE_SIGNING_DLIB` to the default install path; override it with an
`AZURE_SIGNING_DLIB` repository *variable* if you use a self-hosted runner that
provisions it elsewhere.

For eSigner instead:

```yaml
env:
TRANSTRACK_RELEASE_CHANNEL: public # makes signing mandatory
TRANSTRACK_SIGN_MODE: ${{ vars.TRANSTRACK_SIGN_MODE || 'ssl_esigner' }}
ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }}
ESIGNER_CREDENTIAL_ID: ${{ secrets.ESIGNER_CREDENTIAL_ID }}
ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
ESIGNER_TOOL_URL: ${{ secrets.ESIGNER_TOOL_URL }}
ESIGNER_TOOL_PATH: ${{ vars.ESIGNER_TOOL_PATH || 'C:\CodeSignTool\CodeSignTool.bat' }}
ESIGNER_TOOL_URL: ${{ vars.ESIGNER_TOOL_URL }}
ESIGNER_TOOL_PATH: ${{ vars.ESIGNER_TOOL_PATH || 'D:\CodeSignTool\CodeSignTool.bat' }}
```

and for macOS:
Expand All @@ -319,15 +415,14 @@ un-notarized artifact fails there even if the hooks somehow did not.

## What to do first

If you have not bought anything yet, the shortest path to a signed Windows
installer is **Azure Artifact Signing** at roughly $10/month, with no hardware
token and no annual re-issue. It needs a new mode in `sign-win.cjs` (the Azure
signing tool has a different invocation than CodeSignTool) — that is a small,
self-contained piece of work, not a blocker.
Use **Azure Artifact Signing**. It is roughly $10/month, needs no certificate
purchase, no hardware token, and no annual re-issue, and it is implemented as
`azure` mode. The setup is four steps and is written out above; the only thing
to verify before committing is the three-year organisation age requirement.

If you want to ship with what is already implemented, buy an **OV certificate
with cloud HSM signing** (SSL.com eSigner is what `ssl_esigner` mode targets)
and skip EV unless a customer asks for it in writing.
Fall back to an **OV certificate with cloud HSM signing** (`ssl_esigner`) if you
are not eligible for Artifact Signing. Skip EV unless a customer asks for it in
writing.

macOS has no equivalent decision: Apple Developer Program Organization
enrolment at $99/year, and the D-U-N-S number takes about two weeks, so start
Expand Down
16 changes: 16 additions & 0 deletions docs/compliance/SOFTWARE_DESIGN_SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,22 @@ A receiving site's only means of confirming that an installer came from the
vendor and arrived unmodified is its code signature. Two controls protect that
property.

**Signing modes.** `scripts/sign-win.cjs` supports Azure Artifact Signing
(`azure`), a cloud HSM holding a CA-issued certificate (`ssl_esigner`), a
PKCS#12 file (`pfx`), and an explicit unsigned developer build (`skip`). The
first two keep the private key off the build machine entirely: the signature is
produced by the service, and the build host holds only a credential authorising
it to request one. This is the property that matters for a vendor of regulated
software, because it bounds what an attacker gains by compromising a build
machine — they can request signatures while their access lasts, but they cannot
take the key.

Azure Artifact Signing certificates are valid for three days, so the signature
outlives the certificate only by virtue of a trusted timestamp. The signer
always timestamps, and the timestamp authority is not left to a default that
could drift, because an untimestamped artifact verifies for three days and then
begins failing at receiving sites with nothing about it having changed.

**Fail closed on a designated release.** `scripts/sign-win.cjs` and
`scripts/notarize.cjs` both distinguish a developer build, where a missing
certificate is a warning, from a distribution build, where it is a build
Expand Down
Loading
Loading