Skip to content

Commit e453dca

Browse files
committed
ci(win): sign the Windows installer via SignPath instead of Azure
Azure Artifact Signing is not reachable here: Microsoft restricts individual developer identity validation to the United States and Canada, and the EU path requires a registered legal entity. SignPath Foundation issues OV certificates free of charge to OSI-licensed projects, which OpenScreen qualifies for. The shape of the integration changes with it. SignPath signs out of band rather than inside electron-builder: the build produces an unsigned installer, uploads it as a short-lived workflow artifact, and SignPath pulls it by artifact id, signs it on its own HSM and hands the file back. It is swapped over the build output so the published openscreen-windows artifact keeps its name and shape whether or not signing ran, and the release publisher is unaffected. Hence the new actions: read permission — SignPath reads the run's artifacts with the job's own GITHUB_TOKEN. Foundation release policies require a human to approve each request, so the wait is raised to an hour; the action's 600 s default expires while the approver is still reading the notification mail. The gate and the signature verification are unchanged from the Azure version: inert with no secrets, hard failure on a partial configuration, and an assertion on the artifact itself since a signing step that no-ops still exits 0. Adds CODE_SIGNING_POLICY.md, which the Foundation's conditions require to be published and to credit SignPath. It leaves one TODO for the privacy policy URL.
1 parent fd2c9fb commit e453dca

3 files changed

Lines changed: 160 additions & 57 deletions

File tree

.github/workflows/build.yml

Lines changed: 61 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ on:
2222

2323
permissions:
2424
contents: write
25+
# SignPath fetches the unsigned installer from the workflow run by artifact id,
26+
# using the job's own GITHUB_TOKEN, which needs read access to the Actions API.
27+
actions: read
2528

2629
concurrency:
2730
group: build-${{ github.ref_name }}-${{ github.event.inputs.release_tag || 'artifacts' }}
@@ -55,38 +58,28 @@ jobs:
5558
# seeing "Windows protected your PC" on 1.8.0 get the full interstitial
5659
# again on 1.9.0, forever. Signed, the reputation carries across versions.
5760
#
58-
# The certificate lives in Azure Trusted Signing, so its private key stays
59-
# in Microsoft's HSM and nothing has to be installed on the runner —
60-
# electron-builder authenticates with the standard Entra ID environment
61-
# variables that the Azure SDK reads (AZURE_TENANT_ID / AZURE_CLIENT_ID /
62-
# AZURE_CLIENT_SECRET; those exact names are the SDK's, not ours).
63-
#
6461
# Signing is opt-in on the secrets existing, like the macOS job: with none
6562
# configured the build still succeeds and produces an unsigned installer,
66-
# which is what forks and pre-account builds get.
63+
# which is what forks and pre-onboarding builds get.
6764
- name: Resolve Windows signing
6865
id: signing
6966
shell: bash
7067
env:
71-
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
72-
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
73-
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
74-
WIN_SIGN_ENDPOINT: ${{ secrets.WIN_SIGN_ENDPOINT }}
75-
WIN_SIGN_ACCOUNT_NAME: ${{ secrets.WIN_SIGN_ACCOUNT_NAME }}
76-
WIN_SIGN_CERT_PROFILE: ${{ secrets.WIN_SIGN_CERT_PROFILE }}
77-
WIN_SIGN_PUBLISHER_NAME: ${{ secrets.WIN_SIGN_PUBLISHER_NAME }}
68+
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
69+
SIGNPATH_ORGANIZATION_ID: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
70+
SIGNPATH_PROJECT_SLUG: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
71+
SIGNPATH_SIGNING_POLICY_SLUG: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
7872
run: |
79-
required=(AZURE_TENANT_ID AZURE_CLIENT_ID AZURE_CLIENT_SECRET
80-
WIN_SIGN_ENDPOINT WIN_SIGN_ACCOUNT_NAME WIN_SIGN_CERT_PROFILE
81-
WIN_SIGN_PUBLISHER_NAME)
73+
required=(SIGNPATH_API_TOKEN SIGNPATH_ORGANIZATION_ID
74+
SIGNPATH_PROJECT_SLUG SIGNPATH_SIGNING_POLICY_SLUG)
8275
missing=()
8376
for name in "${required[@]}"; do
8477
[[ -n "${!name}" ]] || missing+=("$name")
8578
done
8679
8780
if [[ ${#missing[@]} -eq 0 ]]; then
8881
echo "enabled=true" >> "$GITHUB_OUTPUT"
89-
echo "Signing enabled — installer will be signed via Azure Trusted Signing."
82+
echo "Signing enabled — installer will be signed via SignPath."
9083
elif [[ ${#missing[@]} -eq ${#required[@]} ]]; then
9184
echo "enabled=false" >> "$GITHUB_OUTPUT"
9285
echo "::warning::No Windows signing secrets configured; shipping an unsigned installer."
@@ -98,34 +91,56 @@ jobs:
9891
exit 1
9992
fi
10093
101-
# bash, and an array rather than a flat string, because publisherName must
102-
# match the certificate subject exactly and legal names contain spaces.
10394
- name: Build Windows app
95+
run: npm run build:win -- --publish never
96+
97+
# SignPath signs out of band rather than inside electron-builder: it pulls
98+
# the artifact from this workflow run by id, signs it on its own HSM, and
99+
# hands the signed file back. This upload exists only to give it something
100+
# to fetch — one day of retention, and it is not the release artifact.
101+
- name: Upload unsigned installer for signing
102+
id: unsigned
103+
if: steps.signing.outputs.enabled == 'true'
104+
uses: actions/upload-artifact@v4
105+
with:
106+
name: openscreen-windows-unsigned
107+
path: release/**/Openscreen.Setup.*.exe
108+
if-no-files-found: error
109+
retention-days: 1
110+
111+
- name: Sign installer via SignPath
112+
if: steps.signing.outputs.enabled == 'true'
113+
uses: SignPath/github-action-submit-signing-request@v2
114+
with:
115+
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
116+
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
117+
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
118+
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
119+
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
120+
output-artifact-directory: signed
121+
# SignPath Foundation release policies require a human to approve every
122+
# signing request. The action's 600 s default expires while the approver
123+
# is still reading the notification mail, which fails the release build
124+
# for no reason other than human latency.
125+
wait-for-completion-timeout-in-seconds: 3600
126+
127+
# Put the signed binary back where the build left the unsigned one, so the
128+
# upload step below stays a single unconditional path and the published
129+
# artifact keeps its name and shape whether or not signing ran.
130+
- name: Swap in the signed installer
131+
if: steps.signing.outputs.enabled == 'true'
104132
shell: bash
105-
env:
106-
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
107-
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
108-
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
109-
WIN_SIGN_ENDPOINT: ${{ secrets.WIN_SIGN_ENDPOINT }}
110-
WIN_SIGN_ACCOUNT_NAME: ${{ secrets.WIN_SIGN_ACCOUNT_NAME }}
111-
WIN_SIGN_CERT_PROFILE: ${{ secrets.WIN_SIGN_CERT_PROFILE }}
112-
WIN_SIGN_PUBLISHER_NAME: ${{ secrets.WIN_SIGN_PUBLISHER_NAME }}
113133
run: |
114-
args=(--publish never)
115-
if [[ "${{ steps.signing.outputs.enabled }}" == "true" ]]; then
116-
args+=(
117-
"--config.win.azureSignOptions.endpoint=$WIN_SIGN_ENDPOINT"
118-
"--config.win.azureSignOptions.codeSigningAccountName=$WIN_SIGN_ACCOUNT_NAME"
119-
"--config.win.azureSignOptions.certificateProfileName=$WIN_SIGN_CERT_PROFILE"
120-
"--config.win.azureSignOptions.publisherName=$WIN_SIGN_PUBLISHER_NAME"
121-
)
122-
fi
123-
npm run build:win -- "${args[@]}"
124-
125-
# electron-builder does not fail the build when signing no-ops, so the only
126-
# way to know the artifact is actually signed is to look at the artifact.
127-
# The macOS job carries the same check for the same reason: a bundle that
128-
# nothing had signed shipped once, and nothing caught it.
134+
signed="$(find signed -type f -name 'Openscreen.Setup.*.exe' -print -quit)"
135+
target="$(find release -type f -name 'Openscreen.Setup.*.exe' -print -quit)"
136+
[[ -n "$signed" ]] || { echo "::error::SignPath returned no installer."; exit 1; }
137+
[[ -n "$target" ]] || { echo "::error::No build output to replace."; exit 1; }
138+
cp -f "$signed" "$target"
139+
echo "Replaced $target with the signed build."
140+
141+
# Assert on the artifact, never on the pipeline having run: a signing step
142+
# that quietly no-ops still exits 0. The macOS job carries the equivalent
143+
# check because an unsigned bundle shipped unnoticed once.
129144
- name: Verify installer signature
130145
if: steps.signing.outputs.enabled == 'true'
131146
shell: pwsh
@@ -139,9 +154,9 @@ jobs:
139154
if ($sig.Status -ne 'Valid') {
140155
throw "Installer signature is '$($sig.Status)', expected 'Valid'."
141156
}
142-
# An untimestamped signature stops validating the day the certificate
143-
# expires. Trusted Signing certificates are short-lived by design, so
144-
# this is not a hypothetical.
157+
# Without a timestamp the signature stops validating the day the
158+
# certificate expires, retroactively invalidating every release already
159+
# in users' hands.
145160
if (-not $sig.TimeStamperCertificate) {
146161
throw 'Installer is signed but not timestamped.'
147162
}

CODE_SIGNING_POLICY.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Code signing policy
2+
3+
Windows release binaries of OpenScreen are signed. This page documents who can
4+
change the code that gets signed, who can authorise a signature, and what the
5+
signature does and does not tell you.
6+
7+
Free code signing is provided by [SignPath.io](https://signpath.io/), with a
8+
certificate issued by the [SignPath Foundation](https://signpath.org/).
9+
10+
## What is signed
11+
12+
The Windows installer (`Openscreen.Setup.<version>.exe`) published on the
13+
[GitHub releases page](https://github.com/getopenscreen/openscreen/releases).
14+
15+
The Microsoft Store package is **not** signed with this certificate — Microsoft
16+
re-signs Store submissions during certification, so Store installs carry
17+
Microsoft's signature instead.
18+
19+
macOS builds are signed and notarised separately with an Apple Developer ID.
20+
21+
## Roles
22+
23+
| Role | Who | What they may do |
24+
|---|---|---|
25+
| Committer | Etienne Lescot ([@EtienneLescot](https://github.com/EtienneLescot)) | Push to the repository and merge pull requests. |
26+
| Reviewer | Etienne Lescot | Review pull requests before merge. |
27+
| Approver | Etienne Lescot | Approve a signing request in SignPath. |
28+
29+
OpenScreen currently has a single maintainer, so these roles are held by one
30+
person. This page is updated if that changes.
31+
32+
All accounts with commit or signing access use multi-factor authentication, on
33+
both GitHub and SignPath.
34+
35+
## How signing works
36+
37+
Signing is not performed on a developer machine and no maintainer ever holds the
38+
private key — it stays on SignPath's HSM and is never issued to us.
39+
40+
1. A tagged release triggers the `Build Electron App` workflow on GitHub Actions.
41+
2. The workflow builds the installer from the tagged source and uploads it as a
42+
workflow artifact.
43+
3. SignPath retrieves that artifact directly from the workflow run, verifying it
44+
came from this repository's CI rather than from an uploaded file.
45+
4. The maintainer approves the signing request in the SignPath dashboard.
46+
5. The signed installer is returned to the workflow, its signature is verified,
47+
and it is published as the release artifact.
48+
49+
Because the artifact is pulled from the workflow run rather than submitted by
50+
hand, a signature attests that the binary was built by this repository's CI from
51+
tagged source.
52+
53+
## What the signature means
54+
55+
It confirms that the installer was produced by this project and has not been
56+
modified since it was signed. Windows shows a verified publisher instead of
57+
"Unknown publisher".
58+
59+
It is **not** a security audit, a warranty, or a guarantee that the software is
60+
free of defects. OpenScreen is MIT-licensed and provided as is, without warranty
61+
of any kind; see [LICENSE](LICENSE). The SignPath Foundation accepts no liability
62+
for signed software.
63+
64+
Note that this is an OV certificate, not EV. Microsoft SmartScreen builds
65+
reputation for a signing identity over time, so recent releases may still show a
66+
warning until enough downloads have accumulated. Choosing "More info" then "Run
67+
anyway" is expected in that window; the publisher name shown should read as
68+
documented above.
69+
70+
## Privacy
71+
72+
OpenScreen requires no account and performs no telemetry. Recordings, edits and
73+
automatic captions are processed entirely on the user's device. The one network
74+
request the application makes is downloading the speech-to-text model on first
75+
use of automatic captions.
76+
77+
<!-- TODO(maintainer): link the published privacy policy URL here — the SignPath
78+
Foundation conditions require this page to reference it, and the same URL is
79+
already declared in the Microsoft Store submission. -->
80+
81+
## Reporting a problem
82+
83+
If you believe a signed OpenScreen binary is malicious or has been tampered
84+
with, open an issue at
85+
[getopenscreen/openscreen/issues](https://github.com/getopenscreen/openscreen/issues).

technical-documentation/engineering/release-and-secrets.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,23 +88,26 @@ Rotate the certificate by exporting a replacement P12, base64-encoding it withou
8888

8989
## Windows signing
9090

91-
`build.yml` signs the NSIS installer only when all of these secrets are present. With none of them set the job still succeeds and uploads an unsigned installer; with some but not all of them set it fails, because a half-configured signer is a typo rather than a decision and the alternative is silently publishing unsigned.
91+
`build.yml` signs the NSIS installer through [SignPath Foundation](https://signpath.org/), which issues OV certificates free of charge to open-source projects. Signing runs only when all of these secrets are present. With none of them set the job still succeeds and uploads an unsigned installer; with some but not all of them set it fails, because a half-configured signer is a typo rather than a decision and the alternative is silently publishing unsigned.
9292

9393
| Secret | Purpose |
9494
|---|---|
95-
| `AZURE_TENANT_ID` | Entra ID tenant of the service principal. Name fixed by the Azure SDK, not chosen here. |
96-
| `AZURE_CLIENT_ID` | Service principal application ID. |
97-
| `AZURE_CLIENT_SECRET` | Service principal secret. |
98-
| `WIN_SIGN_ENDPOINT` | Trusted Signing account endpoint, region-specific (e.g. `https://weu.codesigning.azure.net`). |
99-
| `WIN_SIGN_ACCOUNT_NAME` | Trusted Signing account name. |
100-
| `WIN_SIGN_CERT_PROFILE` | Certificate profile name inside that account. |
101-
| `WIN_SIGN_PUBLISHER_NAME` | Certificate subject, character for character. Mismatches here do not fail the signing call, they fail signature *verification* later. |
95+
| `SIGNPATH_API_TOKEN` | SignPath REST API token used to submit the signing request. |
96+
| `SIGNPATH_ORGANIZATION_ID` | SignPath organization ID. |
97+
| `SIGNPATH_PROJECT_SLUG` | SignPath project slug. |
98+
| `SIGNPATH_SIGNING_POLICY_SLUG` | Signing policy to apply — typically `release-signing`, which requires manual approval. |
10299

103-
The signing key lives in Microsoft's HSM and never reaches the runner, so unlike the Apple path there is no certificate material in any secret and nothing to import. The service principal needs the **Trusted Signing Certificate Profile Signer** role on the account; tenant/client IDs alone are not sufficient and the failure is a 403 at signing time.
100+
No certificate material lives in any secret: the private key stays on SignPath's HSM and is never issued to us, so unlike the Apple path there is nothing to import on the runner.
104101

105-
Why this exists at all: SmartScreen keys reputation to the signing identity for a signed installer and to the file hash for an unsigned one. Unsigned, every release starts from zero reputation and users meet the "Windows protected your PC" interstitial again on each new version. Signed, reputation accumulates across versions. The Store package is unaffected either way — Microsoft re-signs it during certification.
102+
**Signing is not part of the build.** electron-builder produces an unsigned installer; the job then uploads it as a short-lived workflow artifact, SignPath fetches it by artifact id, signs it, and returns the signed file, which is swapped back over the build output so the published `openscreen-windows` artifact keeps its name and shape either way. This is why the workflow grants `actions: read` — SignPath reads the run's artifacts with the job's own `GITHUB_TOKEN`.
106103

107-
Rotate by issuing a new client secret on the service principal, updating `AZURE_CLIENT_SECRET`, running a manual build to confirm `Verify installer signature` passes, then deleting the old secret. The endpoint, account, profile and publisher name change only when the Trusted Signing resources do.
104+
**A release build can block on a human.** Foundation release policies require every signing request to be approved in the SignPath dashboard. The action waits up to an hour (`wait-for-completion-timeout-in-seconds: 3600`, well above its 600 s default) — past that the job fails and the release has to be re-run. Whoever cuts a release should expect to approve the request while it is running.
105+
106+
Why this exists at all: SmartScreen keys reputation to the signing identity for a signed installer and to the file hash for an unsigned one. Unsigned, every release starts from zero reputation and users meet the "Windows protected your PC" interstitial again on each new version. Signed, reputation accumulates across versions. Being an OV rather than EV certificate, that reputation still has to build up — signing removes "Unknown publisher" immediately, the interstitial fades with downloads. The Store package is unaffected either way: Microsoft re-signs it during certification.
107+
108+
Onboarding prerequisites, from the [Foundation's conditions](https://signpath.org/terms.html): an OSI-approved licence with no commercial dual-licensing, no proprietary components, an actively maintained public repository, MFA on every team member's SignPath and repository access, and a published code signing policy crediting SignPath — ours is [`CODE_SIGNING_POLICY.md`](../../CODE_SIGNING_POLICY.md) and must stay linked from the project homepage.
109+
110+
Rotate by issuing a new API token in SignPath, updating `SIGNPATH_API_TOKEN`, running a manual build to confirm `Verify installer signature` passes, then revoking the old token. The organization ID and slugs change only when the SignPath project does.
108111

109112
## Discord secrets and variables
110113

0 commit comments

Comments
 (0)