diff --git a/.github/workflows/linux-bundles.yml b/.github/workflows/linux-bundles.yml index 45364871..87100ec3 100644 --- a/.github/workflows/linux-bundles.yml +++ b/.github/workflows/linux-bundles.yml @@ -51,6 +51,11 @@ name: Linux bundles (AppImage + Flatpak) # see SECURITY.md "Release integrity"), so a CI bot can't push to it; this # keeps the Learn site (this repo's docs/) untouched. When the token is unset, the # OSTree publish skips cleanly (the .flatpak bundle still attaches). +# 5. (REQUIRED) AppImage signing key: environment secrets APPIMAGE_GPG_KEY (the +# ASCII-armored PRIVATE key) and APPIMAGE_GPG_KEY_ID (its full fingerprint) +# on release-publish, plus the committed public key +# packaging/appimage/keyroost-appimage-signing.asc. Unlike the Flatpak key, +# a missing key FAILS the AppImage job (probes too). on: push: @@ -141,27 +146,124 @@ jobs: with: toolchain: stable - # build-appimage.sh builds `keyroost` (release), fetches linuxdeploy + - # its appimage plugin, stages the AppDir, and emits keyroost-x86_64.AppImage - # into target/appimage/. APPIMAGE_EXTRACT_AND_RUN avoids needing FUSE for the - # tools themselves inside CI. + # The AppImage is GPG-signed with a dedicated key (SECURITY.md "Release + # integrity"). Once releases are signed, an unsigned one must not + # ship, so a missing key or public key fails the run, probes included: probes exercise the same + # signing path a release takes. The committed public key is checked + # first, before the long compile. + - name: Check AppImage signing public key is committed + run: | + set -euo pipefail + pub=packaging/appimage/keyroost-appimage-signing.asc + if [ ! -s "${pub}" ]; then + echo "::error::${pub} is missing. The AppImage is signed and verified against this committed public key; see packaging/LINUX-BUNDLES.md (one-time setup, AppImage signing key)." + exit 1 + fi + + # The GUI binary is built in its own step, before the signing key is + # imported: `cargo build` runs dependency build scripts and proc-macros, + # and at this point no secret is in their environment and no key is in + # any keyring. (The packaging step below, which signs, does have the + # key; build-appimage.sh hands it only to the final plugin run.) + - name: Build GUI binary + run: cargo build --release -p keyroost --features keyroost/qr + + # Signing key = environment secrets on release-publish (this job's + # environment), exposed ONLY to this step through env:. GNUPGHOME is a + # per-run temp dir, set per step (not via GITHUB_ENV) so no later step + # inherits it. The imported key must be exactly the committed public + # key: a wrong secret fails here rather than shipping a key change. + - name: Import AppImage signing key + env: + APPIMAGE_GPG_KEY: ${{ secrets.APPIMAGE_GPG_KEY }} + APPIMAGE_GPG_KEY_ID: ${{ secrets.APPIMAGE_GPG_KEY_ID }} + GNUPGHOME: ${{ runner.temp }}/gnupg-appimage + run: | + set -euo pipefail + if [ -z "${APPIMAGE_GPG_KEY:-}" ] || [ -z "${APPIMAGE_GPG_KEY_ID:-}" ]; then + echo "::error::APPIMAGE_GPG_KEY / APPIMAGE_GPG_KEY_ID are not set on the release-publish environment. The AppImage must be signed (an unsigned release is refused by updaters once signed ones exist); see packaging/LINUX-BUNDLES.md (one-time setup)." + exit 1 + fi + norm() { tr -d ' \n' | tr 'a-f' 'A-F'; } + want="$(printf '%s' "${APPIMAGE_GPG_KEY_ID}" | norm)" + pub=packaging/appimage/keyroost-appimage-signing.asc + [ -s "${pub}" ] || { echo "::error::${pub} is missing"; exit 1; } + rm -rf "${GNUPGHOME}" + mkdir -m 700 "${GNUPGHOME}" + committed="$(gpg --batch --with-colons --show-keys "${pub}" \ + | awk -F: '$1 == "fpr" {print $10}')" + if [ "$(printf '%s\n' "${committed}" | grep -c .)" != "1" ]; then + echo "::error::${pub} must hold exactly one key with no subkeys" + exit 1 + fi + printf '%s\n' "${APPIMAGE_GPG_KEY}" | gpg --batch --quiet --import + secret="$(gpg --batch --with-colons --list-secret-keys \ + | awk -F: '$1 == "fpr" {print $10}')" + if [ "$(printf '%s\n' "${secret}" | grep -c .)" != "1" ]; then + echo "::error::APPIMAGE_GPG_KEY must hold exactly one secret key with no subkeys" + exit 1 + fi + if [ "$(printf '%s' "${secret}" | norm)" != "${want}" ]; then + echo "::error::the key in APPIMAGE_GPG_KEY does not match APPIMAGE_GPG_KEY_ID" + exit 1 + fi + if [ "$(printf '%s' "${committed}" | norm)" != "${want}" ]; then + echo "::error::the signing key does not match the committed ${pub}; a different key would be refused by updaters (KEY_CHANGED). See SECURITY.md before rotating." + exit 1 + fi + echo "signing key imported and matches ${pub}" + + # Packaging only (KEYROOST_APPIMAGE_SKIP_CARGO): fetches the pinned + # linuxdeploy + appimage plugin, stages the AppDir, and emits + # keyroost-x86_64.AppImage (+ .zsync) into target/appimage/. LDAI_SIGN / + # LDAI_SIGN_KEY make the plugin's appimagetool sign, which happens before + # it writes the .zsync. APPIMAGE_EXTRACT_AND_RUN avoids needing FUSE for + # the tools themselves inside CI. The key ID is passed through env: like + # any secret; the key itself is only in the temp keyring. - name: Build AppImage env: APPIMAGE_EXTRACT_AND_RUN: '1' + KEYROOST_APPIMAGE_SKIP_CARGO: '1' + LDAI_SIGN: '1' + LDAI_SIGN_KEY: ${{ secrets.APPIMAGE_GPG_KEY_ID }} + GNUPGHOME: ${{ runner.temp }}/gnupg-appimage run: bash packaging/appimage/build-appimage.sh + - name: Remove AppImage signing key + if: always() + env: + GNUPGHOME: ${{ runner.temp }}/gnupg-appimage + run: | + gpgconf --kill gpg-agent 2>/dev/null || true + rm -rf "${GNUPGHOME}" + - name: Locate AppImage id: find run: | set -euo pipefail - img="$(ls target/appimage/*.AppImage | head -n1)" + # Explicit name: the folder also holds the linuxdeploy tool + # AppImages, and the guard, Attest, checksum and upload must all + # use this very file. + img=target/appimage/keyroost-x86_64.AppImage + [ -f "${img}" ] || { echo "::error::${img} was not produced"; exit 1; } echo "path=${img}" >> "$GITHUB_OUTPUT" echo "found ${img}" # zsync sidecar for AppImageUpdate delta updates (#53), if produced. - zsync="$(ls target/appimage/*.AppImage.zsync 2>/dev/null | head -n1 || true)" + zsync= + [ ! -f "${img}.zsync" ] || zsync="${img}.zsync" echo "zsync=${zsync}" >> "$GITHUB_OUTPUT" echo "zsync ${zsync:-}" + # Guard: nothing is attested or uploaded unless the signature verifies + # against the COMMITTED public key alone, the update information is + # intact, and the .zsync describes the signed bytes. No secrets here. + # (CI logs may show the fingerprint as ***: its key ID is a secret, so + # GitHub masks it. The fingerprint itself is published in SECURITY.md.) + - name: Verify AppImage signature + run: | + bash packaging/appimage/verify-appimage-signature.sh --require-zsync \ + "${{ steps.find.outputs.path }}" + # Give the AppImage the same provenance + checksum treatment the main # release archives get in release.yml. - name: Attest AppImage provenance @@ -181,8 +283,8 @@ jobs: with: name: keyroost-appimage path: | - target/appimage/*.AppImage - target/appimage/*.AppImage.zsync + ${{ steps.find.outputs.path }} + ${{ steps.find.outputs.zsync }} # Attach to the Release for $TAG (release.yml's on a tag push, an # existing one on a dispatch republish). Retry patiently (20×30s — v0.7.6 diff --git a/README.md b/README.md index d4b77252..349ac136 100644 --- a/README.md +++ b/README.md @@ -501,6 +501,9 @@ chmod +x keyroost-x86_64.AppImage ./keyroost-x86_64.AppImage --appimage-extract-and-run ``` +The AppImage is GPG-signed (key fingerprint `F9E0 4EED 9DC5 DC94 AB2E 6100 6C81 DC35 3794 478B`); see +[SECURITY.md → Verifying the AppImage](SECURITY.md#verifying-the-appimage). + > **Uses the host's pcsc-lite.** The AppImage always prefers the host's > pcsc-lite client library, so the smart-card client matches the host's `pcscd` > daemon; smart cards need `pcscd` installed (see diff --git a/SECURITY.md b/SECURITY.md index 81eaa2a3..e2129458 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -141,7 +141,7 @@ What keyroost does **not** defend against: ## Release integrity -Four mechanisms: +Five mechanisms: - **Signed tags.** Release tags are signed by the maintainer with a hardware key; tag creation is restricted to the repository admin by ruleset. The @@ -155,6 +155,25 @@ Four mechanisms: flatpak publish separate `.sha256` files. - **crates.io Trusted Publishing.** Crates are published by the release workflow over OIDC. No long-lived publishing token exists. +- **AppImage signature.** `keyroost-x86_64.AppImage` carries an embedded + GPG signature from a dedicated signing key, fingerprint + `F9E0 4EED 9DC5 DC94 AB2E 6100 6C81 DC35 3794 478B`. The public key is committed as + `packaging/appimage/keyroost-appimage-signing.asc`, so the signed release + tag covers it. AppImageUpdate-based updaters (appimageupdatetool, + AppImageLauncher) compare the key in the installed AppImage with the key + in the update and refuse an update signed by a different key, and an + unsigned installed AppImage is refused an update to a signed one. That is key continuity between updates. It does not + authenticate a first download: anyone can embed their own key in an + AppImage, so a first download is checked with the attestation and the + `.sha256` file, or against the fingerprint above (see "Verifying the + AppImage" below). The private key exists only as a GitHub environment + secret behind the release approval gate; the release workflow refuses to + build an AppImage when it is missing, or when it does not match the + committed public key. **Rotation:** the key does not expire and is not + rotated on a schedule. It is replaced only if it is lost or compromised. + A new key makes AppImageUpdate refuse that update ("key changed"), so + users download the new AppImage by hand once; the release notes say so + and SECURITY.md and the README publish the new fingerprint. Commits on `main` are not required to be signed. Changes land through reviewed pull requests (`packaging/REVIEWING.md` is the review checklist); @@ -177,5 +196,52 @@ The release assets are named `keyroost--linux-x86_64.tar.gz`, `keyroost--macos-universal2.tar.gz` and `keyroost--windows-x86_64.zip`; `SHA256SUMS` covers all three. +### Verifying the AppImage + +```sh +sha256sum -c keyroost-x86_64.AppImage.sha256 +gh attestation verify keyroost-x86_64.AppImage --repo framefilter/keyroost +``` + +The embedded signature can also be checked against the published key, with +the fingerprint `F9E0 4EED 9DC5 DC94 AB2E 6100 6C81 DC35 3794 478B`. From a checkout of the release tag, +one command checks the signature, that the embedded key is the published +one, and the update information: + +```sh +bash packaging/appimage/verify-appimage-signature.sh keyroost-x86_64.AppImage +``` + +Or by hand with `readelf` and `gpg`. The signed data is the SHA-256 of the +file with the `.sha256_sig` and `.sig_key` sections zeroed, as a lowercase +hex string: + +```sh +export GNUPGHOME="$(mktemp -d)" # a throwaway keyring, not your own +gpg --import packaging/appimage/keyroost-appimage-signing.asc +f=keyroost-x86_64.AppImage +cp "$f" zeroed +for s in .sha256_sig .sig_key; do + set -- $(readelf -SW "$f" | sed 's/\[ */[/' | awk -v s="$s" '$2 == s {print $5, $6}') + [ "$s" = .sha256_sig ] && dd if="$f" bs=1 skip=$((0x$1)) count=$((0x$2)) status=none | tr -d '\000' > sig.asc + dd if=/dev/zero of=zeroed bs=1 seek=$((0x$1)) count=$((0x$2)) conv=notrunc status=none +done +printf %s "$(sha256sum zeroed | cut -d' ' -f1)" > digest.txt +gpg --verify sig.asc digest.txt # "Good signature"; compare the fingerprint +``` + +In the release workflow's logs the fingerprint may appear as `***`: its key +ID is stored as a secret, so GitHub masks it. The fingerprint is public and +is the one published above and in the README. + +AppImageUpdate's `validate` tool (`validate-x86_64.AppImage` from its +releases) checks a file against the key embedded in it, so "validation +successful" means something only once the fingerprint it prints matches the +one above: + +```sh +./validate-x86_64.AppImage keyroost-x86_64.AppImage +``` + Or skip the question entirely and build from source with `cargo build --release --locked`. diff --git a/changelog.d/added-171-appimage-signature.md b/changelog.d/added-171-appimage-signature.md new file mode 100644 index 00000000..8a33f6d8 --- /dev/null +++ b/changelog.d/added-171-appimage-signature.md @@ -0,0 +1,7 @@ +- **The AppImage is GPG-signed.** Each release's AppImage carries an + embedded signature from a dedicated signing key; its fingerprint is in + SECURITY.md and the README, which also explain how to check it. + AppImageUpdate-based updaters (appimageupdatetool, AppImageLauncher) + now refuse an update signed by a different key. Users updating an + unsigned AppImage (v0.12.x or earlier) with these tools download this + release by hand once. ([#171]) diff --git a/changelog.d/fixed-170-bulk-import-dialog.md b/changelog.d/fixed-170-bulk-import-dialog.md new file mode 100644 index 00000000..2e582f3b --- /dev/null +++ b/changelog.d/fixed-170-bulk-import-dialog.md @@ -0,0 +1,10 @@ +- **The Molto2 bulk import dialog closes and says why "Program all" is + gray.** Close in the bulk import dialog, and Cancel in the "Import to + slot" dialog, did nothing; both now close the dialog, and closing + either dialog by any route wipes the typed vault password or pasted URI + and drops the parsed entries (which carry seeds). + When "Program all" can't run, one line under the buttons now says why: + the file is still loading, no Molto2 is selected, the Molto2 isn't + unlocked yet (with an Authenticate button right there, and a failed + attempt is reported in that line too), or the entries + don't all fit before slot #99 (with how many do). ([#170]) diff --git a/crates/keyroost/src/main.rs b/crates/keyroost/src/main.rs index 85aa19ee..c79f7598 100644 --- a/crates/keyroost/src/main.rs +++ b/crates/keyroost/src/main.rs @@ -3687,6 +3687,118 @@ struct BulkDialog { password: String, /// True once the loader has seen an encrypted vault at the current path. needs_password: bool, + /// The last Authenticate attempt failed (cleared on success and on close). + auth_failed: bool, +} + +impl ImportDialog { + /// Settle the dialog's open state after its window was drawn this frame. + /// `window_open` is the copy egui's `[X]` close wrote to; `cancel_clicked` is the + /// in-window Cancel button. A button must never write `open` directly from + /// inside the window closure: the write-back of the stale `window_open` + /// copy would undo it in the same frame (issue #170). + fn settle_open(&mut self, window_open: bool, cancel_clicked: bool) { + if self.open && !(window_open && !cancel_clicked) { + self.close(); + } + } + + /// Close the dialog and wipe the pasted URI, which carries the seed. + fn close(&mut self) { + self.open = false; + wipe(&mut self.uri); + } +} + +impl BulkDialog { + /// Settle the dialog's open state after its window was drawn this frame. + /// `window_open` is the copy egui's `[X]` close wrote to; `close_clicked` is the + /// in-window Close button, recorded as a flag because a direct write from + /// inside the window closure is undone by the write-back of the stale + /// `window_open` copy (issue #170). + fn settle_open(&mut self, window_open: bool, close_clicked: bool) { + if self.open && !(window_open && !close_clicked) { + self.close(); + } + } + + /// Close the dialog by any route and wipe the typed vault password, which + /// may have been kept live after a decrypt error to allow a retry. + fn close(&mut self) { + self.open = false; + wipe(&mut self.password); + // Parsed entries carry seeds; dropping them zeroizes them. + self.entries.clear(); + self.auth_failed = false; + } +} + +/// Why "Program all" in the bulk import dialog is disabled, if it is. The +/// dialog shows exactly one of these so the button is never gray without a +/// reason (issue #170). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BulkBlock { + /// The import file is still being read or decrypted. + Loading, + /// No Molto2 is connected and selected. + NoMolto, + /// The Molto2 has not been unlocked with its customer key. + NotUnlocked, + /// The entries run past slot #99; `fit` of `count` fit from `start`. + TooMany { start: u8, count: usize, fit: usize }, +} + +impl BulkBlock { + /// The first reason "Program all" can't run, in the order the user has + /// to fix them, or `None` when it can. + fn find( + loading: bool, + molto_selected: bool, + unlocked: bool, + start: u8, + count: usize, + ) -> Option { + let fit = (PROFILES as usize).saturating_sub(start as usize); + if loading { + Some(Self::Loading) + } else if !molto_selected { + Some(Self::NoMolto) + } else if !unlocked { + Some(Self::NotUnlocked) + } else if count > fit { + Some(Self::TooMany { start, count, fit }) + } else { + None + } + } + + /// The one plain line shown under the buttons and as the disabled + /// button's hover text. + fn message(self) -> String { + match self { + Self::Loading => "Still reading the file.".to_owned(), + Self::NoMolto => { + "No Molto2 selected. Connect one and select it in the list on the left.".to_owned() + } + Self::NotUnlocked => "The Molto2 is not unlocked yet. Click Authenticate \ + (uses the customer key from the Molto2 view; blank = factory default)." + .to_owned(), + Self::TooMany { start, count, fit } => { + let verb = if fit == 1 { "fits" } else { "fit" }; + let hint = if count <= PROFILES as usize { + format!( + "Start at slot #{:02} or earlier.", + PROFILES as usize - count + ) + } else { + format!("A Molto2 has {PROFILES} slots.") + }; + format!( + "Only {fit} of {count} entries {verb} between slot #{start:02} and #99. {hint}" + ) + } + } + } } struct LogLine { @@ -4026,14 +4138,19 @@ impl App { match result { Ok(()) => { app.authenticated = true; + app.bulk_dialog.auth_failed = false; app.log(Severity::Ok, "authenticated"); } // The Display impl renders the tries-remaining count (or // "unknown" when the card gave none). Err(e @ TransportError::AuthFailed { .. }) => { + app.bulk_dialog.auth_failed = true; app.log(Severity::Err, e.to_string()); } - Err(e) => app.log(Severity::Err, format!("auth failed: {}", e)), + Err(e) => { + app.bulk_dialog.auth_failed = true; + app.log(Severity::Err, format!("auth failed: {}", e)); + } } }) }); @@ -4295,8 +4412,7 @@ impl App { self.slot ), ); - self.import_dialog.open = false; - self.import_dialog.uri.clear(); + self.import_dialog.close(); } fn factory_reset(&mut self) { @@ -4579,7 +4695,7 @@ impl App { app.slot_meta = Some(m); } if fail == 0 { - app.bulk_dialog.open = false; + app.bulk_dialog.close(); } }) }); @@ -20804,11 +20920,13 @@ impl App { /// The Molto2 import dialogs (otpauth:// + bulk). Reused verbatim from the /// original Molto2 view; only the entry point changed. - fn molto_dialogs(&mut self, ctx: &egui::Context, _p: &Palette) { + fn molto_dialogs(&mut self, ctx: &egui::Context, p: &Palette) { if self.bulk_dialog.open { let mut open = self.bulk_dialog.open; let mut do_load = false; let mut do_apply = false; + let mut do_auth = false; + let mut close_clicked = false; egui::Window::new("Bulk import") .open(&mut open) .collapsible(false) @@ -20844,7 +20962,7 @@ impl App { ui.label(label.as_str()); } } - ui.label("Start at profile:"); + ui.label("Start at slot:"); ui.add( egui::DragValue::new(&mut self.bulk_dialog.start) .clamp_existing_to_range(true) @@ -20912,30 +21030,54 @@ impl App { )); } }); + // Block programming while a load is in flight so a + // stale entry list can't be written mid-replace, and + // name the one reason whenever the button is gray. + let blocked = BulkBlock::find( + self.import_busy(), + self.molto_session_usable(), + self.authenticated, + self.bulk_dialog.start, + self.bulk_dialog.entries.len(), + ); ui.horizontal(|ui| { - // Block programming while a load is in flight so a - // stale entry list can't be written mid-replace. - let can_apply = self.authenticated && !self.import_busy(); - if ui - .add_enabled(can_apply, egui::Button::new("Program all")) - .on_hover_text("Write seed, title, and config for every entry") - .clicked() - { + let resp = ui + .add_enabled(blocked.is_none(), egui::Button::new("Program all")) + .on_hover_text("Write seed, title, and config for every entry"); + let resp = match blocked { + Some(b) => resp.on_disabled_hover_text(b.message()), + None => resp, + }; + if resp.clicked() { do_apply = true; } if ui.button("Close").clicked() { - self.bulk_dialog.open = false; + close_clicked = true; + } + if blocked == Some(BulkBlock::NotUnlocked) + && ui + .add_enabled(!self.busy(), egui::Button::new("Authenticate")) + .clicked() + { + do_auth = true; } }); + if let Some(b) = blocked { + let msg = if b == BulkBlock::NotUnlocked && self.bulk_dialog.auth_failed + { + "Couldn't unlock the Molto2 \u{2014} check the customer key".to_owned() + } else { + b.message() + }; + ui.colored_label(p.warn, msg); + } } }); - let was_open = self.bulk_dialog.open; - self.bulk_dialog.open = open; - // Scrub the typed vault password when the dialog closes (X button or - // programmatic close), so a password that survived a decrypt error - // — kept live to allow a retry — never lingers past the dialog. - if was_open && !open { - wipe(&mut self.bulk_dialog.password); + // Apply Close and [X] only now, after the window is drawn; either + // route wipes the typed vault password. + self.bulk_dialog.settle_open(open, close_clicked); + if do_auth { + self.authenticate(); } if do_load { self.bulk_load(); @@ -20948,7 +21090,8 @@ impl App { if self.import_dialog.open { let mut open = self.import_dialog.open; let mut should_apply = false; - egui::Window::new(format!("Import to profile #{:02}", self.slot)) + let mut cancel_clicked = false; + egui::Window::new(format!("Import to slot #{:02}", self.slot)) .open(&mut open) .collapsible(false) .resizable(false) @@ -20967,12 +21110,13 @@ impl App { should_apply = true; } if ui.button("Cancel").clicked() { - self.import_dialog.open = false; - self.import_dialog.uri.clear(); + cancel_clicked = true; } }); }); - self.import_dialog.open = open; + // Apply Cancel and [X] only now, after the window is drawn; either + // route wipes the pasted URI. + self.import_dialog.settle_open(open, cancel_clicked); if should_apply { self.import_otpauth(); } @@ -21148,6 +21292,108 @@ mod tests { assert!(settings::config_path().unwrap().starts_with(&tmp)); } + /// Issue #170: the in-window Close button closes the bulk dialog even + /// though egui's [X] copy still says open, and wipes the vault password. + #[test] + fn bulk_dialog_close_button_wins_over_stale_open_copy() { + let mut d = BulkDialog { + open: true, + password: "hunter2".to_owned(), + ..Default::default() + }; + d.settle_open(true, true); + assert!(!d.open); + assert!(d.password.is_empty()); + } + + #[test] + fn bulk_dialog_x_closes_and_wipes_password() { + let mut d = BulkDialog { + open: true, + password: "hunter2".to_owned(), + ..Default::default() + }; + d.settle_open(false, false); + assert!(!d.open); + assert!(d.password.is_empty()); + } + + #[test] + fn bulk_dialog_stays_open_and_keeps_password_without_a_close() { + let mut d = BulkDialog { + open: true, + password: "hunter2".to_owned(), + ..Default::default() + }; + d.settle_open(true, false); + assert!(d.open); + assert_eq!(d.password, "hunter2"); + } + + /// Issue #170, same bug: Cancel in the "Import to slot" dialog. + #[test] + fn import_dialog_cancel_and_x_close_and_wipe_uri() { + for (window_open, cancel) in [(true, true), (false, false)] { + let mut d = ImportDialog { + open: true, + uri: "otpauth://totp/x?secret=JBSWY3DPEHPK3PXP".to_owned(), + }; + d.settle_open(window_open, cancel); + assert!(!d.open); + assert!(d.uri.is_empty()); + } + let mut d = ImportDialog { + open: true, + uri: "otpauth://totp/x".to_owned(), + }; + d.settle_open(true, false); + assert!(d.open); + assert!(!d.uri.is_empty()); + } + + #[test] + fn bulk_block_reports_reasons_in_fix_order() { + assert_eq!( + BulkBlock::find(true, false, false, 0, 1), + Some(BulkBlock::Loading) + ); + assert_eq!( + BulkBlock::find(false, false, false, 0, 1), + Some(BulkBlock::NoMolto) + ); + assert_eq!( + BulkBlock::find(false, true, false, 0, 1), + Some(BulkBlock::NotUnlocked) + ); + assert_eq!(BulkBlock::find(false, true, true, 0, 1), None); + } + + #[test] + fn bulk_block_too_many_counts_what_fits_before_slot_99() { + // #90..#99 is ten slots: ten entries fit, eleven do not. + assert_eq!(BulkBlock::find(false, true, true, 90, 10), None); + let b = BulkBlock::find(false, true, true, 90, 11).unwrap(); + assert_eq!( + b, + BulkBlock::TooMany { + start: 90, + count: 11, + fit: 10 + } + ); + assert_eq!( + b.message(), + "Only 10 of 11 entries fit between slot #90 and #99. Start at slot #89 or earlier." + ); + let one = BulkBlock::find(false, true, true, 99, 2).unwrap(); + assert!(one.message().starts_with("Only 1 of 2 entries fits")); + let huge = BulkBlock::find(false, true, true, 0, 120).unwrap(); + assert_eq!( + huge.message(), + "Only 100 of 120 entries fit between slot #00 and #99. A Molto2 has 100 slots." + ); + } + /// A minimal `PivStatus` reporting just `fingerprint`/`version` — enough /// to drive `App::piv_current_default_mgmt_key`'s resolution in a test. /// `PivStatus` is `#[non_exhaustive]`, so this crate can't use struct- diff --git a/docs/assets/screenshots/overview.png b/docs/assets/screenshots/overview.png new file mode 100644 index 00000000..1196baa1 Binary files /dev/null and b/docs/assets/screenshots/overview.png differ diff --git a/packaging/LINUX-BUNDLES.md b/packaging/LINUX-BUNDLES.md index b2ad648b..603bcccb 100644 --- a/packaging/LINUX-BUNDLES.md +++ b/packaging/LINUX-BUNDLES.md @@ -10,7 +10,7 @@ > > Before a tagged release produces working bundles, do the > [one-time maintainer setup](#one-time-maintainer-setup) (icon, pcsc-lite -> sha256, optional GPG key, Pages). Remaining open decisions are in +> sha256, optional Flatpak GPG key, Pages, AppImage signing key). Remaining open decisions are in > [Decisions](#decisions) at the bottom — the resolved ones are marked **DECIDED**. ## One-time maintainer setup @@ -95,6 +95,38 @@ bundles automatically. 5. **App-id — DECIDED.** `io.github.framefilter.keyroost`. No action needed. +6. **AppImage signing key (REQUIRED).** The AppImage job signs every + AppImage and **fails when the key is missing**, build-only probes included + (once releases are signed, an unsigned one must not ship, + so the job never falls back to unsigned). Use a key of its own, not the + Flatpak key, so each channel can be rotated alone. Once, from the repo + root: + - Generate an ed25519 signing-only key that never expires, with no + passphrase and a non-personal UID, in a throwaway GnuPG home: + `gpg --batch --passphrase '' --quick-generate-key "keyroost AppImage Signing Key" ed25519 sign never`. + A minimal key keeps the exported public key well under the 8 KiB + `.sig_key` section. + - Pipe the armored private key straight into an **environment secret** + on `release-publish` (released only after the approval gate), and store + the full fingerprint next to it: + `gpg --armor --export-secret-keys | gh secret set APPIMAGE_GPG_KEY --env release-publish` + and `printf %s | gh secret set APPIMAGE_GPG_KEY_ID --env release-publish`. + - Export the public key to `packaging/appimage/keyroost-appimage-signing.asc` + and commit it; put the fingerprint in SECURITY.md ("Release integrity") + and the README's AppImage section. + - Delete the throwaway GnuPG home. No other copy of the private key is + kept: GitHub secrets cannot be read back, so a lost secret means a new + key, and a new key makes AppImageUpdate refuse that one update + (SECURITY.md describes the rotation policy). + + The workflow checks that the imported key matches both + `APPIMAGE_GPG_KEY_ID` and the committed `.asc`, builds the GUI binary + before the key is imported (so the secret is not in the build's + environment; the packaging step that signs does have it, and + `build-appimage.sh` passes it only to the final plugin run), removes the key after packaging, and runs + `packaging/appimage/verify-appimage-signature.sh` as a guard before the + AppImage is attested or uploaded. + ## User install instructions ### Flatpak — auto-updating remote (recommended) @@ -428,6 +460,8 @@ See `packaging/appimage/build-appimage.sh`. Outline: ```bash cargo build --release -p keyroost --features keyroost/qr # GUI binary (glibc) +# (CI runs that build as its own step, then the script with +# KEYROOST_APPIMAGE_SKIP_CARGO=1 LDAI_SIGN=1 LDAI_SIGN_KEY=) # stage AppDir, copy binary + desktop + icon, let linuxdeploy bundle libs: linuxdeploy --appdir AppDir \ --executable target/release/keyroost \ @@ -435,7 +469,7 @@ linuxdeploy --appdir AppDir \ --desktop-file packaging/flatpak/io.github.framefilter.keyroost.desktop \ --icon-file # move libpcsclite into usr/lib/pcsc-fallback/, wrap AppRun, then package: -linuxdeploy-plugin-appimage --appdir AppDir +linuxdeploy-plugin-appimage --appdir AppDir # signs (if LDAI_SIGN), then writes .zsync ``` (The desktop file + icon are **reused from the Flatpak drafts** — same app-id, @@ -459,7 +493,13 @@ is proven for this app. the classic AppImage portability footgun. - FUSE dependency on the user's machine (see above). - Updates go through AppImageUpdate: the build embeds gh-releases zsync update - info and ships the `.zsync` file. + info and ships the `.zsync` file. The AppImage is GPG-signed (setup step 6), + and AppImageUpdate refuses an update whose key differs from the installed + AppImage's. An installed unsigned AppImage (v0.12.x and earlier) is + refused the update to a signed one too ("bad signature"), so that one + update is a manual download. (Verified with AppImageUpdate: unsigned to + signed refused, same-key updates accepted, key change refused.) + Gear Lever compares the `.zsync` SHA-1 and does not check signatures. - GUI-only by design; CLI users get the musl binary or `cargo install`. --- @@ -559,6 +599,7 @@ musl cross toolchain, then build libpcsclite there. See | PC/SC | bundle client lib + `--socket=pcsc` to host pcscd | host libpcsclite + pcscd; bundled client only as fallback | static musl libpcsclite (**unverified**) or FIDO-only | | FIDO HID | `--device=all` + `/run/udev:ro` | host hidraw + host udev rules | host hidraw + host udev rules | | Auto-update | yes (OSTree repo) | yes (.zsync) | no | +| Signing | GPG, OSTree repo (Flatpak key) | GPG, embedded (dedicated AppImage key) | n/a (not shipped) | | Host needs pcscd | yes | yes | yes | | glibc portability | n/a (runtime) | build on old glibc | fully static (no glibc) | | Verified on hardware | **no** | **no** | **no** | @@ -604,6 +645,10 @@ musl cross toolchain, then build libpcsclite there. See hardware testing shows what's actually required? 12. **DECIDED (#47):** use the host libpcsclite; a bundled copy in `usr/lib/pcsc-fallback/` is used only when the host has none. +13. **AppImage signing.** **DECIDED: signed**, with a dedicated ed25519 key + held as `release-publish` environment secrets; a missing key fails the + job (probes too); public key committed, fingerprint in SECURITY.md and + the README; a lost key means a new key (setup step 6). ## Claims to double-check (could not fully web-verify) diff --git a/packaging/RELEASING.md b/packaging/RELEASING.md index 4b0c5cce..ce6da51b 100644 --- a/packaging/RELEASING.md +++ b/packaging/RELEASING.md @@ -55,6 +55,12 @@ publishing gate. Version placeholder below: `vX.Y.Z`. Packaging pulls from upstreams that drift on their own schedule — the v0.7.3 flatpak broke at release time because an upstream source was pruned. Probes catch that; release runs must not. + Approving a probe releases the AppImage signing key to the dispatched + branch's code: check which branch it is before approving. + The AppImage job signs on probes too: its "Verify AppImage signature" + step must be green, and the job fails outright if the + `APPIMAGE_GPG_KEY` environment secrets or the committed + `packaging/appimage/keyroost-appimage-signing.asc` are missing. - [ ] **Packaged-crate asset check** (one command): `packaging/check-packaged-assets.sh` Every file a crate *references* (`include_str!`, `include_bytes!`, @@ -188,6 +194,15 @@ publishing gate. Version placeholder below: `vX.Y.Z`. `SHA256SUMS`, `keyroost-x86_64.AppImage` (+ `.sha256`, `.zsync`), `keyroost.flatpak` (+ `.sha256`). Check: `gh release view vX.Y.Z --json assets --jq '[.assets[].name]'` +- [ ] The AppImage job's "Verify AppImage signature" step is green (it + runs before the attestation, so a red guard means nothing was + attached). +- [ ] **First signed release only** (the first release after the switch + from unsigned AppImages): the release notes tell AppImageUpdate / + AppImageLauncher users to download this AppImage by hand once, since + an unsigned installed AppImage refuses the update to a signed one. + The same line is needed after any signing-key change (SECURITY.md, + rotation). ## 4. Fanout (publish.yml) @@ -290,6 +305,11 @@ publishing gate. Version placeholder below: `vX.Y.Z`. must print `X-AppImage-Version=X.Y.Z`. This is what AppImage managers (Gear Lever et al.) display; it comes from the `VERSION` export in `build-appimage.sh` (#98). +- [ ] The downloaded AppImage verifies against the published key: from a + checkout of the tag, + `bash packaging/appimage/verify-appimage-signature.sh keyroost-x86_64.AppImage` + prints `OK` with the fingerprint in SECURITY.md (AppImageUpdate's + `validate-x86_64.AppImage` must print the same fingerprint). - [ ] Close/comment the issues the release fixes (drafts usually prepared during the work); announcement if any. - [ ] Out-of-band corrections later (metadata fixes, asset re-attach): use the diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh index 0ce8b814..79c82f1e 100755 --- a/packaging/appimage/build-appimage.sh +++ b/packaging/appimage/build-appimage.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# build-appimage.sh — DRAFT. Build a keyroost (GUI) AppImage. NOT wired into CI. +# build-appimage.sh — build a keyroost (GUI) AppImage; linux-bundles.yml runs it. # See ../LINUX-BUNDLES.md for the full design, caveats, and open decisions. # # What this produces: a single self-contained `keyroost-x86_64.AppImage` bundling @@ -22,9 +22,30 @@ # or can run with: ./keyroost-x86_64.AppImage --appimage-extract-and-run # (TODO(maintainer): pin the appimagetool/runtime version and state the # exact FUSE2-vs-FUSE3 story for it — this changed recently.) +# +# ENVIRONMENT (all optional; a plain `bash build-appimage.sh` builds an +# unsigned AppImage exactly as before): +# KEYROOST_APPIMAGE_SKIP_CARGO=1 +# Package only: skip `cargo build` and use the existing +# target/release/keyroost (fails if it is missing). CI builds the binary +# in its own step first, before the signing key is imported. +# LDAI_SIGN=1 LDAI_SIGN_KEY= +# Read by linuxdeploy-plugin-appimage, which passes `-s --sign-key` to +# its bundled appimagetool. appimagetool signs with the host `gpg` +# (honoring GNUPGHOME), embeds the signature and public key in the +# .sha256_sig / .sig_key sections, and only then writes the .zsync, so +# the .zsync describes the signed bytes. Never modify the AppImage after +# this script returns. verify-appimage-signature.sh checks the result. set -euo pipefail +# Hold the signing settings back from every tool except the final plugin run +# (the only one whose appimagetool signs): linuxdeploy and the in-repo helper +# scripts run without GNUPGHOME / LDAI_SIGN* in their environment. +_sign_gnupghome="${GNUPGHOME-}"; _sign_set_gnupghome="${GNUPGHOME+x}" +_sign_flag="${LDAI_SIGN-}"; _sign_key="${LDAI_SIGN_KEY-}" +unset GNUPGHOME LDAI_SIGN LDAI_SIGN_KEY + # --------------------------------------------------------------------------- # Config (app-id + icon path match the Flatpak manifest so metadata stays # consistent across targets). @@ -56,10 +77,16 @@ export VERSION # --------------------------------------------------------------------------- # 1. Build the GUI binary (glibc, release). The CLI is intentionally NOT shipped # as an AppImage — use the musl static CLI (../musl/) or the release tarball. +# With KEYROOST_APPIMAGE_SKIP_CARGO=1 the binary must already exist (built +# with the same command, as linux-bundles.yml does in a separate step). # --------------------------------------------------------------------------- -echo ">> building keyroost (GUI) release binary" -( cd "${REPO_ROOT}" && cargo build --release -p keyroost --features keyroost/qr ) BIN="${REPO_ROOT}/target/release/keyroost" +if [ "${KEYROOST_APPIMAGE_SKIP_CARGO:-}" = "1" ]; then + echo ">> KEYROOST_APPIMAGE_SKIP_CARGO=1: packaging the existing ${BIN}" +else + echo ">> building keyroost (GUI) release binary" + ( cd "${REPO_ROOT}" && cargo build --release -p keyroost --features keyroost/qr ) +fi [ -x "${BIN}" ] || { echo "ERROR: ${BIN} not built"; exit 1; } # --------------------------------------------------------------------------- @@ -209,6 +236,15 @@ export UPDATE_INFORMATION="gh-releases-zsync|framefilter|keyroost|latest|keyroos # stable `keyroost-x86_64.AppImage`. Pin the output name explicitly so the # version lives only in the desktop-file metadata. export LDAI_OUTPUT="keyroost-x86_64.AppImage" +# Signing (see ENVIRONMENT at the top): appimagetool calls the host gpg. Only +# now are the signing settings handed back, for the plugin run alone. +[ -z "${_sign_set_gnupghome}" ] || export GNUPGHOME="${_sign_gnupghome}" +if [ -n "${_sign_flag}" ]; then export LDAI_SIGN="${_sign_flag}"; fi +if [ -n "${_sign_key}" ]; then export LDAI_SIGN_KEY="${_sign_key}"; fi +if [ -n "${LDAI_SIGN:-}" ]; then + command -v gpg >/dev/null || { echo "ERROR: LDAI_SIGN is set but gpg is not on PATH"; exit 1; } + echo ">> signing enabled (LDAI_SIGN): appimagetool signs before writing the .zsync" +fi ./linuxdeploy-plugin-appimage.AppImage --appdir "${APPDIR}" # --------------------------------------------------------------------------- diff --git a/packaging/appimage/keyroost-appimage-signing.asc b/packaging/appimage/keyroost-appimage-signing.asc new file mode 100644 index 00000000..182a7337 --- /dev/null +++ b/packaging/appimage/keyroost-appimage-signing.asc @@ -0,0 +1,9 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEasjzcBYJKwYBBAHaRw8BAQdAsyPFMnThGX0KzJeNcOqf46p5Kgh0HnVnosc+ +UUWUC2O0HWtleXJvb3N0IEFwcEltYWdlIFNpZ25pbmcgS2V5iJAEExYKADgWIQT5 +4E7tncXclKsuYQBsgdw1N5RHiwUCasjzcAIbAwULCQgHAgYVCgkICwIEFgIDAQIe +AQIXgAAKCRBsgdw1N5RHixGGAP9A4L9S18OuuwTrr2WszqbtOVQGiD4JmIBu8Z48 +UDjxKAEAqrH5fyJBh8c6QZIirSWJfEqykBr7NMHunCbQ14RGyws= +=+ApA +-----END PGP PUBLIC KEY BLOCK----- diff --git a/packaging/appimage/verify-appimage-signature.sh b/packaging/appimage/verify-appimage-signature.sh new file mode 100755 index 00000000..a83fa5d6 --- /dev/null +++ b/packaging/appimage/verify-appimage-signature.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# +# verify-appimage-signature.sh — check a keyroost AppImage's embedded GPG +# signature against the PUBLISHED key, not against the key the file carries. +# +# bash packaging/appimage/verify-appimage-signature.sh keyroost-x86_64.AppImage +# +# linux-bundles.yml runs this as a guard before the AppImage is attested or +# uploaded (with --require-zsync), and anyone can run it on a download from a +# checkout of the matching release tag. +# +# What it checks (any failure exits non-zero): +# 1. The .sha256_sig and .sig_key ELF sections exist and hold ASCII-armored +# PGP data (an unsigned AppImage has them all zeros). +# 2. The embedded public key's fingerprint equals the committed key's +# (packaging/appimage/keyroost-appimage-signing.asc by default). +# 3. The signature verifies, in a throwaway GnuPG home holding ONLY the +# committed key, over the SHA-256 of the file with both sections zeroed +# (the AppImage spec's signed data: that digest as a lowercase hex +# string, no newline). gpg must report VALIDSIG for the pinned key. +# 4. The .upd_info section still holds keyroost's update information. +# 5. If .zsync exists (required with --require-zsync): its SHA-1 +# and Length headers match the file, so the .zsync was written after +# signing and delta updates will reassemble the signed bytes. +# +# Needs: bash, readelf (binutils), gpg, sha256sum, sha1sum, coreutils. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PUBKEY="${REPO_ROOT}/packaging/appimage/keyroost-appimage-signing.asc" +# Must match UPDATE_INFORMATION in build-appimage.sh. +EXPECTED_UPD_INFO="gh-releases-zsync|framefilter|keyroost|latest|keyroost-*x86_64.AppImage.zsync" +REQUIRE_ZSYNC=0 + +usage() { + echo "usage: $0 [--key ] [--require-zsync] " >&2 + exit 2 +} + +IMG="" +while [ $# -gt 0 ]; do + case "$1" in + --key) [ $# -ge 2 ] || usage; PUBKEY="$2"; shift 2 ;; + --require-zsync) REQUIRE_ZSYNC=1; shift ;; + -h|--help) usage ;; + -*) usage ;; + *) [ -z "${IMG}" ] || usage; IMG="$1"; shift ;; + esac +done +[ -n "${IMG}" ] || usage + +fail() { + if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::error::AppImage signature check: $*"; fi + echo "FAIL: $*" >&2 + exit 1 +} + +for tool in readelf gpg sha256sum sha1sum dd tr; do + command -v "${tool}" >/dev/null || fail "${tool} is not installed" +done +[ -f "${IMG}" ] || fail "no such file: ${IMG}" +[ -f "${PUBKEY}" ] || fail "public key not found: ${PUBKEY} (the committed signing key; see packaging/LINUX-BUNDLES.md)" + +WORK="$(mktemp -d)" +cleanup() { + GNUPGHOME="${WORK}/gnupg" gpgconf --kill gpg-agent >/dev/null 2>&1 || true + rm -rf "${WORK}" +} +trap cleanup EXIT +mkdir -m 700 "${WORK}/gnupg" + +# Section offset and size (decimal) by name. readelf -SW prints +# "[Nr] Name Type Address Off Size ...", and "[ 9]" splits into two fields, +# so locate the name and count from there instead of using fixed columns. +section() { + local line off size + line="$(readelf -SW "${IMG}" 2>/dev/null \ + | awk -v s="$1" '!r {for (i = 1; i <= NF; i++) if ($i == s) {r = $(i+3) " " $(i+4)}} END {if (r) print r}')" + [ -n "${line}" ] || return 1 + read -r off size <<<"${line}" + printf '%d %d\n' "$((16#${off}))" "$((16#${size}))" +} + +# Section bytes with the NUL padding removed. +extract() { # name dest + local off="" size="" + read -r off size <<<"$(section "$1" || true)" + [ -n "${off}" ] && [ -n "${size}" ] || fail "section $1 is missing" + [ "${size}" -gt 0 ] || fail "section $1 has size 0" + dd if="${IMG}" bs=4096 iflag=skip_bytes,count_bytes skip="${off}" count="${size}" status=none \ + | tr -d '\000' > "$2" +} + +# Fingerprint of the single primary key in an armored key file. +fingerprint() { + local colons count + colons="$(GNUPGHOME="${WORK}/gnupg" gpg --batch --with-colons --show-keys "$1" 2>/dev/null)" || return 1 + count="$(printf '%s\n' "${colons}" | grep -c '^pub:' || true)" + [ "${count}" = "1" ] || return 1 + printf '%s\n' "${colons}" | awk -F: '/^pub:/ {p = 1; next} p && !f && /^fpr:/ {f = toupper($10)} END {print f}' +} + +# --- 1. Sections present and armored -------------------------------------- +section .sha256_sig >/dev/null || fail ".sha256_sig section missing (not an AppImage type 2 runtime?)" +section .sig_key >/dev/null || fail ".sig_key section missing" +extract .sha256_sig "${WORK}/sig.asc" +extract .sig_key "${WORK}/embedded.asc" +[ -s "${WORK}/sig.asc" ] || fail ".sha256_sig is empty: the AppImage is not signed" +[ -s "${WORK}/embedded.asc" ] || fail ".sig_key is empty: the AppImage carries no key" +[ "$(head -n1 "${WORK}/sig.asc")" = "-----BEGIN PGP SIGNATURE-----" ] \ + || fail ".sha256_sig does not hold an armored PGP signature" +[ "$(head -n1 "${WORK}/embedded.asc")" = "-----BEGIN PGP PUBLIC KEY BLOCK-----" ] \ + || fail ".sig_key does not hold an armored PGP public key" + +# --- 2. Embedded key == published key ------------------------------------- +PINNED="$(fingerprint "${PUBKEY}")" || fail "cannot read exactly one key from ${PUBKEY}" +[ -n "${PINNED}" ] || fail "no fingerprint in ${PUBKEY}" +EMBEDDED="$(fingerprint "${WORK}/embedded.asc")" || fail "cannot read exactly one key from .sig_key" +[ "${EMBEDDED}" = "${PINNED}" ] \ + || fail "embedded key ${EMBEDDED} is not the published key ${PINNED}" + +# --- 3. Signature over the zeroed-section digest, published key only ------ +cp "${IMG}" "${WORK}/zeroed" +for s in .sha256_sig .sig_key; do + read -r off size <<<"$(section "${s}" || true)" + [ -n "${off}" ] && [ -n "${size}" ] || fail "section ${s} is missing" + dd if=/dev/zero of="${WORK}/zeroed" bs=1 seek="${off}" count="${size}" conv=notrunc status=none +done +digest="$(sha256sum "${WORK}/zeroed" | cut -d' ' -f1)" +rm -f "${WORK}/zeroed" +printf '%s' "${digest}" > "${WORK}/digest.txt" +GNUPGHOME="${WORK}/gnupg" gpg --batch --quiet --import "${PUBKEY}" 2>/dev/null \ + || fail "cannot import ${PUBKEY}" +status="$(GNUPGHOME="${WORK}/gnupg" gpg --batch --status-fd 1 \ + --verify "${WORK}/sig.asc" "${WORK}/digest.txt" 2>/dev/null || true)" +# VALIDSIG ... : require both to be the pinned key. +validsig="$(printf '%s\n' "${status}" | awk '!v && $1 == "[GNUPG:]" && $2 == "VALIDSIG" {v = toupper($3) " " toupper($NF)} END {print v}')" +[ -n "${validsig}" ] || fail "signature does not verify against ${PUBKEY} (digest ${digest})" +[ "${validsig}" = "${PINNED} ${PINNED}" ] \ + || fail "signature made by ${validsig}, expected ${PINNED}" + +# --- 4. Update information unchanged -------------------------------------- +extract .upd_info "${WORK}/upd_info" +[ "$(cat "${WORK}/upd_info")" = "${EXPECTED_UPD_INFO}" ] \ + || fail ".upd_info is '$(cat "${WORK}/upd_info")', expected '${EXPECTED_UPD_INFO}'" + +# --- 5. .zsync describes the signed bytes --------------------------------- +ZSYNC="${IMG}.zsync" +if [ -f "${ZSYNC}" ]; then + # The header is text lines up to the first blank line; the block checksums + # after it are binary. + header="$(dd if="${ZSYNC}" bs=4096 count=1 status=none | tr -d '\000' | sed -n '1,/^$/p')" + zsha1="$(printf '%s\n' "${header}" | sed -n 's/^SHA-1: \([0-9a-fA-F]\{40\}\)$/\1/p')" + zlen="$(printf '%s\n' "${header}" | sed -n 's/^Length: \([0-9]\{1,\}\)$/\1/p')" + [ -n "${zsha1}" ] || fail "${ZSYNC} has no SHA-1 header" + fsha1="$(sha1sum "${IMG}" | cut -d' ' -f1)" + [ "${zsha1,,}" = "${fsha1}" ] \ + || fail "${ZSYNC} SHA-1 ${zsha1} != file SHA-1 ${fsha1} (file changed after zsyncmake)" + [ "${zlen}" = "$(stat -c %s "${IMG}")" ] || fail "${ZSYNC} Length ${zlen} != file size" + zsync_note="; .zsync matches" +elif [ "${REQUIRE_ZSYNC}" = "1" ]; then + fail "${ZSYNC} not found" +else + zsync_note="; no .zsync next to it (not checked)" +fi + +echo "OK: $(basename "${IMG}") is signed by ${PINNED}${zsync_note}" diff --git a/packaging/flatpak/io.github.framefilter.keyroost.metainfo.xml b/packaging/flatpak/io.github.framefilter.keyroost.metainfo.xml index 6e478504..547be188 100644 --- a/packaging/flatpak/io.github.framefilter.keyroost.metainfo.xml +++ b/packaging/flatpak/io.github.framefilter.keyroost.metainfo.xml @@ -53,11 +53,11 @@ + WebP or JXL; PNG is preferred and what every software center reads). + Refresh them with the GUI design pass. --> - https://framefilter.github.io/keyroost/assets/screenshots/overview.webp + https://framefilter.github.io/keyroost/assets/screenshots/overview.png The keyroost device view