Skip to content

Commit bc1f809

Browse files
committed
fix: add wall-clock certificate expiry check to webhook TLS rotation
The rotation interval uses tokio's monotonic clock, but certificate validity uses wall-clock time. When these diverge (hibernation, VM migration, cgroup freezing), the certificate can expire before rotation. Add a periodic wall-clock check (every 5 minutes) that compares SystemTime::now() against the certificate's not_after field and triggers early rotation if the cert is within 4 hours of expiry. Fixes: #1174
1 parent 7486017 commit bc1f809

2 files changed

Lines changed: 59 additions & 6 deletions

File tree

crates/stackable-webhook/src/tls/cert_resolver.rs

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use std::sync::Arc;
1+
use std::{
2+
sync::Arc,
3+
time::SystemTime,
4+
};
25

36
use arc_swap::ArcSwap;
47
use snafu::{OptionExt, ResultExt, Snafu};
@@ -57,6 +60,9 @@ pub struct CertificateResolver {
5760
/// Using a [`ArcSwap`] (over e.g. [`tokio::sync::RwLock`]), so that we can easily
5861
/// (and performant) bridge between async write and sync read.
5962
current_certified_key: ArcSwap<CertifiedKey>,
63+
/// The wall-clock expiry time (`not_after`) of the current certificate.
64+
/// Used to detect clock drift between monotonic and wall-clock time.
65+
current_not_after: ArcSwap<SystemTime>,
6066
subject_alterative_dns_names: Arc<Vec<String>>,
6167

6268
certificate_tx: mpsc::Sender<Certificate>,
@@ -68,7 +74,7 @@ impl CertificateResolver {
6874
certificate_tx: mpsc::Sender<Certificate>,
6975
) -> Result<Self> {
7076
let subject_alterative_dns_names = Arc::new(subject_alterative_dns_names);
71-
let certified_key = Self::generate_new_certificate_inner(
77+
let (certified_key, not_after) = Self::generate_new_certificate_inner(
7278
subject_alterative_dns_names.clone(),
7379
&certificate_tx,
7480
)
@@ -77,20 +83,33 @@ impl CertificateResolver {
7783
Ok(Self {
7884
subject_alterative_dns_names,
7985
current_certified_key: ArcSwap::new(certified_key),
86+
current_not_after: ArcSwap::new(Arc::new(not_after)),
8087
certificate_tx,
8188
})
8289
}
8390

8491
pub async fn rotate_certificate(&self) -> Result<()> {
85-
let certified_key = self.generate_new_certificate().await?;
92+
let (certified_key, not_after) = self.generate_new_certificate().await?;
8693

8794
// TODO: Sign the new cert somehow with the old cert. See https://github.com/stackabletech/decisions/issues/56
8895
self.current_certified_key.store(certified_key);
96+
self.current_not_after.store(Arc::new(not_after));
8997

9098
Ok(())
9199
}
92100

93-
async fn generate_new_certificate(&self) -> Result<Arc<CertifiedKey>> {
101+
/// Returns `true` if the current certificate is expired or will expire
102+
/// within the given `buffer` duration according to wall-clock time.
103+
///
104+
/// This catches cases where the monotonic timer (used by `tokio::time`)
105+
/// has drifted from wall-clock time, e.g. due to system hibernation.
106+
pub fn needs_rotation(&self, buffer: std::time::Duration) -> bool {
107+
let not_after = **self.current_not_after.load();
108+
let deadline = not_after.checked_sub(buffer).unwrap_or(not_after);
109+
SystemTime::now() >= deadline
110+
}
111+
112+
async fn generate_new_certificate(&self) -> Result<(Arc<CertifiedKey>, SystemTime)> {
94113
let subject_alterative_dns_names = self.subject_alterative_dns_names.clone();
95114
Self::generate_new_certificate_inner(subject_alterative_dns_names, &self.certificate_tx)
96115
.await
@@ -106,7 +125,7 @@ impl CertificateResolver {
106125
async fn generate_new_certificate_inner(
107126
subject_alterative_dns_names: Arc<Vec<String>>,
108127
certificate_tx: &mpsc::Sender<Certificate>,
109-
) -> Result<Arc<CertifiedKey>> {
128+
) -> Result<(Arc<CertifiedKey>, SystemTime)> {
110129
// The certificate generations can take a while, so we use `spawn_blocking`
111130
let (cert, certified_key) = tokio::task::spawn_blocking(move || {
112131
let tls_provider =
@@ -144,12 +163,18 @@ impl CertificateResolver {
144163
.await
145164
.context(TokioSpawnBlockingSnafu)??;
146165

166+
let not_after = cert
167+
.tbs_certificate
168+
.validity
169+
.not_after
170+
.to_system_time();
171+
147172
certificate_tx
148173
.send(cert)
149174
.await
150175
.map_err(|_err| CertificateResolverError::SendCertificateToChannel)?;
151176

152-
Ok(certified_key)
177+
Ok((certified_key, not_after))
153178
}
154179
}
155180

crates/stackable-webhook/src/tls/mod.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ pub const WEBHOOK_CA_LIFETIME: Duration = Duration::from_hours_unchecked(24);
4242
pub const WEBHOOK_CERTIFICATE_LIFETIME: Duration = Duration::from_hours_unchecked(24);
4343
pub const WEBHOOK_CERTIFICATE_ROTATION_INTERVAL: Duration = Duration::from_hours_unchecked(20);
4444

45+
/// How often to check wall-clock time for certificate expiry.
46+
/// This catches clock drift from system hibernation, VM migration, etc.
47+
const WALL_CLOCK_CHECK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
48+
49+
/// Rotate the certificate when it is within this buffer of expiry (wall-clock).
50+
const WALL_CLOCK_EXPIRY_BUFFER: std::time::Duration = std::time::Duration::from_secs(4 * 60 * 60);
51+
4552
pub type Result<T, E = TlsServerError> = std::result::Result<T, E>;
4653

4754
#[derive(Debug, Snafu)]
@@ -156,6 +163,12 @@ impl TlsServer {
156163
let start = tokio::time::Instant::now() + *WEBHOOK_CERTIFICATE_ROTATION_INTERVAL;
157164
let mut interval = tokio::time::interval_at(start, *WEBHOOK_CERTIFICATE_ROTATION_INTERVAL);
158165

166+
// A separate, shorter interval to check wall-clock time against the
167+
// certificate's not_after. This catches monotonic/wall-clock drift
168+
// caused by hibernation, VM migration, cgroup freezing, etc.
169+
let mut wall_clock_check =
170+
tokio::time::interval(tokio::time::Duration::from(WALL_CLOCK_CHECK_INTERVAL));
171+
159172
let tls_acceptor = TlsAcceptor::from(Arc::new(config));
160173
let tcp_listener = TcpListener::bind(socket_addr)
161174
.await
@@ -207,6 +220,21 @@ impl TlsServer {
207220
.context(RotateCertificateSnafu)?
208221
}
209222

223+
// Wall-clock check: detect if the certificate is near expiry
224+
// even though the monotonic rotation interval hasn't fired yet.
225+
// This handles clock drift from hibernation, VM migration, etc.
226+
_ = wall_clock_check.tick() => {
227+
if cert_resolver.needs_rotation(WALL_CLOCK_EXPIRY_BUFFER) {
228+
tracing::info!("wall-clock check detected certificate near expiry, rotating early");
229+
cert_resolver
230+
.rotate_certificate()
231+
.await
232+
.context(RotateCertificateSnafu)?;
233+
// Reset the monotonic interval so we don't double-rotate
234+
interval.reset();
235+
}
236+
}
237+
210238
// This is cancellation-safe. If cancelled, no new connections are accepted.
211239
tcp_connection = tcp_listener.accept() => {
212240
let (tcp_stream, remote_addr) = match tcp_connection {

0 commit comments

Comments
 (0)