Skip to content

feat(desktop): verify the signed update manifest; notify-only check state (#477 3.2a) - #529

Merged
BunsDev merged 4 commits into
mainfrom
feat/desktop-update-verifier
Oct 3, 2026
Merged

BunsDev merged 4 commits into
mainfrom
feat/desktop-update-verifier

Conversation

@BunsDev

@BunsDev BunsDev commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Summary

Desktop half of #477 item 3.2, split in two because the full change is well over 800 non-generated lines. This is part (a), the Rust verifier, app-scoped state and commands. This PR has no UI. Part (b), the banner, is #530, stacked on this branch.

  • src-tauri/src/update_manifest.rs: a pure, byte-for-byte port of verifyManifest in scripts/update-manifest.mjs. It includes a small JSON reader that models JSON.parse/JSON.stringify exactly: strings as UTF-16 code units with lone surrogates allowed, numbers as doubles with safe integers only, the last value winning for a duplicate key, and keys sorted by UTF-16 code unit. That way canonical re-serialization equality matches the reference on edge cases, not only on well-formed input.
  • src-tauri/src/update_check.rs: fetching, bounded states, app-scoped persisted state, the scheduler, and four main-webview-only Tauri commands.
  • Cross-implementation vectors: pnpm generate:update-manifest-vectors has the Node reference sign 44 cases and records the reference's outcome for each. The Rust tests require identical outcomes, and a vitest test fails on drift.
  • docs/RELEASE.md § "Desktop update check".

Owner decisions implemented (2026-10-03)

  • Notify only. No install, no DMG download, no relaunch, no exit. The only network requests are two GETs for small JSON files.
  • Never restart.
  • Cask unchanged. Brew stays the installer, and this PR does not touch the tap or the Cask.

Inert until a key exists

release/update-manifest-keys.json is compiled into the binary with include_bytes! in update_check.rs. build.rs is not involved; it only registers the new commands. While current is null, which is the checked-in value, check_once returns disabled before the fetcher factory runs. No HTTP client is constructed and no request is made. A test asserts this with an injected factory that counts constructions. Checks are also disabled on Windows and Linux, where no update is published (the HTTP client is compiled only for macOS), and in the acceptance profile, which also keeps no state file. A malformed keys file does not fail the build. The built app treats it as having no key, so it stays inert with state disabled. The Rust test embedded_release_keys_file_parses fails in CI if the checked-in file stops parsing.

States

State When
disabled No current key, unsupported platform, or acceptance profile. No request.
off The user turned checks off (update_set_checks_enabled(false)).
unavailable app_data_dir() could not be resolved. A bounded error is logged, nothing is stored, and no check runs.
idle / checking No check has finished in this session yet, or one is running.
unreachable Transport error, timeout, or a non-2xx status.
oversize An asset was over 64 KiB. Checked in the fetcher (Content-Length and streamed) and again after it returns.
invalid_signature signature_malformed or signature_invalid.
unknown_key unknown_key_id.
non_canonical manifest_not_canonical.
malformed manifest_malformed, for example a schema failure or a tag that is not v<version>.
not_yet_valid / expired Outside published_at minus 300 s to expires_at.
not_newer Verified, but not strictly newer than the running version under stable semver. A running x.y.z-dev counts as older than x.y.z.
available Verified and newer.

Each failure ends the check in that state. Nothing else happens, and the next check backs off: 24 h doubles per consecutive failure, up to 7 days.

Persisted state

The app writes update-check.json in app_data_dir() (~/Library/Application Support/dev.opencoven.psyche), through a sibling temp file, fsync, then an atomic rename. It holds checks_enabled, last_check, last_outcome, skipped_version, last_seen_version and consecutive_failures. Loading is bounded to 16 KiB and reads field by field. A missing, mistyped, out-of-range or unknown value (for example a last_outcome written by a newer build before a downgrade) falls back to that field's default only. A valid checks_enabled: false therefore always survives. Corrupt or hostile values never reach the status the app reports. Schema: a missing or legacy schema loads as v1. A newer schema is read but never rewritten, and update_status reports storage: "newer_schema", so a downgrade cannot destroy a later release's state. storage is one of not_loaded, ok, newer_schema or unavailable. Each app instance checks on its own schedule. If two instances write the file, the atomic rename means the last writer wins and the file is never torn. Project configs are never read or written.

Post-upgrade: the first launch of a new version records it as last_seen_version. Only when the new version is strictly newer does it expose the previous value as upgraded_from; a downgrade reports nothing. Reconciling open project configs through the #464 gate is out of scope, because it needs the CLI-side config gate and is not trivial from the desktop crate.

Commands and capability

update_status, update_skip_version (accepted only for the version currently on offer), update_dismiss (in memory, tracked as the dismissed version and cleared only when a different version is offered) and update_set_checks_enabled. Each checks webview.label() == "main". The two preference commands return a bounded error, such as update preference not saved: PermissionDenied, when the save fails, and leave the in-memory choice unchanged. Failed saves of background check results are only logged. They are granted only by the new main-update-check capability (local: true, webviews: ["main"]), never by default and never to psyche-browser-*. tauriDesktopTabs.test.ts pins this.

Risk: R3/R4

The change touches src-tauri/**, adds network I/O, persistence, and dependencies.

Failure boundaries:

  • Verification order matches the reference: signature over raw bytes, then parse and canonical equality, then schema, then time. Nothing from an unverified manifest is parsed or shown.
  • A failed check never withdraws a verified update. After unreachable or a refused manifest, an update this session already verified, and its dismissal, are kept until that manifest's expires_at. Only available or not_newer replace them. last_outcome reports the failure.
  • Output stays bounded. The status (consumed by the banner in feat(desktop): notify-only update banner (#477 3.2b, stacked on #529) #530) contains only enum states, a validated stable version, a tag-derived URL from a fixed prefix, and the hex digest. Logs record only the reason name, or the slot and public key id.
  • Transport: https_only(true), at most 5 redirects, 10 s connect and 20 s total timeouts, a 64 KiB cap, and no cookies.
  • Signature check: verify_strict also refuses small-order points. For any honest signer it accepts exactly what Node/OpenSSL accept, and it fails closed on contrived inputs.
  • Key compromise: the worst case is a false or suppressed notice. Brew and the DMG checksums still guard installation, as documented in RELEASE.md.

New dependencies

  • ed25519-dalek = "2.2" (default-features = false, std): verify-only Ed25519. Widely used, maintained by dalek-cryptography. Nothing in the tree provided Ed25519.
  • reqwest = "0.13" (default-features = false, native-tls), macOS target only: reqwest 0.13.2 was already in Cargo.lock transitively. native-tls uses Apple's Security framework and system trust store, so no TLS stack is bundled. The OpenSSL and schannel entries in Cargo.lock belong to other targets and are not compiled on macOS. No other HTTP client existed in the crate.

Test vectors and keys

The vector keys are derived from fixed public labels so the file is byte-reproducible. They are test-only and publicly recomputable. vectors.json contains only public keys and signatures. The generator refuses to run, and a vitest test fails, if release/update-manifest-keys.json ever trusts one of them. No real key was generated, and no secret was touched.

The vectors cover:

  • valid signatures with the current key and the next key;
  • tampering: a flipped manifest byte, a flipped signature byte, a wrong key claiming the current id, an unknown key id;
  • malformed, non-canonical, wrong-algorithm and short envelopes;
  • non-canonical manifests: pretty-printed, unsorted keys, an escaped digit, missing or extra LF, 1.0, 1e0, an unsafe integer, -0, duplicate keys, and code-point vs UTF-16 key order;
  • malformed manifests: lone surrogate, BOM, invalid UTF-8, trailing garbage, empty input, array, extra field, tag mismatch, prerelease, schema 2, validity over 30 days, calendar overflow, 24:00:00, artifact name mismatch, uppercase digest;
  • time limits: expiry at the instant and 1 s before it, skew boundary ±1 s.

The Rust tests add every single-byte flip of the valid manifest and signature, a wrong-key set, no_trusted_keys, oversize, unreachable, not-newer, next-key acceptance, schedule and backoff, persistence round trip, corrupt-state handling, post-upgrade recording, the checks-off setting, and install-source detection.

Intentional differences:

  • A trusted small-order key (the identity point, with R = B and s = 1) is always refused by Rust's verify_strict as signature_invalid. Node's result depends on its OpenSSL build: v24.18.1 accepts it and v24.20.0 refuses it. It is therefore not a cross-implementation vector; the Rust-only test small_order_key_is_refused pins it.
  • A signed, non-canonical document nested more than 32 levels deep is manifest_malformed in Rust (the reader's stack bound) and manifest_not_canonical in Node. This is pinned by the vector deep_non_canonical_document, marked rust_expect.

Both suites assert that deep_non_canonical_document is the only rust_expect vector. The newer vectors also cover base64 whose only flaw is non-zero trailing bits (signature_malformed in both) and an out-of-range s = s + L (signature_invalid in both).

Mutation check: switching key sort to code-point order, or >= to > at expiry, makes the vector test fail.

Verification

Run at head a5565861cb4ee64b1b43b77a9dffd799a4282ebc:

  • cargo fmt --check, cargo test --locked (510 passed, 0 failed), cargo check --locked, all with the repo toolchain 1.95.0
  • pnpm --dir native/desktop/psyche-build-tauri build:web: no bundle drift
  • npx vitest run (throwaway HOME): 372 files and 5902 tests passed, 12 skipped
  • pnpm typecheck: pass
  • bash ./scripts/agent-check fast: pass

Not done: the real app was not launched against the network. Nothing can be observed end to end until a key is provisioned and a signed release exists. That is the G5 operator record in the design doc.

Review follow-ups (commit 7472341c)

  1. Lenient field-by-field state reading. Tested with {"checks_enabled":false,"last_outcome":"future_state"}, a negative counter, mistyped fields, unknown fields and a non-object.
  2. A failed check keeps a verified update and its dismissal until expiry. Tested, including withdrawal after expiry.
  3. upgraded_from only for a strictly newer version. Downgrade tested.
  4. Depth-cap difference documented and pinned by a vector.
  5. Vectors added for base64 trailing bits, out-of-range s, and a small-order key (intentional verify_strict difference).
  6. This description corrected: include_bytes! rather than build.rs, a malformed keys file leaves the build inert, no UI here. RELEASE.md now notes that app instances check independently.

Copilot thread follow-ups (commit 28f50964)

  • app_data_dir() failure: bounded log plus the unavailable state. Test: a_missing_app_data_directory_is_reported_and_runs_nothing.
  • Schema validation, and a newer schema never rewritten. Test: a_newer_schema_file_is_read_but_never_rewritten.
  • Save errors returned to user commands. Test: user_choices_report_a_failed_save_and_change_nothing.
  • Explicit dismissed version. Test: a_dismissal_lasts_for_its_version_only.
  • upgraded_from on a downgrade was already fixed in 7472341c.

CI fix (commit a5565861)

The vector freshness check failed in CI because generation threw on small_order_key_accepted_by_reference_only: CI's Node 24.20.0 refuses the small-order key that local Node 24.18.1 accepts. That vector was removed and replaced by a Rust-only test. Every remaining vector regenerates byte-identically under Node 20.10.0, 22, 24.18.1 and 24.20.0, and under different TZ and locale settings. The freshness check is unchanged.

Rollback

Revert this PR. No persisted format other than the new, app-scoped update-check.json is introduced, and an older build ignores that file. The feature is inert until a key is provisioned, so merging changes nothing observable today.

Refs #477

🤖 Generated with Claude Code

…tate

Adds the desktop half of outcome #477 item 3.2, verifier side. The app
embeds release/update-manifest-keys.json and, only when it names a current
key, fetches update-manifest.json and its .sig from the latest release over
HTTPS (64 KiB cap, timeouts), verifies them byte-for-byte as
scripts/update-manifest.mjs does, and reports one bounded state. It never
executes, downloads a DMG, installs or restarts anything. With current: null
(today) no HTTP client is constructed and the state is disabled.

- update_manifest.rs: pure verifier with a JavaScript-semantics canonical
  JSON reader; signature before parse, canonical bytes, schema, key slot,
  validity window with 300 s skew, stable-semver "newer" check.
- update_check.rs: app-scoped update-check.json (atomic write) with
  last_check, skipped_version, last_seen_version, a checks setting and
  failure backoff; launch-delayed daily scheduler; main-webview-only
  commands update_status, update_skip_version, update_dismiss,
  update_set_checks_enabled.
- Cross-implementation vectors generated by the Node reference with public
  test-only keys (pnpm generate:update-manifest-vectors), checked for drift.

Refs #477

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 3, 2026 06:02
@vercel

vercel Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
psyche-build-docs Ready Ready Preview Oct 3, 2026 7:15am UTC

Request Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Persisted-state compatibility, save-error handling, dismissal lifetime, upgrade detection, and initialization failure handling remain incorrect.

Review effort: Balanced
Findings: 5 Medium severity

Open (5)
What changed in this PR

Adds the desktop backend for signed, notify-only update checks.

Changes:

  • Implements manifest verification, fetching, scheduling, and persisted state.
  • Adds Tauri commands and main-webview-only capabilities.
  • Adds cross-runtime vectors, tests, dependencies, and release documentation.
File Description
scripts/​generate-update-manifest-vectors.mjs Generates signed interoperability vectors.
scripts/​generate-update-manifest-vectors.d.mts Declares generator exports.
package.json Adds the vector-generation command.
native/​desktop/​psyche-build-tauri/​src-tauri/​test-fixtures/​update-manifest/​vectors.json Stores generated verification vectors.
native/​desktop/​psyche-build-tauri/​src-tauri/​src/​update_manifest.rs Implements manifest verification.
native/​desktop/​psyche-build-tauri/​src-tauri/​src/​update_check.rs Implements update state, persistence, fetching, and commands.
native/​desktop/​psyche-build-tauri/​src-tauri/​src/​lib.rs Registers update modules and commands.
native/​desktop/​psyche-build-tauri/​src-tauri/​src/​app.rs Initializes update state and scheduler.
native/​desktop/​psyche-build-tauri/​src-tauri/​permissions/​autogenerated/​update_status.toml Adds status-command permissions.
native/​desktop/​psyche-build-tauri/​src-tauri/​permissions/​autogenerated/​update_skip_version.toml Adds skip-command permissions.
native/​desktop/​psyche-build-tauri/​src-tauri/​permissions/​autogenerated/​update_set_checks_enabled.toml Adds settings-command permissions.
native/​desktop/​psyche-build-tauri/​src-tauri/​permissions/​autogenerated/​update_dismiss.toml Adds dismiss-command permissions.
native/​desktop/​psyche-build-tauri/​src-tauri/​Cargo.toml Adds cryptography and HTTP dependencies.
native/​desktop/​psyche-build-tauri/​src-tauri/​Cargo.lock Locks new dependency trees.
native/​desktop/​psyche-build-tauri/​src-tauri/​capabilities/​main-update-check.json Restricts commands to the main webview.
native/​desktop/​psyche-build-tauri/​src-tauri/​build.rs Registers command permissions.
docs/​RELEASE.md Documents desktop update checks.
__tests__/​updateManifestVectors.test.ts Guards vector reproducibility and key separation.
__tests__/​tauriDesktopTabs.test.ts Verifies capability isolation.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread native/desktop/psyche-build-tauri/src-tauri/src/app.rs
Comment thread native/desktop/psyche-build-tauri/src-tauri/src/update_check.rs Outdated
Comment thread native/desktop/psyche-build-tauri/src-tauri/src/update_check.rs Outdated
Comment thread native/desktop/psyche-build-tauri/src-tauri/src/update_check.rs Outdated
Comment thread native/desktop/psyche-build-tauri/src-tauri/src/update_check.rs Outdated
Review follow-ups for the update verifier (#529):

- Read update-check.json field by field, so a future last_outcome, a
  negative counter or a mistyped field no longer discards a valid
  checks_enabled=false (which silently re-enabled checks).
- A failed check keeps an update this session already verified, and its
  dismissal, until that manifest expires; only available/not_newer replace
  it. last_outcome is exposed so the failure stays visible.
- upgraded_from is reported only for a move to a strictly newer version,
  not for a downgrade.
- New cross-implementation vectors: base64 with non-zero trailing bits, an
  out-of-range s, and the two documented intentional differences (a
  small-order key refused by verify_strict, and the 32-level nesting cap),
  marked rust_expect and pinned by both test suites.
- RELEASE.md: failure retention, independent checks per app instance,
  lenient state reading, downgrade behaviour, vector divergences.

Refs #477

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot review follow-ups for #529:

- If the app data directory cannot be resolved, log a bounded error and
  report state `unavailable` (storage `unavailable`) instead of idling.
- Validate the state file schema: missing or legacy loads as v1; a newer
  schema is read but never rewritten (storage `newer_schema`), so a
  downgrade cannot destroy a later release's state.
- update_skip_version and update_set_checks_enabled return a bounded error
  when the choice could not be saved and leave the in-memory choice
  unchanged; background check bookkeeping only logs.
- Track the dismissed version explicitly; only an offer of a different
  version clears it (not_newer and failures keep it).

Refs #477

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vector freshness check failed in CI: the generator signs a case in
which the trusted key is the small-order identity point and asserts the
Node reference accepts it. That outcome depends on the Node/OpenSSL build:
Node v24.18.1 (local) accepts it, while Node v24.20.0 (CI's `node-version:
24`) refuses it with signature_invalid, so generation threw in CI.

Remove that case from the cross-implementation vectors and pin the Rust
behaviour (verify_strict always refuses a small-order trusted key) with a
Rust-only unit test, small_order_key_is_refused. Every remaining vector now
regenerates byte-identically under Node 20.10.0, 22, 24.18.1 and 24.20.0,
and under other TZ/locale settings; the freshness check is unchanged.

Refs #477

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@BunsDev
BunsDev merged commit acf7c90 into main Oct 3, 2026
26 of 28 checks passed
@BunsDev
BunsDev deleted the feat/desktop-update-verifier branch October 3, 2026 07:47
BunsDev added a commit that referenced this pull request Oct 3, 2026
…#530)

* feat(desktop): notify-only update banner

Shows the verified update status from update_status (part a) as a polite
live-region banner when, and only when, the state is available and the
version is neither skipped nor dismissed. Instructions follow the inferred
install source: a copyable `brew upgrade --cask psyche-build` for Cask
installs, the release page plus this architecture's DMG SHA-256 from the
manifest for DMG installs, and both when unknown. The only buttons are
"Skip this version" (persisted natively) and "Dismiss" (Escape too); there
is no install button and nothing downloads, installs or restarts. A
"Check for updates" setting under Appearance appears only in builds with a
provisioned key.

New update.bundle.js is produced by the canonical build:web command.

Refs #477

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): banner focus return, quieter live region, toggle resync

Review follow-ups for the update banner (#530):

- Only the title line is the polite live region, kept as one element
  across renders, and an unchanged model re-renders nothing, so screen
  readers do not hear the command or SHA-256 again on each refresh.
- When the banner hides while it holds focus (Skip, Dismiss, Escape),
  focus returns to the element focused before it, or to the active
  terminal.
- A refused update_set_checks_enabled puts the checkbox back to the last
  known native setting.
- Document that Escape dismisses only while focus is inside the banner.

update.bundle.js rebuilt with build:web.

Refs #477

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): keep the update banner clear of native webviews; consume Escape

Copilot review follow-ups for #530:

- Native browser webviews draw above every DOM layer inside .detail, so an
  overlay banner there could be covered and lose its clicks. The banner is
  now its own normal-flow row of .app between the workbench and the footer
  (rows: titlebar, workbench, notice, footer). Showing it shrinks .detail,
  and the existing ResizeObserver on .detail resyncs native bounds.
- Escape consumed by the banner calls stopPropagation, so it no longer
  bubbles to main.js's document Escape cascade and exits pane focus mode.

update.bundle.js rebuilt with build:web.

Refs #477

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): assert banner/footer adjacency without regex sanitization

CodeQL flagged the test's comment-stripping replace as incomplete
multi-character sanitization. The test only needs to assert that nothing but
whitespace and complete HTML comments sits between the banner section and the
footer stack, so match that whole span with a full-match pattern instead of
stripping comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — a5565861 Deployed Oct 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants