#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Level {
Ok,
Notice,
Warning,
Alarm,
Failed,
}
impl Level {
pub(crate) fn word(self) -> &'static str {
match self {
Self::Ok => "[ ok ]",
Self::Notice => "[note]",
Self::Warning => "[warn]",
Self::Alarm => "[ALRM]",
Self::Failed => "[FAIL]",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Cert {
pub(crate) label: String,
pub(crate) subject: String,
pub(crate) issuer: String,
pub(crate) san: String,
pub(crate) key_algorithm: String,
pub(crate) eku: String,
pub(crate) not_after: String,
pub(crate) window: String,
pub(crate) level: Level,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Credentials {
pub(crate) inspected_at: String,
pub(crate) certificates: Vec<Cert>,
pub(crate) key: (Level, String),
pub(crate) issuer: (Level, String),
pub(crate) findings: Vec<(Level, String)>,
}
fn days(seconds: i128) -> i128 {
seconds / 86_400
}
fn key_algorithm(oid: &str) -> String {
match oid {
"1.2.840.113549.1.1.1" => "RSA".into(),
"1.2.840.113549.1.1.10" => "RSASSA-PSS".into(),
"1.2.840.10045.2.1" => "EC".into(),
"1.3.101.112" => "Ed25519".into(),
"1.3.101.113" => "Ed448".into(),
other => format!("OID {other}"),
}
}
pub(crate) fn signature_algorithm(oid: &str) -> &str {
match oid {
"1.2.840.10045.4.3.2" => "ecdsa-with-SHA256",
"1.2.840.10045.4.3.3" => "ecdsa-with-SHA384",
"1.2.840.10045.4.3.4" => "ecdsa-with-SHA512",
"1.2.840.113549.1.1.5" => "sha1WithRSAEncryption",
"1.2.840.113549.1.1.11" => "sha256WithRSAEncryption",
"1.2.840.113549.1.1.12" => "sha384WithRSAEncryption",
"1.2.840.113549.1.1.13" => "sha512WithRSAEncryption",
"1.2.840.113549.1.1.10" => "RSASSA-PSS",
"1.3.101.112" => "Ed25519",
"1.3.101.113" => "Ed448",
other => other,
}
}
#[cfg(feature = "sc-tls")]
mod build {
use super::*;
use crate::sc_check::lint::{Lint, Weight, SKEW_ALLOWANCE};
use bacnet_transport::sc_tls::inspection::{
ScDirectIssuer, ScExpiry, ScExtensionInspection, ScInputRole, ScInspectedCertificate,
ScKeyConsistency, ScValidity,
};
fn values(extension: &ScExtensionInspection, absent: &str) -> String {
match (extension.assessed, extension.present) {
(false, _) => "not assessed".into(),
(true, false) => absent.into(),
(true, true) if extension.values.is_empty() => "present, unreadable".into(),
(true, true) => extension.values.join(", "),
}
}
fn cert(entry: &ScInspectedCertificate, now: i64) -> Cert {
let label = match (entry.role, entry.index) {
(ScInputRole::Identity, 0) => "certificate 0".into(),
(ScInputRole::Identity, i) => format!("chain {i}"),
(_, i) => format!("CA {i}"),
};
let leaf = entry.role == ScInputRole::Identity && entry.index == 0;
let Some(meta) = &entry.metadata else {
return Cert {
label,
subject: "(certificate could not be read)".into(),
issuer: String::new(),
san: String::new(),
key_algorithm: String::new(),
eku: String::new(),
not_after: String::new(),
window: "unreadable".into(),
level: if leaf { Level::Failed } else { Level::Warning },
};
};
let left = i128::from(meta.not_after_unix()) - i128::from(now);
let ahead = i128::from(meta.not_before_unix()) - i128::from(now);
let (window, level) = match entry.validity {
ScValidity::Expired => (format!("expired {} days ago", days(-left)), Level::Failed),
ScValidity::NotYetValid if ahead <= i128::from(SKEW_ALLOWANCE) => (
format!("valid in {ahead} s; check this computer's clock"),
Level::Warning,
),
ScValidity::NotYetValid => (
format!("not valid for {} more days", days(ahead)),
Level::Failed,
),
ScValidity::InvalidInterval => ("ends before it starts".into(), Level::Failed),
ScValidity::Unavailable => ("not assessed".into(), Level::Notice),
ScValidity::WithinInterval => (
format!("{} days left", days(left)),
match entry.expiry {
ScExpiry::Alarm => Level::Alarm,
ScExpiry::Warning => Level::Warning,
_ => Level::Ok,
},
),
};
let level = match level {
Level::Failed if !leaf => Level::Warning,
other => other,
};
Cert {
label,
subject: meta.subject().into(),
issuer: meta.issuer().into(),
san: values(&entry.san, "none"),
key_algorithm: key_algorithm(meta.public_key_algorithm_oid()),
eku: values(&entry.extended_key_usage, "none (any purpose)").replace('_', "-"),
not_after: crate::sc_check::date(meta.not_after_unix()),
window,
level,
}
}
impl Credentials {
pub(crate) fn from_lint(lint: &Lint) -> Self {
let findings = lint
.findings
.iter()
.filter_map(|f| match f.weight {
Weight::Hard => Some((Level::Failed, f.text.clone())),
Weight::Warning => Some((Level::Warning, f.text.clone())),
Weight::Notice => None,
})
.collect();
let Some(report) = &lint.report else {
return Self {
inspected_at: "not inspected".into(),
certificates: Vec::new(),
key: (Level::Notice, "not checked".into()),
issuer: (Level::Notice, "not checked".into()),
findings,
};
};
let now = report.inspected_at_unix;
let key = match report.key_consistency {
ScKeyConsistency::Match => (Level::Ok, "matches the operational certificate"),
ScKeyConsistency::Mismatch => {
(Level::Failed, "does NOT match the operational certificate")
}
ScKeyConsistency::Unusable => (Level::Failed, "could not be used"),
ScKeyConsistency::Unknown => (Level::Warning, "match could not be established"),
ScKeyConsistency::NotAssessed => (Level::Notice, "not checked"),
};
let issuer = match (report.direct_issuer, report.direct_issuer_index) {
(ScDirectIssuer::Direct, Some(i)) => {
(Level::Ok, format!("issued directly by bundle CA {i}"))
}
(ScDirectIssuer::NotDirect, _) => (
Level::Warning,
"no bundle CA issued it directly (Annex AB.7.4 default)".into(),
),
(ScDirectIssuer::NotAssessed, _) => (Level::Notice, "not checked".into()),
_ => (
Level::Warning,
"direct issuance could not be checked".into(),
),
};
Self {
inspected_at: crate::sc_check::date(now),
certificates: report.certificates.iter().map(|c| cert(c, now)).collect(),
key: (key.0, key.1.into()),
issuer,
findings,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_round_toward_zero_and_algorithms_have_names() {
assert_eq!(days(5 * 86_400 + 86_399), 5);
assert_eq!(days(86_399), 0);
assert_eq!(key_algorithm("1.2.840.10045.2.1"), "EC");
assert_eq!(key_algorithm("1.2.3"), "OID 1.2.3");
assert_eq!(
signature_algorithm("1.2.840.10045.4.3.2"),
"ecdsa-with-SHA256"
);
assert_eq!(
signature_algorithm("1.2.840.113549.1.1.11"),
"sha256WithRSAEncryption"
);
assert_eq!(signature_algorithm("1.2.3.4"), "1.2.3.4");
}
#[cfg(feature = "sc-tls")]
#[test]
fn a_lint_reads_as_levels_with_days_to_expiry() {
use crate::sc_check::lint::Lint;
const NOW: i64 = 1_704_067_200; let dir = tempfile::tempdir().unwrap();
let pair = rcgen::KeyPair::generate().unwrap();
let mut params = rcgen::CertificateParams::new(vec!["node.site".into()]).unwrap();
params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth];
params.not_before = rcgen::date_time_ymd(2024, 1, 1);
params.not_after = params.not_before + std::time::Duration::from_secs(25 * 86_400);
let cert = dir.path().join("node.pem");
let key = dir.path().join("node.key");
std::fs::write(&cert, params.self_signed(&pair).unwrap().pem()).unwrap();
std::fs::write(&key, pair.serialize_pem()).unwrap();
let lint = Lint::run(&cert, Some(&cert), Some(&key), Some(NOW)).unwrap();
let view = Credentials::from_lint(&lint);
let leaf = &view.certificates[0];
assert_eq!(leaf.label, "certificate 0");
assert_eq!(
(leaf.window.as_str(), leaf.level),
("25 days left", Level::Warning)
);
assert_eq!(leaf.san, "DNS:node.site");
assert_eq!(leaf.eku, "client-auth");
assert_eq!(view.certificates[1].label, "CA 0");
assert_eq!(view.key.0, Level::Ok);
assert_eq!(
view.issuer,
(Level::Ok, "issued directly by bundle CA 0".into())
);
assert!(view
.findings
.iter()
.any(|(level, text)| *level == Level::Warning && text.contains("thirty days")));
let late = Lint::run(&cert, Some(&cert), Some(&key), Some(NOW + 27 * 86_400)).unwrap();
let late = Credentials::from_lint(&late);
assert_eq!(
(
late.certificates[0].window.as_str(),
late.certificates[0].level
),
("expired 2 days ago", Level::Failed)
);
assert_eq!(late.certificates[1].level, Level::Warning);
}
}