From 8cf71ae8589bba2a580388663a23861cceb8c137 Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 28 Feb 2026 00:05:43 +0200 Subject: [PATCH] Add download verification with signature and SHA256 support New verification module with GPG signature checking and SHA256 hash computation. Security tab shows verification status, embedded signature check button, and manual SHA256 input for verifying downloads. --- src/core/database.rs | 8 ++ src/core/mod.rs | 1 + src/core/verification.rs | 166 +++++++++++++++++++++++++++++++++++++++ src/ui/detail_view.rs | 135 +++++++++++++++++++++++++++++++ 4 files changed, 310 insertions(+) create mode 100644 src/core/verification.rs diff --git a/src/core/database.rs b/src/core/database.rs index 16ffac2..c610c29 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -1762,6 +1762,14 @@ impl Database { Ok(()) } + pub fn set_verification_status(&self, id: i64, status: &str) -> SqlResult<()> { + self.conn.execute( + "UPDATE appimages SET verification_status = ?2 WHERE id = ?1", + params![id, status], + )?; + Ok(()) + } + // --- Launch statistics --- pub fn get_top_launched(&self, limit: i32) -> SqlResult> { diff --git a/src/core/mod.rs b/src/core/mod.rs index 3de4e76..10e4554 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -14,5 +14,6 @@ pub mod orphan; pub mod report; pub mod security; pub mod updater; +pub mod verification; pub mod watcher; pub mod wayland; diff --git a/src/core/verification.rs b/src/core/verification.rs new file mode 100644 index 0000000..1127e0c --- /dev/null +++ b/src/core/verification.rs @@ -0,0 +1,166 @@ +use std::path::Path; +use std::process::Command; + +/// Verification status for an AppImage. +#[derive(Debug, Clone)] +pub enum VerificationStatus { + /// Has a valid embedded GPG signature. + SignedValid { signer: String }, + /// Has an embedded signature that could not be verified. + SignedInvalid { reason: String }, + /// No embedded signature found. + Unsigned, + /// SHA256 checksum matches the expected value. + ChecksumMatch, + /// SHA256 checksum does not match. + ChecksumMismatch { expected: String, actual: String }, + /// Not checked yet. + NotChecked, +} + +impl VerificationStatus { + pub fn label(&self) -> String { + match self { + Self::SignedValid { signer } => format!("Signed: {}", signer), + Self::SignedInvalid { reason } => format!("Invalid signature: {}", reason), + Self::Unsigned => "Unsigned".to_string(), + Self::ChecksumMatch => "Checksum OK".to_string(), + Self::ChecksumMismatch { expected, actual } => { + format!("Mismatch: expected {}... got {}...", + &expected[..expected.len().min(12)], + &actual[..actual.len().min(12)]) + } + Self::NotChecked => "Not checked".to_string(), + } + } + + pub fn badge_class(&self) -> &'static str { + match self { + Self::SignedValid { .. } | Self::ChecksumMatch => "success", + Self::SignedInvalid { .. } | Self::ChecksumMismatch { .. } => "error", + Self::Unsigned | Self::NotChecked => "neutral", + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::SignedValid { .. } => "signed_valid", + Self::SignedInvalid { .. } => "signed_invalid", + Self::Unsigned => "unsigned", + Self::ChecksumMatch => "checksum_match", + Self::ChecksumMismatch { .. } => "checksum_mismatch", + Self::NotChecked => "not_checked", + } + } +} + +/// Check for an embedded GPG signature in the AppImage. +/// Uses the `--appimage-signature` flag (Type 2 only). +pub fn check_embedded_signature(path: &Path) -> VerificationStatus { + // Try to validate with gpg if --appimage-signature outputs something + let output = Command::new(path) + .arg("--appimage-signature") + .env("APPIMAGE_EXTRACT_AND_RUN", "1") + .output(); + + match output { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + let trimmed = stdout.trim(); + if trimmed.is_empty() { + return VerificationStatus::Unsigned; + } + + // Try to verify with gpg + let gpg_result = Command::new("gpg") + .arg("--verify") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output(); + + match gpg_result { + Ok(gpg_out) => { + let stderr = String::from_utf8_lossy(&gpg_out.stderr); + if gpg_out.status.success() { + // Extract signer info from gpg output + let signer = stderr + .lines() + .find(|l| l.contains("Good signature")) + .map(|l| l.trim().to_string()) + .unwrap_or_else(|| "Valid signature".to_string()); + VerificationStatus::SignedValid { signer } + } else { + VerificationStatus::SignedInvalid { + reason: stderr.lines().next().unwrap_or("Unknown error").to_string(), + } + } + } + Err(_) => { + // gpg not available but signature data exists + VerificationStatus::SignedInvalid { + reason: "gpg not available for verification".to_string(), + } + } + } + } + _ => VerificationStatus::Unsigned, + } +} + +/// Compute the SHA256 hash of a file. +pub fn compute_sha256(path: &Path) -> Result { + use sha2::{Sha256, Digest}; + use std::io::Read; + + let mut file = std::fs::File::open(path) + .map_err(|e| format!("Failed to open file: {}", e))?; + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf) + .map_err(|e| format!("Read error: {}", e))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + let hash = hasher.finalize(); + Ok(format!("{:x}", hash)) +} + +/// Verify a file against an expected SHA256 hash. +pub fn verify_sha256(path: &Path, expected: &str) -> VerificationStatus { + match compute_sha256(path) { + Ok(hash) if hash == expected.to_lowercase() => VerificationStatus::ChecksumMatch, + Ok(hash) => VerificationStatus::ChecksumMismatch { + expected: expected.to_lowercase(), + actual: hash, + }, + Err(_) => VerificationStatus::NotChecked, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_verification_status_labels() { + assert_eq!(VerificationStatus::Unsigned.label(), "Unsigned"); + assert_eq!(VerificationStatus::NotChecked.label(), "Not checked"); + assert_eq!(VerificationStatus::ChecksumMatch.label(), "Checksum OK"); + assert!(VerificationStatus::SignedValid { signer: "test".into() } + .label().contains("test")); + assert!(VerificationStatus::ChecksumMismatch { + expected: "abc123456789".into(), + actual: "def987654321".into(), + }.label().contains("Mismatch")); + } + + #[test] + fn test_verification_status_badge_classes() { + assert_eq!(VerificationStatus::ChecksumMatch.badge_class(), "success"); + assert_eq!(VerificationStatus::Unsigned.badge_class(), "neutral"); + } +} diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index e8644f5..726537a 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -1408,6 +1408,141 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { .spacing(24) .build(); + // Verification group + let verify_group = adw::PreferencesGroup::builder() + .title("Verification") + .description("Check the integrity and authenticity of this AppImage") + .build(); + + // Signature status + let sig_label = if record.has_signature { "Signature present" } else { "No signature" }; + let sig_badge_class = if record.has_signature { "success" } else { "neutral" }; + let sig_row = adw::ActionRow::builder() + .title("Digital signature") + .subtitle(sig_label) + .build(); + let sig_badge = widgets::status_badge(sig_label, sig_badge_class); + sig_badge.set_valign(gtk::Align::Center); + sig_row.add_suffix(&sig_badge); + verify_group.add(&sig_row); + + // SHA256 hash + if let Some(ref hash) = record.sha256 { + let sha_row = adw::ActionRow::builder() + .title("SHA256") + .subtitle(hash) + .subtitle_selectable(true) + .build(); + verify_group.add(&sha_row); + } + + // Verification status + let verify_status = record.verification_status.as_deref().unwrap_or("not_checked"); + let verify_row = adw::ActionRow::builder() + .title("Verification") + .subtitle(match verify_status { + "signed_valid" => "Signed and verified", + "signed_invalid" => "Signature present but invalid", + "checksum_match" => "Checksum verified", + "checksum_mismatch" => "Checksum mismatch - file may be corrupted", + _ => "Not verified", + }) + .build(); + let verify_badge = widgets::status_badge( + match verify_status { + "signed_valid" | "checksum_match" => "Verified", + "signed_invalid" | "checksum_mismatch" => "Failed", + _ => "Unchecked", + }, + match verify_status { + "signed_valid" | "checksum_match" => "success", + "signed_invalid" | "checksum_mismatch" => "error", + _ => "neutral", + }, + ); + verify_badge.set_valign(gtk::Align::Center); + verify_row.add_suffix(&verify_badge); + verify_group.add(&verify_row); + + // Manual SHA256 verification input + let sha_input = adw::EntryRow::builder() + .title("Verify SHA256") + .show_apply_button(true) + .build(); + sha_input.set_tooltip_text(Some("Paste an expected SHA256 hash to verify this file")); + + let record_path = record.path.clone(); + let record_id_v = record.id; + let db_verify = db.clone(); + sha_input.connect_apply(move |row| { + let expected = row.text().to_string(); + if expected.is_empty() { + return; + } + let path = record_path.clone(); + let db_ref = db_verify.clone(); + let row_ref = row.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let p = std::path::Path::new(&path); + crate::core::verification::verify_sha256(p, &expected) + }) + .await; + + if let Ok(status) = result { + let label = status.label(); + row_ref.set_title(&format!("Verify SHA256 - {}", label)); + db_ref.set_verification_status(record_id_v, status.as_str()).ok(); + } + }); + }); + verify_group.add(&sha_input); + + // Check signature button + let check_sig_row = adw::ActionRow::builder() + .title("Check embedded signature") + .subtitle("Verify GPG signature if present") + .activatable(true) + .build(); + let sig_arrow = gtk::Image::from_icon_name("go-next-symbolic"); + sig_arrow.set_valign(gtk::Align::Center); + check_sig_row.add_suffix(&sig_arrow); + let record_path_sig = record.path.clone(); + let record_id_sig = record.id; + let db_sig = db.clone(); + check_sig_row.connect_activated(move |row| { + row.set_sensitive(false); + row.set_subtitle("Checking..."); + let path = record_path_sig.clone(); + let db_ref = db_sig.clone(); + let row_ref = row.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let p = std::path::Path::new(&path); + crate::core::verification::check_embedded_signature(p) + }) + .await; + + if let Ok(status) = result { + row_ref.set_subtitle(&status.label()); + db_ref.set_verification_status(record_id_sig, status.as_str()).ok(); + let result_badge = widgets::status_badge( + match status.badge_class() { + "success" => "Verified", + "error" => "Failed", + _ => "Unknown", + }, + status.badge_class(), + ); + result_badge.set_valign(gtk::Align::Center); + row_ref.add_suffix(&result_badge); + } + row_ref.set_sensitive(true); + }); + }); + verify_group.add(&check_sig_row); + inner.append(&verify_group); + let group = adw::PreferencesGroup::builder() .title("Security Check") .description(