-
Notifications
You must be signed in to change notification settings - Fork 93
fm: add certificate diagnosis engine for expiring silo TLS certificates #11238
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5007ba6
e8c3674
590fba9
4d7489d
9e449d0
391d604
2e1ca73
de88ede
4a64593
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,11 +4,15 @@ | |
|
|
||
| //! Utilities for validating X509 certificates, used by both nexus and wicketd. | ||
|
|
||
| use chrono::DateTime; | ||
| use chrono::Utc; | ||
| use display_error_chain::DisplayErrorChain; | ||
| use omicron_common::api::external::Error; | ||
| use openssl::asn1::Asn1Time; | ||
| use openssl::asn1::Asn1TimeRef; | ||
| use openssl::pkey::PKey; | ||
| use openssl::x509::X509; | ||
| use openssl::x509::X509Ref; | ||
| use std::borrow::Borrow; | ||
| use std::ffi::CString; | ||
|
|
||
|
|
@@ -47,6 +51,9 @@ pub enum CertificateError { | |
| #[error("Unsupported certificate purpose (not usable for server auth)")] | ||
| UnsupportedPurpose, | ||
|
|
||
| #[error("Certificate validity time is out of the representable range")] | ||
| TimeOutOfRange, | ||
|
|
||
| #[error("Unexpected error")] | ||
| Unexpected(#[source] openssl::error::ErrorStack), | ||
| } | ||
|
|
@@ -62,7 +69,8 @@ impl From<CertificateError> for Error { | |
| | InvalidValidationHostname(_) | ||
| | ErrorValidatingHostname(_) | ||
| | NoDnsNameMatchingHostname { .. } | ||
| | UnsupportedPurpose => Error::invalid_value( | ||
| | UnsupportedPurpose | ||
| | TimeOutOfRange => Error::invalid_value( | ||
| "certificate", | ||
| DisplayErrorChain::new(&error).to_string(), | ||
| ), | ||
|
|
@@ -77,6 +85,47 @@ impl From<CertificateError> for Error { | |
| } | ||
| } | ||
|
|
||
| /// The validity window of an X509 certificate. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| pub struct CertificateValidity { | ||
| /// The certificate is not valid before this time. | ||
| pub not_before: DateTime<Utc>, | ||
| /// The certificate is not valid after this time. | ||
| pub not_after: DateTime<Utc>, | ||
| } | ||
|
|
||
| /// Returns the validity window of an X509 certificate. | ||
| /// | ||
| /// When called on the leaf certificate of a chain (the first certificate in | ||
| /// the chain, which is the one presented to TLS clients), the returned window | ||
| /// is the one clients will check. | ||
| pub fn validity( | ||
| cert: &X509Ref, | ||
| ) -> Result<CertificateValidity, CertificateError> { | ||
| Ok(CertificateValidity { | ||
| not_before: asn1_time_to_chrono(cert.not_before())?, | ||
| not_after: asn1_time_to_chrono(cert.not_after())?, | ||
| }) | ||
| } | ||
|
|
||
| /// Converts an ASN.1 time to a `chrono` timestamp by measuring its offset | ||
| /// from the Unix epoch. | ||
| /// | ||
| /// `Asn1TimeRef` offers no direct conversion to a Unix timestamp, but | ||
| /// `ASN1_TIME_diff` can compute the (days, seconds) difference between two | ||
| /// ASN.1 times. | ||
| fn asn1_time_to_chrono( | ||
| time: &Asn1TimeRef, | ||
| ) -> Result<DateTime<Utc>, CertificateError> { | ||
| const SECS_PER_DAY: i64 = 24 * 60 * 60; | ||
| let epoch = Asn1Time::from_unix(0).map_err(CertificateError::Unexpected)?; | ||
| // `diff` computes `time - epoch`, split into whole days and the | ||
| // remaining seconds. | ||
| let diff = epoch.diff(time).map_err(CertificateError::Unexpected)?; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hm, do we know the errors that |
||
| let secs = i64::from(diff.days) * SECS_PER_DAY + i64::from(diff.secs); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we use checked/saturating arithmetic here, since the cert is user controlled and might be overflowy? |
||
| DateTime::from_timestamp(secs, 0).ok_or(CertificateError::TimeOutOfRange) | ||
| } | ||
|
|
||
| pub struct CertificateValidator { | ||
| validate_expiration: bool, | ||
| } | ||
|
|
@@ -444,4 +493,44 @@ mod tests { | |
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_validity_converts_asn1_times() { | ||
| // Pin the leaf's validity window to exact second offsets from the | ||
| // Unix epoch. The root and intermediate certificates in the chain | ||
| // keep rcgen's default (much wider) window, so a correct result | ||
| // proves we read the certificate we were given and not some other | ||
| // link in the chain. | ||
| const NOT_BEFORE_SECS: u64 = 1_000_000_000; | ||
| const NOT_AFTER_SECS: u64 = 2_000_000_000; | ||
| let mut params = CertificateParams::new(vec![ | ||
| "fake.test.oxide.computer".to_string(), | ||
| ]); | ||
| params.not_before = (std::time::SystemTime::UNIX_EPOCH | ||
| + std::time::Duration::from_secs(NOT_BEFORE_SECS)) | ||
| .into(); | ||
| params.not_after = (std::time::SystemTime::UNIX_EPOCH | ||
| + std::time::Duration::from_secs(NOT_AFTER_SECS)) | ||
| .into(); | ||
| let chain = CertificateChain::with_params(params); | ||
| let certs = X509::stack_from_pem(chain.cert_chain_as_pem().as_bytes()) | ||
| .expect("chain should parse"); | ||
|
|
||
| let leaf_validity = | ||
| validity(&certs[0]).expect("leaf validity should convert"); | ||
| assert_eq!( | ||
| leaf_validity, | ||
| CertificateValidity { | ||
| not_before: DateTime::from_timestamp(NOT_BEFORE_SECS as i64, 0) | ||
| .unwrap(), | ||
| not_after: DateTime::from_timestamp(NOT_AFTER_SECS as i64, 0) | ||
| .unwrap(), | ||
| } | ||
| ); | ||
| // The intermediate certificate keeps rcgen's default window, which | ||
| // differs from the leaf's. | ||
| let intermediate_validity = | ||
| validity(&certs[1]).expect("intermediate validity should convert"); | ||
| assert_ne!(leaf_validity, intermediate_validity); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -137,6 +137,20 @@ struct ConfigOpts { | |
| /// revert this setting to the default value. | ||
| #[clap(long, action = ArgAction::Set)] | ||
| analysis_enabled: Option<Setting<settings::AnalysisEnabled>>, | ||
|
|
||
| /// Sets how many days before a silo's external TLS certificate expires | ||
| /// the certificate diagnosis engine opens a case and requests an alert. | ||
|
Comment on lines
+141
to
+142
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is sort of a weirdly structured sentence; I get what you're trying to say, but i wonder if it could be re-structured a bit. |
||
| /// | ||
| /// The window applies to the certificate Nexus serves for the silo (the | ||
| /// one with the latest expiration time), so a case opens only when no | ||
| /// later-expiring replacement is installed. | ||
| /// | ||
| /// This must be a non-zero integer no greater than 3650, or 'default'. If | ||
| /// it is set to 'default', any previous override will be removed, and the | ||
| /// system will revert this setting to the default value. | ||
| #[clap(long, action = ArgAction::Set)] | ||
| certificate_expiry_warning_days: | ||
| Option<Setting<settings::CertificateExpiryWarningDays>>, | ||
|
Comment on lines
+152
to
+153
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good call to put this here rather than in the TOML. |
||
| } | ||
|
|
||
| impl ConfigOpts { | ||
|
|
@@ -151,6 +165,9 @@ impl ConfigOpts { | |
| analysis_enabled: self | ||
| .analysis_enabled | ||
| .unwrap_or(current.analysis_enabled), | ||
| certificate_expiry_warning_days: self | ||
| .certificate_expiry_warning_days | ||
| .unwrap_or(current.certificate_expiry_warning_days), | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what is
ASN1_TIME_diff?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh i guess it's the name of the C function that
Asn1Time::diffis a binding to.