Add download verification with signature and SHA256 support
This commit is contained in:
@@ -1757,6 +1757,14 @@ impl Database {
|
|||||||
Ok(())
|
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 ---
|
// --- Launch statistics ---
|
||||||
|
|
||||||
pub fn get_top_launched(&self, limit: i32) -> SqlResult<Vec<(String, u64)>> {
|
pub fn get_top_launched(&self, limit: i32) -> SqlResult<Vec<(String, u64)>> {
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ pub mod orphan;
|
|||||||
pub mod report;
|
pub mod report;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
|
pub mod verification;
|
||||||
pub mod watcher;
|
pub mod watcher;
|
||||||
pub mod wayland;
|
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)
|
.spacing(24)
|
||||||
.build();
|
.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()
|
let group = adw::PreferencesGroup::builder()
|
||||||
.title("Security Check")
|
.title("Security Check")
|
||||||
.description(
|
.description(
|
||||||
|
|||||||
Reference in New Issue
Block a user