Skip to main content

max / makenotwork

6.0 KB · 174 lines History Blame Raw
1 //! Layer 11: Linux AppImage signature heuristic.
2 //!
3 //! AppImages are ELF binaries with an ISO 9660 image appended. AppImage's
4 //! optional signing mechanism (`appimagetool --sign`) embeds a GPG signature
5 //! plus the signer's public key in two ELF sections (`.sha256_sig` and
6 //! `.sig_key`). Their presence is the trust signal we surface here.
7 //!
8 //! **Scope**: presence detection only. Full GPG signature verification
9 //! against a creator-attested public key would require a trust-store
10 //! decision (whose keys do we accept?) and is intentionally deferred. Even
11 //! presence is meaningful evidence — most AppImages aren't signed at all.
12 //!
13 //! Reference:
14 //! <https://docs.appimage.org/packaging-guide/optional/signatures.html>
15
16 use object::{Object, ObjectSection};
17
18 use crate::storage::FileType;
19
20 use super::{ErrorPolicy, LayerResult, LayerVerdict};
21
22 pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen;
23
24 /// AppImages start with an ELF magic followed by the AppImage type marker
25 /// at offset 8-9: `0x41 0x49` ('AI') and a version byte (1 or 2). Type 2 is
26 /// the modern format and what every current toolchain emits.
27 const APPIMAGE_MARKER: [u8; 3] = *b"AI\x02";
28
29 /// Path-based entry. Mmaps the spooled file and delegates.
30 pub fn verify_appimage_signature_path(path: &std::path::Path, file_type: FileType) -> LayerResult {
31 if !matches!(file_type, FileType::Download) {
32 return skip("Not a download file type");
33 }
34 match crate::scanning::spool::mmap_read(path) {
35 Ok(map) => verify_appimage_signature(&map, file_type),
36 Err(e) => error(e),
37 }
38 }
39
40 /// Entry point: detect AppImage, walk ELF sections for the signature pair.
41 pub fn verify_appimage_signature(data: &[u8], file_type: FileType) -> LayerResult {
42 if !matches!(file_type, FileType::Download) {
43 return skip("Not a download file type");
44 }
45 if !looks_like_appimage(data) {
46 return skip("Not an AppImage");
47 }
48
49 match parse_elf(data) {
50 Ok((has_sig, has_key)) => classify(has_sig, has_key),
51 Err(e) => error(format!("AppImage ELF parse failed: {e}")),
52 }
53 }
54
55 fn skip(reason: &'static str) -> LayerResult {
56 LayerResult { layer: "signing_linux", verdict: LayerVerdict::Skip, detail: Some(reason.to_string()) }
57 }
58 fn pass(detail: String) -> LayerResult {
59 LayerResult { layer: "signing_linux", verdict: LayerVerdict::Pass, detail: Some(detail) }
60 }
61 fn error(detail: String) -> LayerResult {
62 LayerResult { layer: "signing_linux", verdict: LayerVerdict::Error, detail: Some(detail) }
63 }
64
65 /// Cheap heuristic: ELF magic + AppImage marker at offset 8.
66 pub(crate) fn looks_like_appimage(data: &[u8]) -> bool {
67 if data.len() < 11 || &data[0..4] != b"\x7fELF" {
68 return false;
69 }
70 // Either type 1 (legacy, marker is "AI\x01") or type 2 (modern, "AI\x02").
71 data[8..11] == APPIMAGE_MARKER || (data[8] == b'A' && data[9] == b'I' && data[10] == 0x01)
72 }
73
74 /// Walk ELF sections for the two AppImage signature sections.
75 /// Returns (has_signature_section, has_pubkey_section).
76 fn parse_elf(data: &[u8]) -> Result<(bool, bool), String> {
77 let elf = object::File::parse(data).map_err(|e| format!("object parse: {e}"))?;
78 let mut has_sig = false;
79 let mut has_key = false;
80 for section in elf.sections() {
81 let name = section.name().unwrap_or("");
82 match name {
83 ".sha256_sig"
84 if section.size() > 0 => {
85 has_sig = true;
86 }
87 ".sig_key"
88 if section.size() > 0 => {
89 has_key = true;
90 }
91 _ => {}
92 }
93 }
94 Ok((has_sig, has_key))
95 }
96
97 fn classify(has_sig: bool, has_key: bool) -> LayerResult {
98 match (has_sig, has_key) {
99 (true, true) => pass("AppImage signed (.sha256_sig + .sig_key present)".to_string()),
100 (true, false) => pass("AppImage signature present but no embedded key".to_string()),
101 (false, true) => pass("AppImage key embedded but no signature".to_string()),
102 (false, false) => pass("AppImage present, no signature".to_string()),
103 }
104 }
105
106 #[cfg(test)]
107 mod tests {
108 use super::*;
109
110 #[test]
111 fn skips_non_download_types() {
112 let r = verify_appimage_signature(b"any", FileType::Audio);
113 assert_eq!(r.verdict, LayerVerdict::Skip);
114 }
115
116 #[test]
117 fn rejects_non_elf() {
118 assert!(!looks_like_appimage(b"MZ\x00\x00\x00\x00\x00\x00AI\x02"));
119 }
120
121 #[test]
122 fn rejects_elf_without_appimage_marker() {
123 let mut data = vec![0u8; 64];
124 data[0..4].copy_from_slice(b"\x7fELF");
125 // bytes 8-10 default to 0; not the AppImage marker.
126 assert!(!looks_like_appimage(&data));
127 }
128
129 #[test]
130 fn detects_appimage_type_2() {
131 let mut data = vec![0u8; 64];
132 data[0..4].copy_from_slice(b"\x7fELF");
133 data[8..11].copy_from_slice(b"AI\x02");
134 assert!(looks_like_appimage(&data));
135 }
136
137 #[test]
138 fn detects_appimage_type_1() {
139 let mut data = vec![0u8; 64];
140 data[0..4].copy_from_slice(b"\x7fELF");
141 data[8..11].copy_from_slice(b"AI\x01");
142 assert!(looks_like_appimage(&data));
143 }
144
145 #[test]
146 fn classify_both_present() {
147 let r = classify(true, true);
148 assert_eq!(r.verdict, LayerVerdict::Pass);
149 assert!(r.detail.unwrap().contains("signed"));
150 }
151
152 #[test]
153 fn classify_sig_only() {
154 let r = classify(true, false);
155 assert!(r.detail.unwrap().contains("no embedded key"));
156 }
157
158 #[test]
159 fn classify_unsigned() {
160 let r = classify(false, false);
161 assert!(r.detail.unwrap().contains("no signature"));
162 }
163
164 #[test]
165 fn path_entry_matches_buffered_on_plain_text() {
166 let data = b"definitely not an AppImage";
167 let buffered = verify_appimage_signature(data, FileType::Download);
168 let tmp = tempfile::NamedTempFile::new().unwrap();
169 std::fs::write(tmp.path(), data).unwrap();
170 let path_based = verify_appimage_signature_path(tmp.path(), FileType::Download);
171 assert_eq!(buffered.verdict, path_based.verdict);
172 }
173 }
174