From fd2c9fb0ed292447e17940c3b18c0b267032c25a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 13:36:41 +0200 Subject: [PATCH 1/2] ci(win): sign the Windows installer via Azure Trusted Signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SmartScreen keys an installer's reputation to the signing identity when it is signed and to the file hash when it is not, so the unsigned NSIS installer restarts from zero reputation at every release: users who had stopped seeing the "Windows protected your PC" interstitial on one version meet it again on the next, forever. Signing is gated on the secrets existing, like the macOS job, so this is inert until an Azure Trusted Signing account is configured — builds keep producing an unsigned installer meanwhile, and forks are unaffected. A partial configuration fails the job instead, since it is a typo rather than a choice and the quiet alternative is publishing unsigned. The key stays in Microsoft's HSM, so nothing is imported on the runner and no certificate material lives in a secret. Arguments go through a bash array because publisherName must match the certificate subject exactly and legal names contain spaces. Only the NSIS job changes; the Store package is re-signed by Microsoft during certification and is left alone. --- .github/workflows/build.yml | 96 ++++++++++++++++++- .../engineering/release-and-secrets.md | 20 ++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c3f82937..d3da9beb8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,8 +49,102 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: bash scripts/stage-whisper-stt.sh win32-x64 + # SmartScreen keys an installer's reputation to the signing identity when it + # is signed, and to the file hash when it is not. An unsigned installer + # therefore restarts from zero at every release: users who had stopped + # seeing "Windows protected your PC" on 1.8.0 get the full interstitial + # again on 1.9.0, forever. Signed, the reputation carries across versions. + # + # The certificate lives in Azure Trusted Signing, so its private key stays + # in Microsoft's HSM and nothing has to be installed on the runner — + # electron-builder authenticates with the standard Entra ID environment + # variables that the Azure SDK reads (AZURE_TENANT_ID / AZURE_CLIENT_ID / + # AZURE_CLIENT_SECRET; those exact names are the SDK's, not ours). + # + # Signing is opt-in on the secrets existing, like the macOS job: with none + # configured the build still succeeds and produces an unsigned installer, + # which is what forks and pre-account builds get. + - name: Resolve Windows signing + id: signing + shell: bash + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + WIN_SIGN_ENDPOINT: ${{ secrets.WIN_SIGN_ENDPOINT }} + WIN_SIGN_ACCOUNT_NAME: ${{ secrets.WIN_SIGN_ACCOUNT_NAME }} + WIN_SIGN_CERT_PROFILE: ${{ secrets.WIN_SIGN_CERT_PROFILE }} + WIN_SIGN_PUBLISHER_NAME: ${{ secrets.WIN_SIGN_PUBLISHER_NAME }} + run: | + required=(AZURE_TENANT_ID AZURE_CLIENT_ID AZURE_CLIENT_SECRET + WIN_SIGN_ENDPOINT WIN_SIGN_ACCOUNT_NAME WIN_SIGN_CERT_PROFILE + WIN_SIGN_PUBLISHER_NAME) + missing=() + for name in "${required[@]}"; do + [[ -n "${!name}" ]] || missing+=("$name") + done + + if [[ ${#missing[@]} -eq 0 ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "Signing enabled — installer will be signed via Azure Trusted Signing." + elif [[ ${#missing[@]} -eq ${#required[@]} ]]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning::No Windows signing secrets configured; shipping an unsigned installer." + else + # Half a configuration is a mistake, never a choice. Failing here beats + # silently publishing unsigned because one secret was misnamed. + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::error::Windows signing is partially configured; missing: ${missing[*]}" + exit 1 + fi + + # bash, and an array rather than a flat string, because publisherName must + # match the certificate subject exactly and legal names contain spaces. - name: Build Windows app - run: npm run build:win -- --publish never + shell: bash + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + WIN_SIGN_ENDPOINT: ${{ secrets.WIN_SIGN_ENDPOINT }} + WIN_SIGN_ACCOUNT_NAME: ${{ secrets.WIN_SIGN_ACCOUNT_NAME }} + WIN_SIGN_CERT_PROFILE: ${{ secrets.WIN_SIGN_CERT_PROFILE }} + WIN_SIGN_PUBLISHER_NAME: ${{ secrets.WIN_SIGN_PUBLISHER_NAME }} + run: | + args=(--publish never) + if [[ "${{ steps.signing.outputs.enabled }}" == "true" ]]; then + args+=( + "--config.win.azureSignOptions.endpoint=$WIN_SIGN_ENDPOINT" + "--config.win.azureSignOptions.codeSigningAccountName=$WIN_SIGN_ACCOUNT_NAME" + "--config.win.azureSignOptions.certificateProfileName=$WIN_SIGN_CERT_PROFILE" + "--config.win.azureSignOptions.publisherName=$WIN_SIGN_PUBLISHER_NAME" + ) + fi + npm run build:win -- "${args[@]}" + + # electron-builder does not fail the build when signing no-ops, so the only + # way to know the artifact is actually signed is to look at the artifact. + # The macOS job carries the same check for the same reason: a bundle that + # nothing had signed shipped once, and nothing caught it. + - name: Verify installer signature + if: steps.signing.outputs.enabled == 'true' + shell: pwsh + run: | + $exe = Get-ChildItem release -Recurse -Filter 'Openscreen.Setup.*.exe' | Select-Object -First 1 + if (-not $exe) { throw 'No installer was produced.' } + $sig = Get-AuthenticodeSignature $exe.FullName + Write-Host "status=$($sig.Status)" + Write-Host "signer=$($sig.SignerCertificate.Subject)" + Write-Host "timestamp=$($sig.TimeStamperCertificate.Subject)" + if ($sig.Status -ne 'Valid') { + throw "Installer signature is '$($sig.Status)', expected 'Valid'." + } + # An untimestamped signature stops validating the day the certificate + # expires. Trusted Signing certificates are short-lived by design, so + # this is not a hypothetical. + if (-not $sig.TimeStamperCertificate) { + throw 'Installer is signed but not timestamped.' + } - name: Upload Windows installer uses: actions/upload-artifact@v4 diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index f42cde69c..6f3e4f03d 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -86,6 +86,26 @@ The certificate account needs Developer ID signing capability, and the Apple acc Rotate the certificate by exporting a replacement P12, base64-encoding it without line-wrap changes, updating the P12/password/name secrets together, testing a stable-format manual build, then revoking the old certificate if required. Rotate the app-specific password in Apple ID settings, replace `APPLE_APP_SPECIFIC_PASSWORD`, verify notarization, and revoke the old password. `APPLE_ID` and `APPLE_TEAM_ID` normally change only when the owning account or team changes. +## Windows signing + +`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. + +| Secret | Purpose | +|---|---| +| `AZURE_TENANT_ID` | Entra ID tenant of the service principal. Name fixed by the Azure SDK, not chosen here. | +| `AZURE_CLIENT_ID` | Service principal application ID. | +| `AZURE_CLIENT_SECRET` | Service principal secret. | +| `WIN_SIGN_ENDPOINT` | Trusted Signing account endpoint, region-specific (e.g. `https://weu.codesigning.azure.net`). | +| `WIN_SIGN_ACCOUNT_NAME` | Trusted Signing account name. | +| `WIN_SIGN_CERT_PROFILE` | Certificate profile name inside that account. | +| `WIN_SIGN_PUBLISHER_NAME` | Certificate subject, character for character. Mismatches here do not fail the signing call, they fail signature *verification* later. | + +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. + +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. + +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. + ## Discord secrets and variables | Name | Kind | Used for | From e453dca10a283785338abecc3f56aca3337ffaec Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 14:02:23 +0200 Subject: [PATCH 2/2] ci(win): sign the Windows installer via SignPath instead of Azure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/build.yml | 107 ++++++++++-------- CODE_SIGNING_POLICY.md | 85 ++++++++++++++ .../engineering/release-and-secrets.md | 25 ++-- 3 files changed, 160 insertions(+), 57 deletions(-) create mode 100644 CODE_SIGNING_POLICY.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d3da9beb8..299d4b169 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,9 @@ on: permissions: contents: write + # SignPath fetches the unsigned installer from the workflow run by artifact id, + # using the job's own GITHUB_TOKEN, which needs read access to the Actions API. + actions: read concurrency: group: build-${{ github.ref_name }}-${{ github.event.inputs.release_tag || 'artifacts' }} @@ -55,30 +58,20 @@ jobs: # seeing "Windows protected your PC" on 1.8.0 get the full interstitial # again on 1.9.0, forever. Signed, the reputation carries across versions. # - # The certificate lives in Azure Trusted Signing, so its private key stays - # in Microsoft's HSM and nothing has to be installed on the runner — - # electron-builder authenticates with the standard Entra ID environment - # variables that the Azure SDK reads (AZURE_TENANT_ID / AZURE_CLIENT_ID / - # AZURE_CLIENT_SECRET; those exact names are the SDK's, not ours). - # # Signing is opt-in on the secrets existing, like the macOS job: with none # configured the build still succeeds and produces an unsigned installer, - # which is what forks and pre-account builds get. + # which is what forks and pre-onboarding builds get. - name: Resolve Windows signing id: signing shell: bash env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - WIN_SIGN_ENDPOINT: ${{ secrets.WIN_SIGN_ENDPOINT }} - WIN_SIGN_ACCOUNT_NAME: ${{ secrets.WIN_SIGN_ACCOUNT_NAME }} - WIN_SIGN_CERT_PROFILE: ${{ secrets.WIN_SIGN_CERT_PROFILE }} - WIN_SIGN_PUBLISHER_NAME: ${{ secrets.WIN_SIGN_PUBLISHER_NAME }} + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + SIGNPATH_ORGANIZATION_ID: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} + SIGNPATH_PROJECT_SLUG: ${{ secrets.SIGNPATH_PROJECT_SLUG }} + SIGNPATH_SIGNING_POLICY_SLUG: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }} run: | - required=(AZURE_TENANT_ID AZURE_CLIENT_ID AZURE_CLIENT_SECRET - WIN_SIGN_ENDPOINT WIN_SIGN_ACCOUNT_NAME WIN_SIGN_CERT_PROFILE - WIN_SIGN_PUBLISHER_NAME) + required=(SIGNPATH_API_TOKEN SIGNPATH_ORGANIZATION_ID + SIGNPATH_PROJECT_SLUG SIGNPATH_SIGNING_POLICY_SLUG) missing=() for name in "${required[@]}"; do [[ -n "${!name}" ]] || missing+=("$name") @@ -86,7 +79,7 @@ jobs: if [[ ${#missing[@]} -eq 0 ]]; then echo "enabled=true" >> "$GITHUB_OUTPUT" - echo "Signing enabled — installer will be signed via Azure Trusted Signing." + echo "Signing enabled — installer will be signed via SignPath." elif [[ ${#missing[@]} -eq ${#required[@]} ]]; then echo "enabled=false" >> "$GITHUB_OUTPUT" echo "::warning::No Windows signing secrets configured; shipping an unsigned installer." @@ -98,34 +91,56 @@ jobs: exit 1 fi - # bash, and an array rather than a flat string, because publisherName must - # match the certificate subject exactly and legal names contain spaces. - name: Build Windows app + run: npm run build:win -- --publish never + + # SignPath signs out of band rather than inside electron-builder: it pulls + # the artifact from this workflow run by id, signs it on its own HSM, and + # hands the signed file back. This upload exists only to give it something + # to fetch — one day of retention, and it is not the release artifact. + - name: Upload unsigned installer for signing + id: unsigned + if: steps.signing.outputs.enabled == 'true' + uses: actions/upload-artifact@v4 + with: + name: openscreen-windows-unsigned + path: release/**/Openscreen.Setup.*.exe + if-no-files-found: error + retention-days: 1 + + - name: Sign installer via SignPath + if: steps.signing.outputs.enabled == 'true' + uses: SignPath/github-action-submit-signing-request@v2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }} + github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }} + output-artifact-directory: signed + # SignPath Foundation release policies require a human to approve every + # signing request. The action's 600 s default expires while the approver + # is still reading the notification mail, which fails the release build + # for no reason other than human latency. + wait-for-completion-timeout-in-seconds: 3600 + + # Put the signed binary back where the build left the unsigned one, so the + # upload step below stays a single unconditional path and the published + # artifact keeps its name and shape whether or not signing ran. + - name: Swap in the signed installer + if: steps.signing.outputs.enabled == 'true' shell: bash - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - WIN_SIGN_ENDPOINT: ${{ secrets.WIN_SIGN_ENDPOINT }} - WIN_SIGN_ACCOUNT_NAME: ${{ secrets.WIN_SIGN_ACCOUNT_NAME }} - WIN_SIGN_CERT_PROFILE: ${{ secrets.WIN_SIGN_CERT_PROFILE }} - WIN_SIGN_PUBLISHER_NAME: ${{ secrets.WIN_SIGN_PUBLISHER_NAME }} run: | - args=(--publish never) - if [[ "${{ steps.signing.outputs.enabled }}" == "true" ]]; then - args+=( - "--config.win.azureSignOptions.endpoint=$WIN_SIGN_ENDPOINT" - "--config.win.azureSignOptions.codeSigningAccountName=$WIN_SIGN_ACCOUNT_NAME" - "--config.win.azureSignOptions.certificateProfileName=$WIN_SIGN_CERT_PROFILE" - "--config.win.azureSignOptions.publisherName=$WIN_SIGN_PUBLISHER_NAME" - ) - fi - npm run build:win -- "${args[@]}" - - # electron-builder does not fail the build when signing no-ops, so the only - # way to know the artifact is actually signed is to look at the artifact. - # The macOS job carries the same check for the same reason: a bundle that - # nothing had signed shipped once, and nothing caught it. + signed="$(find signed -type f -name 'Openscreen.Setup.*.exe' -print -quit)" + target="$(find release -type f -name 'Openscreen.Setup.*.exe' -print -quit)" + [[ -n "$signed" ]] || { echo "::error::SignPath returned no installer."; exit 1; } + [[ -n "$target" ]] || { echo "::error::No build output to replace."; exit 1; } + cp -f "$signed" "$target" + echo "Replaced $target with the signed build." + + # Assert on the artifact, never on the pipeline having run: a signing step + # that quietly no-ops still exits 0. The macOS job carries the equivalent + # check because an unsigned bundle shipped unnoticed once. - name: Verify installer signature if: steps.signing.outputs.enabled == 'true' shell: pwsh @@ -139,9 +154,9 @@ jobs: if ($sig.Status -ne 'Valid') { throw "Installer signature is '$($sig.Status)', expected 'Valid'." } - # An untimestamped signature stops validating the day the certificate - # expires. Trusted Signing certificates are short-lived by design, so - # this is not a hypothetical. + # Without a timestamp the signature stops validating the day the + # certificate expires, retroactively invalidating every release already + # in users' hands. if (-not $sig.TimeStamperCertificate) { throw 'Installer is signed but not timestamped.' } diff --git a/CODE_SIGNING_POLICY.md b/CODE_SIGNING_POLICY.md new file mode 100644 index 000000000..6277f749f --- /dev/null +++ b/CODE_SIGNING_POLICY.md @@ -0,0 +1,85 @@ +# Code signing policy + +Windows release binaries of OpenScreen are signed. This page documents who can +change the code that gets signed, who can authorise a signature, and what the +signature does and does not tell you. + +Free code signing is provided by [SignPath.io](https://signpath.io/), with a +certificate issued by the [SignPath Foundation](https://signpath.org/). + +## What is signed + +The Windows installer (`Openscreen.Setup..exe`) published on the +[GitHub releases page](https://github.com/getopenscreen/openscreen/releases). + +The Microsoft Store package is **not** signed with this certificate — Microsoft +re-signs Store submissions during certification, so Store installs carry +Microsoft's signature instead. + +macOS builds are signed and notarised separately with an Apple Developer ID. + +## Roles + +| Role | Who | What they may do | +|---|---|---| +| Committer | Etienne Lescot ([@EtienneLescot](https://github.com/EtienneLescot)) | Push to the repository and merge pull requests. | +| Reviewer | Etienne Lescot | Review pull requests before merge. | +| Approver | Etienne Lescot | Approve a signing request in SignPath. | + +OpenScreen currently has a single maintainer, so these roles are held by one +person. This page is updated if that changes. + +All accounts with commit or signing access use multi-factor authentication, on +both GitHub and SignPath. + +## How signing works + +Signing is not performed on a developer machine and no maintainer ever holds the +private key — it stays on SignPath's HSM and is never issued to us. + +1. A tagged release triggers the `Build Electron App` workflow on GitHub Actions. +2. The workflow builds the installer from the tagged source and uploads it as a + workflow artifact. +3. SignPath retrieves that artifact directly from the workflow run, verifying it + came from this repository's CI rather than from an uploaded file. +4. The maintainer approves the signing request in the SignPath dashboard. +5. The signed installer is returned to the workflow, its signature is verified, + and it is published as the release artifact. + +Because the artifact is pulled from the workflow run rather than submitted by +hand, a signature attests that the binary was built by this repository's CI from +tagged source. + +## What the signature means + +It confirms that the installer was produced by this project and has not been +modified since it was signed. Windows shows a verified publisher instead of +"Unknown publisher". + +It is **not** a security audit, a warranty, or a guarantee that the software is +free of defects. OpenScreen is MIT-licensed and provided as is, without warranty +of any kind; see [LICENSE](LICENSE). The SignPath Foundation accepts no liability +for signed software. + +Note that this is an OV certificate, not EV. Microsoft SmartScreen builds +reputation for a signing identity over time, so recent releases may still show a +warning until enough downloads have accumulated. Choosing "More info" then "Run +anyway" is expected in that window; the publisher name shown should read as +documented above. + +## Privacy + +OpenScreen requires no account and performs no telemetry. Recordings, edits and +automatic captions are processed entirely on the user's device. The one network +request the application makes is downloading the speech-to-text model on first +use of automatic captions. + + + +## Reporting a problem + +If you believe a signed OpenScreen binary is malicious or has been tampered +with, open an issue at +[getopenscreen/openscreen/issues](https://github.com/getopenscreen/openscreen/issues). diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index 6f3e4f03d..67adb1dbb 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -88,23 +88,26 @@ Rotate the certificate by exporting a replacement P12, base64-encoding it withou ## Windows signing -`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. +`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. | Secret | Purpose | |---|---| -| `AZURE_TENANT_ID` | Entra ID tenant of the service principal. Name fixed by the Azure SDK, not chosen here. | -| `AZURE_CLIENT_ID` | Service principal application ID. | -| `AZURE_CLIENT_SECRET` | Service principal secret. | -| `WIN_SIGN_ENDPOINT` | Trusted Signing account endpoint, region-specific (e.g. `https://weu.codesigning.azure.net`). | -| `WIN_SIGN_ACCOUNT_NAME` | Trusted Signing account name. | -| `WIN_SIGN_CERT_PROFILE` | Certificate profile name inside that account. | -| `WIN_SIGN_PUBLISHER_NAME` | Certificate subject, character for character. Mismatches here do not fail the signing call, they fail signature *verification* later. | +| `SIGNPATH_API_TOKEN` | SignPath REST API token used to submit the signing request. | +| `SIGNPATH_ORGANIZATION_ID` | SignPath organization ID. | +| `SIGNPATH_PROJECT_SLUG` | SignPath project slug. | +| `SIGNPATH_SIGNING_POLICY_SLUG` | Signing policy to apply — typically `release-signing`, which requires manual approval. | -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. +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. -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. +**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`. -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. +**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. + +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. + +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. + +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. ## Discord secrets and variables