From d659768403272271cffde21c3bcbeb9119c3debc Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 5 Oct 2026 19:44:56 -0300 Subject: [PATCH 1/2] fix(release): every update is signed for its version and a copy refuses one that is not --- .github/workflows/release.yml | 9 +++++++-- RELEASING.md | 12 ++++++++---- app/src-tauri/tauri.conf.json | 1 + 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0f47bbc..75f7f5b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -445,7 +445,7 @@ jobs: v="${{ needs.version.outputs.version }}" exe="../dist/copypaste-installer-$v-windows-x86_64.exe" rm -f "$exe.sig" - npm run tauri -- signer sign "$exe" + npm run tauri -- signer sign --app-version "$v" "$exe" test -s "$exe.sig" || { echo "::error::no updater signature was written"; exit 1; } - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -638,7 +638,8 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | out="../target/$TARGET/release/bundle/macos" - npm run tauri -- signer sign "$out/CopyPaste.app.tar.gz" + npm run tauri -- signer sign --app-version "${{ needs.version.outputs.version }}" \ + "$out/CopyPaste.app.tar.gz" test -s "$out/CopyPaste.app.tar.gz.sig" - name: Name it after the release @@ -1305,6 +1306,10 @@ jobs: echo "::error::$os does not verify against the key this build ships" exit 1 fi + if ! sed -n 3p one.sig | tr '\t' '\n' | grep -qx "version:$v"; then + echo "::error::$os is not signed for $v, and every copy from here on refuses it" + exit 1 + fi done msstore: diff --git a/RELEASING.md b/RELEASING.md index 91073d1f..3cea472f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -70,11 +70,15 @@ never offered anything; one under `Caskroom/copypaste-beta` or `Caskroom/copypaste` is told its own `brew` command rather than handed an installer; anything else installs its own update. -Three things guard the install, all of them borrowed from Tisty: the download +Four things guard the install, all of them borrowed from Tisty: the download address must be this repository's releases on `github.com` (or -`objects.githubusercontent.com`) before a byte is fetched, the version is pinned to the one the person was shown so a -feed that moves cannot hand over another, and a copy running from the mounted -`.dmg` refuses rather than failing after the whole download. The panel is +`objects.githubusercontent.com`) before a byte is fetched; the version is +pinned to the one the person was shown, so a feed that moves cannot hand over +another; every updater signature is bound to its version (`signer sign +--app-version`, `requireSignedVersion`), so an older release cannot be served +under a newer number, and `verify` refuses a release whose signatures do not +say it; and a copy running from the mounted `.dmg` refuses rather than failing +after the whole download. The panel is stopped first, because on Windows an installer cannot replace a binary that is running, and it is brought back if the install does not go through. diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index b672aae3..d85878fa 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -60,6 +60,7 @@ "endpoints": [ "https://raw.githubusercontent.com/rgdevment/CopyPaste/manifest/latest.json" ], + "requireSignedVersion": true, "windows": { "installMode": "passive" } From 0c856387009240aa72d555a380e238e167deb591 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 5 Oct 2026 19:57:36 -0300 Subject: [PATCH 2/2] fix(release): an update not signed for its version is stopped before it is published --- .github/workflows/feed.yml | 10 ++++++++++ .github/workflows/release.yml | 23 +++++++++++++++++++---- RELEASING.md | 5 +++-- app/src-tauri/src/update_test.rs | 10 ++++++++++ scripts/signed_for.sh | 18 ++++++++++++++++++ 5 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 scripts/signed_for.sh diff --git a/.github/workflows/feed.yml b/.github/workflows/feed.yml index 4de0bf2e..506729be 100644 --- a/.github/workflows/feed.yml +++ b/.github/workflows/feed.yml @@ -114,6 +114,16 @@ jobs: every installed copy carries. It answers, and it cannot be installed: $url" return 1 fi + local bound=0 + bash scripts/signed_for.sh one.sig "$expected" || bound=$? + case $bound in + 0) ;; + 2) echo "::warning::the installer for $os in $what is signed for no version; \ + copies that require one will not take it" ;; + *) echo "::error::the installer for $os in $what is signed for another version \ + than the «${expected}» it is offered as: $url" + return 1 ;; + esac echo " $os verifies" done } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75f7f5b0..66f51faf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -447,6 +447,11 @@ jobs: rm -f "$exe.sig" npm run tauri -- signer sign --app-version "$v" "$exe" test -s "$exe.sig" || { echo "::error::no updater signature was written"; exit 1; } + base64 -d < "$exe.sig" > "$RUNNER_TEMP/one.minisig" + if ! bash ../scripts/signed_for.sh "$RUNNER_TEMP/one.minisig" "$v"; then + echo "::error::the updater signature is not bound to $v" + exit 1 + fi - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -637,10 +642,15 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | + v="${{ needs.version.outputs.version }}" out="../target/$TARGET/release/bundle/macos" - npm run tauri -- signer sign --app-version "${{ needs.version.outputs.version }}" \ - "$out/CopyPaste.app.tar.gz" + npm run tauri -- signer sign --app-version "$v" "$out/CopyPaste.app.tar.gz" test -s "$out/CopyPaste.app.tar.gz.sig" + base64 -d < "$out/CopyPaste.app.tar.gz.sig" > "$RUNNER_TEMP/one.minisig" + if ! bash ../scripts/signed_for.sh "$RUNNER_TEMP/one.minisig" "$v"; then + echo "::error::the updater signature is not bound to $v" + exit 1 + fi - name: Name it after the release run: | @@ -887,9 +897,14 @@ jobs: could ever verify this update. Nothing was published." exit 1 fi + if ! bash scripts/signed_for.sh one.minisig "$v"; then + echo "::error::$one is not signed for $v, and every copy refuses an update whose \ + signature does not name it. Nothing was published." + exit 1 + fi done rm -f feed.pub one.minisig - echo "the signatures and the shipped key are halves of the same pair" + echo "the signatures and the shipped key are halves of the same pair, bound to $v" - name: The manifest a 2.x can read if: vars.BRIDGE_MANIFEST == 'true' @@ -1306,7 +1321,7 @@ jobs: echo "::error::$os does not verify against the key this build ships" exit 1 fi - if ! sed -n 3p one.sig | tr '\t' '\n' | grep -qx "version:$v"; then + if ! bash scripts/signed_for.sh one.sig "$v"; then echo "::error::$os is not signed for $v, and every copy from here on refuses it" exit 1 fi diff --git a/RELEASING.md b/RELEASING.md index 3cea472f..c326dc70 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -76,8 +76,9 @@ address must be this repository's releases on `github.com` (or pinned to the one the person was shown, so a feed that moves cannot hand over another; every updater signature is bound to its version (`signer sign --app-version`, `requireSignedVersion`), so an older release cannot be served -under a newer number, and `verify` refuses a release whose signatures do not -say it; and a copy running from the mounted `.dmg` refuses rather than failing +under a newer number, and a release whose signatures do not say it stops +before anything is published (`scripts/signed_for.sh`, run where it is signed, +before `publish`, in `verify` and every morning in `feed.yml`); and a copy running from the mounted `.dmg` refuses rather than failing after the whole download. The panel is stopped first, because on Windows an installer cannot replace a binary that is running, and it is brought back if the install does not go through. diff --git a/app/src-tauri/src/update_test.rs b/app/src-tauri/src/update_test.rs index 836b72ce..3ed2cd45 100644 --- a/app/src-tauri/src/update_test.rs +++ b/app/src-tauri/src/update_test.rs @@ -275,3 +275,13 @@ fn a_candidate_downloads_from_the_candidates_first_and_a_stable_only_from_the_st assert!(one.starts_with("https://raw.githubusercontent.com/rgdevment/CopyPaste/manifest/")); } } + +#[test] +fn a_copy_refuses_an_update_not_signed_for_its_version() { + let conf: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).expect("tauri.conf.json reads"); + assert_eq!( + conf["plugins"]["updater"]["requireSignedVersion"], + serde_json::Value::Bool(true) + ); +} diff --git a/scripts/signed_for.sh b/scripts/signed_for.sh new file mode 100644 index 00000000..8e756c4d --- /dev/null +++ b/scripts/signed_for.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -uo pipefail + +sig=${1:?the decoded minisign signature} +version=${2:?the version it has to be signed for} + +comment=$(grep -m1 '^trusted comment: ' "$sig" | tr -d '\r') +fields=$(printf '%s\n' "${comment#trusted comment: }" | tr '\t' '\n') + +if printf '%s\n' "$fields" | grep -qxF "version:$version"; then + exit 0 +fi +if printf '%s\n' "$fields" | grep -q '^version:'; then + echo "signed for $(printf '%s\n' "$fields" | sed -n 's/^version://p' | head -1), not $version" >&2 + exit 1 +fi +echo "signed for no version at all" >&2 +exit 2