Desktop code signing and notarisation
Feature: 054-desktop-runtime-shell (T005, T006, T007) · Spec: FR-072 … FR-079
Two certificates and two signing keys. Both certificates have weeks of lead time that nobody here controls, which is why they are the first tasks in the feature and not the last: an unsigned installer cannot be piloted, and the work that depends on them cannot start late.
Status
| Item | Owner | Status | Started | Notes |
|---|---|---|---|---|
| Apple Developer ID Application certificate | Requires the Apple Account Holder | ☐ Not started | — | Creation needs the Account Holder, or a suitably authorised admin under a cloud-managed exception |
| Windows organisation code-signing identity | Fixx, S.A. | ☐ Not started | — | Azure Trusted Signing (~$10/month). The workflow is wired for it; only the Azure resource + service principal are outstanding. Identity validation takes one to two weeks |
Doppler config flowpos/prd_release_web (web bundle key) | Ops | ☑ Exists | verified in Block A | Holds WEB_BUNDLE_SIGNING_KEY |
Doppler config flowpos/prd_release_shell (native update key) | Ops | ☑ Exists | verified in Block A | Holds TAURI_UPDATER_SIGNING_KEY |
GitHub Environments release-web / release-shell | Ops — one action left | ◐ Workflows wired, environments not yet created | 2026-09-06 | Both workflows now declare their environment and check-signing-key-separation.mjs fails if either drops it. What remains is creating the two environments and putting each key in one — see below |
Update this table as each is requested and issued. It is the only place the state of these four items is recorded.
Why the keys are separate (FR-073)
Web bundles are signed with WEB_BUNDLE_SIGNING_KEY; native updates with
TAURI_UPDATER_SIGNING_KEY. They live in different Doppler scopes, and the CI job
that publishes web bundles has no access to the native key.
Shipping native code to a merchant's till is a far stronger privilege than shipping a web bundle: one is a page, the other is a process with the machine's own permissions. A compromise of the web deployment pipeline — by far the more frequently exercised and the more broadly permissioned of the two — must not be able to authorise a native binary update. Neither key is ever in the repository (FR-074).
What is left to do, exactly (T007a)
This project syncs Doppler into GitHub secrets rather than calling the Doppler CLI from CI, so the scope that CI actually sees is a GitHub Environment. The workflows are wired; two environments need creating.
In Settings → Environments, create:
| Environment | Holds | Used by |
|---|---|---|
release-web | WEB_BUNDLE_SIGNING_KEY (from flowpos/prd_release_web) | deploy-production.yml → publish-desktop-bundle |
release-shell | TAURI_UPDATER_SIGNING_KEY and its password, the Apple certificate and notarisation credentials, the Windows certificate (from flowpos/prd_release_shell) | desktop-release.reusable.yml → release, called by desktop-release-production.yml |
release-web-staging | WEB_BUNDLE_SIGNING_KEY and the staging VITE_* values (from flowpos/stg_release_web) | deploy-staging-from-main.yml → publish-desktop-bundle |
release-shell-staging | The staging updater keypair and the staging VITE_* values (from flowpos/stg_release_shell) | desktop-release.reusable.yml → release, called by desktop-release-staging.yml |
The -staging environments hold their own keypairs, not copies. A staging build is
therefore never signed by the trust root merchants rely on, and the staging key can never
produce something that passes for a release. They exist as separate environments and not
as extra secrets in release-shell because a job may declare exactly one environment,
and the staging channel needs different VITE_* values — the API address above all,
which is baked into the merchant UI at build time and cannot be repointed afterwards.
They also need no code-signing certificate: the unsigned exception below covers them, so
release-shell-staging can be created with an updater keypair and the staging VITE_*
values alone.
Then remove both signing keys from repository-level secrets. That is the step that
does the work: a repository secret is visible to every job, so leaving one there makes
the environments decorative — the bundle job would still be able to read the native key
without ever declaring release-shell.
Why both a check and a scope
scripts/check-signing-key-separation.mjs proves no job references both keys, and
that every job touching one declares the environment holding it. The environment is
what makes the other key genuinely unreadable.
Neither alone is enough. A static check is one edit away from being wrong; a scope nobody asserts is one console click away from being widened, silently, by somebody solving an unrelated permissions problem. The check runs on every desktop and release workflow run, and fails with the file, the job and the expected environment named.
Why the separation matters at all
Shipping native code to a merchant's till is a far stronger privilege than shipping a web bundle: one is a page inside a WebView that can only reach the bridge, the other is a process with the machine's own permissions. The web pipeline runs several times a day and is broadly permissioned by necessity. Compromising it must not be able to authorise an executable.
macOS
- Sign with Developer ID Application, then notarise with Apple's notary service in CI and staple the ticket. This is an automated scan, not App Review: no guidelines, no protocol strings, no privacy manifest questions.
- Notarisation must be in CI from the first build. An unnotarised
.dmgreports "is damaged and can't be opened" on a merchant's Mac — which is not a diagnosis any merchant can be expected to make, and not a failure mode worth discovering during a pilot. - Distributed as a
.dmgfrom flowandgrow.tech. No App Store.
Windows
Signed with Azure Trusted Signing under the Fixx, S.A. organisation identity. NSIS, bundling the WebView2 evergreen bootstrapper so a stripped-down POS image still installs. Distributed from flowandgrow.tech, never from the GitHub release — see Where installers live.
Why Azure Trusted Signing and not a .pfx
The workflow used to expect WINDOWS_CERTIFICATE, a base64 .pfx. That path could not
have worked with a certificate bought today: since the 2023 CA/B Forum baseline
requirements an OV code-signing key must live on FIPS 140-2 Level 2 hardware — a token
or an HSM — so no CA will issue a downloadable .pfx any more. Azure Trusted Signing
keeps the key in Azure and signs over an API, which is the only shape that fits a CI
runner without a person plugging in a token.
How it is wired
bundle.windows.signCommand invokes trusted-signing-cli, which Tauri runs for every
artefact it produces — including flowpos-desktop.exe before NSIS packs it. Signing
only the finished installer would leave the executable inside it unsigned, and that
executable is the one Defender and SmartScreen judge when the merchant runs the till.
The config carrying signCommand is generated, by
apps/desktop/scripts/windows-sign-config.mjs, and merged with --config in CI only.
Two reasons it is not committed:
signCommandis unconditional. Intauri.conf.jsonit would break every localpnpm buildon a machine with no Azure credentials.- Tauri spawns the sign command directly, not through
cmd.exe, so a committed%AZURE_ACCOUNT%placeholder is passed to the signer as literal text. The values have to be literal by the time Tauri reads them.
The generator refuses to write anything if any of the six inputs is missing, and its
error says which half is missing — Azure resource names (workflow variables) or the
service principal (the release-shell environment) — because that decides who fixes it.
What to configure
All six go into Doppler on prd_release_shell, which syncs into the release-shell
GitHub Environment:
| Name | What it is |
|---|---|
AZURE_CODE_SIGNING_ENDPOINT | e.g. https://eus.codesigning.azure.net |
AZURE_CODE_SIGNING_ACCOUNT | the Trusted Signing account name |
AZURE_CODE_SIGNING_PROFILE | the certificate profile name |
AZURE_TENANT_ID | service principal tenant |
AZURE_CLIENT_ID | service principal app id |
AZURE_CLIENT_SECRET | service principal secret |
The first three are Azure resource identifiers rather than secrets, but they travel the
same path as everything else here: Doppler is the source of truth, so they are secrets
by transport, not by sensitivity. AZURE_CLIENT_SECRET authorises signing an executable
and is covered by check-signing-key-separation.mjs exactly as the updater key is.
SmartScreen
Reputation accrues to a consistent certificate over time, not to a company and not to a file. This is the reason not to change certificates casually, and the reason the unsigned pilot below is time-boxed: every unsigned build is a release that earns no reputation at all.
The non-production unsigned exception
Production is never excused. An unsigned installer reaching a merchant warns them and teaches them to click through, on the application that handles their money.
The internal and staging channels may be excused, because a certificate takes one
to two weeks of organisation validation and a pilot that cannot start until it clears is
a pilot that starts two weeks late — and staging never gets a certificate at all, since
nothing outside the team installs it. Set it in Doppler, on the config backing that
channel's environment:
# internal, which builds from release-shell
doppler secrets set FLOWPOS_ALLOW_UNSIGNED_INTERNAL=true \
--project flowpos --config prd_release_shell
# staging, which builds from release-shell-staging
doppler secrets set FLOWPOS_ALLOW_UNSIGNED_INTERNAL=true \
--project flowpos --config stg_release_shell
The secret keeps its _INTERNAL name because it is already provisioned under it; the
condition the workflow tests is "not production", which is the rule it always encoded.
A secret, not a GitHub variable. Configuration in this project lives in Doppler and
is synced into GitHub Environments, and that sync produces secrets — a vars. read
would resolve to an empty string on a correctly configured repository, which is the
worst way for this particular switch to fail.
It is deliberately awkward: it must be set on purpose, it never applies to production,
and the default with nothing set is still a hard failure. An unsigned build
announces itself in the run log, the job summary, and the published manifest's
signed: false — so it is never quietly indistinguishable from a signed one.
Delete the variable the day the certificate is issued.
Windows will still show "Windows protected your PC" on an unsigned build. The merchant must click More info → Run anyway. That is a real cost, taken knowingly, for a pilot — not a thing to normalise.
Where installers live
tauri-action publishes to GitHub Releases. This repository is private, so those
assets need a GitHub account with access to download — which no merchant has. Without a
further step the release is complete and unreachable.
So the release workflow also publishes to GCS:
{channel}/{platform}/{version}/{file} the installer
{channel}/{platform}/{version}/manifest.json digest, size, signature status
{channel}/{platform}/latest.json the pointer the download page reads
The pointer is a small JSON document, not a mutable copy of the installer at a stable
path. A mutable binary would break the immutability rule the publisher enforces — and
invisibly: the bytes at latest.exe would change with no version, digest or record
saying they had. apps/landing-page/src/content/{en,es}/download.ts currently has empty
href values; they should resolve through latest.json.
The publisher (apps/desktop/scripts/publish-installer.ts) enforces two rules, both
tested without a bucket:
- An unsigned installer may never reach the production channel.
- A published version is immutable — same version, different bytes is refused; same version, identical bytes is a retried CI job and a no-op.
INSTALLER_SIGNED has no default. It carries the signature status the workflow
observed, and the publisher accepts only the literal true or false: a default either
records an unverified artefact as verified, or trains everyone to ignore the field.
Rotation
Extends the 051 certificate-rotation runbook with a desktop section rather than duplicating it. Two keys means two rotation paths; the CI separation between the web-bundle publisher and the desktop release publisher is enforced by Doppler scope, never by convention.
Related
- Native Hardware Runtime — shared bridge with mobile; capabilities beyond printing
- Certificate rotation — 051 runbook this page extends
- Spec:
054-desktop-runtime-shellFR-072 … FR-079