Skip to content

Commit 7ec0514

Browse files
feat: Add retirement duration for CA certificates
1 parent 164780d commit 7ec0514

8 files changed

Lines changed: 331 additions & 23 deletions

File tree

Cargo.nix

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ socket2 = { version = "0.6", features = ["all"] }
4040
strum = { version = "0.27", features = ["derive"] }
4141
sys-mount = { version = "3.0", default-features = false }
4242
tempfile = "3.12"
43-
time = { version = "0.3", features = ["parsing"] }
43+
time = { version = "0.3", features = ["macros", "parsing"] }
4444
tokio = { version = "1.40", features = ["full"] }
4545
tokio-stream = { version = "0.1", features = ["net"] }
4646
tonic = "0.14"

deploy/helm/secret-operator/crds/crds.yaml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ spec:
9696
9797
If `autoGenerate: true` then the Secret Operator will prepare a new CA certificate the old CA approaches expiration. If `autoGenerate: false` then the Secret Operator will log a warning instead.
9898
type: string
99+
caCertificateRetirementDuration:
100+
default: 1h
101+
description: |-
102+
Duration at the end of the CA certificate lifetime where no signed certificate will exist.
103+
104+
Retired (or expired) CA certificates will not be published.
105+
type: string
99106
keyGeneration:
100107
default:
101108
rsa:
@@ -136,7 +143,12 @@ spec:
136143
type: object
137144
maxCertificateLifetime:
138145
default: 15d
139-
description: 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, so this setting takes precedence. The default value is 15 days.
146+
description: |-
147+
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, so this setting takes precedence. The default value is 15 days.
148+
149+
The maximum lifetime must be less than a quarter of the active CA certificate lifetime where the active CA certificate lifetime is `ca.ca_certificate_lifetime - ca.ca_certificate_retirement_duration` to ensure that two subjects always have a common CA certificate in their trust stores – assuming that CAs are rotated at half of their active lifetimes.
150+
151+
For instance, if a pod is created right before half of the active CA lifetime has passed, then it is signed by this CA but it does not know yet the new CA certificate which is created right afterwards. If another pod is created so that its certificate lifetime ends right after the first active CA lifetime then it is signed by the new CA. The `max_certificate_lifetime` must be chosen so that these two pods have no overlapping lifetimes, otherwise the first pod would see the second one signed by an unknown CA certificate. This can be achieved by the mentioned formula.
140152
type: string
141153
required:
142154
- ca

docs/modules/secret-operator/pages/secretclass.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Native support for customizing certificate lifetimes in Stacklet CRDs might be a
9292

9393
Certificate authorities also have a limited lifetime, and need to be rotated before they expire to avoid cluster disruption.
9494

95+
// TODO Adapt this section
9596
If configured to provision its own CA (`autoTls.ca.autoGenerate`), the Secret Operator will create CA certificates that are valid for 365 days (≃ 1 year, configurable via `autoTls.ca.caCertificateLifetime`), and initiate rotation once less than half of that time remains.
9697

9798
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.

rust/operator-binary/src/backend/tls/ca.rs

Lines changed: 220 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ pub struct Config {
189189
/// The duration of any new CA certificates provisioned.
190190
pub ca_certificate_lifetime: Duration,
191191

192+
/// The retirement duration at the end of the CA certificate lifetime, where the CA is not used
193+
/// to sign certificates and where the CA certificate does not have to be published.
194+
pub ca_certificate_retirement_duration: Duration,
195+
192196
/// If no existing CA certificate outlives `rotate_if_ca_expires_before`, a new
193197
/// certificate will be generated.
194198
///
@@ -342,6 +346,7 @@ pub struct Manager {
342346
source_secret: ObjectRef<Secret>,
343347
certificate_authorities: Vec<CertificateAuthority>,
344348
additional_trusted_certificates: Vec<X509>,
349+
ca_certificate_retirement_duration: Duration,
345350
}
346351

347352
impl Manager {
@@ -513,6 +518,7 @@ impl Manager {
513518
.get()
514519
.map(|secret| secret.to_object_ref(()))
515520
.unwrap_or_else(|| secret_ref.into()),
521+
ca_certificate_retirement_duration: config.ca_certificate_retirement_duration,
516522
})
517523
}
518524

@@ -616,25 +622,230 @@ impl Manager {
616622
/// Get an appropriate [`CertificateAuthority`] for signing a given certificate.
617623
pub fn find_certificate_authority_for_signing(
618624
&self,
619-
valid_until_at_least: OffsetDateTime,
625+
active_until_at_least: OffsetDateTime,
620626
) -> Result<&CertificateAuthority, GetCaError> {
621627
use get_ca_error::*;
622-
self.certificate_authorities
623-
.iter()
624-
.filter(|ca| ca.not_after > valid_until_at_least)
628+
self.active_certificate_authorities(active_until_at_least)
629+
.into_iter()
625630
// pick the oldest valid CA, since it will be trusted by the most peers
626631
.min_by_key(|ca| ca.not_after)
627632
.with_context(|| NoCaLivesLongEnoughSnafu {
628-
cutoff: valid_until_at_least,
633+
cutoff: active_until_at_least,
629634
secret: self.source_secret.clone(),
630635
})
631636
}
632637

633638
/// Get all active trust root certificates.
634-
pub fn trust_roots(&self) -> impl IntoIterator<Item = &X509> + '_ {
635-
self.certificate_authorities
636-
.iter()
639+
pub fn trust_roots(
640+
&self,
641+
active_until_at_least: OffsetDateTime,
642+
) -> impl IntoIterator<Item = &X509> + '_ {
643+
self.active_certificate_authorities(active_until_at_least)
644+
.into_iter()
637645
.map(|ca| &ca.certificate)
638-
.chain(&self.additional_trusted_certificates)
646+
.chain(self.active_additional_trusted_certificates(active_until_at_least))
647+
}
648+
649+
/// Returns all certificate authorities which are not retired or expired
650+
fn active_certificate_authorities(
651+
&self,
652+
active_until_at_least: OffsetDateTime,
653+
) -> impl IntoIterator<Item = &CertificateAuthority> {
654+
self.certificate_authorities.iter().filter(move |ca| {
655+
ca.not_after - self.ca_certificate_retirement_duration >= active_until_at_least
656+
})
657+
}
658+
659+
/// Returns all additional trusted certificates which are not retired or expired
660+
fn active_additional_trusted_certificates(
661+
&self,
662+
active_until_at_least: OffsetDateTime,
663+
) -> impl IntoIterator<Item = &X509> + '_ {
664+
self.additional_trusted_certificates
665+
.iter()
666+
.filter(move |cert| {
667+
asn1time_to_offsetdatetime(cert.not_after()).is_ok_and(|not_after| {
668+
not_after - self.ca_certificate_retirement_duration >= active_until_at_least
669+
})
670+
})
671+
}
672+
}
673+
674+
#[cfg(test)]
675+
mod tests {
676+
use kube_runtime::reflector::ObjectRef;
677+
use openssl::{
678+
asn1::{Asn1Integer, Asn1Time},
679+
bn::BigNum,
680+
hash::MessageDigest,
681+
pkey::{PKey, Private},
682+
rsa::Rsa,
683+
x509::{X509, X509Builder},
684+
};
685+
use stackable_operator::{
686+
k8s_openapi::{ByteString, api::core::v1::Secret},
687+
shared::time::Duration,
688+
};
689+
use stackable_secret_operator_utils::crd::SecretReference;
690+
use time::{OffsetDateTime, macros::datetime};
691+
692+
use super::{CertificateAuthority, Manager};
693+
694+
fn create_certificate(
695+
serial_number: u32,
696+
not_before: OffsetDateTime,
697+
not_after: OffsetDateTime,
698+
) -> Result<(X509, PKey<Private>), openssl::error::ErrorStack> {
699+
let key_pair = Rsa::generate(512)?;
700+
let pkey = PKey::try_from(key_pair)?;
701+
702+
let mut x509_builder = X509Builder::new()?;
703+
x509_builder.set_serial_number(
704+
Asn1Integer::from_bn(BigNum::from_u32(serial_number)?.as_ref())?.as_ref(),
705+
)?;
706+
x509_builder.set_not_before(Asn1Time::from_unix(not_before.unix_timestamp())?.as_ref())?;
707+
x509_builder.set_not_after(Asn1Time::from_unix(not_after.unix_timestamp())?.as_ref())?;
708+
x509_builder.set_pubkey(&pkey)?;
709+
x509_builder.sign(&pkey, MessageDigest::sha256())?;
710+
let x509 = x509_builder.build();
711+
712+
Ok((x509, pkey))
713+
}
714+
715+
fn create_certificate_authority(
716+
serial_number: u32,
717+
not_before: OffsetDateTime,
718+
not_after: OffsetDateTime,
719+
) -> Result<CertificateAuthority, openssl::error::ErrorStack> {
720+
let (certificate, pkey) = create_certificate(serial_number, not_before, not_after)?;
721+
722+
let key_certificate = "crt";
723+
let key_private_key = "key";
724+
725+
Ok(CertificateAuthority::from_secret_data(
726+
&[
727+
(
728+
key_certificate.to_owned(),
729+
ByteString(certificate.to_pem()?),
730+
),
731+
(
732+
key_private_key.to_owned(),
733+
ByteString(pkey.private_key_to_pem_pkcs8()?),
734+
),
735+
]
736+
.into(),
737+
&SecretReference {
738+
namespace: "default".to_owned(),
739+
name: "secret-provisioner-tls-ca".to_owned(),
740+
},
741+
key_certificate,
742+
key_private_key,
743+
)
744+
.expect(""))
745+
}
746+
747+
#[test]
748+
fn test_find_certificate_authority_for_signing() {
749+
let ca_certificate_retirement_duration = Duration::from_hours_unchecked(1);
750+
751+
let ca1 = create_certificate_authority(
752+
1,
753+
datetime!(2025-01-01 0:00 UTC),
754+
datetime!(2025-01-01 12:00 UTC),
755+
)
756+
.expect("should create a valid certificate");
757+
let ca2 = create_certificate_authority(
758+
2,
759+
datetime!(2025-01-01 6:00 UTC),
760+
datetime!(2025-01-01 18:00 UTC),
761+
)
762+
.expect("should create a valid certificate");
763+
764+
let manager = Manager {
765+
source_secret: ObjectRef::<Secret>::new("secret-provisioner-tls-ca"),
766+
certificate_authorities: vec![ca1, ca2],
767+
additional_trusted_certificates: vec![],
768+
ca_certificate_retirement_duration,
769+
};
770+
771+
let signing_ca_at_11_00 =
772+
manager.find_certificate_authority_for_signing(datetime!(2025-01-01 11:00 UTC));
773+
774+
assert_eq!(
775+
Some("CertificateAuthority(serial=1)".to_owned()),
776+
signing_ca_at_11_00.ok().map(|ca| format!("{}", ca))
777+
);
778+
779+
let signing_ca_at_11_01 =
780+
manager.find_certificate_authority_for_signing(datetime!(2025-01-01 11:01 UTC));
781+
782+
assert_eq!(
783+
Some("CertificateAuthority(serial=2)".to_owned()),
784+
signing_ca_at_11_01.ok().map(|ca| format!("{}", ca))
785+
);
786+
}
787+
788+
#[test]
789+
fn test_trust_roots() {
790+
let ca_certificate_retirement_duration = Duration::from_hours_unchecked(1);
791+
792+
let ca1 = create_certificate_authority(
793+
1,
794+
datetime!(2025-01-01 0:00 UTC),
795+
datetime!(2025-01-01 12:00 UTC),
796+
)
797+
.expect("should create a valid certificate");
798+
let ca1_certificate = ca1.certificate.clone();
799+
800+
let ca2 = create_certificate_authority(
801+
2,
802+
datetime!(2025-01-01 6:00 UTC),
803+
datetime!(2025-01-01 18:00 UTC),
804+
)
805+
.expect("should create a valid certificate");
806+
let ca2_certificate = ca2.certificate.clone();
807+
808+
let (trust_root1, _) = create_certificate(
809+
3,
810+
datetime!(2025-01-01 0:00 UTC),
811+
datetime!(2025-01-01 12:00 UTC),
812+
)
813+
.expect("should create a valid certificate");
814+
815+
let (trust_root2, _) = create_certificate(
816+
4,
817+
datetime!(2025-01-01 6:00 UTC),
818+
datetime!(2025-01-01 18:00 UTC),
819+
)
820+
.expect("should create a valid certificate");
821+
822+
let manager = Manager {
823+
source_secret: ObjectRef::<Secret>::new("secret-provisioner-tls-ca"),
824+
certificate_authorities: vec![ca1, ca2],
825+
additional_trusted_certificates: vec![trust_root1.clone(), trust_root2.clone()],
826+
ca_certificate_retirement_duration,
827+
};
828+
829+
let trust_roots_at_11_00: Vec<&X509> = manager
830+
.trust_roots(datetime!(2025-01-01 11:00 UTC))
831+
.into_iter()
832+
.collect();
833+
834+
assert_eq!(
835+
vec![
836+
&ca1_certificate,
837+
&ca2_certificate,
838+
&trust_root1,
839+
&trust_root2
840+
],
841+
trust_roots_at_11_00
842+
);
843+
844+
let trust_roots_at_11_01: Vec<&X509> = manager
845+
.trust_roots(datetime!(2025-01-01 11:01 UTC))
846+
.into_iter()
847+
.collect();
848+
849+
assert_eq!(vec![&ca2_certificate, &trust_root2], trust_roots_at_11_01);
639850
}
640851
}

0 commit comments

Comments
 (0)