Skip to content

Commit b1997fe

Browse files
MagicalTuxclaude
andcommitted
acme: parse the leaf cert for expiry, not the whole chain
cert_not_after() called Certificate::from_pem() on the full chain (leaf + intermediates). purecrypto's parser rejects the trailing certificates (Der(Pem)), so it returned None for every real fullchain. Combined with the recent near_expiry(None) => true hardening, the manager judged a perfectly valid cert as "needs renewal" on every connection and re-issued in a loop until Let's Encrypt's duplicate-certificate rate limit refused further orders — at which point issuance failed, the host entered the negative cache, and live TLS connections were rejected (handshake EOF) while the on-disk cert was valid for months. Parse only the leaf (first PEM block) so the real expiry is read and a healthy cert is served without churn. Verified end to end against the production cert (leaf parses; whole chain errors). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ea74041 commit b1997fe

1 file changed

Lines changed: 32 additions & 1 deletion

File tree

src/acme/manager.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,10 +557,25 @@ fn near_expiry(not_after: Option<u64>, now: u64) -> bool {
557557

558558
/// Parse the leaf certificate's `notAfter` (Unix seconds) from a chain PEM.
559559
fn cert_not_after(chain_pem: &str) -> Option<u64> {
560-
let cert = Certificate::from_pem(chain_pem).ok()?;
560+
// A fullchain PEM holds several certificates (leaf + intermediates), and
561+
// `Certificate::from_pem` rejects the trailing blocks (`Der(Pem)`). Parse
562+
// only the leaf — the first block — which is the cert whose expiry we renew
563+
// against. Without this the expiry is unknown, and `near_expiry(None)` is
564+
// `true`, so the server re-issues on every check until it is rate-limited.
565+
let leaf = first_pem_cert(chain_pem)?;
566+
let cert = Certificate::from_pem(&leaf).ok()?;
561567
Some(cert.validity().ok()?.not_after.to_unix())
562568
}
563569

570+
/// Extract the first PEM `CERTIFICATE` block (the leaf) from a chain.
571+
fn first_pem_cert(pem: &str) -> Option<String> {
572+
const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
573+
const END: &str = "-----END CERTIFICATE-----";
574+
let start = pem.find(BEGIN)?;
575+
let end = pem[start..].find(END)? + start + END.len();
576+
Some(pem[start..end].to_string())
577+
}
578+
564579
#[cfg(test)]
565580
mod tests {
566581
use super::*;
@@ -573,6 +588,22 @@ mod tests {
573588
assert!(near_expiry(None, now)); // unknown expiry → renew, don't serve forever
574589
}
575590

591+
#[test]
592+
fn first_pem_cert_takes_the_leaf_from_a_chain() {
593+
// A fullchain has several blocks; the leaf is the first. (Real cert
594+
// parsing is covered indirectly — the bug was that the multi-block chain
595+
// failed to parse at all, so `cert_not_after` saw the whole string.)
596+
let chain = "junk before\n\
597+
-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----\n\
598+
-----BEGIN CERTIFICATE-----\nINTERMEDIATE\n-----END CERTIFICATE-----\n";
599+
let leaf = first_pem_cert(chain).expect("a leaf block");
600+
assert!(leaf.contains("LEAF"));
601+
assert!(!leaf.contains("INTERMEDIATE"));
602+
assert!(leaf.starts_with("-----BEGIN CERTIFICATE-----"));
603+
assert!(leaf.trim_end().ends_with("-----END CERTIFICATE-----"));
604+
assert!(first_pem_cert("no pem here").is_none());
605+
}
606+
576607
#[test]
577608
fn normalize_host() {
578609
assert_eq!(normalize(" Example.COM. "), "example.com");

0 commit comments

Comments
 (0)