Skip to main content

max / makenotwork

6.1 KB · 186 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. Test-only, the live
30 /// path verifies already-resident bytes.
31 #[cfg(test)]
32 pub fn verify_appimage_signature_path(path: &std::path::Path, file_type: FileType) -> LayerResult {
33 if !matches!(file_type, FileType::Download) {
34 return skip("Not a download file type");
35 }
36 match crate::scanning::spool::mmap_read(path) {
37 Ok(map) => verify_appimage_signature(&map, file_type),
38 Err(e) => error(e),
39 }
40 }
41
42 /// Entry point: detect AppImage, walk ELF sections for the signature pair.
43 pub fn verify_appimage_signature(data: &[u8], file_type: FileType) -> LayerResult {
44 if !matches!(file_type, FileType::Download) {
45 return skip("Not a download file type");
46 }
47 if !looks_like_appimage(data) {
48 return skip("Not an AppImage");
49 }
50
51 match parse_elf(data) {
52 Ok((has_sig, has_key)) => classify(has_sig, has_key),
53 Err(e) => error(format!("AppImage ELF parse failed: {e}")),
54 }
55 }
56
57 fn skip(reason: &'static str) -> LayerResult {
58 LayerResult {
59 layer: "signing_linux",
60 verdict: LayerVerdict::Skip,
61 detail: Some(reason.to_string()),
62 }
63 }
64 fn pass(detail: String) -> LayerResult {
65 LayerResult {
66 layer: "signing_linux",
67 verdict: LayerVerdict::Pass,
68 detail: Some(detail),
69 }
70 }
71 fn error(detail: String) -> LayerResult {
72 LayerResult {
73 layer: "signing_linux",
74 verdict: LayerVerdict::Error,
75 detail: Some(detail),
76 }
77 }
78
79 /// Cheap heuristic: ELF magic + AppImage marker at offset 8.
80 pub(crate) fn looks_like_appimage(data: &[u8]) -> bool {
81 if data.len() < 11 || &data[0..4] != b"\x7fELF" {
82 return false;
83 }
84 // Either type 1 (legacy, marker is "AI\x01") or type 2 (modern, "AI\x02").
85 data[8..11] == APPIMAGE_MARKER || (data[8] == b'A' && data[9] == b'I' && data[10] == 0x01)
86 }
87
88 /// Walk ELF sections for the two AppImage signature sections.
89 /// Returns (has_signature_section, has_pubkey_section).
90 fn parse_elf(data: &[u8]) -> Result<(bool, bool), String> {
91 let elf = object::File::parse(data).map_err(|e| format!("object parse: {e}"))?;
92 let mut has_sig = false;
93 let mut has_key = false;
94 for section in elf.sections() {
95 let name = section.name().unwrap_or("");
96 match name {
97 ".sha256_sig" if section.size() > 0 => {
98 has_sig = true;
99 }
100 ".sig_key" if section.size() > 0 => {
101 has_key = true;
102 }
103 _ => {}
104 }
105 }
106 Ok((has_sig, has_key))
107 }
108
109 fn classify(has_sig: bool, has_key: bool) -> LayerResult {
110 match (has_sig, has_key) {
111 (true, true) => pass("AppImage signed (.sha256_sig + .sig_key present)".to_string()),
112 (true, false) => pass("AppImage signature present but no embedded key".to_string()),
113 (false, true) => pass("AppImage key embedded but no signature".to_string()),
114 (false, false) => pass("AppImage present, no signature".to_string()),
115 }
116 }
117
118 #[cfg(test)]
119 mod tests {
120 use super::*;
121
122 #[test]
123 fn skips_non_download_types() {
124 let r = verify_appimage_signature(b"any", FileType::Audio);
125 assert_eq!(r.verdict, LayerVerdict::Skip);
126 }
127
128 #[test]
129 fn rejects_non_elf() {
130 assert!(!looks_like_appimage(b"MZ\x00\x00\x00\x00\x00\x00AI\x02"));
131 }
132
133 #[test]
134 fn rejects_elf_without_appimage_marker() {
135 let mut data = vec![0u8; 64];
136 data[0..4].copy_from_slice(b"\x7fELF");
137 // bytes 8-10 default to 0; not the AppImage marker.
138 assert!(!looks_like_appimage(&data));
139 }
140
141 #[test]
142 fn detects_appimage_type_2() {
143 let mut data = vec![0u8; 64];
144 data[0..4].copy_from_slice(b"\x7fELF");
145 data[8..11].copy_from_slice(b"AI\x02");
146 assert!(looks_like_appimage(&data));
147 }
148
149 #[test]
150 fn detects_appimage_type_1() {
151 let mut data = vec![0u8; 64];
152 data[0..4].copy_from_slice(b"\x7fELF");
153 data[8..11].copy_from_slice(b"AI\x01");
154 assert!(looks_like_appimage(&data));
155 }
156
157 #[test]
158 fn classify_both_present() {
159 let r = classify(true, true);
160 assert_eq!(r.verdict, LayerVerdict::Pass);
161 assert!(r.detail.unwrap().contains("signed"));
162 }
163
164 #[test]
165 fn classify_sig_only() {
166 let r = classify(true, false);
167 assert!(r.detail.unwrap().contains("no embedded key"));
168 }
169
170 #[test]
171 fn classify_unsigned() {
172 let r = classify(false, false);
173 assert!(r.detail.unwrap().contains("no signature"));
174 }
175
176 #[test]
177 fn path_entry_matches_buffered_on_plain_text() {
178 let data = b"definitely not an AppImage";
179 let buffered = verify_appimage_signature(data, FileType::Download);
180 let tmp = tempfile::NamedTempFile::new().unwrap();
181 std::fs::write(tmp.path(), data).unwrap();
182 let path_based = verify_appimage_signature_path(tmp.path(), FileType::Download);
183 assert_eq!(buffered.verdict, path_based.verdict);
184 }
185 }
186