Skip to main content

max / makenotwork

6.0 KB · 176 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 }
88 ".sig_key" => {
89 if section.size() > 0 {
90 has_key = true;
91 }
92 }
93 _ => {}
94 }
95 }
96 Ok((has_sig, has_key))
97 }
98
99 fn classify(has_sig: bool, has_key: bool) -> LayerResult {
100 match (has_sig, has_key) {
101 (true, true) => pass("AppImage signed (.sha256_sig + .sig_key present)".to_string()),
102 (true, false) => pass("AppImage signature present but no embedded key".to_string()),
103 (false, true) => pass("AppImage key embedded but no signature".to_string()),
104 (false, false) => pass("AppImage present, no signature".to_string()),
105 }
106 }
107
108 #[cfg(test)]
109 mod tests {
110 use super::*;
111
112 #[test]
113 fn skips_non_download_types() {
114 let r = verify_appimage_signature(b"any", FileType::Audio);
115 assert_eq!(r.verdict, LayerVerdict::Skip);
116 }
117
118 #[test]
119 fn rejects_non_elf() {
120 assert!(!looks_like_appimage(b"MZ\x00\x00\x00\x00\x00\x00AI\x02"));
121 }
122
123 #[test]
124 fn rejects_elf_without_appimage_marker() {
125 let mut data = vec![0u8; 64];
126 data[0..4].copy_from_slice(b"\x7fELF");
127 // bytes 8-10 default to 0; not the AppImage marker.
128 assert!(!looks_like_appimage(&data));
129 }
130
131 #[test]
132 fn detects_appimage_type_2() {
133 let mut data = vec![0u8; 64];
134 data[0..4].copy_from_slice(b"\x7fELF");
135 data[8..11].copy_from_slice(b"AI\x02");
136 assert!(looks_like_appimage(&data));
137 }
138
139 #[test]
140 fn detects_appimage_type_1() {
141 let mut data = vec![0u8; 64];
142 data[0..4].copy_from_slice(b"\x7fELF");
143 data[8..11].copy_from_slice(b"AI\x01");
144 assert!(looks_like_appimage(&data));
145 }
146
147 #[test]
148 fn classify_both_present() {
149 let r = classify(true, true);
150 assert_eq!(r.verdict, LayerVerdict::Pass);
151 assert!(r.detail.unwrap().contains("signed"));
152 }
153
154 #[test]
155 fn classify_sig_only() {
156 let r = classify(true, false);
157 assert!(r.detail.unwrap().contains("no embedded key"));
158 }
159
160 #[test]
161 fn classify_unsigned() {
162 let r = classify(false, false);
163 assert!(r.detail.unwrap().contains("no signature"));
164 }
165
166 #[test]
167 fn path_entry_matches_buffered_on_plain_text() {
168 let data = b"definitely not an AppImage";
169 let buffered = verify_appimage_signature(data, FileType::Download);
170 let tmp = tempfile::NamedTempFile::new().unwrap();
171 std::fs::write(tmp.path(), data).unwrap();
172 let path_based = verify_appimage_signature_path(tmp.path(), FileType::Download);
173 assert_eq!(buffered.verdict, path_based.verdict);
174 }
175 }
176