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(