Skip to content

Commit 681bca5

Browse files
committed
feat: dont publish expired CA certs
1 parent d2bafff commit 681bca5

File tree

9 files changed

+94
-16
lines changed

9 files changed

+94
-16
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,10 @@ spec:
384384
385385
The requested information is written to a ConfigMap with the same name as the TrustStore.
386386
properties:
387+
caExpiryThreshold:
388+
description: The minimum remaining lifetime that a CA must have to be considered valid for publishing in the TrustStore. If not specified, all CAs (including expired ones) will be published.
389+
nullable: true
390+
type: string
387391
format:
388392
description: The [format](https://docs.stackable.tech/home/nightly/secret-operator/secretclass#format) that the data should be converted into.
389393
enum:

docs/modules/secret-operator/examples/truststore-tls.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ metadata:
66
spec:
77
secretClassName: tls # <2>
88
format: tls-pem # <3>
9+
caExpiryThreshold: 1h # <4>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ include::example$truststore-tls.yaml[]
1414
<1> Also used to name the created ConfigMap
1515
<2> The name of the xref:secretclass.adoc[]
1616
<3> The requested xref:secretclass.adoc#format[format]
17+
<4> Optional threshold to filter out CAs that are about to expire. If not specified, all CAs (including expired ones) will be included.
1718

1819
This will create a ConfigMap named `truststore-pem` containing a `ca.crt` with the trust root certificates.
1920
It can then either be mounted into a Pod or retrieved and used from outside of Kubernetes.

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,25 @@ It can take some hours until all Pods are restarted in a rolling fashion.
133133

134134
The format is documented in xref:concepts:duration.adoc[].
135135

136+
=== `secrets.stackable.tech/backend.autotls.ca.expiry-threshold`
137+
138+
*Required*: false
139+
140+
*Default value*: Not set (all CAs including expired ones are published)
141+
142+
*Backends*: xref:secretclass.adoc#backend-autotls[]
143+
144+
The minimum remaining lifetime that a CA must have to be considered valid.
145+
146+
- A CA is only published if its remaining lifetime is greater than or equal to this threshold.
147+
- A CA may only sign a certificate if it outlives the certificate by at least this threshold.
148+
149+
If not specified, all CAs (including expired ones) will be published. Of course, only the CAs that are still valid will be used to sign certificates.
150+
151+
Use this to avoid publishing almost-expired CA certificates that might expire during pod startup.
152+
153+
The format is documented in xref:concepts:duration.adoc[].
154+
136155
=== `secrets.stackable.tech/backend.autotls.cert.jitter-factor`
137156

138157
*Required*: false

rust/operator-binary/src/backend/mod.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,19 @@ pub struct SecretVolumeSelector {
120120
)]
121121
pub autotls_cert_restart_buffer: Duration,
122122

123+
/// The minimum remaining lifetime that a CA must have to be considered valid for
124+
/// publishing and signing certificates.
125+
/// A CA will only be published if it remains valid for at least this duration,
126+
/// and it may only sign if it outlives the issued certificate by at least this duration.
127+
/// If not specified, all CAs (including expired ones) will be published.
128+
/// The format is documented in <https://docs.stackable.tech/home/nightly/concepts/duration>.
129+
#[serde(
130+
rename = "secrets.stackable.tech/backend.autotls.ca.expiry-threshold",
131+
deserialize_with = "SecretVolumeSelector::deserialize_some",
132+
default
133+
)]
134+
pub autotls_ca_expiry_threshold: Option<Duration>,
135+
123136
/// The part of the certificate's lifetime that may be removed for jittering.
124137
/// Must be within 0.0 and 1.0.
125138
#[serde(
@@ -144,6 +157,10 @@ pub struct SecretVolumeSelector {
144157
pub struct TrustSelector {
145158
/// The name of the [`TrustStore`]'s `Namespace`.
146159
pub namespace: String,
160+
/// Optional CA expiry threshold for filtering out CAs that are about to expire.
161+
/// If specified, only CAs that are valid for at least this duration will be published.
162+
/// If not specified, all CAs (including expired ones) will be published.
163+
pub ca_expiry_threshold: Option<Duration>,
147164
}
148165

149166
/// Internal parameters of [`SecretVolumeSelector`] managed by secret-operator itself.

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

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -614,27 +614,49 @@ impl Manager {
614614
}
615615

616616
/// Get an appropriate [`CertificateAuthority`] for signing a given certificate.
617+
///
618+
/// The selected CA must outlive the certificate by at least `min_remaining_lifetime`.
619+
/// If `min_remaining_lifetime` is None, any CA that outlives the certificate will be acceptable.
617620
pub fn find_certificate_authority_for_signing(
618621
&self,
619622
valid_until_at_least: OffsetDateTime,
623+
min_remaining_lifetime: Option<Duration>,
620624
) -> Result<&CertificateAuthority, GetCaError> {
621625
use get_ca_error::*;
626+
627+
let cutoff = match min_remaining_lifetime {
628+
Some(min_lifetime) => valid_until_at_least + min_lifetime,
629+
None => valid_until_at_least,
630+
};
631+
622632
self.certificate_authorities
623633
.iter()
624-
.filter(|ca| ca.not_after > valid_until_at_least)
634+
.filter(|ca| ca.not_after > cutoff)
625635
// pick the oldest valid CA, since it will be trusted by the most peers
626636
.min_by_key(|ca| ca.not_after)
627637
.with_context(|| NoCaLivesLongEnoughSnafu {
628-
cutoff: valid_until_at_least,
638+
cutoff,
629639
secret: self.source_secret.clone(),
630640
})
631641
}
632642

633643
/// Get all active trust root certificates.
634-
pub fn trust_roots(&self) -> impl IntoIterator<Item = &X509> + '_ {
644+
/// If `min_remaining_lifetime` is specified, only CAs that will stay valid for at least that long
645+
/// will be returned. Otherwise all CAs (including expired ones) will be returned.
646+
pub fn trust_roots(&self, min_remaining_lifetime: Option<Duration>) -> Vec<&X509> {
647+
let cutoff =
648+
min_remaining_lifetime.map(|min_lifetime| OffsetDateTime::now_utc() + min_lifetime);
649+
635650
self.certificate_authorities
636651
.iter()
652+
.filter(|ca| cutoff.is_none_or(|cutoff| ca.not_after >= cutoff))
637653
.map(|ca| &ca.certificate)
638-
.chain(&self.additional_trusted_certificates)
654+
.chain(self.additional_trusted_certificates.iter().filter(|cert| {
655+
cutoff.is_none_or(|cutoff| {
656+
crate::utils::asn1time_to_offsetdatetime(cert.not_after())
657+
.is_ok_and(|not_after| not_after >= cutoff)
658+
})
659+
}))
660+
.collect()
639661
}
640662
}

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

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ impl SecretBackend for TlsGenerate {
208208
// Extract and convert consumer input from the Volume annotations.
209209
let cert_lifetime = selector.autotls_cert_lifetime;
210210
let cert_restart_buffer = selector.autotls_cert_restart_buffer;
211+
let ca_expiry_threshold = selector.autotls_ca_expiry_threshold;
211212

212213
// We need to check that the cert lifetime it is not longer than allowed,
213214
// by capping it to the maximum configured at the SecretClass.
@@ -284,7 +285,7 @@ impl SecretBackend for TlsGenerate {
284285
}
285286
let ca = self
286287
.ca_manager
287-
.find_certificate_authority_for_signing(not_after)
288+
.find_certificate_authority_for_signing(not_after, ca_expiry_threshold)
288289
.context(PickCaSnafu)?;
289290
let pod_cert = X509Builder::new()
290291
.and_then(|mut x509| {
@@ -347,10 +348,13 @@ impl SecretBackend for TlsGenerate {
347348
SecretContents::new(SecretData::WellKnown(WellKnownSecretData::TlsPem(
348349
well_known::TlsPem {
349350
ca_pem: iterator_try_concat_bytes(
350-
self.ca_manager.trust_roots().into_iter().map(|ca| {
351-
ca.to_pem()
352-
.context(SerializeCertificateSnafu { tpe: CertType::Ca })
353-
}),
351+
self.ca_manager
352+
.trust_roots(ca_expiry_threshold)
353+
.into_iter()
354+
.map(|ca| {
355+
ca.to_pem()
356+
.context(SerializeCertificateSnafu { tpe: CertType::Ca })
357+
}),
354358
)?,
355359
certificate_pem: Some(
356360
pod_cert
@@ -372,16 +376,19 @@ impl SecretBackend for TlsGenerate {
372376

373377
async fn get_trust_data(
374378
&self,
375-
_selector: &super::TrustSelector,
379+
selector: &super::TrustSelector,
376380
) -> Result<SecretContents, Self::Error> {
377381
Ok(SecretContents::new(SecretData::WellKnown(
378382
WellKnownSecretData::TlsPem(well_known::TlsPem {
379-
ca_pem: iterator_try_concat_bytes(self.ca_manager.trust_roots().into_iter().map(
380-
|ca| {
381-
ca.to_pem()
382-
.context(SerializeCertificateSnafu { tpe: CertType::Ca })
383-
},
384-
))?,
383+
ca_pem: iterator_try_concat_bytes(
384+
self.ca_manager
385+
.trust_roots(selector.ca_expiry_threshold)
386+
.into_iter()
387+
.map(|ca| {
388+
ca.to_pem()
389+
.context(SerializeCertificateSnafu { tpe: CertType::Ca })
390+
}),
391+
)?,
385392
certificate_pem: None,
386393
key_pem: None,
387394
}),

rust/operator-binary/src/crd.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,12 @@ pub struct TrustStoreSpec {
520520

521521
/// The [format](DOCS_BASE_URL_PLACEHOLDER/secret-operator/secretclass#format) that the data should be converted into.
522522
pub format: Option<SecretFormat>,
523+
524+
/// The minimum remaining lifetime that a CA must have to be considered valid for
525+
/// publishing in the TrustStore.
526+
/// If not specified, all CAs (including expired ones) will be published.
527+
#[serde(default)]
528+
pub ca_expiry_threshold: Option<Duration>,
523529
}
524530

525531
#[cfg(test)]

rust/operator-binary/src/truststore_controller.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ async fn reconcile(
262262
.namespace
263263
.clone()
264264
.context(NoTrustStoreNamespaceSnafu)?,
265+
ca_expiry_threshold: truststore.spec.ca_expiry_threshold,
265266
};
266267
let trust_data = backend
267268
.get_trust_data(&selector)

0 commit comments

Comments
 (0)