Skip to main content

iso_probe/
minisign.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Minisign detached signature verification.
4//!
5//! Looks for `<iso>.minisig` sibling files and verifies them against a trust
6//! store of `.pub` keys (minisign format) provided via the `AEGIS_TRUSTED_KEYS`
7//! environment variable (colon-separated list of directories or individual
8//! `.pub` files).
9//!
10//! Unlike [`crate::signature`] (which only checks hash integrity), minisign
11//! provides **authentication** — the signer possesses the private key
12//! corresponding to the trusted public key, and no byte of the ISO has been
13//! changed since they signed it.
14//!
15//! # Trust model
16//!
17//! - A public key under `AEGIS_TRUSTED_KEYS` is **authoritative**. Anything it
18//!   signs is treated as authentic.
19//! - No key fingerprint pinning beyond minisign's key ID. Key rotation is
20//!   the operator's problem.
21//! - Missing key dir / no loaded keys → every ISO is `KeyNotTrusted` even
22//!   when the signature itself is syntactically valid. Fail-closed.
23
24use std::fs;
25use std::path::{Path, PathBuf};
26
27use minisign_verify::{Error as MinisignError, PublicKey, Signature};
28use serde::{Deserialize, Serialize};
29
30/// Outcome of a minisign signature verification.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub enum SignatureVerification {
33    /// Signature is cryptographically valid AND signed by a key in the trust
34    /// store. Authentication established.
35    Verified {
36        /// Hex-encoded first 8 bytes of the key's raw `keynum` (minisign's
37        /// identifier). Lets the TUI render "signed by: abcd1234" without
38        /// claiming more provenance than we actually have.
39        key_id: String,
40        /// Path to the .minisig file we validated.
41        sig_path: PathBuf,
42    },
43    /// Signature parsed and is structurally valid, but the signing key is
44    /// not in the trust store.
45    KeyNotTrusted {
46        /// Observed key ID from the signature envelope.
47        key_id: String,
48    },
49    /// Signature parsed but the computed signature over the ISO bytes does
50    /// not match what the sig file claims — tampering or corruption.
51    Forged {
52        /// Path to the .minisig file.
53        sig_path: PathBuf,
54    },
55    /// No .minisig sidecar was found.
56    NotPresent,
57    /// An I/O or parse error made verification impossible. Treated the same
58    /// as `NotPresent` for UX purposes but logged separately.
59    Error {
60        /// Human-readable reason.
61        reason: String,
62    },
63}
64
65impl SignatureVerification {
66    /// Short user-facing label for the TUI.
67    #[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/// Verify `iso_path` against its sibling `<iso>.minisig` (if any).
80///
81/// The trust store is read from `AEGIS_TRUSTED_KEYS` (colon-separated list of
82/// either directories containing `.pub` files or individual `.pub` files).
83/// Missing / empty env var → `KeyNotTrusted` even for valid signatures.
84///
85/// # Errors
86///
87/// This function does not return `Err`; all failures are reported as
88/// [`SignatureVerification::Error`] or [`SignatureVerification::NotPresent`]
89/// so the caller can make a UX decision rather than bubble up.
90#[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            // Trusted key matches the signature's key_id but the signature
125            // does not verify over the bytes — the file was tampered after
126            // the trusted signer signed it. Distinct from "wrong signer."
127            // (#57)
128            Err(MinisignError::InvalidSignature) => {
129                saw_forgery_under_trusted_key = true;
130            }
131            // UnexpectedKeyId / other errors: this trusted key didn't sign
132            // it. Keep iterating in case another trusted key did.
133            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    // No trusted key signed this ISO. Either trust store is empty (fail-
144    // closed default) or the signer is unknown to us. Either way the user
145    // sees an "untrusted" diagnostic, not a "forged" one.
146    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            // Defense-in-depth: refuse the entire directory if it's
174            // group- or world-writable. An attacker with write access
175            // could drop a malicious `.pub` file and redirect the
176            // trust anchor. Safe-default in the single-user initramfs
177            // today, but the env var is operator-configurable and
178            // this forecloses a foot-gun on multi-user hosts.
179            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    // Same defense as for the parent dir: refuse group/world-writable
206    // pub-key files regardless of how they were discovered. An
207    // attacker who can overwrite the .pub file can swap in their own
208    // public key and make their signatures appear trusted.
209    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
230/// Return `true` when `path`'s filesystem mode has no group- or
231/// world-write bits set (i.e. it's owned and writable only by the
232/// owner — mode `0o7xx` with no `0o022` bits).
233///
234/// On non-Unix hosts (Windows), Unix mode bits don't meaningfully
235/// map to this attack — returns `true` so key loading works without
236/// a meaningful check. This is an acceptable tradeoff for iso-probe's
237/// primary deployment target (Linux initramfs + Linux operator host).
238///
239/// Failure to stat the path (ENOENT, EACCES) returns `false` — better
240/// to refuse an unreadable key than silently skip the permissions
241/// gate. The caller's subsequent `read_to_string` would have failed
242/// anyway; this just makes the refusal explicit in logs.
243fn 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        // 0o022 = group-write (0o020) | world-write (0o002).
252        // If either bit is set, the file is not safely owned.
253        (mode & 0o022) == 0
254    }
255    #[cfg(not(unix))]
256    {
257        let _ = path;
258        true
259    }
260}
261
262/// Minisign's trusted-comment line is the closest thing to a human-readable
263/// ID we can surface without owning the private key. Truncate to avoid
264/// blowing up the TUI with arbitrary signer-chosen text.
265fn 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    // ---- AEGIS_TRUSTED_KEYS permissions check (CWE-732) ---------------
320
321    #[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        // Fail-closed: if we can't stat the path, refuse rather than
370        // defaulting to trust. The caller's read_to_string would fail
371        // anyway; this just surfaces the refusal in structured logs.
372        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        // Write a syntactically valid pub-key string shape (doesn't
383        // need to be a real minisign key — decode will fail; the
384        // assertion is that we never reach decode because perms are
385        // rejected first).
386        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}