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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user