use std::fs;
use std::path::{Path, PathBuf};
use minisign_verify::{Error as MinisignError, PublicKey, Signature};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SignatureVerification {
Verified {
key_id: String,
sig_path: PathBuf,
},
KeyNotTrusted {
key_id: String,
},
Forged {
sig_path: PathBuf,
},
NotPresent,
Error {
reason: String,
},
}
impl SignatureVerification {
#[must_use]
pub fn summary(&self) -> &'static str {
match self {
Self::Verified { .. } => "verified",
Self::KeyNotTrusted { .. } => "UNTRUSTED KEY",
Self::Forged { .. } => "FORGED",
Self::NotPresent => "not present",
Self::Error { .. } => "error",
}
}
}
#[must_use]
pub fn verify_iso_signature(iso_path: &Path) -> SignatureVerification {
let sig_path = sidecar_sig_path(iso_path);
let Ok(sig_text) = fs::read_to_string(&sig_path) else {
return SignatureVerification::NotPresent;
};
let signature = match Signature::decode(&sig_text) {
Ok(s) => s,
Err(e) => {
return SignatureVerification::Error {
reason: format!("sig parse failed: {e}"),
};
}
};
let trusted = load_trusted_keys();
let iso_bytes = match fs::read(iso_path) {
Ok(b) => b,
Err(e) => {
return SignatureVerification::Error {
reason: format!("ISO read failed: {e}"),
};
}
};
let mut saw_forgery_under_trusted_key = false;
for (pubkey, source) in &trusted {
match pubkey.verify(&iso_bytes, &signature, false) {
Ok(()) => {
return SignatureVerification::Verified {
key_id: key_id_from_sig(&signature),
sig_path: PathBuf::from(source),
};
}
Err(MinisignError::InvalidSignature) => {
saw_forgery_under_trusted_key = true;
}
Err(_) => {}
}
}
if saw_forgery_under_trusted_key {
return SignatureVerification::Forged {
sig_path: sig_path.clone(),
};
}
SignatureVerification::KeyNotTrusted {
key_id: key_id_from_sig(&signature),
}
}
fn sidecar_sig_path(iso_path: &Path) -> PathBuf {
let mut p = PathBuf::from(iso_path);
let ext = p
.extension()
.map(|e| e.to_string_lossy().to_string())
.unwrap_or_default();
p.set_extension(if ext.is_empty() {
"minisig".to_string()
} else {
format!("{ext}.minisig")
});
p
}
fn load_trusted_keys() -> Vec<(PublicKey, String)> {
let Ok(env) = std::env::var("AEGIS_TRUSTED_KEYS") else {
return Vec::new();
};
let mut keys = Vec::new();
for entry in env.split(':').filter(|s| !s.is_empty()) {
let path = PathBuf::from(entry);
if path.is_dir() {
if !is_path_safely_owned(&path) {
tracing::warn!(
key_dir = %path.display(),
"iso-probe: refusing AEGIS_TRUSTED_KEYS directory — \
group- or world-writable (would allow an attacker to \
drop a malicious pub-key). Fix: chmod go-w <dir>."
);
continue;
}
let Ok(iter) = fs::read_dir(&path) else {
continue;
};
for child in iter.flatten() {
let child_path = child.path();
if child_path.extension().and_then(|s| s.to_str()) == Some("pub") {
load_key_into(&child_path, &mut keys);
}
}
} else if path.is_file() {
load_key_into(&path, &mut keys);
}
}
keys
}
fn load_key_into(path: &Path, out: &mut Vec<(PublicKey, String)>) {
if !is_path_safely_owned(path) {
tracing::warn!(
key = %path.display(),
"iso-probe: refusing trusted pub-key file — group- or \
world-writable. Fix: chmod go-w <file>."
);
return;
}
let Ok(text) = fs::read_to_string(path) else {
return;
};
match PublicKey::decode(text.trim()) {
Ok(key) => out.push((key, path.display().to_string())),
Err(e) => tracing::debug!(
key = %path.display(),
error = %e,
"iso-probe: rejected invalid minisign public key"
),
}
}
fn is_path_safely_owned(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = fs::metadata(path) else {
return false;
};
let mode = meta.permissions().mode();
(mode & 0o022) == 0
}
#[cfg(not(unix))]
{
let _ = path;
true
}
}
fn key_id_from_sig(sig: &Signature) -> String {
let comment = sig.trusted_comment();
let truncated: String = comment.chars().take(40).collect();
if comment.chars().count() > 40 {
format!("{truncated}…")
} else {
truncated
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sidecar_path_appends_minisig_to_extension() {
assert_eq!(
sidecar_sig_path(Path::new("/x/y.iso")),
PathBuf::from("/x/y.iso.minisig")
);
}
#[test]
fn sidecar_path_handles_no_extension() {
assert_eq!(
sidecar_sig_path(Path::new("/x/y")),
PathBuf::from("/x/y.minisig")
);
}
#[test]
fn no_sig_returns_not_present() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let iso = dir.path().join("x.iso");
std::fs::write(&iso, b"dummy").unwrap_or_else(|e| panic!("write: {e}"));
assert!(matches!(
verify_iso_signature(&iso),
SignatureVerification::NotPresent
));
}
#[test]
fn malformed_sig_returns_error() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let iso = dir.path().join("x.iso");
std::fs::write(&iso, b"dummy").unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(dir.path().join("x.iso.minisig"), "not-a-minisig\n")
.unwrap_or_else(|e| panic!("write: {e}"));
assert!(matches!(
verify_iso_signature(&iso),
SignatureVerification::Error { .. }
));
}
#[cfg(unix)]
#[test]
fn is_path_safely_owned_accepts_owner_only_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let f = dir.path().join("key.pub");
std::fs::write(&f, b"x").unwrap_or_else(|e| panic!("write: {e}"));
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600))
.unwrap_or_else(|e| panic!("chmod: {e}"));
assert!(is_path_safely_owned(&f));
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644))
.unwrap_or_else(|e| panic!("chmod: {e}"));
assert!(is_path_safely_owned(&f));
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755))
.unwrap_or_else(|e| panic!("chmod: {e}"));
assert!(is_path_safely_owned(&f));
}
#[cfg(unix)]
#[test]
fn is_path_safely_owned_rejects_group_writable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let f = dir.path().join("key.pub");
std::fs::write(&f, b"x").unwrap_or_else(|e| panic!("write: {e}"));
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o664))
.unwrap_or_else(|e| panic!("chmod: {e}"));
assert!(!is_path_safely_owned(&f));
}
#[cfg(unix)]
#[test]
fn is_path_safely_owned_rejects_world_writable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let f = dir.path().join("key.pub");
std::fs::write(&f, b"x").unwrap_or_else(|e| panic!("write: {e}"));
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o646))
.unwrap_or_else(|e| panic!("chmod: {e}"));
assert!(!is_path_safely_owned(&f));
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o666))
.unwrap_or_else(|e| panic!("chmod: {e}"));
assert!(!is_path_safely_owned(&f));
}
#[cfg(unix)]
#[test]
fn is_path_safely_owned_rejects_missing_file() {
let p = std::path::PathBuf::from("/definitely/does/not/exist-aegis-tk");
assert!(!is_path_safely_owned(&p));
}
#[cfg(unix)]
#[test]
fn load_key_into_skips_group_writable_pub_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let f = dir.path().join("attacker.pub");
std::fs::write(&f, b"untrusted").unwrap_or_else(|e| panic!("write: {e}"));
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o664))
.unwrap_or_else(|e| panic!("chmod: {e}"));
let mut keys: Vec<(PublicKey, String)> = Vec::new();
load_key_into(&f, &mut keys);
assert!(
keys.is_empty(),
"group-writable pub-key should be refused before minisign decode"
);
}
#[test]
fn summary_strings_are_stable() {
assert_eq!(SignatureVerification::NotPresent.summary(), "not present");
assert_eq!(
SignatureVerification::KeyNotTrusted { key_id: "x".into() }.summary(),
"UNTRUSTED KEY"
);
assert_eq!(
SignatureVerification::Forged {
sig_path: PathBuf::new()
}
.summary(),
"FORGED"
);
}
}