From 736474506c2ac21f8a2c04c8111e4c90c8f25580 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Mon, 19 Oct 2026 18:30:16 +0800 Subject: [PATCH 1/8] security: select and verify signed installable artifacts --- racket/taskly/updater.rkt | 143 ++++++++------------------------------ 1 file changed, 30 insertions(+), 113 deletions(-) diff --git a/racket/taskly/updater.rkt b/racket/taskly/updater.rkt index 0657a8c..fc596c5 100644 --- a/racket/taskly/updater.rkt +++ b/racket/taskly/updater.rkt @@ -74,6 +74,7 @@ set-update-candidate! update-worker-thread perform-check! + update-install-kind start-download! rollout-bucket) @@ -227,7 +228,11 @@ ;; rivet #271: component #f = the plain whole-app ;; artifact (legacy manifests carry no component). #f)) - (define candidate (select-update config manifest)) + (define kind (update-install-kind)) + (define candidate + (if kind + (select-update config manifest #:install-kind kind) + (select-update config manifest))) (cond [candidate (set-box! candidate-box candidate) @@ -318,12 +323,10 @@ (rename-file-or-directory temporary destination #t) destination)) -;; ---------- MSI flavor (Windows installs under Program Files) ---------- -;; The feed's windows entry is the portable zip; MSI installs update through -;; the sibling .msi release asset: same asset base with .zip → .msi, and the -;; checksum comes from the published .msi.sha256 sidecar. (The Ed25519 -;; manifest still authenticates the zip entry that carried the version; the -;; sidecar is a plain checksum fetched over the same HTTPS origin.) +;; ---------- install flavor selection ---------- +;; MSI and AppImage are first-class artifacts in the signed manifest. The +;; installed flavor selects the matching signed artifact during check-update, +;; so the bytes later executed/installed are covered by Ed25519 + SHA-256. (define (install-flavor) (string-downcase (or (config-ref (read-config) "install-flavor") "zip"))) @@ -332,18 +335,20 @@ (and (eq? (platform-symbol) 'windows) (equal? (install-flavor) "msi"))) -;; ---------- AppImage flavor (Linux installs run from an AppImage) ---------- -;; The feed's linux entry is the tar.gz; AppImage installs update through the -;; sibling .AppImage release asset. Asset families disagree on arch names — -;; feed/deb/rpm use x64|arm64, AppImage builds use x86_64|aarch64 — so the -;; derivation maps the arch before appending. The download lands NEXT TO the -;; running image (same filesystem → atomic rename) and replaces it after the -;; sidecar checksum verifies; the host re-execs the replaced image on consent. +;; AppImage downloads land next to the running image (same filesystem → +;; atomic rename), verify against the signed manifest, then replace $APPIMAGE; +;; the host re-execs the replaced image on consent. (define (appimage-flavor?) (and (eq? (platform-symbol) 'linux) (equal? (install-flavor) "appimage"))) +(define (update-install-kind) + (cond + [(msi-flavor?) 'msi] + [(appimage-flavor?) 'appimage] + [else #f])) + ;; Pure so tests can pin the derivation — the arch-name mapping is the trap. (define (appimage-asset-name tar-gz-filename architecture) (unless (string-suffix? tar-gz-filename ".tar.gz") @@ -383,17 +388,21 @@ #f (path-only self)))) -(define (download-appimage-with-progress! candidate) - (define-values (appimage-url sidecar-url) - (appimage-artifact-urls candidate)) +(define (download-appimage-with-progress! config candidate) (define-values (self temporary) (appimage-download-target)) - (define expected (fetch-sidecar-sha256! sidecar-url)) (with-handlers ([exn:fail? (lambda (e) (when (file-exists? temporary) (delete-file temporary)) (raise e))]) - (define-values (in total) (open-download-with-total! appimage-url)) + (define artifact (update-candidate-artifact candidate)) + (define total (update-artifact-size artifact)) + (when (> total (updater-config-maximum-download-bytes config)) + (error 'download-update "signed artifact size exceeds the download limit")) + (define in + (get-pure-port (string->url (update-artifact-url artifact)) + '("User-Agent: Taskly-Updater/1") + #:redirections 10)) (dynamic-wind void (lambda () @@ -402,102 +411,12 @@ #:mode 'binary (lambda (out) (copy-with-progress! in out total)))) (lambda () (close-input-port in))) - (define actual (string-downcase (sha256-file/hex temporary))) - (unless (string=? actual expected) - (raise-arguments-error 'start-download - "downloaded appimage does not match its checksum sidecar" - "expected" expected - "actual" actual)) - ;; The replaced image must stay executable: rename carries the temp - ;; file's modes, so set them explicitly before the swap. The setter - ;; takes integer bits (the vector form is a contract violation on - ;; Windows, where POSIX modes do not exist) — and the swap itself is - ;; only ever reached under APPIMAGE, i.e. never on Windows. + (verify-update-artifact! candidate temporary) (unless (eq? (system-type 'os) 'windows) (file-or-directory-permissions temporary #o755)) (rename-file-or-directory temporary self #t) self)) -(define (msi-artifact-urls candidate) - (define url (update-artifact-url (update-candidate-artifact candidate))) - (unless (string-suffix? url ".zip") - (error 'start-download "cannot derive an msi url from a non-zip feed entry")) - (define stem (substring url 0 (- (string-length url) 4))) - (values (string-append stem ".msi") - (string-append stem ".msi.sha256"))) - -(define (fetch-sidecar-sha256! url) - ;; sha256sum format: " "; the first token is the digest. - (define in (get-pure-port (string->url url) - '("User-Agent: Taskly-Updater/1") - #:redirections 10)) - (dynamic-wind - void - (lambda () - (define digest - (let ([body (string-trim (port->string in))]) - (if (string=? body "") "" (first (string-split body))))) - (unless (regexp-match? #px"^[0-9a-fA-F]{64}$" digest) - (error 'start-download "msi checksum sidecar has an unexpected shape")) - (string-downcase digest)) - (lambda () (close-input-port in)))) - -;; MSI downloads come from the same CDN without a signed manifest, so the -;; response head is the only total available: open an impure port, parse -;; Content-Length for the progress percent (0 → indeterminate), and hand the -;; body port to the same copy loop. Connection: close keeps the copy ending -;; at EOF, exactly like the zip flow. -(define (open-download-with-total! url) - ;; Shared by the msi and appimage flows: parse the response head so the - ;; progress percent can use Content-Length (0 → indeterminate), then hand - ;; the body port to the copy loop. Connection: close makes the copy end at - ;; EOF like the manifest-verified zip flow. Redirections MUST follow: - ;; release asset URLs 302 into the CDN, and without redirections this - ;; downloads the empty 302 body — a 0-byte "installer" (the 1620 - ;; msiexec failure of 0.2.1's first Windows OTA; the same trap rivet#153 - ;; fixed for the zip flow). - (define in (get-impure-port (string->url url) - '("User-Agent: Taskly-Updater/1" - "Connection: close") - #:redirections 10)) - (let loop ([total 0]) - (define line (read-line in 'return-linefeed)) - (cond - [(eof-object? line) (values in 0)] - [(non-empty-string? (string-trim line)) - (define match - (regexp-match #px"(?i:^content-length:\\s*(\\d+))" line)) - (loop (if match (string->number (second match)) total))] - [else (values in total)]))) - -(define (download-msi-with-progress! config candidate destination) - (define-values (msi-url sidecar-url) (msi-artifact-urls candidate)) - (define expected (fetch-sidecar-sha256! sidecar-url)) - (make-parent-directory* destination) - (define temporary (path-add-extension destination #".partial")) - (when (file-exists? temporary) (delete-file temporary)) - (with-handlers ([exn:fail? - (lambda (e) - (when (file-exists? temporary) (delete-file temporary)) - (raise e))]) - (define-values (in total) (open-download-with-total! msi-url)) - (dynamic-wind - void - (lambda () - (call-with-output-file temporary - #:exists 'truncate/replace - #:mode 'binary - (lambda (out) (copy-with-progress! in out total)))) - (lambda () (close-input-port in))) - (define actual (string-downcase (sha256-file/hex temporary))) - (unless (string=? actual expected) - (raise-arguments-error 'start-download - "downloaded msi does not match its checksum sidecar" - "expected" expected - "actual" actual)) - (rename-file-or-directory temporary destination #t) - destination)) - ;; Runs on a backend worker thread; the host follows progress via the ;; update-state RPC. Never raises: failures surface through the phase. (define (start-download! data-dir) @@ -551,10 +470,8 @@ (exn-message e))))]) (define path (cond - [(msi-flavor?) - (download-msi-with-progress! config candidate destination)] [(appimage-flavor?) - (download-appimage-with-progress! candidate)] + (download-appimage-with-progress! config candidate)] [else (download-with-progress! config candidate destination)])) (state-set! 'phase "downloaded") From 7fd5da884efd71963ae118ee6bf8cfe6c8459944 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Mon, 19 Oct 2026 18:30:27 +0800 Subject: [PATCH 2/8] release: sign MSI and AppImage artifacts in update manifest --- scripts/make-update-manifest.sh | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/scripts/make-update-manifest.sh b/scripts/make-update-manifest.sh index 5ddeb20..a55758b 100755 --- a/scripts/make-update-manifest.sh +++ b/scripts/make-update-manifest.sh @@ -7,8 +7,10 @@ # directory containing the release artifacts, i.e. the names # the release pipeline produces: # taskly--macos-arm64.zip taskly--macos-x64.zip -# taskly--windows-x64.zip taskly--linux-x64.tar.gz -# taskly--linux-arm64.tar.gz +# taskly--windows-x64.zip taskly--windows-x64.msi +# taskly--linux-x64.tar.gz taskly--linux-arm64.tar.gz +# taskly--linux-x86_64.AppImage +# taskly--linux-aarch64.AppImage # Ed25519 private key PEM (default: $UPDATE_KEY_PATH, then # ~/.taskly/update-signing-key.pem) # @@ -31,8 +33,11 @@ VERSION="${TAG#v}" for artifact in "$DIST/taskly-$VERSION-macos-arm64.zip" \ "$DIST/taskly-$VERSION-macos-x64.zip" \ "$DIST/taskly-$VERSION-windows-x64.zip" \ + "$DIST/taskly-$VERSION-windows-x64.msi" \ "$DIST/taskly-$VERSION-linux-x64.tar.gz" \ - "$DIST/taskly-$VERSION-linux-arm64.tar.gz"; do + "$DIST/taskly-$VERSION-linux-arm64.tar.gz" \ + "$DIST/taskly-$VERSION-linux-x86_64.AppImage" \ + "$DIST/taskly-$VERSION-linux-aarch64.AppImage"; do [[ -f "$artifact" ]] || { echo "error: missing $artifact" >&2; exit 1; } done [[ -f "$KEY" ]] || { echo "error: missing signing key $KEY (see scripts/update-keys.sh)" >&2; exit 1; } @@ -114,10 +119,16 @@ cat > "$SCRIPT" < Date: Mon, 19 Oct 2026 18:30:53 +0800 Subject: [PATCH 3/8] docs: bind MSI and AppImage updates to signed manifest --- shared/spec/UPDATE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/shared/spec/UPDATE.md b/shared/spec/UPDATE.md index 01e94c1..3f3725d 100644 --- a/shared/spec/UPDATE.md +++ b/shared/spec/UPDATE.md @@ -52,9 +52,9 @@ exists on all platforms from day one. |---|---|---|---|---| | macOS Apple silicon | feed `macos`/`arm64` zip entry | zip → sha256 → ditto → atomic swap in place (`.old` copy kept until relaunch); the DMG is the human installer, not a feed artifact | Ed25519 wrapper signature (CryptoKit, `UpdateService.swift`) + artifact sha256 | wired since 1.1.1 | | macOS Intel | feed `macos`/`x64` zip entry | same swap flow on the x64 build (hosts match their compile-time architecture) | same | wired since 1.3.0 | -| Windows x64 | feed `windows`/`x64` zip entry | portable zip → handoff `update-install.cmd`: wait for app exit → `tar -xf` extract → swap install dir in place → relaunch; failure marker (`update-failed.txt`) reported on next launch. MSI installs (host declares `install-flavor=msi` at startup): backend derives the sibling `.msi` asset from the feed zip URL, checksums it against the published `.msi.sha256` sidecar, then handoff `msi-install.cmd`: wait for app exit → `msiexec /passive /norestart /i` (per-machine installs surface one UAC consent; msiexec log at `%TEMP%\taskly-update\msiexec.log`) → relaunch; failure marker on next launch | portable zip: Ed25519 wrapper signature (backend, libcrypto) + artifact sha256; MSI: `.msi.sha256` release sidecar over the same HTTPS origin | portable wired since 1.2.0; MSI online upgrade since 0.1.2 (releases-page guidance 1.3.0–0.1.1) | +| Windows x64 | signed `windows`/`x64` zip + MSI entries | portable zip → handoff `update-install.cmd`: wait for app exit → `tar -xf` extract → swap install dir in place → relaunch; failure marker (`update-failed.txt`) reported on next launch. MSI installs (host declares `install-flavor=msi` at startup): backend selects the signed MSI artifact directly, then handoff `msi-install.cmd`: wait for app exit → `msiexec /passive /norestart /i` (per-machine installs surface one UAC consent; msiexec log at `%TEMP%\taskly-update\msiexec.log`) → relaunch; failure marker on next launch | Ed25519 wrapper signature + signed size/SHA-256 for both zip and MSI | portable wired since 1.2.0; MSI online upgrade since 0.1.2 (signed-artifact hardening in 0.2.x) | | Windows ARM64 | — | no native package exists (Racket ships no Windows-on-ARM runtime); run the x64 build under Windows on ARM's built-in x64 emulation | — | documented since 1.3.0 | -| Linux x64 | feed `linux`/`x64` tar.gz entry | three install shapes (host declares `install-flavor` at startup): **AppImage** (`APPIMAGE` env set) — backend derives the sibling `.AppImage` asset from the feed tar.gz URL (arch mapped: x64→x86_64, arm64→aarch64), checksums against the published `.AppImage.sha256` sidecar, stages beside the running image and atomically renames it over `$APPIMAGE` with exec bits set; host asks for consent, then `execl`s the replaced image (Tauri-2 style in-place update). **deb/rpm** (exe under /opt/taskly or /usr) — download kept, dialog points at the system package manager (`updateUsePackageManager`); no self-swap of a system-managed prefix. **tarball** — download + "open folder" manual extract, unchanged | tar.gz: Ed25519 wrapper signature (backend, libcrypto) + artifact sha256; AppImage: `.AppImage.sha256` release sidecar over the same HTTPS origin | tarball wired since 1.2.0; AppImage in-place since 0.1.2; deb/rpm guidance since 0.1.2 | +| Linux x64 | signed tar.gz + AppImage entries | three install shapes (host declares `install-flavor` at startup): **AppImage** (`APPIMAGE` env set) — backend selects the signed AppImage artifact directly, stages beside the running image, verifies signed size/SHA-256, and atomically renames it over `$APPIMAGE` with exec bits set; host asks for consent, then `execl`s the replaced image. **deb/rpm** (exe under /opt/taskly or /usr) — download kept, dialog points at the system package manager (`updateUsePackageManager`); no self-swap of a system-managed prefix. **tarball** — download + "open folder" manual extract, unchanged | Ed25519 wrapper signature + signed size/SHA-256 for tar.gz and AppImage | tarball wired since 1.2.0; AppImage in-place since 0.1.2 (signed-artifact hardening in 0.2.x); deb/rpm guidance since 0.1.2 | (The frozen v1 line used Velopack on Windows and a tarball replacer on Linux; those mechanisms died with that line — the Rivet hosts start from @@ -96,8 +96,10 @@ Inner manifest (decoded `payload`) — rivet's manifest schema: "url": "https://…/taskly-1.3.0-macos-arm64.zip", "sha256": "", "size": 12345678, "installer": "zip", "arguments": [] }, { "platform": "macos", "architecture": "x64", "…": "…" }, - { "platform": "windows", "architecture": "x64", "…": "…" }, - { "platform": "linux", "architecture": "x64", "…": "…" } + { "platform": "windows", "architecture": "x64", "installer": "zip", "…": "…" }, + { "platform": "windows", "architecture": "x64", "installer": "msi", "…": "…" }, + { "platform": "linux", "architecture": "x64", "installer": "targz", "…": "…" }, + { "platform": "linux", "architecture": "x64", "installer": "appimage", "…": "…" } ] } ``` From bd499be748bef55ed12a3ba0f6d6f0e192c52c17 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Mon, 19 Oct 2026 18:34:35 +0800 Subject: [PATCH 4/8] docs: describe signed installer OTA paths --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7213026..375f4fa 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,9 @@ Grab your build from | Linux ARM64 | full Linux artifact set on `ubuntu-24.04-arm` builds (`-linux-arm64`) | — | Every release carries a `SHA256SUMS` manifest and a signed -`update-manifest.json` — the in-app updater's feed. Portable zip -installs update themselves in place; MSI installs get pointed at the -releases page. +`update-manifest.json` — the in-app updater's feed. Portable zip, +Windows MSI, and Linux AppImage installs update in place; every installed +artifact is selected from and verified against the signed manifest. macOS builds are ad-hoc signed; if Gatekeeper complains on first launch, run `xattr -cr /Applications/Taskly.app`. From 8f8005f25ae840b9cc5f7eb1556e555572a3f627 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Mon, 19 Oct 2026 18:34:44 +0800 Subject: [PATCH 5/8] docs: sync signed installer OTA description --- README.zh-CN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index a4ae0cf..a11f5ac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,7 +43,7 @@ Taskly 构建在 [Rivet](https://github.com/turinglambdaai/rivet) 之上:全 | Linux x64 | `taskly--linux-x64.tar.gz` | `taskly--linux-x64.deb` / `.rpm` / `.AppImage` | | Linux ARM64 | `ubuntu-24.04-arm` 构建全套 Linux 产物(`-linux-arm64`) | — | -每个版本都带 `SHA256SUMS` 清单和签名的 `update-manifest.json`(应用内更新的 feed)。便携 zip 安装原地自动更新;MSI 安装版会引导到发布页。 +每个版本都带 `SHA256SUMS` 清单和签名的 `update-manifest.json`(应用内更新的 feed)。便携 zip、Windows MSI 和 Linux AppImage 都可原地更新;实际安装的产物都会直接从签名清单中选择并校验。 macOS 包为 ad-hoc 签名;首次启动如被 Gatekeeper 拦截,执行 `xattr -cr /Applications/Taskly.app` 即可。 From 55d0fdf148af70e09a59b645badf528cd94dc5b2 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Mon, 19 Oct 2026 18:35:31 +0800 Subject: [PATCH 6/8] test: pin signed installable artifact selection --- racket/tests/updater-test.rkt | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/racket/tests/updater-test.rkt b/racket/tests/updater-test.rkt index fb86cea..2cdfdd9 100644 --- a/racket/tests/updater-test.rkt +++ b/racket/tests/updater-test.rkt @@ -20,6 +20,9 @@ update-manifest update-artifact update-candidate + update-candidate-artifact + updater-config + select-update fetch-update-manifest update-manifest-version update-manifest-artifacts @@ -146,6 +149,40 @@ (delete-file priv-path) (delete-file artifact-file)) +(test-case "signed manifest selects the requested installable artifact" + (define manifest + (update-manifest + app-identifier "9.9.9" 7 'stable + "2026-10-09T00:00:00Z" "0.0.0" #f #t 100 + (list + (update-artifact 'windows 'x64 + "https://dl.example/taskly/taskly-9.9.9-windows-x64.zip" + (make-string 64 #\a) 10 'zip '() #f) + (update-artifact 'windows 'x64 + "https://dl.example/taskly/taskly-9.9.9-windows-x64.msi" + (make-string 64 #\b) 20 'msi '() #f) + (update-artifact 'linux 'x64 + "https://dl.example/taskly/taskly-9.9.9-linux-x64.tar.gz" + (make-string 64 #\c) 30 'targz '() #f) + (update-artifact 'linux 'x64 + "https://dl.example/taskly/taskly-9.9.9-linux-x86_64.AppImage" + (make-string 64 #\d) 40 'appimage '() #f)))) + (define common + (updater-config app-identifier "0.0.1" 'stable + 'windows 'x64 #f #f 0 (* 1024 1024) #f)) + (check-equal? + (update-artifact-installer + (update-candidate-artifact + (select-update common manifest #:install-kind 'msi))) + 'msi) + (check-equal? + (update-artifact-installer + (update-candidate-artifact + (select-update (struct-copy updater-config common [platform 'linux]) + manifest + #:install-kind 'appimage))) + 'appimage)) + (test-case "embedded release identity is coherent" (check-equal? app-identifier "app.taskly.Taskly") (check-equal? app-channel 'stable) From 989f0c44d60c49cef69865000e7af8f1a1a70bbf Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Mon, 19 Oct 2026 18:40:38 +0800 Subject: [PATCH 7/8] test: import installer accessor for signed artifact assertions --- racket/tests/updater-test.rkt | 1 + 1 file changed, 1 insertion(+) diff --git a/racket/tests/updater-test.rkt b/racket/tests/updater-test.rkt index 2cdfdd9..1413c7f 100644 --- a/racket/tests/updater-test.rkt +++ b/racket/tests/updater-test.rkt @@ -19,6 +19,7 @@ verify-signed-manifest update-manifest update-artifact + update-artifact-installer update-candidate update-candidate-artifact updater-config From d5cf0882b1416be9145220e5cdb0703b7b707306 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Mon, 19 Oct 2026 18:42:01 +0800 Subject: [PATCH 8/8] release: report all signed update artifacts --- scripts/make-update-manifest.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/make-update-manifest.sh b/scripts/make-update-manifest.sh index a55758b..b11dce2 100755 --- a/scripts/make-update-manifest.sh +++ b/scripts/make-update-manifest.sh @@ -141,7 +141,7 @@ cat > "$SCRIPT" <