diff --git a/Cargo.lock b/Cargo.lock index cb8ab4f7a..497bce410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced38439e7a86a4761f7f7d5ded5ff009135939ecb464a24452eaa4c1696af7d" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce61d2d3844c6b8d31b2353d9f66cf5e632b3e9549583fe3cac2f4f6136725e" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.101", +] + [[package]] name = "bstr" version = "1.12.0" @@ -2934,6 +2959,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" name = "stackable-certs" version = "0.3.1" dependencies = [ + "bon", "const-oid", "ecdsa", "k8s-openapi", diff --git a/Cargo.toml b/Cargo.toml index 29c73bc9e..65ec0808a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ repository = "https://github.com/stackabletech/operator-rs" product-config = { git = "https://github.com/stackabletech/product-config.git", tag = "0.7.0" } axum = { version = "0.8.1", features = ["http2"] } +bon = "3.6.3" chrono = { version = "0.4.38", default-features = false } clap = { version = "4.5.17", features = ["derive", "cargo", "env"] } const_format = "0.2.33" diff --git a/crates/stackable-certs/CHANGELOG.md b/crates/stackable-certs/CHANGELOG.md index 0f69fa198..797da96a7 100644 --- a/crates/stackable-certs/CHANGELOG.md +++ b/crates/stackable-certs/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Support adding SAN entries to generated certificates, this is needed for basically all modern TLS + certificate validations when used with HTTPS ([#1044]). + +### Changed + +- GIGA BREAKING: Rewrite entire CA and cert generation to use a builder pattern ([#1044]). +- BREAKING: The constant `DEFAULT_CA_VALIDITY_SECONDS` has been renamed to `DEFAULT_CA_VALIDITY` and now is of type `stackable_operator::time::Duration`. + Also, the constant `ROOT_CA_SUBJECT` has been renamed to `SDP_ROOT_CA_SUBJECT` ([#1044]). + ## [0.3.1] - 2024-07-10 ### Changed @@ -11,6 +22,7 @@ All notable changes to this project will be documented in this file. - Bump rust-toolchain to 1.79.0 ([#822]). [#822]: https://github.com/stackabletech/operator-rs/pull/822 +[#1044]: https://github.com/stackabletech/operator-rs/pull/1044 ## [0.3.0] - 2024-05-08 diff --git a/crates/stackable-certs/Cargo.toml b/crates/stackable-certs/Cargo.toml index be109d1ae..7dbf0682f 100644 --- a/crates/stackable-certs/Cargo.toml +++ b/crates/stackable-certs/Cargo.toml @@ -13,6 +13,7 @@ rustls = ["dep:tokio-rustls"] [dependencies] stackable-operator = { path = "../stackable-operator" } +bon.workspace = true const-oid.workspace = true ecdsa.workspace = true k8s-openapi.workspace = true diff --git a/crates/stackable-certs/src/ca/ca_builder.rs b/crates/stackable-certs/src/ca/ca_builder.rs new file mode 100644 index 000000000..babcb96fe --- /dev/null +++ b/crates/stackable-certs/src/ca/ca_builder.rs @@ -0,0 +1,270 @@ +use bon::Builder; +use rsa::pkcs8::EncodePublicKey; +use snafu::{ResultExt, Snafu}; +use stackable_operator::time::Duration; +use tracing::{debug, instrument}; +use x509_cert::{ + builder::{Builder, CertificateBuilder, Profile}, + der::{DecodePem, referenced::OwnedToRef}, + ext::pkix::AuthorityKeyIdentifier, + name::Name, + serial_number::SerialNumber, + spki::SubjectPublicKeyInfoOwned, + time::Validity, +}; + +use super::CertificateAuthority; +use crate::{ + CertificatePair, + ca::{DEFAULT_CA_VALIDITY, PEM_LINE_ENDING, SDP_ROOT_CA_SUBJECT}, + keys::CertificateKeypair, +}; + +/// Defines all error variants which can occur when creating a CA +#[derive(Debug, Snafu)] +pub enum CreateCertificateAuthorityError +where + E: std::error::Error + 'static, +{ + #[snafu(display("failed to parse validity"))] + ParseValidity { source: x509_cert::der::Error }, + + #[snafu(display("failed to parse \"{subject}\" as subject"))] + ParseSubject { + source: x509_cert::der::Error, + subject: String, + }, + + #[snafu(display("failed to create signing key pair"))] + CreateSigningKeyPair { source: E }, + + #[snafu(display("failed to serialize public key as PEM"))] + SerializePublicKey { source: x509_cert::spki::Error }, + + #[snafu(display("failed to decode SPKI from PEM"))] + DecodeSpkiFromPem { source: x509_cert::der::Error }, + + #[snafu(display("failed to parse AuthorityKeyIdentifier"))] + ParseAuthorityKeyIdentifier { source: x509_cert::der::Error }, + + #[snafu(display("failed to create certificate builder"))] + CreateCertificateBuilder { source: x509_cert::builder::Error }, + + #[snafu(display("failed to add certificate extension"))] + AddCertificateExtension { source: x509_cert::builder::Error }, + + #[snafu(display("failed to build certificate"))] + BuildCertificate { source: x509_cert::builder::Error }, +} + +/// This builder builds certificate authorities of type [`CertificateAuthority`]. +/// +/// It has many default values, notably; +/// +/// - A default validity of [`DEFAULT_CA_VALIDITY`] +/// - A default subject of [`SDP_ROOT_CA_SUBJECT`] +/// - A randomly generated serial number +/// - In case no `signing_key_pair` was provided, a fresh keypair will be created. The algorithm +/// (`rsa`/`ecdsa`) is chosen by the generic [`CertificateKeypair`] type of this struct. +/// +/// The CA contains the public half of the provided `signing_key_pair` and is signed by the private +/// half of said key. +/// +/// Example code to construct a CA: +/// +/// ```no_run +/// use stackable_certs::{ +/// keys::ecdsa, ca::CertificateAuthority, +/// }; +/// +/// let ca = CertificateAuthority::::builder() +/// .build() +/// .expect("failed to build CA"); +/// ``` +/// +/// Instead of using generics to determine the algorithm to use you can also use [`CertificateAuthority::builder_with_rsa`] +/// or [`CertificateAuthority::builder_with_ecdsa`] instead: +/// +/// ```no_run +/// use stackable_certs::{ +/// keys::ecdsa, ca::CertificateAuthority, +/// }; +/// +/// let ca = CertificateAuthority::builder_with_ecdsa() +/// .build() +/// .expect("failed to build CA"); +/// ``` +#[derive(Builder)] +#[builder(start_fn = start_builder, finish_fn = finish_builder)] +pub struct CertificateAuthorityBuilder<'a, SKP> +where + SKP: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, +{ + /// Required subject of the certificate authority, usually starts with `CN=`. + #[builder(default = SDP_ROOT_CA_SUBJECT)] + subject: &'a str, + + /// Validity/lifetime of the certificate. + /// + /// If not specified the default of [`DEFAULT_CA_VALIDITY`] will be used. + #[builder(default = DEFAULT_CA_VALIDITY)] + validity: Duration, + + /// Cryptographic keypair used to sign leaf certificates. + /// + /// If not specified a random keypair will be generated. + signing_key_pair: Option, +} + +impl CertificateAuthorityBuilderBuilder<'_, SKP, S> +where + SKP: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, + S: certificate_authority_builder_builder::IsComplete, +{ + /// Convenience function to avoid calling `builder().finish_builder().build()` + pub fn build( + self, + ) -> Result, CreateCertificateAuthorityError> { + self.finish_builder().build() + } +} + +impl CertificateAuthorityBuilder<'_, SKP> +where + SKP: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, +{ + #[instrument( + name = "build_certificate_authority", + skip(self), + fields(subject = self.subject), + )] + pub fn build( + self, + ) -> Result, CreateCertificateAuthorityError> { + let validity = Validity::from_now(*self.validity).context(ParseValiditySnafu)?; + let subject: Name = self.subject.parse().context(ParseSubjectSnafu { + subject: self.subject, + })?; + let signing_key_pair = match self.signing_key_pair { + Some(signing_key_pair) => signing_key_pair, + None => SKP::new().context(CreateSigningKeyPairSnafu)?, + }; + + // By choosing a random serial number we can make the reasonable assumption that we generate + // a unique serial for each CA. + let serial_number = SerialNumber::from(rand::random::()); + + let spki_pem = signing_key_pair + .verifying_key() + .to_public_key_pem(PEM_LINE_ENDING) + .context(SerializePublicKeySnafu)?; + + let spki = SubjectPublicKeyInfoOwned::from_pem(spki_pem.as_bytes()) + .context(DecodeSpkiFromPemSnafu)?; + + // There are multiple default extensions included in the profile. For + // the root profile, these are: + // + // - BasicConstraints marked as critical and CA = true + // - SubjectKeyIdentifier with the 160-bit SHA-1 hash of the subject + // public key. + // - KeyUsage with KeyCertSign and CRLSign bits set. Ideally we also + // want to include the DigitalSignature bit, which for example is + // required for CA certs which want to sign an OCSP response. + // Currently, the root profile doesn't include that bit. + // + // The root profile doesn't add the AuthorityKeyIdentifier extension. + // We manually add it below by using the 160-bit SHA-1 hash of the + // subject public key. This conforms to one of the outlined methods for + // generating key identifiers outlined in RFC 5280, section 4.2.1.2. + // + // Prepare extensions so we can avoid clones. + let aki = AuthorityKeyIdentifier::try_from(spki.owned_to_ref()) + .context(ParseAuthorityKeyIdentifierSnafu)?; + + debug!( + ca.subject = %subject, + ca.not_after = %validity.not_after, + ca.not_before = %validity.not_before, + ca.serial = ?serial_number, + ca.public_key.algorithm = SKP::algorithm_name(), + ca.public_key.size = SKP::key_size(), + "creating certificate authority" + ); + let signer = signing_key_pair.signing_key(); + let mut builder = CertificateBuilder::new( + Profile::Root, + serial_number, + validity, + subject, + spki, + signer, + ) + .context(CreateCertificateBuilderSnafu)?; + + builder + .add_extension(&aki) + .context(AddCertificateExtensionSnafu)?; + let certificate = builder.build().context(BuildCertificateSnafu)?; + + Ok(CertificateAuthority { + certificate_pair: CertificatePair { + certificate, + key_pair: signing_key_pair, + }, + }) + } +} + +#[cfg(test)] +mod tests { + use x509_cert::certificate::TbsCertificateInner; + + use super::*; + use crate::keys::rsa; + + #[test] + fn minimal_ca() { + let ca = CertificateAuthority::builder_with_ecdsa() + .build() + .expect("failed to build CA"); + + assert_ca_cert_attributes( + &ca.ca_cert().tbs_certificate, + SDP_ROOT_CA_SUBJECT, + DEFAULT_CA_VALIDITY, + ) + } + + #[test] + fn customized_ca() { + let ca = CertificateAuthority::builder() + .subject("CN=Test") + .signing_key_pair(rsa::SigningKey::new().unwrap()) + .validity(Duration::from_days_unchecked(13)) + .build() + .expect("failed to build CA"); + + assert_ca_cert_attributes( + &ca.ca_cert().tbs_certificate, + "CN=Test", + Duration::from_days_unchecked(13), + ) + } + + fn assert_ca_cert_attributes(ca_cert: &TbsCertificateInner, subject: &str, validity: Duration) { + assert_eq!(ca_cert.subject, subject.parse().unwrap()); + + let not_before = ca_cert.validity.not_before.to_system_time(); + let not_after = ca_cert.validity.not_after.to_system_time(); + assert_eq!( + not_after + .duration_since(not_before) + .expect("Failed to calculate duration between notBefore and notAfter"), + *validity + ); + } +} diff --git a/crates/stackable-certs/src/ca/consts.rs b/crates/stackable-certs/src/ca/consts.rs index 600bb5f7c..329e7301c 100644 --- a/crates/stackable-certs/src/ca/consts.rs +++ b/crates/stackable-certs/src/ca/consts.rs @@ -1,5 +1,14 @@ -/// The default CA validity time span of one hour (3600 seconds). -pub const DEFAULT_CA_VALIDITY_SECONDS: u64 = 3600; +use rsa::pkcs8::LineEnding; +use stackable_operator::time::Duration; + +/// The default CA validity time span of one hour. +pub const DEFAULT_CA_VALIDITY: Duration = Duration::from_days_unchecked(1); + +/// The default certificate validity time span of one hour. +pub const DEFAULT_CERTIFICATE_VALIDITY: Duration = Duration::from_hours_unchecked(1); /// The root CA subject name containing only the common name. -pub const ROOT_CA_SUBJECT: &str = "CN=Stackable Data Platform Internal CA"; +pub const SDP_ROOT_CA_SUBJECT: &str = "CN=Stackable Data Platform Internal CA"; + +/// As we are mostly on Unix systems, we are using `\ņ`. +pub const PEM_LINE_ENDING: LineEnding = LineEnding::LF; diff --git a/crates/stackable-certs/src/ca/k8s.rs b/crates/stackable-certs/src/ca/k8s.rs new file mode 100644 index 000000000..bc923a3d2 --- /dev/null +++ b/crates/stackable-certs/src/ca/k8s.rs @@ -0,0 +1,126 @@ +use k8s_openapi::api::core::v1::Secret; +use kube::runtime::reflector::ObjectRef; +use rsa::pkcs8::EncodePublicKey; +use snafu::{OptionExt, ResultExt, Snafu, ensure}; +use stackable_operator::{client::Client, commons::secret::SecretReference}; +use tracing::{debug, instrument}; + +use super::CertificateAuthority; +use crate::{CertificatePair, keys::CertificateKeypair}; + +pub const TLS_SECRET_TYPE: &str = "kubernetes.io/tls"; + +/// Defines all error variants which can occur when loading a CA from a Kubernetes [`Secret`]. +#[derive(Debug, Snafu)] +pub enum SecretError +where + E: std::error::Error + 'static, +{ + #[snafu(display("failed to retrieve secret \"{secret_ref}\""))] + GetSecret { + source: kube::Error, + secret_ref: SecretReference, + }, + + #[snafu(display("invalid secret type, expected {TLS_SECRET_TYPE}"))] + InvalidSecretType, + + #[snafu(display("the secret {secret:?} does not contain any data"))] + NoSecretData { secret: ObjectRef }, + + #[snafu(display("the secret {secret:?} does not contain TLS certificate data"))] + NoCertificateData { secret: ObjectRef }, + + #[snafu(display("the secret {secret:?} does not contain TLS private key data"))] + NoPrivateKeyData { secret: ObjectRef }, + + #[snafu(display("failed to read PEM-encoded certificate chain from secret {secret:?}"))] + ReadChain { + source: x509_cert::der::Error, + secret: ObjectRef, + }, + + #[snafu(display("failed to parse UTF-8 encoded byte string"))] + DecodeUtf8String { source: std::str::Utf8Error }, + + #[snafu(display("failed to deserialize private key from PEM"))] + DeserializeKeyFromPem { source: E }, +} + +/// Create a [`CertificateAuthority`] from a Kubernetes [`Secret`]. +/// +/// Both the `certificate_key` and `private_key_key` parameters describe +/// the _key_ used to lookup the certificate and private key value in the +/// Kubernetes [`Secret`]. Common keys are `ca.crt` and `ca.key`. +#[instrument(skip(secret))] +pub fn ca_from_k8s_secret( + secret: Secret, + certificate_key: &str, + private_key_key: &str, +) -> Result, SecretError> +where + SK: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, +{ + ensure!( + secret.type_.as_ref().is_none_or(|s| s != TLS_SECRET_TYPE), + InvalidSecretTypeSnafu + ); + let data = secret.data.as_ref().with_context(|| NoSecretDataSnafu { + secret: ObjectRef::from_obj(&secret), + })?; + + debug!("retrieving certificate data from secret via key \"{certificate_key}\""); + let certificate_data = data + .get(certificate_key) + .with_context(|| NoCertificateDataSnafu { + secret: ObjectRef::from_obj(&secret), + })?; + + let certificate = x509_cert::Certificate::load_pem_chain(&certificate_data.0) + .with_context(|_| ReadChainSnafu { + secret: ObjectRef::from_obj(&secret), + })? + .remove(0); + + debug!("retrieving private key data from secret via key \"{private_key_key}\""); + let private_key_data = data + .get(private_key_key) + .with_context(|| NoPrivateKeyDataSnafu { + secret: ObjectRef::from_obj(&secret), + })?; + + let private_key_data = + std::str::from_utf8(&private_key_data.0).context(DecodeUtf8StringSnafu)?; + + let signing_key_pair = + SK::from_pkcs8_pem(private_key_data).context(DeserializeKeyFromPemSnafu)?; + + Ok(CertificateAuthority::new(CertificatePair::new( + certificate, + signing_key_pair, + ))) +} + +/// Create a [`CertificateAuthority`] from a Kubernetes [`SecretReference`]. +#[instrument(skip(secret_ref, client))] +pub async fn ca_from_k8s_secret_ref( + secret_ref: &SecretReference, + certificate_key: &str, + private_key_key: &str, + client: &Client, +) -> Result, SecretError> +where + SK: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, +{ + let secret_api = client.get_api::(&secret_ref.namespace); + let secret = secret_api + .get(&secret_ref.name) + .await + .with_context(|_| GetSecretSnafu { + secret_ref: secret_ref.to_owned(), + })?; + + ca_from_k8s_secret(secret, certificate_key, private_key_key) +} diff --git a/crates/stackable-certs/src/ca/mod.rs b/crates/stackable-certs/src/ca/mod.rs index 7c793d4f8..18987475b 100644 --- a/crates/stackable-certs/src/ca/mod.rs +++ b/crates/stackable-certs/src/ca/mod.rs @@ -1,486 +1,71 @@ //! Contains types and functions to generate and sign certificate authorities -//! (CAs). -use std::str::FromStr; +//! (CAs) and certificates. +use std::fmt::Debug; -use const_oid::db::rfc5280::{ID_KP_CLIENT_AUTH, ID_KP_SERVER_AUTH}; -use k8s_openapi::api::core::v1::Secret; -use kube::runtime::reflector::ObjectRef; -use snafu::{OptionExt, ResultExt, Snafu}; -use stackable_operator::{client::Client, commons::secret::SecretReference, time::Duration}; -use tracing::{debug, instrument}; -use x509_cert::{ - builder::{Builder, CertificateBuilder, Profile}, - der::{DecodePem, pem::LineEnding, referenced::OwnedToRef}, - ext::pkix::{AuthorityKeyIdentifier, ExtendedKeyUsage}, - name::Name, - serial_number::SerialNumber, - spki::{EncodePublicKey, SubjectPublicKeyInfoOwned}, - time::Validity, -}; +use x509_cert::{Certificate, name::RdnSequence, spki::EncodePublicKey}; use crate::{ CertificatePair, keys::{CertificateKeypair, ecdsa, rsa}, }; +mod ca_builder; mod consts; +mod k8s; +pub use ca_builder::*; pub use consts::*; +pub use k8s::*; -pub const TLS_SECRET_TYPE: &str = "kubernetes.io/tls"; - -pub type Result = std::result::Result; - -/// Defines all error variants which can occur when creating a CA and/or leaf +/// A certificate authority (CA) which is used to generate and sign intermediate or leaf /// certificates. -#[derive(Debug, Snafu)] -pub enum Error { - #[snafu(display("failed to generate RSA signing key"))] - GenerateRsaSigningKey { source: rsa::Error }, - - #[snafu(display("failed to generate ECDSA signign key"))] - GenerateEcdsaSigningKey { source: ecdsa::Error }, - - #[snafu(display("failed to parse {subject:?} as subject"))] - ParseSubject { - source: x509_cert::der::Error, - subject: String, - }, - - #[snafu(display("failed to parse validity"))] - ParseValidity { source: x509_cert::der::Error }, - - #[snafu(display("failed to serialize public key as PEM"))] - SerializePublicKey { source: x509_cert::spki::Error }, - - #[snafu(display("failed to decode SPKI from PEM"))] - DecodeSpkiFromPem { source: x509_cert::der::Error }, - - #[snafu(display("failed to create certificate builder"))] - CreateCertificateBuilder { source: x509_cert::builder::Error }, - - #[snafu(display("failed to add certificate extension"))] - AddCertificateExtension { source: x509_cert::builder::Error }, - - #[snafu(display("failed to build certificate"))] - BuildCertificate { source: x509_cert::builder::Error }, - - #[snafu(display("failed to parse AuthorityKeyIdentifier"))] - ParseAuthorityKeyIdentifier { source: x509_cert::der::Error }, -} - -/// Custom implementation of [`std::cmp::PartialEq`] because some inner types -/// don't implement it. /// -/// Note that this implementation is restritced to testing because there is a -/// variant that is impossible to compare, and will cause a panic if it is -/// attemped. -#[cfg(test)] -impl PartialEq for Error { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - ( - Self::CreateCertificateBuilder { source: lhs_source }, - Self::CreateCertificateBuilder { source: rhs_source }, - ) - | ( - Self::AddCertificateExtension { source: lhs_source }, - Self::AddCertificateExtension { source: rhs_source }, - ) - | ( - Self::BuildCertificate { source: lhs_source }, - Self::BuildCertificate { source: rhs_source }, - ) => match (lhs_source, rhs_source) { - (x509_cert::builder::Error::Asn1(lhs), x509_cert::builder::Error::Asn1(rhs)) => { - lhs == rhs - } - ( - x509_cert::builder::Error::PublicKey(lhs), - x509_cert::builder::Error::PublicKey(rhs), - ) => lhs == rhs, - ( - x509_cert::builder::Error::Signature(_), - x509_cert::builder::Error::Signature(_), - ) => panic!( - "it is impossible to compare the opaque Error contained witin signature::error::Error" - ), - _ => false, - }, - (lhs, rhs) => lhs == rhs, - } - } -} - -/// Defines all error variants which can occur when loading a CA from a -/// Kubernetes [`Secret`]. -#[derive(Debug, Snafu)] -pub enum SecretError -where - E: std::error::Error + 'static, -{ - #[snafu(display("failed to retrieve secret \"{secret_ref}\""))] - GetSecret { - source: kube::Error, - secret_ref: SecretReference, - }, - - #[snafu(display("invalid secret type, expected {TLS_SECRET_TYPE}"))] - InvalidSecretType, - - #[snafu(display("the secret {secret:?} does not contain any data"))] - NoSecretData { secret: ObjectRef }, - - #[snafu(display("the secret {secret:?} does not contain TLS certificate data"))] - NoCertificateData { secret: ObjectRef }, - - #[snafu(display("the secret {secret:?} does not contain TLS private key data"))] - NoPrivateKeyData { secret: ObjectRef }, - - #[snafu(display("failed to read PEM-encoded certificate chain from secret {secret:?}"))] - ReadChain { - source: x509_cert::der::Error, - secret: ObjectRef, - }, - - #[snafu(display("failed to parse UTF-8 encoded byte string"))] - DecodeUtf8String { source: std::str::Utf8Error }, - - #[snafu(display("failed to deserialize private key from PEM"))] - DeserializeKeyFromPem { source: E }, -} - -/// A certificate authority (CA) which is used to generate and sign -/// intermidiate or leaf certificates. +/// Use [`CertificateAuthorityBuilder`] to create new certificates. #[derive(Debug)] -pub struct CertificateAuthority +pub struct CertificateAuthority where - S: CertificateKeypair, - ::VerifyingKey: EncodePublicKey, + SK: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, { - certificate_pair: CertificatePair, + certificate_pair: CertificatePair, } -impl CertificateAuthority +impl CertificateAuthority where - S: CertificateKeypair, - ::VerifyingKey: EncodePublicKey, + SK: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, { - /// Creates a new CA certificate with many parameters set to their default - /// values. - /// - /// These parameters include: - /// - /// - a randomly generated serial number - /// - a default validity of one hour (see [`DEFAULT_CA_VALIDITY_SECONDS`]) - /// - /// The CA contains the public half of the provided `signing_key` and is - /// signed by the private half of said key. - /// - /// If the default values for the serial number and validity don't satisfy - /// the requirements of the caller, use [`CertificateAuthority::new_with`] - /// instead. - #[instrument(name = "create_certificate_authority", skip(signing_key_pair))] - pub fn new(signing_key_pair: S) -> Result { - let serial_number = rand::random::(); - let validity = Duration::from_secs(DEFAULT_CA_VALIDITY_SECONDS); - - Self::new_with(signing_key_pair, serial_number, validity) + pub fn new(certificate_pair: CertificatePair) -> Self { + Self { certificate_pair } } - /// Creates a new CA certificate. - /// - /// Instead of providing sensible defaults for the serial number and - /// validity, this function offers complete control over these parameters. - /// If this level of control is not needed, use [`CertificateAuthority::new`] - /// instead. - #[instrument(name = "create_certificate_authority_with", skip(signing_key_pair))] - pub fn new_with(signing_key_pair: S, serial_number: u64, validity: Duration) -> Result { - let serial_number = SerialNumber::from(serial_number); - let validity = Validity::from_now(*validity).context(ParseValiditySnafu)?; - - // We don't allow customization of the CA subject by callers. Every CA - // created by us should contain the same subject consisting a common set - // of distinguished names (DNs). - let subject = Name::from_str(ROOT_CA_SUBJECT).context(ParseSubjectSnafu { - subject: ROOT_CA_SUBJECT, - })?; - - let spki_pem = signing_key_pair - .verifying_key() - .to_public_key_pem(LineEnding::LF) - .context(SerializePublicKeySnafu)?; - - let spki = SubjectPublicKeyInfoOwned::from_pem(spki_pem.as_bytes()) - .context(DecodeSpkiFromPemSnafu)?; - - // There are multiple default extensions included in the profile. For - // the root profile, these are: - // - // - BasicConstraints marked as critical and CA = true - // - SubjectKeyIdentifier with the 160-bit SHA-1 hash of the subject - // public key. - // - KeyUsage with KeyCertSign and CRLSign bits set. Ideally we also - // want to include the DigitalSignature bit, which for example is - // required for CA certs which want to sign an OCSP response. - // Currently, the root profile doesn't include that bit. - // - // The root profile doesn't add the AuthorityKeyIdentifier extension. - // We manually add it below by using the 160-bit SHA-1 hash of the - // subject pulic key. This conforms to one of the outlined methods for - // generating key identifiers outlined in RFC 5280, section 4.2.1.2. - // - // Prepare extensions so we can avoid clones. - let aki = AuthorityKeyIdentifier::try_from(spki.owned_to_ref()) - .context(ParseAuthorityKeyIdentifierSnafu)?; - - let signer = signing_key_pair.signing_key(); - let mut builder = CertificateBuilder::new( - Profile::Root, - serial_number, - validity, - subject, - spki, - signer, - ) - .context(CreateCertificateBuilderSnafu)?; - - // Add extension constructed above - builder - .add_extension(&aki) - .context(AddCertificateExtensionSnafu)?; - - debug!("create and sign CA certificate"); - let certificate = builder.build().context(BuildCertificateSnafu)?; - - Ok(Self { - certificate_pair: CertificatePair { - key_pair: signing_key_pair, - certificate, - }, - }) - } - - /// Generates a leaf certificate which is signed by this CA. - /// - /// The certificate requires a `name` and a `scope`. Both these values - /// are part of the certificate subject. The format is: `{name} Certificate - /// for {scope}`. These leaf certificates can be used for client/server - /// authentication, because they include [`ID_KP_CLIENT_AUTH`] and - /// [`ID_KP_SERVER_AUTH`] in the extended key usage extension. - /// - /// It is also possible to directly greate RSA or ECDSA-based leaf - /// certificates using [`CertificateAuthority::generate_rsa_leaf_certificate`] - /// and [`CertificateAuthority::generate_ecdsa_leaf_certificate`]. - #[instrument(skip(self, key_pair))] - pub fn generate_leaf_certificate( - &mut self, - key_pair: T, - name: &str, - scope: &str, - validity: Duration, - ) -> Result> - where - T: CertificateKeypair, - ::VerifyingKey: EncodePublicKey, - { - // We generate a random serial number, but ensure the same CA didn't - // issue another certificate with the same serial number. We try to - // generate a unique serial number at max five times before giving up - // and returning an error. - let serial_number = SerialNumber::from(rand::random::()); - - // NOTE (@Techassi): Should we validate that the validity is shorter - // than the validity of the issuing CA? - let validity = Validity::from_now(*validity).context(ParseValiditySnafu)?; - let subject = format_leaf_certificate_subject(name, scope)?; - - let spki_pem = key_pair - .verifying_key() - .to_public_key_pem(LineEnding::LF) - .context(SerializePublicKeySnafu)?; - - let spki = SubjectPublicKeyInfoOwned::from_pem(spki_pem.as_bytes()) - .context(DecodeSpkiFromPemSnafu)?; - - // The leaf certificate can be used for WWW client and server - // authentication. This is a base requirement for TLS certs. - let eku = ExtendedKeyUsage(vec![ID_KP_CLIENT_AUTH, ID_KP_SERVER_AUTH]); - - let signer = self.certificate_pair.key_pair.signing_key(); - let mut builder = CertificateBuilder::new( - Profile::Leaf { - issuer: self - .certificate_pair - .certificate - .tbs_certificate - .issuer - .clone(), - enable_key_agreement: false, - enable_key_encipherment: true, - }, - serial_number, - validity, - subject, - spki, - signer, - ) - .context(CreateCertificateBuilderSnafu)?; - - // Again, add the extension created above. - builder - .add_extension(&eku) - .context(AddCertificateExtensionSnafu)?; - - debug!("create and sign leaf certificate"); - let certificate = builder.build().context(BuildCertificateSnafu)?; - - Ok(CertificatePair { - certificate, - key_pair, - }) - } - - /// Generates an RSA-based leaf certificate which is signed by this CA. - /// - /// See [`CertificateAuthority::generate_leaf_certificate`] for more - /// information. - #[instrument(skip(self))] - pub fn generate_rsa_leaf_certificate( - &mut self, - name: &str, - scope: &str, - validity: Duration, - ) -> Result> { - let key = rsa::SigningKey::new().context(GenerateRsaSigningKeySnafu)?; - self.generate_leaf_certificate(key, name, scope, validity) + /// Use this function in combination with [`CertificateAuthorityBuilder`] to create new CAs. + pub fn builder() -> CertificateAuthorityBuilderBuilder<'static, SK> { + CertificateAuthorityBuilder::start_builder() } - /// Generates an ECDSAasync -based leaf certificate which is signed by this CA. - /// - /// See [`CertificateAuthority::generate_leaf_certificate`] for more - /// information. - #[instrument(skip(self))] - pub fn generate_ecdsa_leaf_certificate( - &mut self, - name: &str, - scope: &str, - validity: Duration, - ) -> Result> { - let key = ecdsa::SigningKey::new().context(GenerateEcdsaSigningKeySnafu)?; - self.generate_leaf_certificate(key, name, scope, validity) + pub fn signing_key(&self) -> &SK::SigningKey { + self.certificate_pair.key_pair().signing_key() } - /// Create a [`CertificateAuthority`] from a Kubernetes [`Secret`]. - /// - /// Both the `key_certificate` and `key_private_key` parameters describe - /// the _key_ used to lookup the certificate and private key value in the - /// Kubernetes [`Secret`]. Common keys are `ca.crt` and `ca.key`. - #[instrument(name = "create_certificate_authority_from_k8s_secret", skip(secret))] - pub fn from_secret( - secret: Secret, - key_certificate: &str, - key_private_key: &str, - ) -> Result> { - if secret.type_.as_ref().is_none_or(|s| s != TLS_SECRET_TYPE) { - return InvalidSecretTypeSnafu.fail(); - } - - let data = secret.data.as_ref().with_context(|| NoSecretDataSnafu { - secret: ObjectRef::from_obj(&secret), - })?; - - debug!("retrieving certificate data from secret via key {key_certificate:?}"); - let certificate_data = - data.get(key_certificate) - .with_context(|| NoCertificateDataSnafu { - secret: ObjectRef::from_obj(&secret), - })?; - - let certificate = x509_cert::Certificate::load_pem_chain(&certificate_data.0) - .with_context(|_| ReadChainSnafu { - secret: ObjectRef::from_obj(&secret), - })? - .remove(0); - - debug!("retrieving private key data from secret via key {key_certificate:?}"); - let private_key_data = - data.get(key_private_key) - .with_context(|| NoPrivateKeyDataSnafu { - secret: ObjectRef::from_obj(&secret), - })?; - - let private_key_data = - std::str::from_utf8(&private_key_data.0).context(DecodeUtf8StringSnafu)?; - - let signing_key_pair = - S::from_pkcs8_pem(private_key_data).context(DeserializeKeyFromPemSnafu)?; - - Ok(Self { - certificate_pair: CertificatePair { - key_pair: signing_key_pair, - certificate, - }, - }) + pub fn ca_cert(&self) -> &Certificate { + &self.certificate_pair.certificate } - /// Create a [`CertificateAuthority`] from a Kubernetes [`SecretReference`]. - #[instrument( - name = "create_certificate_authority_from_k8s_secret_ref", - skip(secret_ref, client) - )] - pub async fn from_secret_ref( - secret_ref: &SecretReference, - key_certificate: &str, - key_private_key: &str, - client: &Client, - ) -> Result> { - let secret_api = client.get_api::(&secret_ref.namespace); - let secret = secret_api - .get(&secret_ref.name) - .await - .with_context(|_| GetSecretSnafu { - secret_ref: secret_ref.to_owned(), - })?; - - Self::from_secret(secret, key_certificate, key_private_key) + pub fn issuer_name(&self) -> &RdnSequence { + &self.ca_cert().tbs_certificate.issuer } } impl CertificateAuthority { - /// High-level function to create a new CA using a RSA key pair. - #[instrument(name = "create_certificate_authority_with_rsa")] - pub fn new_rsa() -> Result { - Self::new(rsa::SigningKey::new().context(GenerateRsaSigningKeySnafu)?) + /// Same as [`Self::builder`], but enforces the RSA algorithm for key creation. + pub fn builder_with_rsa() -> CertificateAuthorityBuilderBuilder<'static, rsa::SigningKey> { + Self::builder() } } impl CertificateAuthority { - /// High-level function to create a new CA using a ECDSA key pair. - #[instrument(name = "create_certificate_authority_with_ecdsa")] - pub fn new_ecdsa() -> Result { - Self::new(ecdsa::SigningKey::new().context(GenerateEcdsaSigningKeySnafu)?) - } -} - -fn format_leaf_certificate_subject(name: &str, scope: &str) -> Result { - let subject = format!("CN={name} Certificate for {scope}"); - Name::from_str(&subject).context(ParseSubjectSnafu { subject }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn rsa_key_generation() { - let mut ca = CertificateAuthority::new_rsa().unwrap(); - ca.generate_rsa_leaf_certificate("Airflow", "pod", Duration::from_secs(3600)) - .unwrap(); - } - - #[tokio::test] - async fn ecdsa_key_generation() { - let mut ca = CertificateAuthority::new_ecdsa().unwrap(); - ca.generate_ecdsa_leaf_certificate("Airflow", "pod", Duration::from_secs(3600)) - .unwrap(); + /// Same as [`Self::builder`], but enforces the ecdsa algorithm for key creation. + pub fn builder_with_ecdsa() -> CertificateAuthorityBuilderBuilder<'static, ecdsa::SigningKey> { + Self::builder() } } diff --git a/crates/stackable-certs/src/cert_builder.rs b/crates/stackable-certs/src/cert_builder.rs new file mode 100644 index 000000000..94ffa155a --- /dev/null +++ b/crates/stackable-certs/src/cert_builder.rs @@ -0,0 +1,423 @@ +use std::{fmt::Debug, net::IpAddr, time::SystemTime}; + +use bon::Builder; +use const_oid::db::rfc5280::{ID_KP_CLIENT_AUTH, ID_KP_SERVER_AUTH}; +use rsa::pkcs8::EncodePublicKey; +use snafu::{ResultExt, Snafu, ensure}; +use stackable_operator::time::Duration; +use tracing::{debug, instrument, warn}; +use x509_cert::{ + builder::{Builder, Profile}, + der::{DecodePem, asn1::Ia5String}, + ext::pkix::{ExtendedKeyUsage, SubjectAltName, name::GeneralName}, + name::Name, + serial_number::SerialNumber, + spki::SubjectPublicKeyInfoOwned, + time::Validity, +}; + +use crate::{ + CertificatePair, + ca::{CertificateAuthority, DEFAULT_CERTIFICATE_VALIDITY, PEM_LINE_ENDING}, + keys::CertificateKeypair, +}; + +/// Defines all error variants which can occur when creating a certificate +#[derive(Debug, Snafu)] +pub enum CreateCertificateError +where + E: std::error::Error + 'static, +{ + #[snafu(display("failed to parse validity"))] + ParseValidity { source: x509_cert::der::Error }, + + #[snafu(display("failed to parse \"{subject}\" as subject"))] + ParseSubject { + source: x509_cert::der::Error, + subject: String, + }, + + #[snafu(display("failed to create key pair"))] + CreateKeyPair { source: E }, + + #[snafu(display("failed to serialize public key as PEM"))] + SerializePublicKey { source: x509_cert::spki::Error }, + + #[snafu(display("failed to decode SPKI from PEM"))] + DecodeSpkiFromPem { source: x509_cert::der::Error }, + + #[snafu(display("failed to create certificate builder"))] + CreateCertificateBuilder { source: x509_cert::builder::Error }, + + #[snafu(display("failed to add certificate extension"))] + AddCertificateExtension { source: x509_cert::builder::Error }, + + #[snafu(display( + "failed to parse subject alternative DNS name \"{subject_alternative_dns_name}\" as a Ia5 string" + ))] + ParseSubjectAlternativeDnsName { + subject_alternative_dns_name: String, + source: x509_cert::der::Error, + }, + + #[snafu(display("failed to build certificate"))] + BuildCertificate { source: x509_cert::builder::Error }, + + #[snafu(display( + "the generated certificate would outlive the CA, subject {subject:?}, \ + CA notAfter {ca_not_after:?}, CA notBefore {ca_not_before:?}, \ + cert notAfter {cert_not_after:?}, cert notBefore {cert_not_before:?}" + ))] + CertOutlivesCa { + subject: String, + ca_not_after: SystemTime, + ca_not_before: SystemTime, + cert_not_after: SystemTime, + cert_not_before: SystemTime, + }, +} + +/// This builder builds certificates of type [`CertificatePair`]. +/// +/// Currently you are required to specify a [`CertificateAuthority`], which is used to create a leaf +/// certificate, which is signed by this CA. +/// +/// These leaf certificates can be used for client/server authentication, because they include +/// [`ID_KP_CLIENT_AUTH`] and [`ID_KP_SERVER_AUTH`] in the extended key usage extension. +/// +/// This builder has many default values, notably; +/// +/// - A default validity of [`DEFAULT_CERTIFICATE_VALIDITY`] +/// - A randomly generated serial number +/// - In case no `key_pair` was provided, a fresh keypair will be created. The algorithm +/// (`rsa`/`ecdsa`) is chosen by the generic [`CertificateKeypair`] type of this struct, +/// which is normally inferred from the [`CertificateAuthority`]. +/// +/// Example code to construct a CA and a signed certificate: +/// +/// ```no_run +/// use stackable_certs::{ +/// keys::ecdsa, +/// ca::CertificateAuthority, +/// CertificatePair, +/// }; +/// +/// let ca = CertificateAuthority::::builder() +/// .build() +/// .expect("failed to build CA"); +/// +/// let certificate = CertificatePair::builder() +/// .subject("CN=trino-coordinator-default-0") +/// .signed_by(&ca) +/// .build() +/// .expect("failed to build certificate"); +/// ``` +#[derive(Builder)] +#[builder(start_fn = start_builder, finish_fn = finish_builder)] +pub struct CertificateBuilder<'a, KP> +where + KP: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, +{ + /// Required subject of the certificate, usually starts with `CN=`, e.g. `CN=mypod`. + subject: &'a str, + + /// Optional list of subject alternative name DNS entries + /// that are added to the certificate. + #[builder(default)] + subject_alternative_dns_names: &'a [&'a str], + + /// Optional list of subject alternative name IP address entries + /// that are added to the certificate. + #[builder(default)] + subject_alternative_ip_addresses: &'a [IpAddr], + + /// Validity/lifetime of the certificate. + /// + /// If not specified the default of [`DEFAULT_CERTIFICATE_VALIDITY`] will be used. + #[builder(default = DEFAULT_CERTIFICATE_VALIDITY)] + validity: Duration, + + /// Cryptographic keypair used to for the certificates. + /// + /// If not specified a random keypair will be generated. + key_pair: Option, + + /// Mandatorily sign the certificate using the provided [`CertificateAuthority`]. + signed_by: &'a CertificateAuthority, +} + +impl CertificateBuilderBuilder<'_, KP, S> +where + KP: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, + S: certificate_builder_builder::IsComplete, +{ + /// Convenience function to avoid calling `builder().finish_builder().build()` + pub fn build(self) -> Result, CreateCertificateError> { + self.finish_builder().build() + } +} + +impl CertificateBuilder<'_, SKP> +where + SKP: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, +{ + #[instrument( + name = "build_certificate", + skip(self), + fields(subject = self.subject), + )] + pub fn build(self) -> Result, CreateCertificateError> { + let validity = Validity::from_now(*self.validity).context(ParseValiditySnafu)?; + let subject_for_error = &self.subject; + let subject: Name = self.subject.parse().context(ParseSubjectSnafu { + subject: self.subject, + })?; + let key_pair = match self.key_pair { + Some(key_pair) => key_pair, + None => SKP::new().context(CreateKeyPairSnafu)?, + }; + + // By choosing a random serial number we can make the reasonable assumption that we generate + // a unique serial for each certificate. + let serial_number = SerialNumber::from(rand::random::()); + + let ca_validity = self.signed_by.ca_cert().tbs_certificate.validity; + let ca_not_after = ca_validity.not_after.to_system_time(); + let ca_not_before = ca_validity.not_before.to_system_time(); + let cert_not_after = validity.not_after.to_system_time(); + let cert_not_before = validity.not_before.to_system_time(); + + ensure!( + ca_not_after > cert_not_after, + CertOutlivesCaSnafu { + subject: subject_for_error.to_string(), + ca_not_after, + ca_not_before, + cert_not_after, + cert_not_before, + } + ); + + let spki_pem = key_pair + .verifying_key() + .to_public_key_pem(PEM_LINE_ENDING) + .context(SerializePublicKeySnafu)?; + + let spki = SubjectPublicKeyInfoOwned::from_pem(spki_pem.as_bytes()) + .context(DecodeSpkiFromPemSnafu)?; + + debug!( + certificate.subject = %subject, + certificate.not_after = %validity.not_after, + certificate.not_before = %validity.not_before, + certificate.serial = %serial_number, + certificate.san.dns_names = ?self.subject_alternative_dns_names, + certificate.san.ip_addresses = ?self.subject_alternative_ip_addresses, + certificate.signed_by.issuer = %self.signed_by.issuer_name(), + certificate.public_key.algorithm = SKP::algorithm_name(), + certificate.public_key.size = SKP::key_size(), + "creating and signing certificate" + ); + let signing_key = self.signed_by.signing_key(); + let mut builder = x509_cert::builder::CertificateBuilder::new( + Profile::Leaf { + issuer: self.signed_by.issuer_name().clone(), + enable_key_agreement: false, + enable_key_encipherment: true, + }, + serial_number, + validity, + subject, + spki, + signing_key, + ) + .context(CreateCertificateBuilderSnafu)?; + + // The leaf certificate can be used for WWW client and server + // authentication. This is a base requirement for TLS certs. + builder + .add_extension(&ExtendedKeyUsage(vec![ + ID_KP_CLIENT_AUTH, + ID_KP_SERVER_AUTH, + ])) + .context(AddCertificateExtensionSnafu)?; + + let san_dns = self.subject_alternative_dns_names.iter().map(|dns_name| { + Ok(GeneralName::DnsName( + Ia5String::new(dns_name).with_context(|_| ParseSubjectAlternativeDnsNameSnafu { + subject_alternative_dns_name: dns_name.to_string(), + })?, + )) + }); + let san_ips = self + .subject_alternative_ip_addresses + .iter() + .copied() + .map(GeneralName::from) + .map(Result::Ok); + let sans = san_dns + .chain(san_ips) + .collect::, CreateCertificateError>>()?; + + builder + .add_extension(&SubjectAltName(sans)) + .context(AddCertificateExtensionSnafu)?; + + let certificate = builder.build().context(BuildCertificateSnafu)?; + + Ok(CertificatePair { + certificate, + key_pair, + }) + } +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, Ipv6Addr}; + + use x509_cert::{ + certificate::TbsCertificateInner, der::Decode, ext::pkix::ID_CE_SUBJECT_ALT_NAME, + }; + + use super::*; + use crate::keys::rsa; + + #[test] + fn minimal_certificate() { + let ca = CertificateAuthority::builder_with_ecdsa() + .build() + .expect("failed to build CA"); + + let certificate = CertificatePair::builder() + .subject("CN=trino-coordinator-default-0") + .signed_by(&ca) + .build() + .expect("failed to build certificate"); + + assert_certificate_attributes( + &certificate.certificate.tbs_certificate, + "CN=trino-coordinator-default-0", + &[], + &[], + DEFAULT_CERTIFICATE_VALIDITY, + ); + } + + #[test] + fn customized_certificate() { + let ca = CertificateAuthority::builder_with_rsa() + .build() + .expect("failed to build CA"); + + let sans = [ + "trino-coordinator-default-0.trino-coordinator-default.default.svc.cluster-local", + "trino-coordinator-default.default.svc.cluster-local", + ]; + let san_ips = ["10.0.0.1".parse().unwrap(), "fe80::42".parse().unwrap()]; + + let certificate = CertificatePair::builder() + .subject("CN=trino-coordinator-default-0") + .subject_alternative_dns_names(&sans) + .subject_alternative_ip_addresses(&san_ips) + .validity(Duration::from_hours_unchecked(12)) + .key_pair(rsa::SigningKey::new().unwrap()) + .signed_by(&ca) + .build() + .expect("failed to build certificate"); + + assert_certificate_attributes( + &certificate.certificate.tbs_certificate, + "CN=trino-coordinator-default-0", + &sans, + &san_ips, + Duration::from_hours_unchecked(12), + ); + } + + #[test] + fn cert_outlives_ca() { + let ca = CertificateAuthority::builder_with_ecdsa() + .validity(Duration::from_days_unchecked(365)) + .build() + .expect("failed to build CA"); + + let err = CertificatePair::builder() + .subject("CN=Test") + .signed_by(&ca) + .validity(Duration::from_days_unchecked(366)) + .build() + .expect_err("Certificate creation must error"); + assert!(matches!(err, CreateCertificateError::CertOutlivesCa { .. })); + } + + fn assert_certificate_attributes( + certificate: &TbsCertificateInner, + subject: &str, + sans: &[&str], + san_ips: &[IpAddr], + validity: Duration, + ) { + assert_eq!(certificate.subject, subject.parse().unwrap()); + + let extensions = certificate + .extensions + .as_ref() + .expect("cert had no extension"); + let san_extension = extensions + .iter() + .find(|ext| ext.extn_id == ID_CE_SUBJECT_ALT_NAME) + .expect("cert had no SAN extension"); + + let san_entries = SubjectAltName::from_der(san_extension.extn_value.as_bytes()) + .expect("failed to parse SAN") + .0; + let actual_sans = san_entries + .iter() + .filter_map(|san| match san { + GeneralName::DnsName(dns_name) => Some(dns_name.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(actual_sans, sans); + let actual_san_ips = san_entries + .iter() + .filter_map(|san| match san { + GeneralName::IpAddress(ip) => Some(bytes_to_ip_addr(ip.as_bytes())), + _ => None, + }) + .collect::>(); + assert_eq!(actual_san_ips, san_ips); + + let not_before = certificate.validity.not_before.to_system_time(); + let not_after = certificate.validity.not_after.to_system_time(); + assert_eq!( + not_after + .duration_since(not_before) + .expect("Failed to calculate duration between notBefore and notAfter"), + *validity + ); + } + + fn bytes_to_ip_addr(bytes: &[u8]) -> IpAddr { + match bytes.len() { + 4 => { + let mut array = [0u8; 4]; + array.copy_from_slice(bytes); + IpAddr::V4(Ipv4Addr::from(array)) + } + 16 => { + let mut array = [0u8; 16]; + array.copy_from_slice(bytes); + IpAddr::V6(Ipv6Addr::from(array)) + } + _ => panic!( + "Invalid IP byte length: expected 4 or 16, got {}", + bytes.len() + ), + } + } +} diff --git a/crates/stackable-certs/src/keys/ecdsa.rs b/crates/stackable-certs/src/keys/ecdsa.rs index 38de9d225..b9311dd7a 100644 --- a/crates/stackable-certs/src/keys/ecdsa.rs +++ b/crates/stackable-certs/src/keys/ecdsa.rs @@ -22,28 +22,26 @@ pub enum Error { #[derive(Debug)] pub struct SigningKey(p256::ecdsa::SigningKey); -impl SigningKey { +impl CertificateKeypair for SigningKey { + type Error = Error; + type Signature = ecdsa::der::Signature; + type SigningKey = p256::ecdsa::SigningKey; + type VerifyingKey = p256::ecdsa::VerifyingKey; + #[instrument(name = "create_ecdsa_signing_key")] - pub fn new() -> Result { + fn new() -> Result { let mut csprng = OsRng; Self::new_with_rng(&mut csprng) } #[instrument(name = "create_ecdsa_signing_key_custom_rng", skip_all)] - pub fn new_with_rng(csprng: &mut R) -> Result + fn new_with_rng(rng: &mut Rng) -> Result where - R: CryptoRngCore + Sized, + Rng: CryptoRngCore + Sized, { - let signing_key = p256::ecdsa::SigningKey::random(csprng); + let signing_key = p256::ecdsa::SigningKey::random(rng); Ok(Self(signing_key)) } -} - -impl CertificateKeypair for SigningKey { - type Error = Error; - type Signature = ecdsa::der::Signature; - type SigningKey = p256::ecdsa::SigningKey; - type VerifyingKey = p256::ecdsa::VerifyingKey; fn signing_key(&self) -> &Self::SigningKey { &self.0 @@ -60,4 +58,13 @@ impl CertificateKeypair for SigningKey { Ok(Self(signing_key)) } + + fn algorithm_name() -> &'static str { + "ecdsa" + } + + fn key_size() -> usize { + // Different than by RSA, we can not pass the key size during construction + 256 + } } diff --git a/crates/stackable-certs/src/keys/mod.rs b/crates/stackable-certs/src/keys/mod.rs index 862d33a49..804c5ca31 100644 --- a/crates/stackable-certs/src/keys/mod.rs +++ b/crates/stackable-certs/src/keys/mod.rs @@ -9,8 +9,8 @@ //! [`ecdsa`], which provides primitives and traits, and [`p256`] which //! implements the NIST P-256 elliptic curve and supports ECDSA. //! -//! ```ignore -//! use stackable_certs::keys::ecdsa::SigningKey; +//! ```no_run +//! use stackable_certs::keys::{ecdsa::SigningKey, CertificateKeypair}; //! let key = SigningKey::new().unwrap(); //! ``` //! @@ -18,8 +18,8 @@ //! //! In order to work with RSA keys, this crate requires the [`rsa`] dependency. //! -//! ```ignore -//! use stackable_certs::keys::rsa::SigningKey; +//! ```no_run +//! use stackable_certs::keys::{rsa::SigningKey, CertificateKeypair}; //! let key = SigningKey::new().unwrap(); //! ``` //! @@ -32,6 +32,7 @@ use std::fmt::Debug; use p256::pkcs8::EncodePrivateKey; +use rand_core::CryptoRngCore; use signature::{Keypair, Signer}; use x509_cert::spki::{EncodePublicKey, SignatureAlgorithmIdentifier, SignatureBitStringEncoding}; @@ -54,6 +55,14 @@ where type Error: std::error::Error + 'static; + /// Generates a new key with the default random-number generator [`rand_core::OsRng`]. + fn new() -> Result; + + /// Generates a new key with a custom random-number generator. + fn new_with_rng(rng: &mut Rng) -> Result + where + Rng: CryptoRngCore + Sized; + /// Returns the signing (private) key half of the keypair. fn signing_key(&self) -> &Self::SigningKey; @@ -62,4 +71,10 @@ where /// Creates a signing key pair from the PEM-encoded private key. fn from_pkcs8_pem(input: &str) -> Result; + + /// The name of the algorithm such as `rsa` or `ecdsa`. + fn algorithm_name() -> &'static str; + + /// The key length in bits + fn key_size() -> usize; } diff --git a/crates/stackable-certs/src/keys/rsa.rs b/crates/stackable-certs/src/keys/rsa.rs index 2bcd018ee..babeb72b7 100644 --- a/crates/stackable-certs/src/keys/rsa.rs +++ b/crates/stackable-certs/src/keys/rsa.rs @@ -29,7 +29,12 @@ pub enum Error { #[derive(Debug)] pub struct SigningKey(rsa::pkcs1v15::SigningKey); -impl SigningKey { +impl CertificateKeypair for SigningKey { + type Error = Error; + type Signature = rsa::pkcs1v15::Signature; + type SigningKey = rsa::pkcs1v15::SigningKey; + type VerifyingKey = rsa::pkcs1v15::VerifyingKey; + /// Generates a new RSA key with the default random-number generator /// [`OsRng`]. /// @@ -37,7 +42,7 @@ impl SigningKey { /// larger key sizes. The generation of an RSA key with a key size of /// `4096` (which is used) can take up to multiple seconds. #[instrument(name = "create_rsa_signing_key")] - pub fn new() -> Result { + fn new() -> Result { let mut csprng = OsRng; Self::new_with_rng(&mut csprng) } @@ -48,22 +53,15 @@ impl SigningKey { /// larger key sizes. The generation of an RSA key with a key size of /// `4096` (which is used) can take up to multiple seconds. #[instrument(name = "create_rsa_signing_key_custom_rng", skip_all)] - pub fn new_with_rng(csprng: &mut R) -> Result + fn new_with_rng(rng: &mut Rng) -> Result where - R: CryptoRngCore + ?Sized, + Rng: CryptoRngCore + ?Sized, { - let private_key = RsaPrivateKey::new(csprng, KEY_SIZE).context(CreateKeySnafu)?; + let private_key = RsaPrivateKey::new(rng, KEY_SIZE).context(CreateKeySnafu)?; let signing_key = rsa::pkcs1v15::SigningKey::::new(private_key); Ok(Self(signing_key)) } -} - -impl CertificateKeypair for SigningKey { - type Error = Error; - type Signature = rsa::pkcs1v15::Signature; - type SigningKey = rsa::pkcs1v15::SigningKey; - type VerifyingKey = rsa::pkcs1v15::VerifyingKey; fn signing_key(&self) -> &Self::SigningKey { &self.0 @@ -81,4 +79,12 @@ impl CertificateKeypair for SigningKey { Ok(Self(signing_key)) } + + fn algorithm_name() -> &'static str { + "rsa" + } + + fn key_size() -> usize { + KEY_SIZE + } } diff --git a/crates/stackable-certs/src/lib.rs b/crates/stackable-certs/src/lib.rs index 5b9c87327..debad4940 100644 --- a/crates/stackable-certs/src/lib.rs +++ b/crates/stackable-certs/src/lib.rs @@ -34,8 +34,11 @@ use { use crate::keys::CertificateKeypair; pub mod ca; +mod cert_builder; pub mod keys; +pub use cert_builder::*; + /// Error variants which can be encountered when creating a new /// [`CertificatePair`]. #[derive(Debug, Snafu)] @@ -43,7 +46,7 @@ pub enum CertificatePairError where E: std::error::Error + 'static, { - #[snafu(display("failed to seralize certificate as {key_encoding}"))] + #[snafu(display("failed to serialize certificate as {key_encoding}"))] SerializeCertificate { source: x509_cert::der::Error, key_encoding: KeyEncoding, @@ -74,7 +77,8 @@ where ReadFile { source: std::io::Error }, } -/// Custom implementation of [`std::cmp::PartialEq`] because [`std::io::Error`] doesn't implement it, but [`std::io::ErrorKind`] does. +/// Custom implementation of [`std::cmp::PartialEq`] because [`std::io::Error`] doesn't implement it, +/// but [`std::io::ErrorKind`] does. impl PartialEq for CertificatePairError { fn eq(&self, other: &Self) -> bool { match (self, other) { @@ -95,6 +99,8 @@ impl PartialEq for CertificatePairError where @@ -110,6 +116,18 @@ where S: CertificateKeypair, ::VerifyingKey: EncodePublicKey, { + pub fn new(certificate: Certificate, key_pair: S) -> Self { + Self { + certificate, + key_pair, + } + } + + /// Use this function in combination with [`CertificateBuilder`] to create new CAs. + pub fn builder() -> CertificateBuilderBuilder<'static, S> { + CertificateBuilder::start_builder() + } + /// Returns a reference to the [`Certificate`]. pub fn certificate(&self) -> &Certificate { &self.certificate diff --git a/crates/stackable-webhook/CHANGELOG.md b/crates/stackable-webhook/CHANGELOG.md index a7354362f..14affc4c5 100644 --- a/crates/stackable-webhook/CHANGELOG.md +++ b/crates/stackable-webhook/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed + +- Use `ecdsa` keys instead of `rsa`. This is because generating 4096 bit `rsa` keys takes forever, + thus making webhook development nearly impossible ([#1044]). + ### Fixed - Don't pull in the `aws-lc-rs` crate, as this currently fails to build in `make run-dev` ([#1043]). @@ -17,6 +22,7 @@ All notable changes to this project will be documented in this file. Also, the constant `DEFAULT_SOCKET_ADDR` has been renamed to `DEFAULT_SOCKET_ADDRESS` ([#1045]). [#1043]: https://github.com/stackabletech/operator-rs/pull/1043 +[#1044]: https://github.com/stackabletech/operator-rs/pull/1044 [#1045]: https://github.com/stackabletech/operator-rs/pull/1045 ## [0.3.1] - 2024-07-10 diff --git a/crates/stackable-webhook/src/tls.rs b/crates/stackable-webhook/src/tls.rs index 92d9eb345..e90fbbab8 100644 --- a/crates/stackable-webhook/src/tls.rs +++ b/crates/stackable-webhook/src/tls.rs @@ -8,8 +8,11 @@ use hyper::{body::Incoming, service::service_fn}; use hyper_util::rt::{TokioExecutor, TokioIo}; use opentelemetry::trace::{FutureExt, SpanKind}; use snafu::{ResultExt, Snafu}; -use stackable_certs::{CertificatePairError, ca::CertificateAuthority, keys::rsa}; -use stackable_operator::time::Duration; +use stackable_certs::{ + CertificatePair, CertificatePairError, CreateCertificateError, + ca::{CertificateAuthority, CreateCertificateAuthorityError}, + keys::ecdsa, +}; use tokio::net::TcpListener; use tokio_rustls::{ TlsAcceptor, @@ -36,20 +39,24 @@ pub enum Error { socket_addr: SocketAddr, }, - #[snafu(display("failed to create CA to generate and sign webhook leaf certificate"))] - CreateCertificateAuthority { source: stackable_certs::ca::Error }, + #[snafu(display("failed to create certificate authority"))] + CreateCertificateAuthority { + source: CreateCertificateAuthorityError, + }, - #[snafu(display("failed to generate webhook leaf certificate"))] - GenerateLeafCertificate { source: stackable_certs::ca::Error }, + #[snafu(display("failed to create certificate"))] + CreateCertificate { + source: CreateCertificateError, + }, #[snafu(display("failed to encode leaf certificate as DER"))] EncodeCertificateDer { - source: CertificatePairError, + source: CertificatePairError, }, #[snafu(display("failed to encode private key as DER"))] EncodePrivateKeyDer { - source: CertificatePairError, + source: CertificatePairError, }, #[snafu(display("failed to set safe TLS protocol versions"))] @@ -102,18 +109,21 @@ impl TlsServer { // blocked. // See https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html let task = tokio::task::spawn_blocking(move || { - let mut certificate_authority = - CertificateAuthority::new_rsa().context(CreateCertificateAuthoritySnafu)?; + let ca = CertificateAuthority::builder_with_ecdsa() + .build() + .context(CreateCertificateAuthoritySnafu)?; - let leaf_certificate = certificate_authority - .generate_rsa_leaf_certificate("Leaf", "webhook", Duration::from_secs(3600)) - .context(GenerateLeafCertificateSnafu)?; + let certificate = CertificatePair::builder() + .subject("CN=webhook") + .signed_by(&ca) + .build() + .context(CreateCertificateSnafu)?; - let certificate_der = leaf_certificate + let certificate_der = certificate .certificate_der() .context(EncodeCertificateDerSnafu)?; - let private_key_der = leaf_certificate + let private_key_der = certificate .private_key_der() .context(EncodePrivateKeyDerSnafu)?;