150 lines
5.5 KiB
Rust
150 lines
5.5 KiB
Rust
//! A client certificate's validity window, read from the PEM itself.
|
|
//!
|
|
//! The window is the certificate's own `notBefore`/`notAfter`, the same two
|
|
//! timestamps the store checks at login, so a caller that asks "how much of
|
|
//! its life is left" and the store never disagree about the answer.
|
|
|
|
use std::path::Path;
|
|
|
|
use x509_cert::der::DecodePem as _;
|
|
|
|
use crate::{Error, client::ENV_CLIENT_CERT};
|
|
|
|
/// The gauge a service exports for its client certificate's `notBefore`, unix
|
|
/// seconds. The alert rule `hh-bao-client-cert-lifetime` selects this name.
|
|
pub const NOT_BEFORE_METRIC: &str = "bao.client_cert.not_before";
|
|
/// The gauge for the certificate's `notAfter`, unix seconds.
|
|
pub const NOT_AFTER_METRIC: &str = "bao.client_cert.not_after";
|
|
|
|
/// A certificate's validity window, `(notBefore, notAfter)` in unix seconds.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::Certificate`] when `pem` is not one PEM-encoded X.509 certificate.
|
|
pub fn validity(pem: &str) -> Result<(i64, i64), Error> {
|
|
let cert = x509_cert::Certificate::from_pem(pem.as_bytes())
|
|
.map_err(|e| Error::Certificate(e.to_string()))?;
|
|
let validity = &cert.tbs_certificate.validity;
|
|
let secs = |t: x509_cert::time::Time| {
|
|
i64::try_from(t.to_unix_duration().as_secs()).unwrap_or(i64::MAX)
|
|
};
|
|
Ok((secs(validity.not_before), secs(validity.not_after)))
|
|
}
|
|
|
|
/// [`validity`] of the client certificate file at `path` (the file
|
|
/// [`ENV_CLIENT_CERT`] names).
|
|
///
|
|
/// # Errors
|
|
/// [`Error::Identity`] when the file cannot be read, [`Error::Certificate`]
|
|
/// when it is not a certificate.
|
|
pub fn file_validity(path: &Path) -> Result<(i64, i64), Error> {
|
|
let pem = std::fs::read_to_string(path).map_err(|source| Error::Identity {
|
|
var: ENV_CLIENT_CERT,
|
|
path: path.display().to_string(),
|
|
source,
|
|
})?;
|
|
validity(&pem)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests {
|
|
use super::*;
|
|
|
|
/// The window `certificate_pem` is built with: 2026-01-01T00:00:00Z to
|
|
/// 2026-04-01T00:00:00Z.
|
|
pub(crate) const NOT_BEFORE: i64 = 1_767_225_600;
|
|
pub(crate) const NOT_AFTER: i64 = 1_775_001_600;
|
|
|
|
/// A certificate valid from `not_before` to `not_after` (unix seconds),
|
|
/// PEM-encoded. The name, key and signature are placeholders: [`validity`]
|
|
/// reads the window and verifies nothing.
|
|
pub(crate) fn certificate_pem(not_before: i64, not_after: i64) -> String {
|
|
use x509_cert::der::{
|
|
EncodePem as _,
|
|
asn1::{BitString, ObjectIdentifier, UtcTime},
|
|
pem::LineEnding,
|
|
};
|
|
use x509_cert::{
|
|
Certificate, TbsCertificate, Version,
|
|
name::Name,
|
|
serial_number::SerialNumber,
|
|
spki::{AlgorithmIdentifierOwned, SubjectPublicKeyInfoOwned},
|
|
time::{Time, Validity},
|
|
};
|
|
|
|
let time = |secs: i64| {
|
|
let secs = u64::try_from(secs).expect("a date after 1970");
|
|
Time::UtcTime(
|
|
UtcTime::from_unix_duration(std::time::Duration::from_secs(secs))
|
|
.expect("a date UTCTime can hold"),
|
|
)
|
|
};
|
|
let algorithm = AlgorithmIdentifierOwned {
|
|
oid: ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2"),
|
|
parameters: None,
|
|
};
|
|
let cert = Certificate {
|
|
tbs_certificate: TbsCertificate {
|
|
version: Version::V3,
|
|
serial_number: SerialNumber::new(&[1]).expect("a serial number"),
|
|
signature: algorithm.clone(),
|
|
issuer: Name::default(),
|
|
validity: Validity {
|
|
not_before: time(not_before),
|
|
not_after: time(not_after),
|
|
},
|
|
subject: Name::default(),
|
|
subject_public_key_info: SubjectPublicKeyInfoOwned {
|
|
algorithm: algorithm.clone(),
|
|
subject_public_key: BitString::from_bytes(&[0]).expect("a bit string"),
|
|
},
|
|
issuer_unique_id: None,
|
|
subject_unique_id: None,
|
|
extensions: None,
|
|
},
|
|
signature_algorithm: algorithm,
|
|
signature: BitString::from_bytes(&[0]).expect("a bit string"),
|
|
};
|
|
cert.to_pem(LineEnding::LF).expect("encodes")
|
|
}
|
|
|
|
#[test]
|
|
fn the_window_is_read_from_the_certificate() {
|
|
assert_eq!(
|
|
validity(&certificate_pem(NOT_BEFORE, NOT_AFTER)).expect("a well-formed certificate"),
|
|
(NOT_BEFORE, NOT_AFTER)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn something_that_is_not_a_certificate_is_an_error() {
|
|
assert!(matches!(
|
|
validity("not a certificate"),
|
|
Err(Error::Certificate(_))
|
|
));
|
|
assert!(validity("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_is_read_through_its_path() {
|
|
let dir =
|
|
std::env::temp_dir().join(format!("swarm-secret-client-cert-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).expect("scratch dir");
|
|
let path = dir.join("cert.pem");
|
|
std::fs::write(&path, certificate_pem(NOT_BEFORE, NOT_AFTER)).expect("write");
|
|
assert_eq!(
|
|
file_validity(&path).expect("readable"),
|
|
(NOT_BEFORE, NOT_AFTER)
|
|
);
|
|
std::fs::remove_dir_all(&dir).expect("cleanup");
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_file_names_the_variable_and_path() {
|
|
let err = file_validity(Path::new("/nonexistent/cert.pem")).expect_err("no such file");
|
|
assert!(
|
|
matches!(&err, Error::Identity { var, path, .. }
|
|
if *var == ENV_CLIENT_CERT && path == "/nonexistent/cert.pem"),
|
|
"{err}"
|
|
);
|
|
}
|
|
}
|