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.
This commit is contained in:
@@ -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<Vec<(String, u64)>> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
166
src/core/verification.rs
Normal file
166
src/core/verification.rs
Normal file
@@ -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<String, String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1408,6 +1408,141 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> 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(
|
||||
|
||||
Reference in New Issue
Block a user