feat(desktop): verify signed update manifest + binary hash before self-replace [operator-action] (LC-210-BINARY-INTEGRITY) #280

Merged
longjacksonle merged 1 commit from feat/lc-210-binary-integrity-manifest-signing into main 2026-05-31 04:06:27 +02:00

Closes #277 (LC-210-BINARY-INTEGRITY). Implements the deferral from LC-210 (#276): the SSRF guard made the update fetch safe, but the downloaded artifact was still unverified. A redirect to a public attacker host, a compromised mirror, or a TLS-trust break would serve a binary that update::apply would chmod +x and self_replace into the running process. This PR adds authenticity + integrity verification, fail-closed.

Chain of trust

A build-embedded Ed25519 public key verifies a detached signature (latest.json.sig) over the raw manifest bytes BEFORE the JSON is parsed. The now-signed manifest carries a per-artifact SHA-256. The downloaded binary is hashed and compared against that signed value BEFORE anything is written or the running binary is replaced. An attacker who swaps the binary OR rewrites the hash fails verification, because the hash lives inside the signed manifest.

Client (desktop/src/update_verify.rs, new)

  • verify_manifest_signature uses verify_strict (rejects malleable signatures and small-order public keys), returning a NotConfigured fail-closed error when no key is embedded (the state before the first signed release).
  • verify_artifact_sha256 compares decoded-byte digests (case-insensitive hex).
  • update::fetch_manifest now fetches manifest + signature and verifies before parsing, so BOTH the GUI background check and --update refuse an unsigned/unverifiable manifest.
  • update::apply buffers the download (capped to refuse an oversized/hostile body so we do not OOM hashing it), verifies the hash, then writes.
  • 7 unit tests: good signature, tampered manifest, wrong key, hash match/mismatch, malformed key/sig/hash (rejected not panicked), and the fail-closed default. just test-desktop: 13 passed (7 new + 6 existing net_guard).

Release pipeline (publish-release.yml)

  • Manifest gains per-artifact hash sha256.
  • New Sign update manifest step signs latest.json with openssl pkeyutl -sign -rawin using the DESKTOP_UPDATE_SIGNING_KEY secret and publishes latest.json.sig; it refuses to ship an unsigned manifest if the secret is missing.
  • Both desktop Dockerfiles thread LETS_CHAT_UPDATE_PUBLIC_KEY (the non-secret public key, a repo/org variable) into the build so the shipped binary enforces verification.

Operator action

Before the next desktop release, provision update signing or the updater fails closed and the publish workflow refuses to ship. Generate an Ed25519 keypair, set the DESKTOP_UPDATE_SIGNING_KEY secret (private PEM) and the DESKTOP_UPDATE_PUBLIC_KEY variable (public key hex). Full runbook, including key rotation, in docs/desktop-update-signing.md. Security fix: upgrade promptly once a signed release is cut; the vector is an unverified binary served via update-mirror compromise or a redirect to a public attacker host.

What this PR does NOT close

  • Downgrade / rollback signing floor: a signed-but-older manifest still verifies (is_newer only advertises strictly-higher versions, but there is no anti-rollback floor).
  • Transparency log / key revocation, and a multi-key keyring. Single embedded key with a documented rotation dance. These are noted as future work in docs/desktop-update-signing.md.

Validation

just test-desktop 13/13. just check clean: server standalone + saas clippy (-D warnings), desktop clippy (-D warnings), cargo fmt --check. No server source touched, so server suites are unaffected. The CI signing/build-arg paths cannot be exercised here (no runner, no provisioned key); they are implemented to the documented contract and gated by the fail-closed secret check.

🤖 Generated with Claude Code

Closes #277 (LC-210-BINARY-INTEGRITY). Implements the deferral from LC-210 (#276): the SSRF guard made the update *fetch* safe, but the downloaded artifact was still unverified. A redirect to a public attacker host, a compromised mirror, or a TLS-trust break would serve a binary that `update::apply` would `chmod +x` and `self_replace` into the running process. This PR adds authenticity + integrity verification, fail-closed. ## Chain of trust A build-embedded Ed25519 public key verifies a detached signature (`latest.json.sig`) over the raw manifest bytes BEFORE the JSON is parsed. The now-signed manifest carries a per-artifact SHA-256. The downloaded binary is hashed and compared against that signed value BEFORE anything is written or the running binary is replaced. An attacker who swaps the binary OR rewrites the hash fails verification, because the hash lives inside the signed manifest. ## Client (`desktop/src/update_verify.rs`, new) - `verify_manifest_signature` uses `verify_strict` (rejects malleable signatures and small-order public keys), returning a `NotConfigured` fail-closed error when no key is embedded (the state before the first signed release). - `verify_artifact_sha256` compares decoded-byte digests (case-insensitive hex). - `update::fetch_manifest` now fetches manifest + signature and verifies before parsing, so BOTH the GUI background check and `--update` refuse an unsigned/unverifiable manifest. - `update::apply` buffers the download (capped to refuse an oversized/hostile body so we do not OOM hashing it), verifies the hash, then writes. - 7 unit tests: good signature, tampered manifest, wrong key, hash match/mismatch, malformed key/sig/hash (rejected not panicked), and the fail-closed default. `just test-desktop`: 13 passed (7 new + 6 existing net_guard). ## Release pipeline (`publish-release.yml`) - Manifest gains per-artifact `hash sha256`. - New `Sign update manifest` step signs `latest.json` with `openssl pkeyutl -sign -rawin` using the `DESKTOP_UPDATE_SIGNING_KEY` secret and publishes `latest.json.sig`; it refuses to ship an unsigned manifest if the secret is missing. - Both desktop Dockerfiles thread `LETS_CHAT_UPDATE_PUBLIC_KEY` (the non-secret public key, a repo/org variable) into the build so the shipped binary enforces verification. ## Operator action Before the next desktop release, provision update signing or the updater fails closed and the publish workflow refuses to ship. Generate an Ed25519 keypair, set the `DESKTOP_UPDATE_SIGNING_KEY` secret (private PEM) and the `DESKTOP_UPDATE_PUBLIC_KEY` variable (public key hex). Full runbook, including key rotation, in `docs/desktop-update-signing.md`. Security fix: upgrade promptly once a signed release is cut; the vector is an unverified binary served via update-mirror compromise or a redirect to a public attacker host. ## What this PR does NOT close - Downgrade / rollback signing floor: a signed-but-older manifest still verifies (`is_newer` only advertises strictly-higher versions, but there is no anti-rollback floor). - Transparency log / key revocation, and a multi-key keyring. Single embedded key with a documented rotation dance. These are noted as future work in `docs/desktop-update-signing.md`. ## Validation `just test-desktop` 13/13. `just check` clean: server standalone + saas clippy (`-D warnings`), desktop clippy (`-D warnings`), `cargo fmt --check`. No server source touched, so server suites are unaffected. The CI signing/build-arg paths cannot be exercised here (no runner, no provisioned key); they are implemented to the documented contract and gated by the fail-closed secret check. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(desktop): verify signed update manifest + binary hash before self-replace [operator-action] (LC-210-BINARY-INTEGRITY, #277)
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 3s
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Successful in 2m25s
Create release / Create release from merged PR (pull_request) Has been skipped
54c1fca2ad
LC-210 SSRF-guarded the self-updater's fetch, but the SSRF guard does not make the downloaded bytes trustworthy: a redirect to a public attacker host, a compromised mirror, or a TLS-trust break would still serve a binary that `update::apply` would `chmod +x` and `self_replace` into the running process. This adds the authenticity + integrity layer the LC-210 PR explicitly deferred (#277).

Chain of trust: a build-embedded Ed25519 public key verifies a detached signature (`latest.json.sig`) over the raw manifest bytes before the JSON is parsed; the now-signed manifest carries a per-artifact SHA-256; the downloaded binary is hashed and compared against that signed value before anything is written or replaced. Both checks fail closed.

Client (`desktop/src/update_verify.rs`, new): `verify_manifest_signature` (verify_strict, rejecting malleable sigs / small-order keys) and `verify_artifact_sha256`, with a fail-closed `NotConfigured` when no key is embedded (the pre-first-signed-release state). `update::fetch_manifest` fetches manifest + signature and verifies before parsing; `update::apply` buffers the download (capped to refuse an oversized/hostile body), verifies the hash, then writes. 7 unit tests cover good signature, tampered manifest, wrong key, hash match/mismatch, malformed inputs, and the fail-closed default.

Release pipeline (`publish-release.yml`): the manifest gains per-artifact `hash sha256`; a new sign step uses `openssl pkeyutl -sign -rawin` with the `DESKTOP_UPDATE_SIGNING_KEY` secret to publish `latest.json.sig`, and refuses to ship an unsigned manifest if the secret is missing. The two desktop Dockerfiles thread `LETS_CHAT_UPDATE_PUBLIC_KEY` (the non-secret public key, a repo/org variable) into the build so the shipped binary enforces verification.

Docs: `docs/desktop-update-signing.md` (threat model, keygen, CI provisioning, key-rotation path, local-fixture testing, out-of-scope downgrade/keyring); env tables in CLAUDE.md + README and the releasing.md desktop bullet updated.

Out of scope (noted in the doc): downgrade/rollback signing floor, transparency log / key revocation, and a multi-key keyring. Single embedded key with a documented rotation dance.

Operator-Action: before cutting the next desktop release, provision update signing or the updater fails closed and the publish workflow refuses to ship: generate an Ed25519 keypair, set the `DESKTOP_UPDATE_SIGNING_KEY` secret (private PEM) and the `DESKTOP_UPDATE_PUBLIC_KEY` variable (public key hex) per docs/desktop-update-signing.md. Security fix: upgrade promptly once a signed release is cut; vector is an unverified binary served via update-mirror compromise or a redirect to a public attacker host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/lc-210-binary-integrity-manifest-signing 2026-05-31 04:06:27 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!280
No description provided.