From ab9652d2eb20e12b12813221dc24539656f67b34 Mon Sep 17 00:00:00 2001 From: burhankhanzada Date: Sat, 3 Oct 2026 08:42:25 +0200 Subject: [PATCH] Sign every update; launch-test the Windows and Linux builds The app's updater accepted anything its appcast pointed to: the macOS build had no SUPublicEDKey and the Windows build no DSAPub, and releases carried no signatures. - macOS: Info.plist carries SUPublicEDKey, an Ed25519 key, so Sparkle installs an update only with a valid signature from it. - Windows: Runner.rc carries a DSAPub resource, since the WinSparkle 0.8 that auto_updater bundles verifies DSA (over SHA-1), not EdDSA. - release.yml signs the .dmg and the installer with the private halves (the SPARKLE_ED_PRIVATE_KEY and WINSPARKLE_DSA_PRIVATE_KEY secrets), verifies each signature against the key the app embeds, and fails if either secret is missing. tools/release_metadata.dart writes sparkle:dsaSignature as well as sparkle:edSignature; tested. - The Ed25519 signatures OpenSSL makes are byte-identical to Sparkle's own sign_update's for the same key, so Sparkle accepts them. - The Windows build is installed silently, launched, checked to still be running after 15 s, and uninstalled; the Linux tarball is unpacked and launched under Xvfb. Until now only the macOS build was ever started. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 78 ++++++++++++++++++- CONTRIBUTING.md | 10 ++- macos/Runner/Info.plist | 5 ++ test/tools/release_metadata_test.dart | 26 +++++++ tools/release_metadata.dart | 30 +++++-- windows/runner/Runner.rc | 11 +++ .../resources/winsparkle_dsa_public.pem | 36 +++++++++ 7 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 windows/runner/resources/winsparkle_dsa_public.pem diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b4ba08..9ab3759 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,8 @@ on: paths: - .github/workflows/release.yml - windows/packaging/** + - windows/runner/Runner.rc + - macos/Runner/Info.plist - tools/release_metadata.dart concurrency: @@ -156,12 +158,49 @@ jobs: windows\packaging\installer.iss if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # What a user does with it: install (per user, silently), start the app + # and see it stay up, then uninstall and see it gone. + - name: Install, launch and uninstall (Windows) + if: matrix.platform == 'windows' + shell: pwsh + run: | + $setup = Get-ChildItem dist\*-windows-setup.exe | Select-Object -First 1 + Start-Process $setup.FullName -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CURRENTUSER' -Wait + $dir = Join-Path $env:LOCALAPPDATA 'Programs\PinBench' + $exe = Join-Path $dir 'pinbench.exe' + if (-not (Test-Path $exe)) { Write-Error "the installer did not put pinbench.exe in $dir"; exit 1 } + $app = Start-Process $exe -PassThru + Start-Sleep -Seconds 15 + if ($app.HasExited) { Write-Error "the installed app exited within 15 s (code $($app.ExitCode))"; exit 1 } + Stop-Process -Id $app.Id -Force + Start-Sleep -Seconds 2 + Start-Process (Join-Path $dir 'unins000.exe') -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait + Start-Sleep -Seconds 3 + if (Test-Path $exe) { Write-Error 'the uninstaller left pinbench.exe behind'; exit 1 } + - name: Package (Linux tarball) if: matrix.platform == 'linux' run: | mkdir -p dist tar -czf "dist/pinbench-$VERSION-linux.tar.gz" -C build/linux/x64/release/bundle . + # Unpacked from the tarball as a user would, and started on a virtual + # display (Mesa's software GL stands in for a GPU) to see it stay up. + - name: Launch from the tarball (Linux) + if: matrix.platform == 'linux' + run: | + sudo apt-get install -y xvfb libgl1-mesa-dri + mkdir -p "$RUNNER_TEMP/app" + tar -xzf "dist/pinbench-$VERSION-linux.tar.gz" -C "$RUNNER_TEMP/app" + xvfb-run -a "$RUNNER_TEMP/app/pinbench" > "$RUNNER_TEMP/app.log" 2>&1 & pid=$! + sleep 15 + if ! kill -0 "$pid" 2>/dev/null; then + cat "$RUNNER_TEMP/app.log" + echo "::error::the app exited within 15 s of launch" + exit 1 + fi + kill "$pid" + - uses: actions/upload-artifact@v7 with: name: pinbench-${{ matrix.platform }} @@ -204,6 +243,39 @@ jobs: path: dist merge-multiple: true + # The app installs an update only with a valid signature: Sparkle checks + # the .dmg's Ed25519 one against Info.plist's SUPublicEDKey, WinSparkle + # the installer's DSA one against Runner.rc's DSAPub. Each signature is + # verified here against those embedded keys, so a release signed with + # the wrong key fails now instead of every user's next update. + - name: Sign the updates + env: + ED_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + DSA_KEY: ${{ secrets.WINSPARKLE_DSA_PRIVATE_KEY }} + run: | + if [ -z "$ED_KEY" ] || [ -z "$DSA_KEY" ]; then + echo "::error::SPARKLE_ED_PRIVATE_KEY and WINSPARKLE_DSA_PRIVATE_KEY must be set: the app rejects unsigned updates" + exit 1 + fi + keys="$RUNNER_TEMP/keys" && mkdir -m 700 "$keys" + printf '%s\n' "$ED_KEY" > "$keys/ed.pem" + printf '%s\n' "$DSA_KEY" > "$keys/dsa.pem" + dmg="dist/pinbench-$VERSION-macos.dmg" + exe="dist/pinbench-$VERSION-windows-setup.exe" + + openssl pkeyutl -sign -inkey "$keys/ed.pem" -rawin -in "$dmg" -out "$keys/ed.sig" + openssl dgst -sha1 -binary < "$exe" | openssl dgst -sha1 -sign "$keys/dsa.pem" -out "$keys/dsa.sig" + + # The app's public keys, as it carries them. + ed_public=$(sed -n '/SUPublicEDKey<\/key>/{n;s/.*\(.*\)<\/string>.*/\1/p;}' macos/Runner/Info.plist) + { printf '\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00'; printf '%s' "$ed_public" | base64 -d; } > "$keys/ed_public.der" + openssl pkeyutl -verify -pubin -keyform DER -inkey "$keys/ed_public.der" -rawin -in "$dmg" -sigfile "$keys/ed.sig" + openssl dgst -sha1 -binary < "$exe" | openssl dgst -sha1 -verify windows/runner/resources/winsparkle_dsa_public.pem -signature "$keys/dsa.sig" + + echo "ED_SIGNATURE=$(base64 -w0 < "$keys/ed.sig")" >> "$GITHUB_ENV" + echo "DSA_SIGNATURE=$(base64 -w0 < "$keys/dsa.sig")" >> "$GITHUB_ENV" + rm -rf "$keys" + - name: Write the updater's files and the checksums run: | tag="v$VERSION" @@ -217,10 +289,14 @@ jobs: --out dist \ --asset "macos=dist/pinbench-$VERSION-macos.dmg" \ --asset "windows=dist/pinbench-$VERSION-windows-setup.exe" \ - --asset "linux=dist/pinbench-$VERSION-linux.tar.gz" + --asset "linux=dist/pinbench-$VERSION-linux.tar.gz" \ + --ed-signature "macos=$ED_SIGNATURE" \ + --dsa-signature "windows=$DSA_SIGNATURE" (cd dist && sha256sum pinbench-* > SHA256SUMS.txt) ls -la dist cat dist/SHA256SUMS.txt + grep -q 'sparkle:edSignature=' dist/appcast-macos.xml + grep -q 'sparkle:dsaSignature=' dist/appcast-windows.xml - name: Keep the trial's files if: github.event_name == 'pull_request' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b497067..f1cfc67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -201,4 +201,12 @@ installer and a `.tar.gz`, writes the updater's appcasts and `latest.json` (`tools/release_metadata.dart`) and `SHA256SUMS.txt`, and makes a **draft** release. Publishing the draft is what the updater and the download page see. A tag `vX.Y.Z` gets build number `X*10000 + Y*100 + Z`, so tags must only go -up. The builds are not code-signed yet. +up. The builds are not code-signed yet, but every update is: the app installs +one only with a valid signature from the release keys, an Ed25519 key for +Sparkle on macOS (public half in `macos/Runner/Info.plist`) and a DSA key for +WinSparkle on Windows (public half in `windows/runner/resources/`). The +private halves are the `SPARKLE_ED_PRIVATE_KEY` and +`WINSPARKLE_DSA_PRIVATE_KEY` secrets, with a backup kept offline. **Never +replace them** once a release is out: an installed app accepts only updates +signed with the key it shipped with, so a new key strands every user on the +version they have. diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 3bf144f..a92a598 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -44,5 +44,10 @@ GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS + + SUPublicEDKey + jqMWn4Bb3aiIhESqJSLgTYG42nx6nAvWt6ONYS5KLpM= diff --git a/test/tools/release_metadata_test.dart b/test/tools/release_metadata_test.dart index df67464..8b8ec63 100644 --- a/test/tools/release_metadata_test.dart +++ b/test/tools/release_metadata_test.dart @@ -68,6 +68,26 @@ void main() { asset: unsigned, ); expect(output, isNot(contains('edSignature'))); + expect(output, isNot(contains('dsaSignature'))); + }); + + test("carries WinSparkle's DSA signature for the Windows feed", () { + const windows = AssetInfo( + fileName: 'app-setup.exe', + url: 'https://example.test/app-setup.exe', + sha256: 'beef', + size: 1, + dsaSignature: 'ZHNhLXNpZw==', + ); + final output = buildAppcast( + version: '0.4.0', + buildNumber: 4000, + notesUrl: 'https://example.test/tag/v0.4.0', + publishedAt: DateTime.utc(2026, 8, 13, 12), + asset: windows, + ); + expect(output, contains('sparkle:dsaSignature="ZHNhLXNpZw=="')); + expect(output, isNot(contains('edSignature'))); }); test('escapes a URL containing an ampersand', () { @@ -102,10 +122,13 @@ void main() { 'linux=dist/a.tar.gz', '--ed-signature', 'macos=sig', + '--dsa-signature', + 'windows=dsa', ]); expect(options.assets, {'macos': 'dist/a.dmg', 'linux': 'dist/a.tar.gz'}); expect(options.edSignatures, {'macos': 'sig'}); + expect(options.dsaSignatures, {'windows': 'dsa'}); // The trailing slash would otherwise produce `…/v0.4.0//a.dmg`. expect(options.baseUrl, 'https://example.test/v0.4.0'); }); @@ -126,8 +149,11 @@ void main() { '2026-08-13T12:00:00Z', '--ed-signature', 'macos=', + '--dsa-signature', + 'windows=', ]); expect(options.edSignatures, isEmpty); + expect(options.dsaSignatures, isEmpty); }); }); diff --git a/tools/release_metadata.dart b/tools/release_metadata.dart index b5e8a98..62b4448 100644 --- a/tools/release_metadata.dart +++ b/tools/release_metadata.dart @@ -15,7 +15,8 @@ // --asset macos=dist/App-macos.dmg \ // --asset windows=dist/App-windows-setup.exe \ // --asset linux=dist/App-linux.tar.gz \ -// --ed-signature macos= +// --ed-signature macos= \ +// --dsa-signature windows= // // Written in Dart rather than as a shell heredoc because it has to hash // files, XML-escape strings and emit two different formats — and because a @@ -48,6 +49,7 @@ Future generateReleaseMetadata(List args) async { sha256: sha256.convert(bytes).toString(), size: bytes.length, edSignature: options.edSignatures[entry.key], + dsaSignature: options.dsaSignatures[entry.key], ); } @@ -100,11 +102,15 @@ class const AssetInfo({ required final String sha256, required final int size, - /// Sparkle's EdDSA signature over the file, from `sign_update`. Null when - /// no signing key was available — Sparkle then falls back to verifying the - /// update's Developer ID against the running app's, which is weaker but is - /// still a real check, and is better than refusing to publish. + /// Sparkle's EdDSA (Ed25519) signature over the file, which the macOS app + /// checks against the SUPublicEDKey in its Info.plist: with that key set, + /// Sparkle refuses an update without a valid one. final String? edSignature, + + /// WinSparkle's DSA signature (over the file's SHA-1), which the Windows + /// app checks against the DSAPub resource in Runner.rc. WinSparkle 0.8, + /// which auto_updater bundles, verifies DSA, not EdDSA. + final String? dsaSignature, }); /// A single-item Sparkle appcast. @@ -121,7 +127,10 @@ String buildAppcast({ required DateTime publishedAt, required AssetInfo asset, }) { - final signature = asset.edSignature; + final signatures = [ + if (asset.edSignature case final ed?) 'sparkle:edSignature="${_xml(ed)}"', + if (asset.dsaSignature case final dsa?) 'sparkle:dsaSignature="${_xml(dsa)}"', + ].map((attribute) => '\n $attribute').join(); return ''' @@ -138,7 +147,7 @@ String buildAppcast({ ${_xml(notesUrl)} + type="application/octet-stream"$signatures /> @@ -185,11 +194,13 @@ class const ReleaseOptions({ required final String outDir, required final Map assets, required final Map edSignatures, + required final Map dsaSignatures, }) { factory parse(List args) { final single = {}; final assets = {}; final signatures = {}; + final dsaSignatures = {}; for (var i = 0; i < args.length; i += 2) { final name = args[i].replaceFirst(RegExp('^--'), ''); @@ -205,6 +216,10 @@ class const ReleaseOptions({ // flag — treat it as absent rather than emitting an empty // signature attribute, which Sparkle rejects the whole item over. if (signature.isNotEmpty) signatures[value.substring(0, split)] = signature; + case 'dsa-signature': + final split = value.indexOf('='); + final signature = value.substring(split + 1); + if (signature.isNotEmpty) dsaSignatures[value.substring(0, split)] = signature; default: single[name] = value; } @@ -220,6 +235,7 @@ class const ReleaseOptions({ outDir: single['out'] ?? 'dist', assets: assets, edSignatures: signatures, + dsaSignatures: dsaSignatures, ); } } diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 7fa5af3..f126c74 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -54,6 +54,17 @@ END // remains consistent on all systems. IDI_APP_ICON ICON "resources\\app_icon.ico" +///////////////////////////////////////////////////////////////////////////// +// +// WinSparkle +// + +// The public half of the update-signing DSA key: WinSparkle installs an +// update only with a valid signature from the private half (the +// WINSPARKLE_DSA_PRIVATE_KEY secret, which .github/workflows/release.yml +// signs each release with). +DSAPub DSAPEM "resources\\winsparkle_dsa_public.pem" + ///////////////////////////////////////////////////////////////////////////// // diff --git a/windows/runner/resources/winsparkle_dsa_public.pem b/windows/runner/resources/winsparkle_dsa_public.pem new file mode 100644 index 0000000..0b0ed2b --- /dev/null +++ b/windows/runner/resources/winsparkle_dsa_public.pem @@ -0,0 +1,36 @@ +-----BEGIN PUBLIC KEY----- +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 +KELw1aS55bBPk0SDv5kC4fdjMtFwZg== +-----END PUBLIC KEY-----