Skip to content

Commit a3378e3

Browse files
committed
fix(gateway): gate certificate installation on the chain, not the record
`CertStoreBuilder::add_cert` decided whether a certificate was still valid from `CertData.not_after` alone, a replicated field, and never looked at the `notAfter` of the chain it was about to serve. A record claiming `u64::MAX` over an expired chain installed on every node that replicated it. Both now have to be in the future. A record that outlives its chain delays renewal, so it is logged. `get_cert_expiry` moves to `cert_store` so there is one copy.
1 parent 9db0b6f commit a3378e3

2 files changed

Lines changed: 65 additions & 12 deletions

File tree

‎dstack/gateway/src/cert_store.rs‎

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ use std::collections::HashMap;
1818
use std::fmt;
1919
use std::sync::Arc;
2020

21-
use anyhow::{Context, Result};
21+
use anyhow::{bail, Context, Result};
2222
use arc_swap::{ArcSwap, Guard};
2323
use or_panic::ResultOrPanic;
2424
use rustls::pki_types::pem::PemObject;
2525
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
2626
use rustls::server::{ClientHello, ResolvesServerCert};
2727
use rustls::sign::CertifiedKey;
28-
use tracing::info;
28+
use tracing::{info, warn};
2929

3030
use crate::kv::CertData;
3131

@@ -241,7 +241,23 @@ impl CertStoreBuilder {
241241
.duration_since(std::time::UNIX_EPOCH)
242242
.context("system time is before Unix epoch")?
243243
.as_secs();
244-
anyhow::ensure!(data.not_after > now, "certificate is expired");
244+
// `not_after` is a replicated field; the chain is what gets served.
245+
if data.not_after <= now {
246+
bail!("certificate record for {domain} is expired");
247+
}
248+
let cert_not_after = get_cert_expiry(&data.cert_pem).with_context(|| {
249+
format!("failed to read the expiry of the certificate for {domain}")
250+
})?;
251+
if cert_not_after <= now {
252+
bail!("certificate for {domain} is expired");
253+
}
254+
if cert_not_after < data.not_after {
255+
// Renewal is scheduled from the record, so this delays it.
256+
warn!(
257+
"cert_store: the record for {domain} outlives its certificate by {} seconds",
258+
data.not_after - cert_not_after
259+
);
260+
}
245261

246262
let certified_key = parse_certified_key(&data.cert_pem, &data.key_pem)
247263
.with_context(|| format!("failed to parse certificate for {}", domain))?;
@@ -255,7 +271,7 @@ impl CertStoreBuilder {
255271
info!(
256272
"cert_store: prepared wildcard certificate for *.{} (expires: {})",
257273
domain,
258-
format_expiry(data.not_after)
274+
format_expiry(cert_not_after)
259275
);
260276

261277
// Store metadata
@@ -280,6 +296,14 @@ impl Default for CertStoreBuilder {
280296
}
281297
}
282298

299+
/// The `notAfter` of the first certificate in a PEM chain, as a Unix timestamp.
300+
pub(crate) fn get_cert_expiry(cert_pem: &str) -> Option<u64> {
301+
use x509_parser::prelude::*;
302+
let pem = Pem::iter_from_buffer(cert_pem.as_bytes()).next()?.ok()?;
303+
let cert = pem.parse_x509().ok()?;
304+
u64::try_from(cert.validity().not_after.timestamp()).ok()
305+
}
306+
283307
/// Parse certificate and private key PEM strings into a CertifiedKey
284308
fn parse_certified_key(cert_pem: &str, key_pem: &str) -> Result<CertifiedKey> {
285309
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
@@ -445,6 +469,42 @@ mod tests {
445469
assert!(!store.has_cert_for_sni("example.org"));
446470
}
447471

472+
/// A certificate whose chain has already expired, described by a record
473+
/// that says otherwise.
474+
fn make_expired_cert_data() -> CertData {
475+
use ra_tls::rcgen::{self, CertificateParams, KeyPair};
476+
477+
let key_pair = KeyPair::generate().expect("failed to generate key pair");
478+
let mut params = CertificateParams::new(vec!["test.example.com".to_string()])
479+
.expect("failed to create cert params");
480+
params.not_before = rcgen::date_time_ymd(2019, 1, 1);
481+
params.not_after = rcgen::date_time_ymd(2020, 1, 1);
482+
let cert = params
483+
.self_signed(&key_pair)
484+
.expect("failed to generate self-signed cert");
485+
486+
CertData {
487+
cert_pem: cert.pem(),
488+
key_pem: key_pair.serialize_pem(),
489+
not_after: u64::MAX,
490+
issued_by: 1,
491+
issued_at: 0,
492+
}
493+
}
494+
495+
#[test]
496+
fn an_expired_chain_is_rejected_however_the_record_dates_it() {
497+
let mut builder = CertStoreBuilder::new();
498+
let err = builder
499+
.add_cert("example.com", &make_expired_cert_data())
500+
.expect_err("an expired certificate chain must be rejected");
501+
assert!(
502+
err.to_string().contains("expired"),
503+
"unexpected rejection reason: {err:#}"
504+
);
505+
assert!(!builder.build().has_cert("example.com"));
506+
}
507+
448508
#[test]
449509
fn mismatched_key_update_retains_previous_certificate() {
450510
let original = make_test_cert_data();

‎dstack/gateway/src/distributed_certbot.rs‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use ra_tls::attestation::QuoteContentType;
2020
use ra_tls::rcgen::KeyPair;
2121
use tracing::{error, info, warn};
2222

23-
use crate::cert_store::CertResolver;
23+
use crate::cert_store::{get_cert_expiry, CertResolver};
2424
use crate::kv::{
2525
AcmeAttestation, CertAttestation, CertCredentials, CertData, DnsCredential, DnsProvider,
2626
KvStore, PersistentWriteNotifier, ZtDomainConfig,
@@ -1083,13 +1083,6 @@ fn dns_credential_for(kv_store: &KvStore, config: &ZtDomainConfig) -> Result<Dns
10831083
}
10841084
}
10851085

1086-
fn get_cert_expiry(cert_pem: &str) -> Option<u64> {
1087-
use x509_parser::prelude::*;
1088-
let pem = Pem::iter_from_buffer(cert_pem.as_bytes()).next()?.ok()?;
1089-
let cert = pem.parse_x509().ok()?;
1090-
Some(cert.validity().not_after.timestamp() as u64)
1091-
}
1092-
10931086
fn acme_url_matches(credentials_json: &str, expected_url: &str) -> Result<bool> {
10941087
#[derive(serde::Deserialize)]
10951088
struct Creds {

0 commit comments

Comments
 (0)