diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b24cf7..78389966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,13 @@ All notable changes to this project will be documented in this file. ## Changed -- Use new annotation builder ([#341]) +- Use new annotation builder ([#341]). +- `autoTLS` certificate authorities will now be rotated regularly ([#350]). + - [BREAKING] This changes the format of the CA secrets. Old secrets will be migrated automatically, but manual intervention will be required to downgrade back to 23.11.x. [#333]: https://github.com/stackabletech/secret-operator/pull/333 [#341]: https://github.com/stackabletech/secret-operator/pull/341 +[#350]: https://github.com/stackabletech/secret-operator/pull/350 ## [23.11.0] - 2023-11-24 diff --git a/Cargo.lock b/Cargo.lock index b617e37f..cda70396 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2495,6 +2495,7 @@ dependencies = [ "serde_json", "snafu 0.7.5", "stackable-operator", + "stackable-secret-operator-crd-utils", "tokio", "tracing", "tracing-subscriber", @@ -2574,6 +2575,7 @@ dependencies = [ "socket2 0.5.4", "stackable-krb5-provision-keytab", "stackable-operator", + "stackable-secret-operator-crd-utils", "strum", "sys-mount", "tempfile", @@ -2588,6 +2590,14 @@ dependencies = [ "yasna", ] +[[package]] +name = "stackable-secret-operator-crd-utils" +version = "0.0.0-dev" +dependencies = [ + "serde", + "stackable-operator", +] + [[package]] name = "strsim" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 413470b8..a0c33c95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["rust/operator-binary", "rust/krb5", "rust/krb5-provision-keytab", "rust/krb5-sys"] +members = ["rust/*"] default-members = ["rust/operator-binary"] resolver = "2" diff --git a/deploy/helm/secret-operator/crds/crds.yaml b/deploy/helm/secret-operator/crds/crds.yaml index f5ac18db..5d7f1951 100644 --- a/deploy/helm/secret-operator/crds/crds.yaml +++ b/deploy/helm/secret-operator/crds/crds.yaml @@ -45,17 +45,20 @@ spec: properties: autoGenerate: default: false - description: Whether a new certificate authority should be generated if it does not already exist. + description: Whether the certificate authority should be managed by Secret Operator, including being generated if it does not already exist. type: boolean secret: description: Reference (name and namespace) to a Kubernetes Secret object where the CA certificate and key is stored in the keys `ca.crt` and `ca.key` respectively. properties: name: - description: name is unique within a namespace to reference a secret resource. + description: Name of the Secret being referred to. type: string namespace: - description: namespace defines the space within which the secret name must be unique. + description: Namespace of the Secret being referred to. type: string + required: + - name + - namespace type: object required: - secret @@ -109,21 +112,27 @@ spec: description: Reference (name and namespace) to a Kubernetes Secret object containing the TLS CA (in `ca.crt`) that the LDAP server’s certificate should be authenticated against. properties: name: - description: name is unique within a namespace to reference a secret resource. + description: Name of the Secret being referred to. type: string namespace: - description: namespace defines the space within which the secret name must be unique. + description: Namespace of the Secret being referred to. type: string + required: + - name + - namespace type: object passwordCacheSecret: description: Reference (name and namespace) to a Kubernetes Secret object where workload passwords will be stored. This must not be accessible to end users. properties: name: - description: name is unique within a namespace to reference a secret resource. + description: Name of the Secret being referred to. type: string namespace: - description: namespace defines the space within which the secret name must be unique. + description: Namespace of the Secret being referred to. type: string + required: + - name + - namespace type: object schemaDistinguishedName: description: The root Distinguished Name (DN) for AD-managed schemas, typically `CN=Schema,CN=Configuration,{domain_dn}`. @@ -152,11 +161,14 @@ spec: description: Reference (`name` and `namespace`) to a K8s Secret object where a keytab with administrative privileges is stored in the key `keytab`. properties: name: - description: name is unique within a namespace to reference a secret resource. + description: Name of the Secret being referred to. type: string namespace: - description: namespace defines the space within which the secret name must be unique. + description: Namespace of the Secret being referred to. type: string + required: + - name + - namespace type: object adminPrincipal: description: The admin principal. diff --git a/deploy/helm/secret-operator/templates/roles.yaml b/deploy/helm/secret-operator/templates/roles.yaml index 7b607a23..8a4abc8e 100644 --- a/deploy/helm/secret-operator/templates/roles.yaml +++ b/deploy/helm/secret-operator/templates/roles.yaml @@ -15,6 +15,7 @@ rules: - watch - create - patch + - update - apiGroups: - "" resources: diff --git a/docs/modules/secret-operator/pages/secretclass.adoc b/docs/modules/secret-operator/pages/secretclass.adoc index 38ce1652..b026f7af 100644 --- a/docs/modules/secret-operator/pages/secretclass.adoc +++ b/docs/modules/secret-operator/pages/secretclass.adoc @@ -56,7 +56,18 @@ We have spent a considerate amount of time thinking about this issue and decided Most of our product operators will not set any specific certificate lifetime, so the default applies. In case an operator sets a higher lifetime, a tracking issue must be created to document and track the steps to reduce the certificate lifetime. -Users can use podOverrides to extend the certificate lifetime by adding volume annotations. We might add native support to customize certificate lifetimes in the future by using the product CRDs. +Users can use podOverrides to extend the certificate lifetime by adding volume annotations. We might add native support for customizing certificate lifetimes in the future to the Stacklet CRDs. + +==== Certificate Authority rotation + +Certificate authorities also have a limited lifetime, and need to be rotated before they expire to avoid cluster disruption. + +If configured to provision its own CA (`autoTls.ca.autoGenerate`), the Secret Operator will create CA certificates that are valid for 2 years, +and initiate rotation when there is less than 1 year remaining. If configured _not_ to provision its own CA, a warning will be issued when there is less than 1 year remaining. + +To avoid disruption and let the new CA propagate through the cluster, the Secret Operator will prefer using the oldest CA that will last for the entire lifetime of the issued certificate. + +Expired certificates will currently not be deleted automatically, and should be cleaned up manually. ==== Reference @@ -77,7 +88,7 @@ spec: `autoTls.ca`:: Configures the certificate authority used to issue `Pod` certificates. `autoTls.ca.secret`:: Reference (`name` and `namespace`) to a K8s `Secret` object where the CA certificate and key is stored in the keys `ca.crt` and `ca.key` respectively. -`autoTls.ca.autoGenerate`:: Whether the certificate authority should be provisioned if it can not be found. +`autoTls.ca.autoGenerate`:: Whether the certificate authority should be provisioned and managed by the Secret Operator. `autoTls.maxCertificateLifetime`:: Maximum lifetime the created certificates are allowed to have. In case consumers request a longer lifetime than allowed by this setting, the lifetime will be the minimum of both. [#backend-kerberoskeytab] diff --git a/rust/crd-utils/Cargo.toml b/rust/crd-utils/Cargo.toml new file mode 100644 index 00000000..a1f84185 --- /dev/null +++ b/rust/crd-utils/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "stackable-secret-operator-crd-utils" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde.workspace = true +stackable-operator.workspace = true diff --git a/rust/crd-utils/src/lib.rs b/rust/crd-utils/src/lib.rs new file mode 100644 index 00000000..cc6b962b --- /dev/null +++ b/rust/crd-utils/src/lib.rs @@ -0,0 +1,37 @@ +//! CRD types that are shared between secret-operator components, but aren't clearly owned by one of them. + +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; +use stackable_operator::{ + k8s_openapi::api::core::v1::Secret, + kube::runtime::reflector::ObjectRef, + schemars::{self, JsonSchema}, +}; + +// Redefine SecretReference instead of reusing k8s-openapi's, in order to make name/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/rust/krb5-provision-keytab/Cargo.toml b/rust/krb5-provision-keytab/Cargo.toml index 79e0fb93..b90bca3e 100644 --- a/rust/krb5-provision-keytab/Cargo.toml +++ b/rust/krb5-provision-keytab/Cargo.toml @@ -10,6 +10,7 @@ publish = false [dependencies] krb5 = { path = "../krb5" } +stackable-secret-operator-crd-utils = { path = "../crd-utils" } byteorder.workspace = true futures.workspace = true diff --git a/rust/krb5-provision-keytab/src/active_directory.rs b/rust/krb5-provision-keytab/src/active_directory.rs index f8b45b44..eb933bed 100644 --- a/rust/krb5-provision-keytab/src/active_directory.rs +++ b/rust/krb5-provision-keytab/src/active_directory.rs @@ -5,22 +5,17 @@ use krb5::{Keyblock, Keytab, KrbContext, Principal, PrincipalUnparseOptions}; use ldap3::{Ldap, LdapConnAsync, LdapConnSettings}; use rand::{seq::SliceRandom, thread_rng, CryptoRng}; use snafu::{OptionExt, ResultExt, Snafu}; -use stackable_operator::k8s_openapi::api::core::v1::{Secret, SecretReference}; +use stackable_operator::{k8s_openapi::api::core::v1::Secret, kube::runtime::reflector::ObjectRef}; +use stackable_secret_operator_crd_utils::SecretReference; -use crate::{ - credential_cache::{self, CredentialCache}, - secret_ref::{FullSecretRef, IncompleteSecretRef}, -}; +use crate::credential_cache::{self, CredentialCache}; #[derive(Debug, Snafu)] pub enum Error { - #[snafu(display("LDAP TLS CA reference is invalid"))] - LdapTlsCaReferenceInvalid { source: IncompleteSecretRef }, - #[snafu(display("failed to retrieve LDAP TLS CA {ca_ref}"))] GetLdapTlsCa { source: stackable_operator::error::Error, - ca_ref: FullSecretRef, + ca_ref: ObjectRef, }, #[snafu(display("LDAP TLS CA secret is missing required key {key}"))] @@ -29,9 +24,6 @@ pub enum Error { #[snafu(display("failed to parse LDAP TLS CA"))] ParseLdapTlsCa { source: native_tls::Error }, - #[snafu(display("password cache reference is invalid"))] - PasswordCacheReferenceInvalid { source: IncompleteSecretRef }, - #[snafu(display("password cache error"))] PasswordCache { source: credential_cache::Error }, @@ -55,13 +47,13 @@ pub enum Error { #[snafu(display("failed to get password cache {password_cache_ref}"))] GetPasswordCache { source: stackable_operator::error::Error, - password_cache_ref: FullSecretRef, + password_cache_ref: ObjectRef, }, #[snafu(display("failed to update password cache {password_cache_ref}"))] UpdatePasswordCache { source: stackable_operator::error::Error, - password_cache_ref: FullSecretRef, + password_cache_ref: ObjectRef, }, #[snafu(display("failed to create LDAP user"))] @@ -72,7 +64,7 @@ pub enum Error { ))] CreateLdapUserConflict { source: ldap3::LdapError, - password_cache_ref: FullSecretRef, + password_cache_ref: ObjectRef, }, #[snafu(display("failed to decode generated password"))] @@ -121,15 +113,9 @@ impl<'a> AdAdmin<'a> { ldap.sasl_gssapi_bind(ldap_server) .await .context(LdapAuthnSnafu)?; - let password_cache = CredentialCache::new( - "AD passwords", - kube, - password_cache_secret - .try_into() - .context(PasswordCacheReferenceInvalidSnafu)?, - ) - .await - .context(PasswordCacheSnafu)?; + let password_cache = CredentialCache::new("AD passwords", kube, password_cache_secret) + .await + .context(PasswordCacheSnafu)?; Ok(Self { ldap, krb, @@ -190,9 +176,6 @@ async fn get_ldap_ca_certificate( kube: &stackable_operator::client::Client, ca_secret_ref: SecretReference, ) -> Result { - let ca_secret_ref: FullSecretRef = ca_secret_ref - .try_into() - .context(LdapTlsCaReferenceInvalidSnafu)?; let ca_secret = kube .get::(&ca_secret_ref.name, &ca_secret_ref.namespace) .await @@ -241,7 +224,7 @@ async fn create_ad_user( password: &str, user_dn_base: &str, schema_dn_base: &str, - password_cache_ref: FullSecretRef, + password_cache_ref: SecretReference, ) -> Result<()> { // Flags are a subset of https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties const AD_UAC_NORMAL_ACCOUNT: u32 = 0x0200; diff --git a/rust/krb5-provision-keytab/src/credential_cache.rs b/rust/krb5-provision-keytab/src/credential_cache.rs index cdf9220b..f61413bb 100644 --- a/rust/krb5-provision-keytab/src/credential_cache.rs +++ b/rust/krb5-provision-keytab/src/credential_cache.rs @@ -1,28 +1,30 @@ use futures::{TryFuture, TryFutureExt}; use snafu::{OptionExt, ResultExt, Snafu}; -use stackable_operator::k8s_openapi::{api::core::v1::Secret, ByteString}; - -use crate::secret_ref::FullSecretRef; +use stackable_operator::{ + k8s_openapi::{api::core::v1::Secret, ByteString}, + kube::runtime::reflector::ObjectRef, +}; +use stackable_secret_operator_crd_utils::SecretReference; #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("failed to load initial cache from {cache_ref}"))] GetInitialCache { source: stackable_operator::error::Error, - cache_ref: FullSecretRef, + cache_ref: ObjectRef, }, #[snafu(display("failed to save credential {key} to {cache_ref}"))] SaveToCache { source: stackable_operator::error::Error, key: String, - cache_ref: FullSecretRef, + cache_ref: ObjectRef, }, #[snafu(display("newly saved credential {key} was not found in {cache_ref}"))] SavedKeyNotFound { key: String, - cache_ref: FullSecretRef, + cache_ref: ObjectRef, }, } type Result = std::result::Result; @@ -30,7 +32,7 @@ type Result = std::result::Result; pub struct CredentialCache { name: &'static str, kube: stackable_operator::client::Client, - cache_ref: FullSecretRef, + cache_ref: SecretReference, current_state: Secret, } impl CredentialCache { @@ -38,7 +40,7 @@ impl CredentialCache { pub async fn new( name: &'static str, kube: stackable_operator::client::Client, - cache_ref: FullSecretRef, + cache_ref: SecretReference, ) -> Result { Ok(Self { name, @@ -127,5 +129,5 @@ impl CredentialCache { /// Information that may be useful for generating error messages in get_or_insert handlers pub struct Ctx { - pub cache_ref: FullSecretRef, + pub cache_ref: SecretReference, } diff --git a/rust/krb5-provision-keytab/src/lib.rs b/rust/krb5-provision-keytab/src/lib.rs index fc6a119c..236e802a 100644 --- a/rust/krb5-provision-keytab/src/lib.rs +++ b/rust/krb5-provision-keytab/src/lib.rs @@ -1,4 +1,4 @@ -//! API wrapper for accessing +//! API wrapper for accessing krb5-provision-keytab binary use std::{ path::{Path, PathBuf}, @@ -7,7 +7,7 @@ use std::{ use serde::{Deserialize, Serialize}; use snafu::{ResultExt, Snafu}; -use stackable_operator::k8s_openapi::api::core::v1::SecretReference; +use stackable_secret_operator_crd_utils::SecretReference; use tokio::{io::AsyncWriteExt, process::Command}; #[derive(Serialize, Deserialize)] diff --git a/rust/krb5-provision-keytab/src/main.rs b/rust/krb5-provision-keytab/src/main.rs index 53960d08..02d1ddef 100644 --- a/rust/krb5-provision-keytab/src/main.rs +++ b/rust/krb5-provision-keytab/src/main.rs @@ -12,7 +12,6 @@ use tracing::info; mod active_directory; mod credential_cache; mod mit; -mod secret_ref; #[derive(Debug, Snafu)] enum Error { diff --git a/rust/krb5-provision-keytab/src/secret_ref.rs b/rust/krb5-provision-keytab/src/secret_ref.rs deleted file mode 100644 index 3dcc52f8..00000000 --- a/rust/krb5-provision-keytab/src/secret_ref.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::fmt::Display; - -use snafu::{OptionExt, Snafu}; -use stackable_operator::{ - k8s_openapi::api::core::v1::{Secret, SecretReference}, - kube::runtime::reflector::ObjectRef, -}; - -#[derive(Debug, Snafu)] -#[snafu(display("secret ref is missing {field}"))] -pub struct IncompleteSecretRef { - field: String, -} -#[derive(Debug, Clone)] -pub struct FullSecretRef { - pub name: String, - pub namespace: String, -} -impl TryFrom for FullSecretRef { - type Error = IncompleteSecretRef; - - fn try_from(secret_ref: SecretReference) -> Result { - Ok(Self { - name: secret_ref - .name - .context(IncompleteSecretRefSnafu { field: "name" })?, - namespace: secret_ref - .namespace - .context(IncompleteSecretRefSnafu { field: "namespace" })?, - }) - } -} -impl From<&FullSecretRef> for FullSecretRef { - fn from(pcr: &FullSecretRef) -> Self { - pcr.clone() - } -} -impl Display for FullSecretRef { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - ObjectRef::::new(&self.name) - .within(&self.namespace) - .fmt(f) - } -} diff --git a/rust/krb5-sys/src/lib.rs b/rust/krb5-sys/src/lib.rs index 1d8d9347..5a6b3814 100644 --- a/rust/krb5-sys/src/lib.rs +++ b/rust/krb5-sys/src/lib.rs @@ -1,4 +1,4 @@ -#![allow(non_upper_case_globals, non_camel_case_types)] +#![allow(non_upper_case_globals, non_camel_case_types, non_snake_case)] // krb5 docs are not written following the Rust conventions, // so some annotations are misinterpreted by rustdoc as links. #![allow(rustdoc::broken_intra_doc_links)] diff --git a/rust/operator-binary/Cargo.toml b/rust/operator-binary/Cargo.toml index 6d33e3d0..026dd2e0 100644 --- a/rust/operator-binary/Cargo.toml +++ b/rust/operator-binary/Cargo.toml @@ -10,6 +10,7 @@ publish = false [dependencies] stackable-krb5-provision-keytab = { path = "../krb5-provision-keytab" } +stackable-secret-operator-crd-utils = { path = "../crd-utils" } anyhow.workspace = true async-trait.workspace = true diff --git a/rust/operator-binary/src/backend/kerberos_keytab.rs b/rust/operator-binary/src/backend/kerberos_keytab.rs index e4986e1d..082adb16 100644 --- a/rust/operator-binary/src/backend/kerberos_keytab.rs +++ b/rust/operator-binary/src/backend/kerberos_keytab.rs @@ -1,7 +1,8 @@ use async_trait::async_trait; use snafu::{OptionExt, ResultExt, Snafu}; use stackable_krb5_provision_keytab::provision_keytab; -use stackable_operator::k8s_openapi::api::core::v1::{Secret, SecretReference}; +use stackable_operator::{k8s_openapi::api::core::v1::Secret, kube::runtime::reflector::ObjectRef}; +use stackable_secret_operator_crd_utils::SecretReference; use tempfile::tempdir; use tokio::{ fs::File, @@ -20,23 +21,20 @@ use super::{ #[derive(Debug, Snafu)] pub enum Error { - #[snafu(display("invalid secret reference: {secret:?}"))] - InvalidSecretRef { secret: SecretReference }, - #[snafu(display("failed to get addresses for scope {scope}"))] ScopeAddresses { source: ScopeAddressesError, scope: SecretScope, }, - #[snafu(display("failed to load admin keytab {secret:?}"))] + #[snafu(display("failed to load admin keytab from {secret}"))] LoadAdminKeytab { source: stackable_operator::error::Error, - secret: SecretReference, + secret: ObjectRef, }, - #[snafu(display(r#"admin keytab {secret:?} does not contain key "keytab""#))] - NoAdminKeytabKeyInSecret { secret: SecretReference }, + #[snafu(display(r#"admin keytab {secret} does not contain key "keytab""#))] + NoAdminKeytabKeyInSecret { secret: ObjectRef }, #[snafu(display("failed to create temp dir"))] TempSetup { source: std::io::Error }, @@ -61,7 +59,6 @@ pub enum Error { impl SecretBackendError for Error { fn grpc_code(&self) -> tonic::Code { match self { - Error::InvalidSecretRef { .. } => tonic::Code::FailedPrecondition, Error::LoadAdminKeytab { .. } => tonic::Code::FailedPrecondition, Error::NoAdminKeytabKeyInSecret { .. } => tonic::Code::FailedPrecondition, Error::TempSetup { .. } => tonic::Code::Unavailable, @@ -94,20 +91,11 @@ impl KerberosKeytab { admin_keytab_secret_ref: &SecretReference, admin_principal: KerberosPrincipal, ) -> Result { - let (keytab_secret_name, keytab_secret_ns) = match admin_keytab_secret_ref { - SecretReference { - name: Some(name), - namespace: Some(ns), - } => (name, ns), - _ => { - return InvalidSecretRefSnafu { - secret: admin_keytab_secret_ref.clone(), - } - .fail() - } - }; let admin_keytab_secret = client - .get::(keytab_secret_name, keytab_secret_ns) + .get::( + &admin_keytab_secret_ref.name, + &admin_keytab_secret_ref.namespace, + ) .await .context(LoadAdminKeytabSnafu { secret: admin_keytab_secret_ref.clone(), diff --git a/rust/operator-binary/src/backend/tls/ca.rs b/rust/operator-binary/src/backend/tls/ca.rs new file mode 100644 index 00000000..f1a69467 --- /dev/null +++ b/rust/operator-binary/src/backend/tls/ca.rs @@ -0,0 +1,460 @@ +//! Dynamically provisions and picks Certificate Authorities. + +use std::{collections::BTreeMap, fmt::Display}; + +use openssl::{ + asn1::{Asn1Integer, Asn1Time}, + bn::{BigNum, MsbOption}, + conf::{Conf, ConfMethod}, + hash::MessageDigest, + nid::Nid, + pkey::{PKey, Private}, + rsa::Rsa, + x509::{ + extension::{AuthorityKeyIdentifier, BasicConstraints, KeyUsage, SubjectKeyIdentifier}, + X509Builder, X509NameBuilder, X509, + }, +}; +use snafu::{OptionExt, ResultExt, Snafu}; +use stackable_operator::{ + k8s_openapi::{api::core::v1::Secret, ByteString}, + kube::{ + self, + api::{ + entry::{self, Entry}, + PostParams, + }, + runtime::reflector::ObjectRef, + }, + time::Duration, +}; +use stackable_secret_operator_crd_utils::SecretReference; +use time::OffsetDateTime; +use tracing::{info, info_span, warn}; + +use crate::{ + backend::SecretBackendError, + utils::{asn1time_to_offsetdatetime, Asn1TimeParseError}, +}; + +/// v1 format: support a single cert/pkey pair +mod secret_v1_keys { + pub const CERTIFICATE: &str = "ca.crt"; + pub const PRIVATE_KEY: &str = "ca.key"; +} + +/// v2 format: support multiple cert/pkey pairs, prefixed by `{i}.` +mod secret_v2_key_suffixes { + pub const CERTIFICATE: &str = ".ca.crt"; + pub const PRIVATE_KEY: &str = ".ca.key"; +} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("failed to generate certificate key"))] + GenerateKey { source: openssl::error::ErrorStack }, + + #[snafu(display("failed to load CA {secret}"))] + FindCa { + source: kube::Error, + secret: ObjectRef, + }, + + #[snafu(display("CA {secret} does not exist, and autoGenerate is false"))] + CaNotFoundAndGenDisabled { secret: ObjectRef }, + + #[snafu(display("CA {secret} is missing required key {key:?}"))] + MissingCertificate { + key: String, + secret: ObjectRef, + }, + + #[snafu(display("failed to load certificate from key {key:?} of {secret}"))] + LoadCertificate { + source: openssl::error::ErrorStack, + key: String, + secret: ObjectRef, + }, + + #[snafu(display("failed to parse CA lifetime from key {key:?} of {secret}"))] + ParseLifetime { + source: Asn1TimeParseError, + key: String, + secret: ObjectRef, + }, + + #[snafu(display("failed to build certificate"))] + BuildCertificate { source: openssl::error::ErrorStack }, + + #[snafu(display("failed to serialize certificate"))] + SerializeCertificate { source: openssl::error::ErrorStack }, + + #[snafu(display("failed to save CA certificate to {secret}"))] + SaveCaCertificate { + source: entry::CommitError, + secret: ObjectRef, + }, + + #[snafu(display("CA save was requested but automatic management is disabled"))] + SaveRequestedButForbidden, +} +type Result = std::result::Result; + +impl SecretBackendError for Error { + fn grpc_code(&self) -> tonic::Code { + match self { + Error::GenerateKey { .. } => tonic::Code::Internal, + Error::MissingCertificate { .. } => tonic::Code::FailedPrecondition, + Error::FindCa { .. } => tonic::Code::Unavailable, + Error::CaNotFoundAndGenDisabled { .. } => tonic::Code::FailedPrecondition, + Error::LoadCertificate { .. } => tonic::Code::FailedPrecondition, + Error::ParseLifetime { .. } => tonic::Code::FailedPrecondition, + Error::BuildCertificate { .. } => tonic::Code::FailedPrecondition, + Error::SerializeCertificate { .. } => tonic::Code::FailedPrecondition, + Error::SaveCaCertificate { .. } => tonic::Code::Unavailable, + Error::SaveRequestedButForbidden { .. } => tonic::Code::FailedPrecondition, + } + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum GetCaError { + #[snafu(display("No CA will live until at least {cutoff}"))] + NoCaLivesLongEnough { cutoff: OffsetDateTime }, +} + +impl SecretBackendError for GetCaError { + fn grpc_code(&self) -> tonic::Code { + match self { + GetCaError::NoCaLivesLongEnough { .. } => tonic::Code::FailedPrecondition, + } + } +} + +#[derive(Debug)] +pub struct Config { + /// Whether [`Manager`] is allowed to automatically provision and manage this CA. + /// + /// If `false`, logs will be emitted where Secret Operator would have taken action. + pub manage_ca: bool, + + /// The duration of any new CA certificates provisioned. + pub ca_lifetime: Duration, + + /// If no existing CA certificate outlives `rotate_if_ca_expires_before`, a new + /// certificate will be generated. + /// + /// To ensure compatibility with pods that have already been started, the old CA + /// will still be used as long as the provisioned certificate's lifetime fits + /// inside the old CA's. This allows the new CA to be gradually introduced to all + /// pods' truststores. + /// + /// Hence, this value _should_ be larger than the PKI's maximum certificate lifetime, + /// and smaller than [`Self::ca_lifetime`]. + pub rotate_if_ca_expires_before: Option, +} + +/// A single certificate authority certificate. +pub struct CertificateAuthority { + pub certificate: X509, + pub private_key: PKey, + not_after: OffsetDateTime, +} + +impl Display for CertificateAuthority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("CertificateAuthority(serial=")?; + match self.certificate.serial_number().to_bn() { + Ok(sn) => write!(f, "{}", sn)?, + Err(_) => f.write_str("")?, + } + f.write_str(")") + } +} + +impl CertificateAuthority { + /// Generate a new self-signed CA with a random key. + fn new_self_signed(config: &Config) -> Result { + let subject_name = X509NameBuilder::new() + .and_then(|mut name| { + name.append_entry_by_nid(Nid::COMMONNAME, "secret-operator self-signed")?; + Ok(name) + }) + .context(BuildCertificateSnafu)? + .build(); + let now = OffsetDateTime::now_utc(); + let not_before = now - Duration::from_minutes_unchecked(5); + let not_after = now + config.ca_lifetime; + let conf = Conf::new(ConfMethod::default()).unwrap(); + let private_key = Rsa::generate(2048) + .and_then(PKey::try_from) + .context(GenerateKeySnafu)?; + let certificate = X509Builder::new() + .and_then(|mut x509| { + x509.set_subject_name(&subject_name)?; + x509.set_issuer_name(&subject_name)?; + x509.set_not_before(Asn1Time::from_unix(not_before.unix_timestamp())?.as_ref())?; + x509.set_not_after(Asn1Time::from_unix(not_after.unix_timestamp())?.as_ref())?; + x509.set_pubkey(&private_key)?; + let mut serial = BigNum::new()?; + serial.rand(64, MsbOption::MAYBE_ZERO, false)?; + x509.set_serial_number(Asn1Integer::from_bn(&serial)?.as_ref())?; + x509.set_version( + 3 - 1, // zero-indexed + )?; + let ctx = x509.x509v3_context(None, Some(&conf)); + let exts = [ + BasicConstraints::new().critical().ca().build()?, + SubjectKeyIdentifier::new().build(&ctx)?, + AuthorityKeyIdentifier::new() + .issuer(false) + .keyid(false) + .build(&ctx)?, + KeyUsage::new() + .critical() + .digital_signature() + .key_cert_sign() + .crl_sign() + .build()?, + ]; + for ext in exts { + x509.append_extension(ext)?; + } + x509.sign(&private_key, MessageDigest::sha256())?; + Ok(x509) + }) + .context(BuildCertificateSnafu)? + .build(); + Ok(Self { + private_key, + certificate, + not_after, + }) + } + + /// Loads an existing CA from the data of a [`Secret`]. + fn from_secret_data( + secret_data: &BTreeMap, + secret_ref: &SecretReference, + key_certificate: &str, + key_private_key: &str, + ) -> Result { + let certificate = X509::from_pem( + &secret_data + .get(key_certificate) + .context(MissingCertificateSnafu { + key: key_certificate, + secret: secret_ref, + })? + .0, + ) + .with_context(|_| LoadCertificateSnafu { + key: key_certificate, + secret: secret_ref, + })?; + let private_key = PKey::private_key_from_pem( + &secret_data + .get(key_private_key) + .context(MissingCertificateSnafu { + key: key_private_key, + secret: secret_ref, + })? + .0, + ) + .with_context(|_| LoadCertificateSnafu { + key: key_private_key, + secret: secret_ref, + })?; + Ok(CertificateAuthority { + not_after: asn1time_to_offsetdatetime(certificate.not_after()).with_context(|_| { + ParseLifetimeSnafu { + key: key_certificate, + secret: secret_ref, + } + })?, + certificate, + private_key, + }) + } +} + +/// Manages multiple [`CertificateAuthorities`](`CertificateAuthority`), rotating them as needed. +pub struct Manager { + certificate_authorities: Vec, +} + +impl Manager { + pub async fn load_or_create( + client: &stackable_operator::client::Client, + secret_ref: &SecretReference, + config: &Config, + ) -> Result { + // Use entry API rather than apply so that we crash and retry on conflicts (to avoid creating spurious certs that we throw away immediately) + let secrets_api = &client.get_api::(&secret_ref.namespace); + let ca_secret = secrets_api + .entry(&secret_ref.name) + .await + .with_context(|_| FindCaSnafu { secret: secret_ref })?; + let mut update_ca_secret = false; + let mut certificate_authorities = match &ca_secret { + Entry::Occupied(ca_secret) => { + // Existing CA has been found, load and use this + let empty = BTreeMap::new(); + let ca_data = ca_secret.get().data.as_ref().unwrap_or(&empty); + if ca_data.contains_key(secret_v1_keys::CERTIFICATE) { + if config.manage_ca { + update_ca_secret = true; + info!( + secret = %secret_ref, + "Migrating CA secret from legacy naming scheme" + ); + } else { + warn!( + secret = %secret_ref, + "CA secret uses legacy certificate naming ({v1}), please rename to 0{v2}", + v1 = secret_v1_keys::CERTIFICATE, + v2 = secret_v2_key_suffixes::CERTIFICATE, + ); + } + vec![CertificateAuthority::from_secret_data( + ca_data, + secret_ref, + secret_v1_keys::CERTIFICATE, + secret_v1_keys::PRIVATE_KEY, + )?] + } else { + ca_data + .keys() + .filter_map(|cert_key| { + Some(CertificateAuthority::from_secret_data( + ca_data, + secret_ref, + cert_key, + &cert_key + .ends_with(secret_v2_key_suffixes::CERTIFICATE) + .then(|| { + cert_key.replace( + secret_v2_key_suffixes::CERTIFICATE, + secret_v2_key_suffixes::PRIVATE_KEY, + ) + })?, + )) + }) + .collect::>()? + } + } + Entry::Vacant(_) if config.manage_ca => { + update_ca_secret = true; + let ca = CertificateAuthority::new_self_signed(config)?; + info!( + secret = %secret_ref, + %ca, + %ca.not_after, + "Provisioning a new CA certificate, because it could not be found" + ); + vec![ca] + } + Entry::Vacant(_) => { + return CaNotFoundAndGenDisabledSnafu { secret: secret_ref }.fail(); + } + }; + // Check whether CA should be rotated + let newest_ca = certificate_authorities.iter().max_by_key(|ca| ca.not_after); + if let (Some(cutoff_duration), Some(newest_ca)) = + (config.rotate_if_ca_expires_before, newest_ca) + { + let cutoff = OffsetDateTime::now_utc() + cutoff_duration; + let _span = info_span!( + "ca_rotation", + secret = %secret_ref, + %cutoff, + cutoff.duration = %cutoff_duration, + %newest_ca, + %newest_ca.not_after, + ) + .entered(); + if newest_ca.not_after < cutoff { + if config.manage_ca { + update_ca_secret = true; + info!( + "Provisioning a new CA certificate, because the old one will soon expire" + ); + certificate_authorities.push(CertificateAuthority::new_self_signed(config)?); + } else { + warn!("CA certificate will soon expire, please provision a new one"); + } + } else { + info!("CA is not close to expiring, will not initiate rotation"); + } + } + if update_ca_secret { + if config.manage_ca { + info!(secret = %secret_ref, "CA has been modified, saving"); + // Sort CAs by age to avoid spurious writes + certificate_authorities.sort_by_key(|ca| ca.not_after); + let mut ca_secret = ca_secret.or_insert(Secret::default); + ca_secret.get_mut().data = Some( + certificate_authorities + .iter() + .enumerate() + .flat_map(|(i, ca)| { + [ + ca.certificate + .to_pem() + .context(SerializeCertificateSnafu) + .map(|cert| { + ( + format!("{i}{}", secret_v2_key_suffixes::CERTIFICATE), + ByteString(cert), + ) + }), + ca.private_key + .private_key_to_pem_pkcs8() + .context(SerializeCertificateSnafu) + .map(|key| { + ( + format!("{i}{}", secret_v2_key_suffixes::PRIVATE_KEY), + ByteString(key), + ) + }), + ] + }) + .collect::>()?, + ); + ca_secret + .commit(&PostParams::default()) + .await + .context(SaveCaCertificateSnafu { secret: secret_ref })?; + } else { + return SaveRequestedButForbiddenSnafu.fail(); + } + } + Ok(Self { + certificate_authorities, + }) + } + + /// Get an appropriate [`CertificateAuthority`] for signing a given certificate. + pub fn find_certificate_authority_for_signing( + &self, + valid_until_at_least: OffsetDateTime, + ) -> Result<&CertificateAuthority, GetCaError> { + use get_ca_error::*; + self.certificate_authorities + .iter() + .filter(|ca| ca.not_after > valid_until_at_least) + // pick the oldest valid CA, since it will be trusted by the most peers + .min_by_key(|ca| ca.not_after) + .context(NoCaLivesLongEnoughSnafu { + cutoff: valid_until_at_least, + }) + } + + /// Get all active trust root certificates. + pub fn trust_roots(&self) -> impl IntoIterator + '_ { + self.certificate_authorities + .iter() + .map(|ca| &ca.certificate) + } +} diff --git a/rust/operator-binary/src/backend/tls.rs b/rust/operator-binary/src/backend/tls/mod.rs similarity index 55% rename from rust/operator-binary/src/backend/tls.rs rename to rust/operator-binary/src/backend/tls/mod.rs index e9aaf4d1..a42ac92b 100644 --- a/rust/operator-binary/src/backend/tls.rs +++ b/rust/operator-binary/src/backend/tls/mod.rs @@ -9,30 +9,28 @@ use openssl::{ conf::{Conf, ConfMethod}, hash::MessageDigest, nid::Nid, - pkey::{PKey, Private}, + pkey::PKey, rsa::Rsa, x509::{ extension::{ AuthorityKeyIdentifier, BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAlternativeName, SubjectKeyIdentifier, }, - X509Builder, X509NameBuilder, X509, + X509Builder, X509NameBuilder, }, }; use snafu::{OptionExt, ResultExt, Snafu}; use stackable_operator::{ - builder::ObjectMetaBuilder, - k8s_openapi::{ - api::core::v1::{Secret, SecretReference}, - chrono::{self, FixedOffset, TimeZone}, - ByteString, - }, - kube::runtime::reflector::ObjectRef, + k8s_openapi::chrono::{self, FixedOffset, TimeZone}, time::Duration, }; +use stackable_secret_operator_crd_utils::SecretReference; use time::OffsetDateTime; -use crate::format::{well_known, SecretData, WellKnownSecretData}; +use crate::{ + format::{well_known, SecretData, WellKnownSecretData}, + utils::iterator_try_concat_bytes, +}; use super::{ pod_info::{Address, PodInfo}, @@ -40,6 +38,8 @@ use super::{ ScopeAddressesError, SecretBackend, SecretBackendError, SecretContents, }; +mod ca; + /// As the Pods will be evicted [`DEFAULT_CERT_RESTART_BUFFER`] before /// the cert actually expires, this results in a restart in approx every 2 weeks, /// which matches the rolling re-deploy of k8s nodes of e.g.: @@ -69,29 +69,14 @@ pub enum Error { #[snafu(display("failed to generate certificate key"))] GenerateKey { source: openssl::error::ErrorStack }, - #[snafu(display("could not find CA {secret}, and autoGenerate is false"))] - FindCaAndGenDisabled { - source: stackable_operator::error::Error, - secret: ObjectRef, - }, - - #[snafu(display("CA secret is missing required certificate file"))] - MissingCaCertificate, - - #[snafu(display("failed to load {tpe:?} certificate"))] - LoadCertificate { - source: openssl::error::ErrorStack, - tpe: CertType, - }, + #[snafu(display("failed to load CA"))] + LoadCa { source: ca::Error }, - #[snafu(display("invalid secret reference: {secret:?}"))] - InvalidSecretRef { secret: SecretReference }, + #[snafu(display("failed to pick a CA"))] + PickCa { source: ca::GetCaError }, - #[snafu(display("failed to build {tpe:?} certificate"))] - BuildCertificate { - source: openssl::error::ErrorStack, - tpe: CertType, - }, + #[snafu(display("failed to build certificate"))] + BuildCertificate { source: openssl::error::ErrorStack }, #[snafu(display("failed to serialize {tpe:?} certificate"))] SerializeCertificate { @@ -99,12 +84,6 @@ pub enum Error { tpe: CertType, }, - #[snafu(display("failed to save CA certificate to {secret}"))] - SaveCaCertificate { - source: stackable_operator::error::Error, - secret: ObjectRef, - }, - #[snafu(display("invalid certificate lifetime"))] InvalidCertLifetime { source: DateTimeOutOfBoundsError }, @@ -127,13 +106,10 @@ impl SecretBackendError for Error { match self { Error::ScopeAddresses { .. } => tonic::Code::Unavailable, Error::GenerateKey { .. } => tonic::Code::Internal, - Error::FindCaAndGenDisabled { .. } => tonic::Code::FailedPrecondition, - Error::MissingCaCertificate { .. } => tonic::Code::FailedPrecondition, - Error::LoadCertificate { .. } => tonic::Code::FailedPrecondition, - Error::InvalidSecretRef { .. } => tonic::Code::FailedPrecondition, + Error::LoadCa { source } => source.grpc_code(), + Error::PickCa { source } => source.grpc_code(), Error::BuildCertificate { .. } => tonic::Code::FailedPrecondition, Error::SerializeCertificate { .. } => tonic::Code::FailedPrecondition, - Error::SaveCaCertificate { .. } => tonic::Code::Unavailable, Error::InvalidCertLifetime { .. } => tonic::Code::Internal, Error::TooShortCertLifetimeRequiresTimeTravel { .. } => tonic::Code::InvalidArgument, } @@ -141,151 +117,36 @@ impl SecretBackendError for Error { } pub struct TlsGenerate { - ca_cert: X509, - ca_key: PKey, + ca_manager: ca::Manager, max_cert_lifetime: Duration, } impl TlsGenerate { - pub fn new_self_signed(max_cert_lifetime: Duration) -> Result { - let subject_name = X509NameBuilder::new() - .and_then(|mut name| { - name.append_entry_by_nid(Nid::COMMONNAME, "secret-operator self-signed")?; - Ok(name) - }) - .context(BuildCertificateSnafu { tpe: CertType::Ca })? - .build(); - let now = OffsetDateTime::now_utc(); - let not_before = now - Duration::from_minutes_unchecked(5); - let not_after = now + Duration::from_days_unchecked(2 * 365); - let conf = Conf::new(ConfMethod::default()).unwrap(); - let ca_key = Rsa::generate(2048) - .and_then(PKey::try_from) - .context(GenerateKeySnafu)?; - let ca_cert = X509Builder::new() - .and_then(|mut x509| { - x509.set_subject_name(&subject_name)?; - x509.set_issuer_name(&subject_name)?; - x509.set_not_before(Asn1Time::from_unix(not_before.unix_timestamp())?.as_ref())?; - x509.set_not_after(Asn1Time::from_unix(not_after.unix_timestamp())?.as_ref())?; - x509.set_pubkey(&ca_key)?; - let mut serial = BigNum::new()?; - serial.rand(64, MsbOption::MAYBE_ZERO, false)?; - x509.set_serial_number(Asn1Integer::from_bn(&serial)?.as_ref())?; - x509.set_version( - 3 - 1, // zero-indexed - )?; - let ctx = x509.x509v3_context(None, Some(&conf)); - let exts = [ - BasicConstraints::new().critical().ca().build()?, - SubjectKeyIdentifier::new().build(&ctx)?, - AuthorityKeyIdentifier::new() - .issuer(false) - .keyid(false) - .build(&ctx)?, - KeyUsage::new() - .critical() - .digital_signature() - .key_cert_sign() - .crl_sign() - .build()?, - ]; - for ext in exts { - x509.append_extension(ext)?; - } - x509.sign(&ca_key, MessageDigest::sha256())?; - Ok(x509) - }) - .context(BuildCertificateSnafu { tpe: CertType::Ca })? - .build(); - Ok(Self { - ca_key, - ca_cert, - max_cert_lifetime, - }) - } - /// Check if a signing CA has already been instantiated in a specified Kubernetes secret - if /// one is found the key is loaded and used for signing certs. /// If no current authority can be found, a new keypair and self signed certificate is created /// and stored for future use. - /// This allows users to provide their own CA files, but also enables using this for dev and test - /// scenarios where self signed, ephemeral CAs are ok to use. + /// This allows users to provide their own CA files, but also enables secret-operator to generate + /// an independent self-signed CA. pub async fn get_or_create_k8s_certificate( client: &stackable_operator::client::Client, secret_ref: &SecretReference, - auto_generate_if_missing: bool, + auto_generate: bool, max_cert_lifetime: Duration, ) -> Result { - let (k8s_secret_name, k8s_ns) = match secret_ref { - SecretReference { - name: Some(name), - namespace: Some(ns), - } => (name, ns), - _ => { - return InvalidSecretRefSnafu { - secret: secret_ref.clone(), - } - .fail() - } - }; - let existing_secret = client.get::(k8s_secret_name, k8s_ns).await; - Ok(match existing_secret { - Ok(ca_secret) => { - // Existing CA has been found, load and use this - let ca_data = ca_secret.data.unwrap_or_default(); - Self { - ca_key: PKey::private_key_from_pem( - &ca_data.get("ca.key").context(MissingCaCertificateSnafu)?.0, - ) - .context(LoadCertificateSnafu { tpe: CertType::Ca })?, - ca_cert: X509::from_pem( - &ca_data.get("ca.crt").context(MissingCaCertificateSnafu)?.0, - ) - .context(LoadCertificateSnafu { tpe: CertType::Ca })?, - max_cert_lifetime, - } - } - Err(_) if auto_generate_if_missing => { - // Failed to get existing cert, try to create a new self-signed one - let ca = Self::new_self_signed(max_cert_lifetime)?; - // Use create rather than apply so that we crash and retry on conflicts (to avoid creating spurious certs that we throw away immediately) - client - .create(&Secret { - metadata: ObjectMetaBuilder::new() - .namespace(k8s_ns) - .name(k8s_secret_name) - .build(), - data: Some( - [ - ( - "ca.key".to_string(), - ByteString(ca.ca_key.private_key_to_pem_pkcs8().context( - SerializeCertificateSnafu { tpe: CertType::Ca }, - )?), - ), - ( - "ca.crt".to_string(), - ByteString(ca.ca_cert.to_pem().context( - SerializeCertificateSnafu { tpe: CertType::Ca }, - )?), - ), - ] - .into(), - ), - ..Secret::default() - }) - .await - .context(SaveCaCertificateSnafu { - secret: ObjectRef::new(k8s_secret_name).within(k8s_ns), - })?; - ca - } - Err(err) => { - return Err(err).context(FindCaAndGenDisabledSnafu { - secret: ObjectRef::new(k8s_secret_name).within(k8s_ns), - }); - } + Ok(Self { + ca_manager: ca::Manager::load_or_create( + client, + secret_ref, + &ca::Config { + manage_ca: auto_generate, + ca_lifetime: Duration::from_days_unchecked(2 * 365), + rotate_if_ca_expires_before: Some(Duration::from_days_unchecked(365)), + }, + ) + .await + .context(LoadCaSnafu)?, + max_cert_lifetime, }) } } @@ -333,6 +194,10 @@ impl SecretBackend for TlsGenerate { .context(ScopeAddressesSnafu { scope })?, ); } + let ca = self + .ca_manager + .find_certificate_authority_for_signing(not_after) + .context(PickCaSnafu)?; let pod_cert = X509Builder::new() .and_then(|mut x509| { let subject_name = X509NameBuilder::new() @@ -342,7 +207,7 @@ impl SecretBackend for TlsGenerate { })? .build(); x509.set_subject_name(&subject_name)?; - x509.set_issuer_name(self.ca_cert.issuer_name())?; + x509.set_issuer_name(ca.certificate.issuer_name())?; x509.set_not_before(Asn1Time::from_unix(not_before.unix_timestamp())?.as_ref())?; x509.set_not_after(Asn1Time::from_unix(not_after.unix_timestamp())?.as_ref())?; x509.set_pubkey(&pod_key)?; @@ -352,7 +217,7 @@ impl SecretBackend for TlsGenerate { let mut serial = BigNum::new()?; serial.rand(64, MsbOption::MAYBE_ZERO, false)?; x509.set_serial_number(Asn1Integer::from_bn(&serial)?.as_ref())?; - let ctx = x509.x509v3_context(Some(&self.ca_cert), Some(&conf)); + let ctx = x509.x509v3_context(Some(&ca.certificate), Some(&conf)); let mut exts = vec![ BasicConstraints::new().critical().build()?, KeyUsage::new() @@ -385,18 +250,20 @@ impl SecretBackend for TlsGenerate { for ext in exts { x509.append_extension(ext)?; } - x509.sign(&self.ca_key, MessageDigest::sha256())?; + x509.sign(&ca.private_key, MessageDigest::sha256())?; Ok(x509) }) - .context(BuildCertificateSnafu { tpe: CertType::Pod })? + .context(BuildCertificateSnafu)? .build(); Ok( SecretContents::new(SecretData::WellKnown(WellKnownSecretData::TlsPem( well_known::TlsPem { - ca_pem: self - .ca_cert - .to_pem() - .context(SerializeCertificateSnafu { tpe: CertType::Pod })?, + ca_pem: iterator_try_concat_bytes( + self.ca_manager.trust_roots().into_iter().map(|ca| { + ca.to_pem() + .context(SerializeCertificateSnafu { tpe: CertType::Ca }) + }), + )?, certificate_pem: pod_cert .to_pem() .context(SerializeCertificateSnafu { tpe: CertType::Pod })?, diff --git a/rust/operator-binary/src/crd.rs b/rust/operator-binary/src/crd.rs index 0475610e..20b0ea91 100644 --- a/rust/operator-binary/src/crd.rs +++ b/rust/operator-binary/src/crd.rs @@ -3,11 +3,11 @@ use std::{fmt::Display, ops::Deref}; use serde::{Deserialize, Serialize}; use snafu::Snafu; use stackable_operator::{ - k8s_openapi::api::core::v1::SecretReference, kube::CustomResource, schemars::{self, JsonSchema}, time::Duration, }; +use stackable_secret_operator_crd_utils::SecretReference; use crate::backend::tls::DEFAULT_MAX_CERT_LIFETIME; @@ -97,7 +97,9 @@ pub struct AutoTlsCa { /// and key is stored in the keys `ca.crt` and `ca.key` respectively. pub secret: SecretReference, - /// Whether a new certificate authority should be generated if it does not already exist. + /// Whether the certificate authority should be managed by Secret Operator, including being generated + /// if it does not already exist. + // TODO: Consider renaming to `manage` for v1alpha2 #[serde(default)] pub auto_generate: bool, } @@ -282,8 +284,8 @@ mod test { backend: crate::crd::SecretClassBackend::AutoTls(AutoTlsBackend { ca: crate::crd::AutoTlsCa { secret: SecretReference { - name: Some("secret-provisioner-tls-ca".to_string()), - namespace: Some("default".to_string()), + name: "secret-provisioner-tls-ca".to_string(), + namespace: "default".to_string(), }, auto_generate: false, }, @@ -316,8 +318,8 @@ mod test { backend: crate::crd::SecretClassBackend::AutoTls(AutoTlsBackend { ca: crate::crd::AutoTlsCa { secret: SecretReference { - name: Some("secret-provisioner-tls-ca".to_string()), - namespace: Some("default".to_string()), + name: "secret-provisioner-tls-ca".to_string(), + namespace: "default".to_string(), }, auto_generate: true, }, diff --git a/rust/operator-binary/src/utils.rs b/rust/operator-binary/src/utils.rs index b3d46f7c..e5445b62 100644 --- a/rust/operator-binary/src/utils.rs +++ b/rust/operator-binary/src/utils.rs @@ -1,9 +1,12 @@ use std::{fmt::LowerHex, os::unix::prelude::AsRawFd, path::Path}; use futures::{pin_mut, Stream, StreamExt}; +use openssl::asn1::{Asn1Time, Asn1TimeRef, TimeDiff}; use pin_project::pin_project; +use snafu::{OptionExt as _, ResultExt as _, Snafu}; use socket2::Socket; use std::fmt::Write as _; // import without risk of name clashing +use time::OffsetDateTime; use tokio::{ io::{AsyncRead, AsyncWrite}, net::{UnixListener, UnixStream}, @@ -124,12 +127,62 @@ pub async fn trystream_any>, E>(stream: S) -> R Ok(false) } +/// Concatenate chunks of bytes, short-circuiting on [`Err`]. +/// +/// This is a byte-oriented equivalent to [`Iterator::collect::>`](`Iterator::collect`). +pub fn iterator_try_concat_bytes(iter: I1) -> Result, E> +where + I1: IntoIterator>, + I2: IntoIterator, +{ + let mut buffer = Vec::new(); + for chunk in iter { + buffer.extend(chunk?) + } + Ok(buffer) +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum Asn1TimeParseError { + #[snafu(display("unix epoch is not a valid Asn1Time"))] + Epoch { source: openssl::error::ErrorStack }, + + #[snafu(display("unable to diff Asn1Time"))] + Diff { source: openssl::error::ErrorStack }, + + #[snafu(display("unable to parse as OffsetDateTime"))] + Parse { source: time::error::ComponentRange }, + + #[snafu(display("time overflowed"))] + Overflow, +} + +/// Converts an OpenSSL [`Asn1TimeRef`] into a Rustier [`OffsetDateTime`]. +pub fn asn1time_to_offsetdatetime(asn: &Asn1TimeRef) -> Result { + use asn1_time_parse_error::*; + const SECS_PER_DAY: i64 = 60 * 60 * 24; + let epoch = Asn1Time::from_unix(0).context(EpochSnafu)?; + let TimeDiff { days, secs } = epoch.diff(asn).context(DiffSnafu)?; + OffsetDateTime::from_unix_timestamp( + i64::from(days) + .checked_mul(SECS_PER_DAY) + .and_then(|day_secs| day_secs.checked_add(i64::from(secs))) + .context(OverflowSnafu)?, + ) + .context(ParseSnafu) +} + #[cfg(test)] mod tests { use futures::StreamExt; + use openssl::asn1::Asn1Time; + use time::OffsetDateTime; use crate::utils::{error_full_message, trystream_any, FmtByteSlice}; + use super::{asn1time_to_offsetdatetime, iterator_try_concat_bytes}; + #[test] fn fmt_hex_byte_slice() { assert_eq!(format!("{:x}", FmtByteSlice(&[1, 2, 255, 128])), "0102ff80"); @@ -184,4 +237,35 @@ mod tests { Result::<_, ()>::Err(()) ); } + + #[test] + fn iterator_try_concat_bytes_should_work() { + assert_eq!( + iterator_try_concat_bytes([Result::<_, ()>::Ok(vec![0, 1]), Ok(vec![2])]), + Ok(vec![0, 1, 2]) + ); + assert_eq!( + iterator_try_concat_bytes([Ok(vec![0, 1]), Err(())]), + Err(()) + ); + assert_eq!(iterator_try_concat_bytes([Err(()), Ok(vec![2])]), Err(())); + assert_eq!(iterator_try_concat_bytes::<_, Vec<_>, ()>([]), Ok(vec![])); + } + + #[test] + fn asn1time_to_offsetdatetime_should_work() { + assert_eq!( + asn1time_to_offsetdatetime( + // Asn1Time uses a custom time format (https://www.openssl.org/docs/man3.2/man3/ASN1_TIME_set.html) + // that is _roughly_ "ISO8601-1 without separator characters" + &Asn1Time::from_str("20240102020304Z").unwrap() + ) + .unwrap(), + OffsetDateTime::parse( + "2024-01-02T02:03:04Z", + &time::format_description::well_known::Iso8601::DEFAULT + ) + .unwrap() + ); + } }