Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/cert.rs

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}"
);
}
}