Saltar al contenido principal

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​

ItemOwnerStatusStartedNotes
Apple Developer ID Application certificateRequires 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 identityFixx, 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☑ Existsverified in Block AHolds WEB_BUNDLE_SIGNING_KEY
Doppler config flowpos/prd_release_shell (native update key)Ops☑ Existsverified in Block AHolds TAURI_UPDATER_SIGNING_KEY
GitHub Environments release-web / release-shellOps — one action left◐ Workflows wired, environments not yet created2026-09-06Both 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:

EnvironmentHoldsUsed by
release-webWEB_BUNDLE_SIGNING_KEY (from flowpos/prd_release_web)deploy-production.yml → publish-desktop-bundle
release-shellTAURI_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-stagingWEB_BUNDLE_SIGNING_KEY and the staging VITE_* values (from flowpos/stg_release_web)deploy-staging-from-main.yml → publish-desktop-bundle
release-shell-stagingThe 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 .dmg reports "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 .dmg from 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:

  • signCommand is unconditional. In tauri.conf.json it would break every local pnpm build on 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:

NameWhat it is
AZURE_CODE_SIGNING_ENDPOINTe.g. https://eus.codesigning.azure.net
AZURE_CODE_SIGNING_ACCOUNTthe Trusted Signing account name
AZURE_CODE_SIGNING_PROFILEthe certificate profile name
AZURE_TENANT_IDservice principal tenant
AZURE_CLIENT_IDservice principal app id
AZURE_CLIENT_SECRETservice 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:

  1. An unsigned installer may never reach the production channel.
  2. 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.