1use std::fs;
25use std::path::{Path, PathBuf};
26
27use minisign_verify::{Error as MinisignError, PublicKey, Signature};
28use serde::{Deserialize, Serialize};
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub enum SignatureVerification {
33 Verified {
36 key_id: String,
40 sig_path: PathBuf,
42 },
43 KeyNotTrusted {
46 key_id: String,
48 },
49 Forged {
52 sig_path: PathBuf,
54 },
55 NotPresent,
57 Error {
60 reason: String,
62 },
63}
64
65impl SignatureVerification {
66 #[must_use]
68 pub fn summary(&self) -> &'static str {
69 match self {
70 Self::Verified { .. } => "verified",
71 Self::KeyNotTrusted { .. } => "UNTRUSTED KEY",
72 Self::Forged { .. } => "FORGED",
73 Self::NotPresent => "not present",
74 Self::Error { .. } => "error",
75 }
76 }
77}
78
79#[must_use]
91pub fn verify_iso_signature(iso_path: &Path) -> SignatureVerification {
92 let sig_path = sidecar_sig_path(iso_path);
93 let Ok(sig_text) = fs::read_to_string(&sig_path) else {
94 return SignatureVerification::NotPresent;
95 };
96 let signature = match Signature::decode(&sig_text) {
97 Ok(s) => s,
98 Err(e) => {
99 return SignatureVerification::Error {
100 reason: format!("sig parse failed: {e}"),
101 };
102 }
103 };
104
105 let trusted = load_trusted_keys();
106 let iso_bytes = match fs::read(iso_path) {
107 Ok(b) => b,
108 Err(e) => {
109 return SignatureVerification::Error {
110 reason: format!("ISO read failed: {e}"),
111 };
112 }
113 };
114
115 let mut saw_forgery_under_trusted_key = false;
116 for (pubkey, source) in &trusted {
117 match pubkey.verify(&iso_bytes, &signature, false) {
118 Ok(()) => {
119 return SignatureVerification::Verified {
120 key_id: key_id_from_sig(&signature),
121 sig_path: PathBuf::from(source),
122 };
123 }
124 Err(MinisignError::InvalidSignature) => {
129 saw_forgery_under_trusted_key = true;
130 }
131 Err(_) => {}
134 }
135 }
136
137 if saw_forgery_under_trusted_key {
138 return SignatureVerification::Forged {
139 sig_path: sig_path.clone(),
140 };
141 }
142
143 SignatureVerification::KeyNotTrusted {
147 key_id: key_id_from_sig(&signature),
148 }
149}
150
151fn sidecar_sig_path(iso_path: &Path) -> PathBuf {
152 let mut p = PathBuf::from(iso_path);
153 let ext = p
154 .extension()
155 .map(|e| e.to_string_lossy().to_string())
156 .unwrap_or_default();
157 p.set_extension(if ext.is_empty() {
158 "minisig".to_string()
159 } else {
160 format!("{ext}.minisig")
161 });
162 p
163}
164
165fn load_trusted_keys() -> Vec<(PublicKey, String)> {
166 let Ok(env) = std::env::var("AEGIS_TRUSTED_KEYS") else {
167 return Vec::new();
168 };
169 let mut keys = Vec::new();
170 for entry in env.split(':').filter(|s| !s.is_empty()) {
171 let path = PathBuf::from(entry);
172 if path.is_dir() {
173 if !is_path_safely_owned(&path) {
180 tracing::warn!(
181 key_dir = %path.display(),
182 "iso-probe: refusing AEGIS_TRUSTED_KEYS directory — \
183 group- or world-writable (would allow an attacker to \
184 drop a malicious pub-key). Fix: chmod go-w <dir>."
185 );
186 continue;
187 }
188 let Ok(iter) = fs::read_dir(&path) else {
189 continue;
190 };
191 for child in iter.flatten() {
192 let child_path = child.path();
193 if child_path.extension().and_then(|s| s.to_str()) == Some("pub") {
194 load_key_into(&child_path, &mut keys);
195 }
196 }
197 } else if path.is_file() {
198 load_key_into(&path, &mut keys);
199 }
200 }
201 keys
202}
203
204fn load_key_into(path: &Path, out: &mut Vec<(PublicKey, String)>) {
205 if !is_path_safely_owned(path) {
210 tracing::warn!(
211 key = %path.display(),
212 "iso-probe: refusing trusted pub-key file — group- or \
213 world-writable. Fix: chmod go-w <file>."
214 );
215 return;
216 }
217 let Ok(text) = fs::read_to_string(path) else {
218 return;
219 };
220 match PublicKey::decode(text.trim()) {
221 Ok(key) => out.push((key, path.display().to_string())),
222 Err(e) => tracing::debug!(
223 key = %path.display(),
224 error = %e,
225 "iso-probe: rejected invalid minisign public key"
226 ),
227 }
228}
229
230fn is_path_safely_owned(path: &Path) -> bool {
244 #[cfg(unix)]
245 {
246 use std::os::unix::fs::PermissionsExt;
247 let Ok(meta) = fs::metadata(path) else {
248 return false;
249 };
250 let mode = meta.permissions().mode();
251 (mode & 0o022) == 0
254 }
255 #[cfg(not(unix))]
256 {
257 let _ = path;
258 true
259 }
260}
261
262fn key_id_from_sig(sig: &Signature) -> String {
266 let comment = sig.trusted_comment();
267 let truncated: String = comment.chars().take(40).collect();
268 if comment.chars().count() > 40 {
269 format!("{truncated}…")
270 } else {
271 truncated
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn sidecar_path_appends_minisig_to_extension() {
281 assert_eq!(
282 sidecar_sig_path(Path::new("/x/y.iso")),
283 PathBuf::from("/x/y.iso.minisig")
284 );
285 }
286
287 #[test]
288 fn sidecar_path_handles_no_extension() {
289 assert_eq!(
290 sidecar_sig_path(Path::new("/x/y")),
291 PathBuf::from("/x/y.minisig")
292 );
293 }
294
295 #[test]
296 fn no_sig_returns_not_present() {
297 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
298 let iso = dir.path().join("x.iso");
299 std::fs::write(&iso, b"dummy").unwrap_or_else(|e| panic!("write: {e}"));
300 assert!(matches!(
301 verify_iso_signature(&iso),
302 SignatureVerification::NotPresent
303 ));
304 }
305
306 #[test]
307 fn malformed_sig_returns_error() {
308 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
309 let iso = dir.path().join("x.iso");
310 std::fs::write(&iso, b"dummy").unwrap_or_else(|e| panic!("write: {e}"));
311 std::fs::write(dir.path().join("x.iso.minisig"), "not-a-minisig\n")
312 .unwrap_or_else(|e| panic!("write: {e}"));
313 assert!(matches!(
314 verify_iso_signature(&iso),
315 SignatureVerification::Error { .. }
316 ));
317 }
318
319 #[cfg(unix)]
322 #[test]
323 fn is_path_safely_owned_accepts_owner_only_mode() {
324 use std::os::unix::fs::PermissionsExt;
325 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
326 let f = dir.path().join("key.pub");
327 std::fs::write(&f, b"x").unwrap_or_else(|e| panic!("write: {e}"));
328 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600))
329 .unwrap_or_else(|e| panic!("chmod: {e}"));
330 assert!(is_path_safely_owned(&f));
331 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644))
332 .unwrap_or_else(|e| panic!("chmod: {e}"));
333 assert!(is_path_safely_owned(&f));
334 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755))
335 .unwrap_or_else(|e| panic!("chmod: {e}"));
336 assert!(is_path_safely_owned(&f));
337 }
338
339 #[cfg(unix)]
340 #[test]
341 fn is_path_safely_owned_rejects_group_writable() {
342 use std::os::unix::fs::PermissionsExt;
343 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
344 let f = dir.path().join("key.pub");
345 std::fs::write(&f, b"x").unwrap_or_else(|e| panic!("write: {e}"));
346 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o664))
347 .unwrap_or_else(|e| panic!("chmod: {e}"));
348 assert!(!is_path_safely_owned(&f));
349 }
350
351 #[cfg(unix)]
352 #[test]
353 fn is_path_safely_owned_rejects_world_writable() {
354 use std::os::unix::fs::PermissionsExt;
355 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
356 let f = dir.path().join("key.pub");
357 std::fs::write(&f, b"x").unwrap_or_else(|e| panic!("write: {e}"));
358 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o646))
359 .unwrap_or_else(|e| panic!("chmod: {e}"));
360 assert!(!is_path_safely_owned(&f));
361 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o666))
362 .unwrap_or_else(|e| panic!("chmod: {e}"));
363 assert!(!is_path_safely_owned(&f));
364 }
365
366 #[cfg(unix)]
367 #[test]
368 fn is_path_safely_owned_rejects_missing_file() {
369 let p = std::path::PathBuf::from("/definitely/does/not/exist-aegis-tk");
373 assert!(!is_path_safely_owned(&p));
374 }
375
376 #[cfg(unix)]
377 #[test]
378 fn load_key_into_skips_group_writable_pub_file() {
379 use std::os::unix::fs::PermissionsExt;
380 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
381 let f = dir.path().join("attacker.pub");
382 std::fs::write(&f, b"untrusted").unwrap_or_else(|e| panic!("write: {e}"));
387 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o664))
388 .unwrap_or_else(|e| panic!("chmod: {e}"));
389 let mut keys: Vec<(PublicKey, String)> = Vec::new();
390 load_key_into(&f, &mut keys);
391 assert!(
392 keys.is_empty(),
393 "group-writable pub-key should be refused before minisign decode"
394 );
395 }
396
397 #[test]
398 fn summary_strings_are_stable() {
399 assert_eq!(SignatureVerification::NotPresent.summary(), "not present");
400 assert_eq!(
401 SignatureVerification::KeyNotTrusted { key_id: "x".into() }.summary(),
402 "UNTRUSTED KEY"
403 );
404 assert_eq!(
405 SignatureVerification::Forged {
406 sig_path: PathBuf::new()
407 }
408 .summary(),
409 "FORGED"
410 );
411 }
412}