Skip to content
Closed
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
109 changes: 109 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand Down Expand Up @@ -49,9 +52,115 @@ 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.
#
# 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-onboarding builds get.
- name: Resolve Windows signing
id: signing
shell: bash
env:
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=(SIGNPATH_API_TOKEN SIGNPATH_ORGANIZATION_ID
SIGNPATH_PROJECT_SLUG SIGNPATH_SIGNING_POLICY_SLUG)
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 SignPath."
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

- 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
run: |
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
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'."
}
# 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.'
}

- name: Upload Windows installer
uses: actions/upload-artifact@v4
with:
Expand Down
85 changes: 85 additions & 0 deletions CODE_SIGNING_POLICY.md
Original file line number Diff line number Diff line change
@@ -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.<version>.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.
Comment on lines +40 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect workflow triggers and any ref checks around the signing step.
sed -n '1,175p' .github/workflows/build.yml
rg -n -C5 'workflow_dispatch|refs/tags|github\.ref|Sign installer via SignPath' .github/workflows

Repository: getopenscreen/openscreen

Length of output: 29484


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the macOS signing job, Windows Store signing job, and release-job gating for ref/tag checks.
sed -n '170,250p' .github/workflows/build.yml
sed -n '250,400p' .github/workflows/build.yml
sed -n '400,510p' .github/workflows/build.yml
sed -n '570,620p' .github/workflows/build.yml

# Verify local YAML event expression forms do not enforce ref tag constraints besides the publish/release step.
python3 - <<'PY'
from pathlib import Path
p = Path('.github/workflows/build.yml')
text = p.read_text()
events = []
for line in text.splitlines():
    line = line.strip()
    if line.startswith(('push:', 'release:', 'workflow_dispatch:', 'schedule:', 'pull_request:')):
        events.append(line)
print("workflow event declarations in build.yml:")
for e in events:
    print("-", e)
print("contains refs/tags check:", "refs/tags" in text)
print("macOS signing step names:", [line.strip() for line in text.splitlines() if "Signing" in line or "notarize" in line.lower() or "SignPath" in line])
PY

Repository: getopenscreen/openscreen

Length of output: 19954


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Confirm manual dispatch has no release-specific trigger constraint and inspect Linux build context.
sed -n '1,15p' .github/workflows/build.yml
sed -n '200,240p' .github/workflows/build.yml
sed -n '500,585p' .github/workflows/build.yml

Repository: getopenscreen/openscreen

Length of output: 5168


Require an immutable tag before signing.

build.yml runs from any manual dispatch to build-windows and build-macos, and those jobs sign when secrets are present. The release gate accepts workflow_dispatch with any release_tag, even if that tag has no refs/tags/ ref. Gate signing on github.ref == "refs/tags/${input_release_tag}" or amend the policy to state that manual-dispatch releases are not tagged-source attestations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CODE_SIGNING_POLICY.md` around lines 40 - 51, The code-signing policy must
require signing workflows to run for an immutable tag matching the requested
release tag. Update the release gate in build.yml to allow signing only when
github.ref equals refs/tags/${input_release_tag}, or explicitly revise the
policy to exclude manual-dispatch releases from tagged-source attestations.


## 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.

<!-- TODO(maintainer): link the published privacy policy URL here — the SignPath
Foundation conditions require this page to reference it, and the same URL is
already declared in the Microsoft Store submission. -->
Comment on lines +77 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Replace the required privacy-policy placeholder.

This TODO leaves the required privacy-policy link absent. The text states that the SignPath Foundation requires this reference. Add the maintained public URL before using this policy for SignPath onboarding or renewal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CODE_SIGNING_POLICY.md` around lines 77 - 79, Replace the TODO placeholder in
the policy document with the maintained public privacy-policy URL, reusing the
same URL declared for the Microsoft Store submission. Ensure the URL is
presented as an explicit privacy-policy reference before SignPath onboarding or
renewal.


## 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).
23 changes: 23 additions & 0 deletions technical-documentation/engineering/release-and-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,29 @@ 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 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 |
|---|---|
| `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. |

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.

**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`.

**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

| Name | Kind | Used for |
Expand Down
Loading