Skip to content

feat: added auto updater#540

Open
dazzling-no-more wants to merge 1 commit intotherealaleph:mainfrom
dazzling-no-more:feature/auto-update
Open

feat: added auto updater#540
dazzling-no-more wants to merge 1 commit intotherealaleph:mainfrom
dazzling-no-more:feature/auto-update

Conversation

@dazzling-no-more
Copy link
Copy Markdown
Contributor

@dazzling-no-more dazzling-no-more commented Apr 30, 2026

Summary

End-to-end auto-update for desktop and Android, gated behind a minisign signature embedded at compile time.

  • Desktop (src/update_apply.rs, new): download release archive → fetch sibling .minisig → verify against embedded pubkey → extract → stage <exe>.new next to the running binary → swap + re-exec. Handles Linux/Windows single-binary, macOS bare binary, and macOS .app bundle layouts. On Windows, where you can't replace a running .exe, the new process detects it was launched from a .new path and finishes the rename + re-exec at startup (finalize_pending_at_startup, called first thing in src/bin/ui.rs and src/main.rs).
  • Android (UpdateInstaller.kt, new): downloads the per-ABI APK via a new Native.downloadAsset JNI export (rustls + minisign through the same code path the desktop uses — no OkHttp), then hands the file to PackageInstaller via FileProvider. Routes the user to "Install unknown apps" settings if the permission isn't granted yet.
  • UI (src/bin/ui.rs): adds an "Install update" primary button + "Restart now to apply" follow-up, with an install_in_progress guard against double-clicks. The pre-existing "Download" path remains as a secondary action for users who'd rather apply by hand.
  • Update check (src/update_check.rs, src/android_jni.rs): JSON returned to Android now includes assetName/assetUrl/assetSize for the matched per-ABI APK so the Kotlin updater knows what to fetch.
  • Release pipeline (.github/workflows/release.yml): new sign job runs rsign2 against every .tar.gz / .zip / .apk artifact and uploads <asset>.minisig files. release and commit-releases now needs: [..., sign] but use if: always() && (sign.result == 'success' || sign.result == 'skipped') so the workflow keeps shipping releases while signing is still being rolled out.

Rollout mode

Until the maintainer sets up the keypair, the updater runs in rollout mode: it still applies updates but logs MHRV_UPDATE_PUBKEY was not set at build time — applying update without signature check (insecure), and the sign job is skipped (gated on vars.MINISIGN_SIGNING_ENABLED == 'true'). Once MINISIGN_PUBLIC_KEY (repo variable), MINISIGN_SECRET_KEY (secret), and MINISIGN_SIGNING_ENABLED=true are set, the next tag push produces signed artifacts and freshly-built binaries enforce verification on every subsequent update.

Full setup walkthrough lives in docs/maintainer/references/update-signing.md, including the threat-model recap and the (deliberately not-yet-automated) key rotation procedure.

New deps

  • minisign-verify = "0.2" (all targets) — signature check at apply time
  • zip, tar, tempfile (scoped to cfg(not(target_os = "android"))) — desktop archive extraction + scratch dirs; Android cdylib stays lean since APK swap goes through PackageInstaller
  • tokio fs feature added — used by the Android JNI download path

Test plan

  • Rollout mode (signing disabled): cut a tag with MINISIGN_SIGNING_ENABLED unset; confirm sign is skipped, release + commit-releases still complete, and binaries log the rollout-mode warning when applying an update.
  • Signed mode: set the keypair + flip the toggle, cut a tag, confirm <asset>.minisig files appear on the release page and in releases/.
  • Desktop apply — Linux: from the UI, click "Install update" → confirm staged <exe>.new, then "Restart now to apply" → confirm rename-over-running-binary + execv re-launch.
  • Desktop apply — Windows: same flow; verify the <exe>.new → spawn → rename <exe><exe>.old → rename <exe>.new<exe> → re-exec chain in finalize_pending_at_startup.
  • Desktop apply — macOS .app bundle: confirm whole-bundle swap (Info.plist included) and that the inner Contents/MacOS/<bin> keeps 0o755.
  • Signature missing: with a pubkey embedded, point the updater at an asset with no .minisig neighbor; confirm ApplyError::SignatureMissing (refuses to apply).
  • Signature invalid: tamper with the archive after signing; confirm ApplyError::SignatureInvalid.
  • Android apply: trigger update from HomeScreen; confirm minisign verification, the OS install dialog, and the post-install package replacement (same signing key, so PackageInstaller accepts the swap).
  • Android — install permission missing: revoke "Install unknown apps" for the app; confirm we route to the right settings page instead of failing silently.
  • Double-click guard: spam-click "Install update"; confirm only one stage operation runs and the duplicate request gets logged.

@github-actions github-actions Bot added the type: feature feat: PR — auto-applied by release-drafter label Apr 30, 2026
@therealaleph
Copy link
Copy Markdown
Owner

@dazzling-no-more — substantive PR. Tests pass locally (182 / 0; +13 new). Build clean. Architecture looks right.

Holding the merge though, with security framing as the gate. Walking through:

What I want to land vs. what concerns me:

I want this. In-app updater has been on the v1.9.x roadmap (#366) and the threat model in docs/maintainer/references/update-signing.md is exactly the right framing — minisign signature embedded at compile time, no fallback to "trust the bytes" once the keypair is set up.

What concerns me is the rollout mode as currently designed. From your PR description:

Until the maintainer sets up the keypair, the updater runs in rollout mode: it still applies updates but logs MHRV_UPDATE_PUBKEY was not set at build time — applying update without signature check (insecure)

For the project's threat model, this is the wrong default. mhrv-rs ships to users in censored regions where supply-chain attacks would be catastrophic — flipping unsigned auto-update on by default, even with a log warning that nobody reads, would be a regression in the security posture of the binary distribution. The user-facing security guarantee today is "what you downloaded from the Telegram channel + GitHub release is what we built". An unsigned auto-updater can quietly break that.

Three options to land this safely:

Option A (preferred): set up the keypair first, then merge.

I'll generate the minisign keypair following docs/maintainer/references/update-signing.md, set the repo vars + secret, and confirm the sign job runs on the next tag push. Once that's working, this PR merges with MHRV_UPDATE_PUBKEY baked in and signing enforced from day one — no rollout-mode window.

ETA: ~2-3 days for me to do the keypair setup + verify the signing CI job in a non-public branch first.

Option B: change rollout-mode behavior to download-only, no apply.

If we want to merge before keypair setup is done, change rollout mode to:

  • Display "Update available" + "Download" button (current "Download" path)
  • Hide the "Install update" / "Restart now to apply" buttons until MHRV_UPDATE_PUBKEY is set at build time
  • Log the warning + refuse the apply call

User experience parity with what we have today (download-and-manually-apply) but with an obvious upgrade path the moment the keypair is set up + a new build is shipped.

This avoids the supply-chain regression but lets the code land + start collecting feedback on the desktop / Android apply paths.

Option C: opt-in flag for unsigned updates.

config.json field like allow_unsigned_updates: false (default false). User must explicitly opt in to get the auto-apply path while signing is being rolled out. Power users who understand the trade-off can opt in; default users get download-only.

Less attractive than B because it puts a security decision on users who shouldn't have to think about it.

My recommendation: Option A.

Going to do the keypair setup over the next 2-3 days. Will reply on this PR when the signing CI job is verified working in a side branch. Then we merge this PR with signing enforced from day one + no rollout window.

If you'd rather B (so the code lands sooner and we can iterate on the apply paths in field-test), happy to take a follow-up commit on this branch that restricts apply-button visibility to MHRV_UPDATE_PUBKEY.is_set(). Let me know.

Smaller line-by-line stuff (review pass):

The architecture is clean — separate update_apply module, the Windows <exe>.new swap chain via finalize_pending_at_startup is the right pattern, the cfg(not(target_os = "android")) scope for zip/tar/tempfile keeps Android cdylib lean. The Android path going through PackageInstaller + FileProvider is the canonical Android approach.

Two small items I noticed:

  1. tokio fs feature — added for the Android download path. Minor: if it's only used in cfg(target_os = "android") paths, we can scope the feature add to the android target dep block to keep desktop builds slightly leaner. Not blocking; just a nice-to-have if it's easy.
  2. Test plan unticked — please run through the [ ] checkboxes in your test plan locally before we merge (especially the Windows <exe>.new swap chain — that one's the trickiest). Or note which you've covered + which you'd like me to verify on my end.

On the docs/maintainer/update-signing.md walkthrough:

I read it. Threat-model recap is accurate, the key-rotation procedure is reasonable. The "deliberately not-yet-automated" note on rotation is honest — automating rotation properly needs key-versioning in the binary which is a separate design call. Leaving as manual is fine for now.

Net assessment: this is the right design + good code. Just need to gate the merge on signing being fully set up so we don't ship an unsigned-by-default updater to the userbase.

Will close the loop when keypair is ready. Thanks for the substantial work — this is the most important security-adjacent infrastructure feature in the v1.9.x roadmap.


[reply via Anthropic Claude | reviewed by @therealaleph]

@dazzling-no-more
Copy link
Copy Markdown
Contributor Author

I prefer option A.

Test plan status — local verification done before pushing the branch.

Covered locally:

✓ Desktop apply — Windows: .new → spawn → rename → re-exec chain
verified end-to-end in a scratch dir. Built mhrv-rs.exe, copied to
both .exe and .exe.new, ran .exe.new --version. Post-state: .new
gone, .exe present (the spawned canonical process printed
"mhrv-rs 1.9.1", which only fires on the rename's Ok branch in
update_apply.rs:699-709), .old cleaned up by cleanup_stale_old in
the spawned process. The full file-system swap mechanics work; the
UI-level Install/Restart buttons still need a real release.

✓ Rollout mode: MHRV_UPDATE_PUBKEY wiring verified across all three
states the workflow YAML can produce — unset, empty string (the
'' fallback when MINISIGN_SIGNING_ENABLED != 'true'), and a real
base64 pubkey. signature_verification_enabled() returns false for
the first two and true for the third. Confirms option_env! reads
correctly from the workflow's env block.

✓ stage_from_archive end-to-end: synthetic tar.gz containing a binary
matching current_exe().file_name(), call stage_from_archive, assert
the .new lands next to current_exe with the right contents.
Passes. Covers the extract → find_binary → copy glue that's not
in the unit suite.

✓ Unit suite: 181 passed, 0 failed (cargo test --release). New tests
cover extract_zip_rejects_path_traversal,
extract_tar_gz_unpacks_files / rejects_link_entries,
find_binary_* (3 cases), cleanup_stale_old_only_touches_our_name,
staged_path_appends_new, staged_update_swap_target_strips_new,
embedded_pubkey_normalization_treats_empty_as_unset,
signature_url_keeps_query_on_signature_asset.

Not covered locally — folding into your Option-A side-branch run:

[ ] Signed mode end-to-end (real CI-produced .minisig verify against
embedded pubkey)
[ ] Desktop apply — Linux + macOS .app bundle swap (no Linux/Mac
handy)
[ ] Android apply — PackageInstaller hand-off + same-key check
[ ] Android — "Install unknown apps" permission gate
[ ] Signature missing / invalid against a real release URL
[ ] Double-click guard at the UI layer

Happy to drive any of those once the side-branch pre-release is up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature feat: PR — auto-applied by release-drafter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants