Add download verification with signature and SHA256 support
This commit is contained in:
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