use super::{projection, Diagnostic};
use bacnet_transport::sc_tls::inspection::{
inspect_sc_pem_files, ScInputErrorKind, ScInspectionError, ScPemInspection,
};
use std::path::Path;
pub(crate) const SKEW_ALLOWANCE: i64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Weight {
Hard,
Warning,
Notice,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Finding {
pub(crate) weight: Weight,
pub(crate) code: String,
pub(crate) text: String,
}
#[derive(Debug, Clone)]
pub(crate) struct Lint {
#[cfg_attr(not(feature = "tui"), allow(dead_code))]
pub(crate) report: Option<ScPemInspection>,
pub(crate) findings: Vec<Finding>,
}
fn subject(diagnostic: &Diagnostic) -> String {
let index = diagnostic
.index
.map_or(String::new(), |i| format!(" [{i}]"));
match diagnostic.role {
Some("identity") => format!("Operational certificate{index}"),
Some("peer_trust") => format!("CA bundle{index}"),
Some("private_key") => "Key file".into(),
_ => "Credentials".into(),
}
}
fn starts_in(report: &ScPemInspection) -> Option<i64> {
let leaf = report.certificates.first()?.metadata.as_ref()?;
let ahead = i128::from(leaf.not_before_unix()) - i128::from(report.inspected_at_unix);
i64::try_from(ahead).ok().filter(|s| *s > 0)
}
fn weigh(report: &ScPemInspection, diagnostic: Diagnostic) -> Finding {
let leaf = diagnostic.role == Some("identity") && diagnostic.index == Some(0);
let mut text = format!("{}: {}", subject(&diagnostic), diagnostic.message);
let weight = match (diagnostic.severity, diagnostic.code.as_str()) {
("error", "certificate.malformed") if !leaf => Weight::Warning,
("error", "validity.not_yet_valid") if leaf => match starts_in(report) {
Some(secs) if secs <= SKEW_ALLOWANCE => {
text = format!(
"{}: valid in {secs} s; check this computer's clock.",
subject(&diagnostic)
);
Weight::Warning
}
_ => Weight::Hard,
},
("error", _) => Weight::Hard,
("warning", _) => Weight::Warning,
_ => Weight::Notice,
};
Finding {
weight,
text,
code: diagnostic.code,
}
}
impl Lint {
pub(crate) fn run(
cert: &Path,
ca: Option<&Path>,
key: Option<&Path>,
at_unix: Option<i64>,
) -> Result<Self, String> {
let report = match inspect_sc_pem_files(cert, ca, key, at_unix) {
Ok(report) => report,
Err(ScInspectionError::Input {
kind:
kind @ (ScInputErrorKind::InputTooLarge
| ScInputErrorKind::TooManyCertificates
| ScInputErrorKind::CertificateTooLarge),
..
}) => {
return Ok(Self {
report: None,
findings: vec![Finding {
weight: Weight::Warning,
code: format!("credentials.{}", kind.as_str()),
text: format!(
"Credentials: not assessed; the files exceed the inspection \
limits ({}). TLS still checks them.",
kind.as_str()
),
}],
});
}
Err(error) => {
let error = projection::input_error(error);
let mut text =
format!("SC credential check could not complete: {}", error.category);
if let Some(role) = error.role {
text.push_str(&format!(" {role}"));
}
if let Some(kind) = error.kind {
text.push_str(&format!(" ({kind})"));
}
return Err(format!("{text}: {}", error.message));
}
};
let findings = projection::diagnostics(&report)
.into_iter()
.map(|diagnostic| weigh(&report, diagnostic))
.collect();
Ok(Self {
report: Some(report),
findings,
})
}
pub(crate) fn of(&self, weight: Weight) -> impl Iterator<Item = &Finding> {
self.findings.iter().filter(move |f| f.weight == weight)
}
pub(crate) fn refusal(&self) -> Option<String> {
let hard: Vec<&str> = self.of(Weight::Hard).map(|f| f.text.as_str()).collect();
(!hard.is_empty()).then(|| {
format!(
"SC credential check refused the dial before connecting: {}",
hard.join(" ")
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_704_067_200;
fn files(dir: &Path, days: u64) -> (std::path::PathBuf, std::path::PathBuf) {
let pair = rcgen::KeyPair::generate().unwrap();
let mut params = rcgen::CertificateParams::new(vec!["node".into()]).unwrap();
params.not_before = rcgen::date_time_ymd(2024, 1, 1);
params.not_after = params.not_before + std::time::Duration::from_secs(days * 86_400);
let cert = dir.join("node.pem");
let key = dir.join("node.key");
std::fs::write(&cert, params.self_signed(&pair).unwrap().pem()).unwrap();
std::fs::write(&key, pair.serialize_pem()).unwrap();
(cert, key)
}
fn codes(lint: &Lint, weight: Weight) -> Vec<&str> {
lint.of(weight).map(|f| f.code.as_str()).collect()
}
#[test]
fn thresholds_weigh_as_alarm_warning_and_ok_and_dates_block() {
let dir = tempfile::tempdir().unwrap();
for (days, expected) in [
(5, Some("expiry.alarm")),
(25, Some("expiry.warning")),
(400, None),
] {
let (cert, key) = files(dir.path(), days);
let lint = Lint::run(&cert, Some(&cert), Some(&key), Some(NOW)).unwrap();
let warnings = codes(&lint, Weight::Warning);
let expiry: Vec<_> = warnings
.iter()
.filter(|c| c.starts_with("expiry."))
.collect();
assert_eq!(expiry.first().map(|c| **c), expected, "{days} days");
assert!(lint.refusal().is_none(), "{days} days: {:?}", lint.findings);
}
let (cert, key) = files(dir.path(), 400);
for (at, code) in [
(NOW - SKEW_ALLOWANCE - 1, "validity.not_yet_valid"),
(NOW + 401 * 86_400, "validity.expired"),
] {
let lint = Lint::run(&cert, Some(&cert), Some(&key), Some(at)).unwrap();
assert_eq!(codes(&lint, Weight::Hard), [code]);
assert!(lint
.refusal()
.unwrap()
.contains("Operational certificate [0]"));
}
}
#[test]
fn a_mismatched_key_is_hard_and_unreadable_input_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let (cert, _) = files(dir.path(), 400);
let other = dir.path().join("other.key");
std::fs::write(&other, rcgen::KeyPair::generate().unwrap().serialize_pem()).unwrap();
let lint = Lint::run(&cert, Some(&cert), Some(&other), Some(NOW)).unwrap();
assert_eq!(codes(&lint, Weight::Hard), ["key.mismatch"]);
assert!(lint.refusal().unwrap().contains("Key file:"));
let empty = dir.path().join("empty.pem");
std::fs::write(&empty, "").unwrap();
let error = Lint::run(&empty, None, None, Some(NOW)).unwrap_err();
assert!(error.contains("empty_certificates"), "{error}");
}
#[test]
fn a_leaf_starting_within_the_skew_allowance_only_warns() {
let dir = tempfile::tempdir().unwrap();
let (cert, key) = files(dir.path(), 400);
for ahead in [1, 120, SKEW_ALLOWANCE] {
let lint = Lint::run(&cert, Some(&cert), Some(&key), Some(NOW - ahead)).unwrap();
assert!(lint.refusal().is_none(), "{ahead}: {:?}", lint.findings);
let skew = lint
.of(Weight::Warning)
.find(|f| f.code == "validity.not_yet_valid")
.expect("a skew warning");
assert!(
skew.text.contains(&format!("valid in {ahead} s")),
"{skew:?}"
);
}
}
fn ca_pem(name: &str) -> String {
let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
params
.distinguished_name
.push(rcgen::DnType::CommonName, name);
let key = rcgen::KeyPair::generate().unwrap();
params.self_signed(&key).unwrap().pem()
}
#[test]
fn an_unreadable_ca_entry_beside_a_good_one_only_warns() {
let dir = tempfile::tempdir().unwrap();
let (cert, key) = files(dir.path(), 400);
let bundle = dir.path().join("bundle.pem");
let malformed = "-----BEGIN CERTIFICATE-----\nYWJj\n-----END CERTIFICATE-----\n";
let good = std::fs::read_to_string(&cert).unwrap();
std::fs::write(&bundle, format!("{good}{malformed}")).unwrap();
let lint = Lint::run(&cert, Some(&bundle), Some(&key), Some(NOW)).unwrap();
assert!(lint.refusal().is_none(), "{:?}", lint.findings);
assert!(codes(&lint, Weight::Warning).contains(&"certificate.malformed"));
let lint = Lint::run(&bundle, Some(&cert), None, Some(NOW)).unwrap();
assert!(lint.refusal().is_none(), "the chain entry only warns");
let leaf_bad = dir.path().join("leaf-bad.pem");
std::fs::write(&leaf_bad, format!("{malformed}{good}")).unwrap();
let lint = Lint::run(&leaf_bad, Some(&cert), None, Some(NOW)).unwrap();
assert_eq!(codes(&lint, Weight::Hard), ["certificate.malformed"]);
}
#[test]
fn inputs_past_the_inspection_limits_warn_instead_of_refusing() {
let dir = tempfile::tempdir().unwrap();
let (cert, key) = files(dir.path(), 400);
let bundle = dir.path().join("big-bundle.pem");
let cas: String = (0..64).map(|i| ca_pem(&format!("CA {i}"))).collect();
std::fs::write(&bundle, cas).unwrap();
let lint = Lint::run(&cert, Some(&bundle), Some(&key), Some(NOW)).unwrap();
assert!(lint.report.is_none());
assert!(lint.refusal().is_none());
assert_eq!(
codes(&lint, Weight::Warning),
["credentials.too_many_certificates"]
);
}
}