//! Layer 11: Linux AppImage signature heuristic. //! //! AppImages are ELF binaries with an ISO 9660 image appended. AppImage's //! optional signing mechanism (`appimagetool --sign`) embeds a GPG signature //! plus the signer's public key in two ELF sections (`.sha256_sig` and //! `.sig_key`). Their presence is the trust signal we surface here. //! //! **Scope**: presence detection only. Full GPG signature verification //! against a creator-attested public key would require a trust-store //! decision (whose keys do we accept?) and is intentionally deferred. Even //! presence is meaningful evidence — most AppImages aren't signed at all. //! //! Reference: //! use object::{Object, ObjectSection}; use crate::storage::FileType; use super::{ErrorPolicy, LayerResult, LayerVerdict}; pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen; /// AppImages start with an ELF magic followed by the AppImage type marker /// at offset 8-9: `0x41 0x49` ('AI') and a version byte (1 or 2). Type 2 is /// the modern format and what every current toolchain emits. const APPIMAGE_MARKER: [u8; 3] = *b"AI\x02"; /// Path-based entry. Mmaps the spooled file and delegates. pub fn verify_appimage_signature_path(path: &std::path::Path, file_type: FileType) -> LayerResult { if !matches!(file_type, FileType::Download) { return skip("Not a download file type"); } match crate::scanning::spool::mmap_read(path) { Ok(map) => verify_appimage_signature(&map, file_type), Err(e) => error(e), } } /// Entry point: detect AppImage, walk ELF sections for the signature pair. pub fn verify_appimage_signature(data: &[u8], file_type: FileType) -> LayerResult { if !matches!(file_type, FileType::Download) { return skip("Not a download file type"); } if !looks_like_appimage(data) { return skip("Not an AppImage"); } match parse_elf(data) { Ok((has_sig, has_key)) => classify(has_sig, has_key), Err(e) => error(format!("AppImage ELF parse failed: {e}")), } } fn skip(reason: &'static str) -> LayerResult { LayerResult { layer: "signing_linux", verdict: LayerVerdict::Skip, detail: Some(reason.to_string()) } } fn pass(detail: String) -> LayerResult { LayerResult { layer: "signing_linux", verdict: LayerVerdict::Pass, detail: Some(detail) } } fn error(detail: String) -> LayerResult { LayerResult { layer: "signing_linux", verdict: LayerVerdict::Error, detail: Some(detail) } } /// Cheap heuristic: ELF magic + AppImage marker at offset 8. pub(crate) fn looks_like_appimage(data: &[u8]) -> bool { if data.len() < 11 || &data[0..4] != b"\x7fELF" { return false; } // Either type 1 (legacy, marker is "AI\x01") or type 2 (modern, "AI\x02"). data[8..11] == APPIMAGE_MARKER || (data[8] == b'A' && data[9] == b'I' && data[10] == 0x01) } /// Walk ELF sections for the two AppImage signature sections. /// Returns (has_signature_section, has_pubkey_section). fn parse_elf(data: &[u8]) -> Result<(bool, bool), String> { let elf = object::File::parse(data).map_err(|e| format!("object parse: {e}"))?; let mut has_sig = false; let mut has_key = false; for section in elf.sections() { let name = section.name().unwrap_or(""); match name { ".sha256_sig" if section.size() > 0 => { has_sig = true; } ".sig_key" if section.size() > 0 => { has_key = true; } _ => {} } } Ok((has_sig, has_key)) } fn classify(has_sig: bool, has_key: bool) -> LayerResult { match (has_sig, has_key) { (true, true) => pass("AppImage signed (.sha256_sig + .sig_key present)".to_string()), (true, false) => pass("AppImage signature present but no embedded key".to_string()), (false, true) => pass("AppImage key embedded but no signature".to_string()), (false, false) => pass("AppImage present, no signature".to_string()), } } #[cfg(test)] mod tests { use super::*; #[test] fn skips_non_download_types() { let r = verify_appimage_signature(b"any", FileType::Audio); assert_eq!(r.verdict, LayerVerdict::Skip); } #[test] fn rejects_non_elf() { assert!(!looks_like_appimage(b"MZ\x00\x00\x00\x00\x00\x00AI\x02")); } #[test] fn rejects_elf_without_appimage_marker() { let mut data = vec![0u8; 64]; data[0..4].copy_from_slice(b"\x7fELF"); // bytes 8-10 default to 0; not the AppImage marker. assert!(!looks_like_appimage(&data)); } #[test] fn detects_appimage_type_2() { let mut data = vec![0u8; 64]; data[0..4].copy_from_slice(b"\x7fELF"); data[8..11].copy_from_slice(b"AI\x02"); assert!(looks_like_appimage(&data)); } #[test] fn detects_appimage_type_1() { let mut data = vec![0u8; 64]; data[0..4].copy_from_slice(b"\x7fELF"); data[8..11].copy_from_slice(b"AI\x01"); assert!(looks_like_appimage(&data)); } #[test] fn classify_both_present() { let r = classify(true, true); assert_eq!(r.verdict, LayerVerdict::Pass); assert!(r.detail.unwrap().contains("signed")); } #[test] fn classify_sig_only() { let r = classify(true, false); assert!(r.detail.unwrap().contains("no embedded key")); } #[test] fn classify_unsigned() { let r = classify(false, false); assert!(r.detail.unwrap().contains("no signature")); } #[test] fn path_entry_matches_buffered_on_plain_text() { let data = b"definitely not an AppImage"; let buffered = verify_appimage_signature(data, FileType::Download); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), data).unwrap(); let path_based = verify_appimage_signature_path(tmp.path(), FileType::Download); assert_eq!(buffered.verdict, path_based.verdict); } }