diff --git a/.gitignore b/.gitignore index 11a8de14b..f5f7c2a1e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ Cargo.lock .idea/ *.iws *.iml + +# TLS certificates for testing +*.crt +*.key diff --git a/Cargo.toml b/Cargo.toml index a376a682f..1fcc18496 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,4 +67,4 @@ rstest = "0.18.1" tempfile = "3.7.1" [workspace] -members = ["stackable-operator-derive", "stackable-webhook"] +members = ["stackable-certs", "stackable-operator-derive", "stackable-webhook"] diff --git a/src/commons/mod.rs b/src/commons/mod.rs index b65f450ab..05b10b207 100644 --- a/src/commons/mod.rs +++ b/src/commons/mod.rs @@ -11,4 +11,5 @@ pub mod product_image_selection; pub mod rbac; pub mod resources; pub mod s3; +pub mod secret; pub mod secret_class; diff --git a/src/commons/secret.rs b/src/commons/secret.rs new file mode 100644 index 000000000..2330c940d --- /dev/null +++ b/src/commons/secret.rs @@ -0,0 +1,45 @@ +use std::fmt::Display; + +use k8s_openapi::api::core::v1::Secret; +use kube::runtime::reflector::ObjectRef; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// [`SecretReference`] represents a Kubernetes [`Secret`] reference. +/// +/// In order to use this struct, the following two requirements must be met: +/// +/// - Must only be used in cluster-scoped objects +/// - Namespaced objects must not be able to define cross-namespace secret +/// references +/// +/// This struct is a redefinition of the one provided by k8s-openapi to make +/// name and namespace mandatory. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SecretReference { + /// Namespace of the Secret being referred to. + pub namespace: String, + + /// Name of the Secret being referred to. + pub name: String, +} + +// Use ObjectRef for logging/errors +impl Display for SecretReference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + ObjectRef::::from(self).fmt(f) + } +} + +impl From for ObjectRef { + fn from(val: SecretReference) -> Self { + ObjectRef::::from(&val) + } +} + +impl From<&SecretReference> for ObjectRef { + fn from(val: &SecretReference) -> Self { + ObjectRef::::new(&val.name).within(&val.namespace) + } +} diff --git a/stackable-certs/Cargo.toml b/stackable-certs/Cargo.toml new file mode 100644 index 000000000..2f7bc445b --- /dev/null +++ b/stackable-certs/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "stackable-certs" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true + +[features] +default = [] +rustls = ["dep:tokio-rustls", "dep:rustls-pemfile"] + +[dependencies] +stackable-operator = { path = ".." } + +const-oid = "0.9.6" +ecdsa = { version = "0.16.9", features = ["digest", "pem"] } +p256 = { version = "0.13.2", features = ["ecdsa"] } +k8s-openapi = { version = "0.21.0", default-features = false, features = [ + "v1_28", +] } +kube = { version = "0.88.1", default-features = false, features = [ + "client", + "rustls-tls", +] } +tracing = "0.1.40" +tokio = { version = "1.29.1", features = ["fs"] } +tokio-rustls = { version = "0.25.0", optional = true } +rand = "0.8.5" +rand_core = "0.6.4" +rsa = { version = "0.9.6", features = ["sha2"] } +rustls-pemfile = { version = "2.0.0", optional = true } +sha2 = { version = "0.10.8", features = ["oid"] } +signature = "2.2.0" +snafu = "0.8.0" +x509-cert = { version = "0.2.5", features = ["builder"] } +zeroize = "1.7.0" diff --git a/stackable-certs/src/ca/consts.rs b/stackable-certs/src/ca/consts.rs new file mode 100644 index 000000000..600bb5f7c --- /dev/null +++ b/stackable-certs/src/ca/consts.rs @@ -0,0 +1,5 @@ +/// The default CA validity time span of one hour (3600 seconds). +pub const DEFAULT_CA_VALIDITY_SECONDS: u64 = 3600; + +/// The root CA subject name containing only the common name. +pub const ROOT_CA_SUBJECT: &str = "CN=Stackable Data Platform Internal CA"; diff --git a/stackable-certs/src/ca/mod.rs b/stackable-certs/src/ca/mod.rs new file mode 100644 index 000000000..f06581c0a --- /dev/null +++ b/stackable-certs/src/ca/mod.rs @@ -0,0 +1,448 @@ +//! Contains types and functions to generate and sign certificate authorities +//! (CAs). +use std::str::FromStr; + +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::{pem::LineEnding, referenced::OwnedToRef, DecodePem}, + ext::pkix::{AuthorityKeyIdentifier, ExtendedKeyUsage}, + name::Name, + serial_number::SerialNumber, + spki::{EncodePublicKey, SubjectPublicKeyInfoOwned}, + time::Validity, +}; + +use crate::{ + keys::{ecdsa, rsa, CertificateKeypair}, + CertificatePair, +}; + +mod consts; +pub use consts::*; + +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 +/// 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 }, +} + +/// 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. +#[derive(Debug)] +pub struct CertificateAuthority +where + S: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, +{ + certificate_pair: CertificatePair, +} + +impl CertificateAuthority +where + S: 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) + } + + /// 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(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 aki = AuthorityKeyIdentifier::try_from(spki.owned_to_ref()) + .context(ParseAuthorityKeyIdentifierSnafu)?; + + 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)?; + builder + .add_extension(&aki) + .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] + 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) + } + + /// Generates an ECDSAasync -based leaf certificate which is signed by this CA. + /// + /// See [`CertificateAuthority::generate_leaf_certificate`] for more + /// information. + #[instrument] + 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) + } + + /// 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_some_and(|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, + }, + }) + } + + /// 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) + } +} + +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)?) + } +} + +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 test { + + use super::*; + + #[tokio::test] + async fn test() { + let mut ca = CertificateAuthority::new_rsa().unwrap(); + ca.generate_leaf_certificate( + rsa::SigningKey::new().unwrap(), + "Airflow", + "pod", + Duration::from_secs(3600), + ) + .unwrap(); + } +} diff --git a/stackable-certs/src/keys/ecdsa.rs b/stackable-certs/src/keys/ecdsa.rs new file mode 100644 index 000000000..0f016eb0d --- /dev/null +++ b/stackable-certs/src/keys/ecdsa.rs @@ -0,0 +1,64 @@ +//! Abstraction layer around the [`ecdsa`] crate. This module provides types +//! which abstract away the generation of ECDSA keys used for signing of CAs +//! and other certificates. +use p256::{pkcs8::DecodePrivateKey, NistP256}; +use rand_core::{CryptoRngCore, OsRng}; +use snafu::{ResultExt, Snafu}; +use tracing::instrument; + +use crate::keys::CertificateKeypair; + +pub type Result = std::result::Result; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(context(false))] + SerializeKeyToPem { source: x509_cert::spki::Error }, + + #[snafu(display("failed to deserialize ECDSA key from PEM"))] + DeserializeKeyFromPem { source: p256::pkcs8::Error }, +} + +#[derive(Debug)] +pub struct SigningKey(p256::ecdsa::SigningKey); + +impl SigningKey { + #[instrument(name = "create_ecdsa_signing_key")] + pub 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 + where + R: CryptoRngCore + Sized, + { + let signing_key = p256::ecdsa::SigningKey::random(csprng); + Ok(Self(signing_key)) + } +} + +impl CertificateKeypair for SigningKey { + type SigningKey = p256::ecdsa::SigningKey; + type Signature = ecdsa::der::Signature; + type VerifyingKey = p256::ecdsa::VerifyingKey; + + type Error = Error; + + fn signing_key(&self) -> &Self::SigningKey { + &self.0 + } + + fn verifying_key(&self) -> Self::VerifyingKey { + *self.0.verifying_key() + } + + #[instrument(name = "create_ecdsa_signing_key_from_pkcs8_pem")] + fn from_pkcs8_pem(input: &str) -> Result { + let signing_key = + p256::ecdsa::SigningKey::from_pkcs8_pem(input).context(DeserializeKeyFromPemSnafu)?; + + Ok(Self(signing_key)) + } +} diff --git a/stackable-certs/src/keys/mod.rs b/stackable-certs/src/keys/mod.rs new file mode 100644 index 000000000..851e4aa70 --- /dev/null +++ b/stackable-certs/src/keys/mod.rs @@ -0,0 +1,65 @@ +//! Contains primitives to create private keys, which are used to sign CAs +//! and bind to leaf certificates. +//! +//! This module currently provides the following algorithms: +//! +//! ## ECDSA +//! +//! In order to work with ECDSA keys, this crate requires two dependencies: +//! [`ecdsa`], which provides primitives and traits, and [`p256`] which +//! implements the NIST P-256 elliptic curve and supports ECDSA. +//! +//! ``` +//! use stackable_certs::sign::ecdsa::SigningKey; +//! let key = SigningKey::new().unwrap(); +//! ``` +//! +//! ## RSA +//! +//! In order to work with RSA keys, this crate requires the [`rsa`] dependency. +//! +//! ``` +//! use stackable_certs::sign::rsa::SigningKey; +//! let key = SigningKey::new().unwrap(); +//! ``` +//! +//! It should be noted, that the crate is currently vulnerable to the recently +//! discovered Marvin attack. The `openssl` crate is also impacted by this. See: +//! +//! - +//! - +//! - +use std::fmt::Debug; + +use p256::pkcs8::EncodePrivateKey; +use signature::{Keypair, Signer}; +use x509_cert::spki::{EncodePublicKey, SignatureAlgorithmIdentifier, SignatureBitStringEncoding}; + +pub mod ecdsa; +pub mod rsa; + +// NOTE (@Techassi): This can _maybe_ be slightly simplified by adjusting the +// trait and using a blanket impl on types which implement Deref. +pub trait CertificateKeypair +where + ::VerifyingKey: EncodePublicKey, + Self: Debug + Sized, +{ + type SigningKey: SignatureAlgorithmIdentifier + + Keypair + + Signer + + EncodePrivateKey; + type Signature: SignatureBitStringEncoding; + type VerifyingKey: EncodePublicKey; + + type Error: std::error::Error + 'static; + + /// Returns the signing (private) key half of the keypair. + fn signing_key(&self) -> &Self::SigningKey; + + /// Returns the verifying (public) half of the keypair. + fn verifying_key(&self) -> Self::VerifyingKey; + + /// Creates a signing key pair from the PEM-encoded private key. + fn from_pkcs8_pem(input: &str) -> Result; +} diff --git a/stackable-certs/src/keys/rsa.rs b/stackable-certs/src/keys/rsa.rs new file mode 100644 index 000000000..eceda60f1 --- /dev/null +++ b/stackable-certs/src/keys/rsa.rs @@ -0,0 +1,80 @@ +//! Abstraction layer around the [`rsa`] crate. This module provides types +//! which abstract away the generation of RSA keys used for signing of CAs +//! and other certificates. +use rand_core::{CryptoRngCore, OsRng}; +use rsa::{pkcs8::DecodePrivateKey, RsaPrivateKey}; +use signature::Keypair; +use snafu::{ResultExt, Snafu}; +use tracing::instrument; + +use crate::keys::CertificateKeypair; + +const KEY_SIZE: usize = 4096; + +pub type Result = std::result::Result; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("failed to create RSA key"))] + CreateKey { source: rsa::Error }, + + #[snafu(display("failed to deserialize the signing (private) key from PEM-encoded PKCS8"))] + DeserializeSigningKey { source: rsa::pkcs8::Error }, +} + +#[derive(Debug)] +pub struct SigningKey(rsa::pkcs1v15::SigningKey); + +impl SigningKey { + /// Generates a new RSA key with the default random-number generator + /// [`OsRng`]. + /// + /// It should be noted that the generation of the key takes longer for + /// 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 { + let mut csprng = OsRng; + Self::new_with_rng(&mut csprng) + } + + /// Generates a new RSA key with a custom random-number generator. + /// + /// It should be noted that the generation of the key takes longer for + /// 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 + where + R: CryptoRngCore + ?Sized, + { + let private_key = RsaPrivateKey::new(csprng, KEY_SIZE).context(CreateKeySnafu)?; + let signing_key = rsa::pkcs1v15::SigningKey::::new(private_key); + + Ok(Self(signing_key)) + } +} + +impl CertificateKeypair for SigningKey { + type SigningKey = rsa::pkcs1v15::SigningKey; + type Signature = rsa::pkcs1v15::Signature; + type VerifyingKey = rsa::pkcs1v15::VerifyingKey; + type Error = Error; + + fn signing_key(&self) -> &Self::SigningKey { + &self.0 + } + + fn verifying_key(&self) -> Self::VerifyingKey { + self.0.verifying_key() + } + + #[instrument(name = "create_rsa_signing_key_from_pkcs8_pem")] + fn from_pkcs8_pem(input: &str) -> Result { + let private_key = + RsaPrivateKey::from_pkcs8_pem(input).context(DeserializeSigningKeySnafu)?; + let signing_key = rsa::pkcs1v15::SigningKey::::new(private_key); + + Ok(Self(signing_key)) + } +} diff --git a/stackable-certs/src/lib.rs b/stackable-certs/src/lib.rs new file mode 100644 index 000000000..232a8e615 --- /dev/null +++ b/stackable-certs/src/lib.rs @@ -0,0 +1,171 @@ +//! This crate provides types, traits and functions to work with X.509 TLS +//! certificates. It can be used to create certificate authorities (CAs) +//! which can sign leaf certificates. These leaf certificates can be used +//! for webhook servers or other components which need TLS certificates +//! to encrypt connections. +//! +//! ## Feature Flags +//! +//! The crate allows to selectively enable additional features using +//! different feature flags. Currently, these flags are supported: +//! +//! - `rustls`: This enables interoperability between this crates types +//! and the certificate formats required for the `stackable-webhook` +//! crate. +//! +//! ## References +//! +//! - +//! - +//! - +#[cfg(feature = "rustls")] +use std::ops::Deref; + +#[cfg(feature = "rustls")] +use { + p256::pkcs8::EncodePrivateKey, + snafu::ResultExt, + tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}, + x509_cert::der::Encode, +}; + +use snafu::Snafu; +use x509_cert::{spki::EncodePublicKey, Certificate}; + +use crate::keys::CertificateKeypair; + +pub mod ca; +pub mod keys; + +/// Error variants which can be encountered when creating a new +/// [`CertificatePair`]. +#[derive(Debug, Snafu)] +pub enum CertificatePairError +where + E: std::error::Error + 'static, +{ + #[snafu(display("failed to seralize certificate as {key_encoding}"))] + SerializeCertificate { + source: x509_cert::der::Error, + key_encoding: KeyEncoding, + }, + + #[snafu(display("failed to deserialize certificate from {key_encoding}"))] + DeserializeCertificate { + source: x509_cert::der::Error, + key_encoding: KeyEncoding, + }, + + #[snafu(display("failed to serialize private key as PKCS8 {key_encoding}"))] + SerializePrivateKey { + source: p256::pkcs8::Error, + key_encoding: KeyEncoding, + }, + + #[snafu(display("failed to deserialize private key from PKCS8 {key_encoding}"))] + DeserializePrivateKey { + source: E, + key_encoding: KeyEncoding, + }, + + #[snafu(display("failed to write file"))] + WriteFile { source: std::io::Error }, + + #[snafu(display("failed to read file"))] + ReadFile { source: std::io::Error }, +} + +/// Contains the certificate and the signing / embedded key pair. +/// +/// A [`CertificateAuthority`](crate::ca::CertificateAuthority) uses this struct +/// internally to store the signing key pair which is used to sign the CA +/// itself (self-signed) and all child leaf certificates. Leaf certificates on +/// the other hand use this to store the bound keypair. +#[derive(Debug)] +pub struct CertificatePair +where + S: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, +{ + certificate: Certificate, + key_pair: S, +} + +impl CertificatePair +where + S: CertificateKeypair, + ::VerifyingKey: EncodePublicKey, +{ + /// Returns a reference to the [`Certificate`]. + pub fn certificate(&self) -> &Certificate { + &self.certificate + } + + /// Returns a reference to the (signing) key pair. + pub fn key_pair(&self) -> &S { + &self.key_pair + } +} + +#[cfg(feature = "rustls")] +impl CertificatePair +where + S: CertificateKeypair + 'static, + ::VerifyingKey: EncodePublicKey, +{ + pub fn certificate_der( + &self, + ) -> Result, CertificatePairError> { + let der = self + .certificate + .to_der() + .context(SerializeCertificateSnafu { + key_encoding: KeyEncoding::Der, + })? + .into(); + + Ok(der) + } + + pub fn private_key_der( + &self, + ) -> Result, CertificatePairError> { + // FIXME (@Techassi): Can we make this more elegant? + let doc = self + .key_pair + .signing_key() + .to_pkcs8_der() + .context(SerializePrivateKeySnafu { + key_encoding: KeyEncoding::Der, + })?; + + let bytes = doc.to_bytes().deref().to_owned(); + let der = PrivateKeyDer::from(PrivatePkcs8KeyDer::from(bytes)); + + Ok(der) + } +} + +/// Supported private key types, currently [RSA](crate::keys::rsa) and +/// [ECDSA](crate::keys::ecdsa). +#[derive(Debug)] +pub enum PrivateKeyType { + Ecdsa, + Rsa, +} + +/// Private and public key encoding, either DER or PEM. +#[derive(Debug)] +pub enum KeyEncoding { + Pem, + Der, +} + +impl std::fmt::Display for KeyEncoding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + KeyEncoding::Pem => write!(f, "PEM"), + KeyEncoding::Der => write!(f, "DER"), + } + } +} diff --git a/stackable-webhook/Cargo.toml b/stackable-webhook/Cargo.toml index d2b91e5b8..540549513 100644 --- a/stackable-webhook/Cargo.toml +++ b/stackable-webhook/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true repository.workspace = true [dependencies] +stackable-certs = { path = "../stackable-certs", features = ["rustls"] } +stackable-operator = { path = ".." } + axum = "0.7.4" k8s-openapi = { version = "0.21.0", default-features = false, features = [ "v1_28", diff --git a/stackable-webhook/src/lib.rs b/stackable-webhook/src/lib.rs index f6e310026..759fa74e8 100644 --- a/stackable-webhook/src/lib.rs +++ b/stackable-webhook/src/lib.rs @@ -148,33 +148,11 @@ impl WebhookServer { // Create server for TLS termination debug!("create TLS server"); - let tls_server = TlsServer::new(self.options.socket_addr, router, self.options.tls) + let tls_server = TlsServer::new(self.options.socket_addr, router) + .await .context(CreateTlsServerSnafu)?; debug!("running TLS server"); tls_server.run().await.context(RunTlsServerSnafu) } } - -#[cfg(test)] -mod test { - use crate::tls::certs::PrivateKeyEncoding; - - use super::*; - use axum::{routing::get, Router}; - - #[tokio::test] - async fn test() { - let router = Router::new().route("/", get(|| async { "Ok" })); - let options = Options::builder() - .tls_mount( - "/tmp/webhook-certs/serverCert.pem", - "/tmp/webhook-certs/serverKey.pem", - PrivateKeyEncoding::Pkcs8, - ) - .build(); - - let server = WebhookServer::new(router, options); - server.run().await.unwrap() - } -} diff --git a/stackable-webhook/src/options.rs b/stackable-webhook/src/options.rs index 49349da17..bf810ebd6 100644 --- a/stackable-webhook/src/options.rs +++ b/stackable-webhook/src/options.rs @@ -4,7 +4,9 @@ use std::{ path::PathBuf, }; -use crate::{constants::DEFAULT_SOCKET_ADDR, tls::certs::PrivateKeyEncoding}; +use stackable_certs::PrivateKeyType; + +use crate::constants::DEFAULT_SOCKET_ADDR; /// Specifies available webhook server options. /// @@ -34,28 +36,11 @@ use crate::{constants::DEFAULT_SOCKET_ADDR, tls::certs::PrivateKeyEncoding}; /// .bind_port(12345) /// .build(); /// ``` -/// -/// ### Example with Mounted TLS Certificate -/// -/// ``` -/// use stackable_webhook::{Options, tls::certs::PrivateKeyEncoding}; -/// -/// let options = Options::builder() -/// .tls_mount( -/// "path/to/pem/cert", -/// "path/to/pem/key", -/// PrivateKeyEncoding::Pkcs8, -/// ) -/// .build(); -/// ``` #[derive(Debug)] pub struct Options { /// The default HTTPS socket address the [`TcpListener`][tokio::net::TcpListener] /// binds to. pub socket_addr: SocketAddr, - - /// Either auto-generate or use an injected TLS certificate. - pub tls: TlsOption, } impl Default for Options { @@ -81,7 +66,6 @@ impl Options { #[derive(Debug, Default)] pub struct OptionsBuilder { socket_addr: Option, - tls: Option, } impl OptionsBuilder { @@ -107,37 +91,11 @@ impl OptionsBuilder { self } - /// Enables TLS certificate auto-generation instead of using a mounted - /// one. If instead a mounted TLS certificate is needed, use the - /// [`OptionsBuilder::tls_mount()`] function. - pub fn tls_autogenerate(mut self) -> Self { - self.tls = Some(TlsOption::AutoGenerate); - self - } - - /// Uses a mounted TLS certificate instead of auto-generating one. If - /// instead a auto-generated TLS certificate is needed, us ethe - /// [`OptionsBuilder::tls_autogenerate()`] function. - pub fn tls_mount( - mut self, - public_key_path: impl Into, - private_key_path: impl Into, - private_key_encoding: PrivateKeyEncoding, - ) -> Self { - self.tls = Some(TlsOption::Mount { - public_key_path: public_key_path.into(), - private_key_path: private_key_path.into(), - private_key_encoding, - }); - self - } - /// Builds the final [`Options`] by using default values for any not /// explicitly set option. pub fn build(self) -> Options { Options { socket_addr: self.socket_addr.unwrap_or(DEFAULT_SOCKET_ADDR), - tls: self.tls.unwrap_or_default(), } } } @@ -146,9 +104,9 @@ impl OptionsBuilder { pub enum TlsOption { AutoGenerate, Mount { - private_key_encoding: PrivateKeyEncoding, - public_key_path: PathBuf, + private_key_type: PrivateKeyType, private_key_path: PathBuf, + certificate_path: PathBuf, }, } diff --git a/stackable-webhook/src/tls/server.rs b/stackable-webhook/src/tls.rs similarity index 71% rename from stackable-webhook/src/tls/server.rs rename to stackable-webhook/src/tls.rs index 16de311fb..3dfed5f48 100644 --- a/stackable-webhook/src/tls/server.rs +++ b/stackable-webhook/src/tls.rs @@ -7,23 +7,17 @@ use futures_util::pin_mut; use hyper::{body::Incoming, service::service_fn}; use hyper_util::rt::{TokioExecutor, TokioIo}; use snafu::{ResultExt, Snafu}; +use stackable_certs::{ca::CertificateAuthority, keys::rsa, CertificatePairError}; +use stackable_operator::time::Duration; use tokio::net::TcpListener; use tokio_rustls::{rustls::ServerConfig, TlsAcceptor}; use tower::Service; use tracing::{error, instrument, warn}; -use crate::{ - options::TlsOption, - tls::certs::{CertifacteError, CertificateChain}, -}; - pub type Result = std::result::Result; #[derive(Debug, Snafu)] pub enum Error { - #[snafu(display("failed to create TLS certificate chain"))] - TlsCertificateChain { source: CertifacteError }, - #[snafu(display("failed to construct TLS server config, bad certificate/key"))] InvalidTlsPrivateKey { source: tokio_rustls::rustls::Error }, @@ -34,6 +28,22 @@ pub enum Error { source: std::io::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 generate webhook leaf certificate"))] + GenerateLeafCertificate { source: stackable_certs::ca::Error }, + + #[snafu(display("failed to encode leaf certificate as DER"))] + EncodeCertificateDer { + source: CertificatePairError, + }, + + #[snafu(display("failed to encode private key as DER"))] + EncodePrivateKeyDer { + source: CertificatePairError, + }, } /// A server which terminates TLS connections and allows clients to commnunicate @@ -46,38 +56,28 @@ pub struct TlsServer { impl TlsServer { #[instrument(name = "create_tls_server", skip(router))] - pub fn new(socket_addr: SocketAddr, router: Router, tls: TlsOption) -> Result { - let config = match tls { - TlsOption::AutoGenerate => { - // let mut config = ServerConfig::builder() - // .with_safe_defaults() - // .with_no_client_auth() - // .with_cert_resolver(cert_resolver); - // config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - todo!() - } - TlsOption::Mount { - public_key_path, - private_key_path, - private_key_encoding, - } => { - let (chain, private_key) = CertificateChain::from_files( - public_key_path, - private_key_path, - private_key_encoding, - ) - .context(TlsCertificateChainSnafu)? - .into_parts(); - - let mut config = ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(chain, private_key) - .context(InvalidTlsPrivateKeySnafu)?; - - config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - config - } - }; + pub async fn new(socket_addr: SocketAddr, router: Router) -> Result { + let mut certificate_authority = + CertificateAuthority::new_rsa().context(CreateCertificateAuthoritySnafu)?; + + let leaf_certificate = certificate_authority + .generate_rsa_leaf_certificate("Leaf", "webhook", Duration::from_secs(3600)) + .context(GenerateLeafCertificateSnafu)?; + + let certificate_der = leaf_certificate + .certificate_der() + .context(EncodeCertificateDerSnafu)?; + + let private_key_der = leaf_certificate + .private_key_der() + .context(EncodePrivateKeyDerSnafu)?; + + let mut config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certificate_der], private_key_der) + .context(InvalidTlsPrivateKeySnafu)?; + + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; let config = Arc::new(config); Ok(Self { diff --git a/stackable-webhook/src/tls/certs.rs b/stackable-webhook/src/tls/certs.rs deleted file mode 100644 index 5f0a5138e..000000000 --- a/stackable-webhook/src/tls/certs.rs +++ /dev/null @@ -1,99 +0,0 @@ -// TODO (@Techassi): Move this into a separate crate which handles TLS cert -// generation and reading. -use std::{fs::File, io::BufReader, path::Path}; - -use rustls_pemfile::{certs, ec_private_keys, pkcs8_private_keys, rsa_private_keys}; -use snafu::{ResultExt, Snafu}; -use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; - -pub type Result = std::result::Result; - -#[derive(Debug, Snafu)] -pub enum CertifacteError { - #[snafu(display("failed to read certificate file"))] - ReadCertFile { source: std::io::Error }, - - #[snafu(display("failed to read buffered certificate file"))] - ReadBufferedCertFile { source: std::io::Error }, - - #[snafu(display("failed to read private key file"))] - ReadKeyFile { source: std::io::Error }, - - #[snafu(display("failed to read buffered private key file"))] - ReadBufferedKeyFile { source: std::io::Error }, -} - -pub struct CertificateChain { - chain: Vec>, - private_key: PrivateKeyDer<'static>, -} - -impl CertificateChain { - pub fn from_files( - cert_path: impl AsRef, - pk_path: impl AsRef, - pk_encoding: PrivateKeyEncoding, - ) -> Result { - let cert_file = File::open(cert_path).context(ReadCertFileSnafu)?; - let mut cert_reader = BufReader::new(cert_file); - - let key_file = File::open(pk_path).context(ReadKeyFileSnafu)?; - let mut pk_reader = BufReader::new(key_file); - - let chain = certs(&mut cert_reader) - .collect::, _>>() - .context(ReadBufferedCertFileSnafu)?; - - let private_key = match pk_encoding { - PrivateKeyEncoding::Pkcs8 => Self::pkcs8_to_pk_der(&mut pk_reader)?, - PrivateKeyEncoding::Rsa => Self::rsa_to_pk_der(&mut pk_reader)?, - PrivateKeyEncoding::Ec => Self::ec_to_pk_der(&mut pk_reader)?, - } - .remove(0); - - Ok(Self { chain, private_key }) - } - - fn pkcs8_to_pk_der<'a>(pk_reader: &mut dyn std::io::BufRead) -> Result>> { - let ders = pkcs8_private_keys(pk_reader) - .collect::, _>>() - .context(ReadBufferedKeyFileSnafu)?; - - Ok(ders.into_iter().map(PrivateKeyDer::from).collect()) - } - - fn rsa_to_pk_der<'a>(pk_reader: &mut dyn std::io::BufRead) -> Result>> { - let ders = rsa_private_keys(pk_reader) - .collect::, _>>() - .context(ReadBufferedKeyFileSnafu)?; - - Ok(ders.into_iter().map(PrivateKeyDer::from).collect()) - } - - fn ec_to_pk_der<'a>(pk_reader: &mut dyn std::io::BufRead) -> Result>> { - let ders = ec_private_keys(pk_reader) - .collect::, _>>() - .context(ReadBufferedKeyFileSnafu)?; - - Ok(ders.into_iter().map(PrivateKeyDer::from).collect()) - } - - pub fn chain(&self) -> &[CertificateDer] { - &self.chain - } - - pub fn private_key(&self) -> &PrivateKeyDer { - &self.private_key - } - - pub fn into_parts(self) -> (Vec>, PrivateKeyDer<'static>) { - (self.chain, self.private_key) - } -} - -#[derive(Debug)] -pub enum PrivateKeyEncoding { - Pkcs8, - Rsa, - Ec, -} diff --git a/stackable-webhook/src/tls/mod.rs b/stackable-webhook/src/tls/mod.rs deleted file mode 100644 index 7c7918041..000000000 --- a/stackable-webhook/src/tls/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Contains structs and functions to easily create a TLS termination server, -//! which can be used in combination with an Axum [`Router`][axum::Router]. -pub mod certs; -mod server; - -pub use server::*;