From ef032424c6a99c6f388d1c7561f85b02f2245a77 Mon Sep 17 00:00:00 2001 From: Shon Thomas Date: Sat, 9 May 2026 08:24:53 -0800 Subject: [PATCH] feat(namecheap): support Sectigo CNAMECSRHASH DCV method Modern Sectigo PositiveSSL certs (which is what Namecheap issues by default in 2026+) use CNAME-CSR-Hash DCV: the validation record is computed locally from the CSR rather than returned in the API response. rota's reissue handler previously only knew the legacy TxtName/TxtValue and HostName/Target shapes and erroneously returned `namecheap reissue response missing DCV record fields` when ApproverEmail=CNAMECSRHASH was the actual indicator. Algorithm (per Sectigo "Domain Control Validation" spec v1.09): der = DER(CSR) md5_hex = uppercase(hex(MD5(der))) -> 32 chars sha256_hex = lowercase(hex(SHA256(der))) -> 64 chars Host: _. Target: ..comodoca.com The 32/32 split on SHA256 sidesteps DNS's 63-octet label limit. The zone is `comodoca.com`; Sectigo's marketing pages occasionally cite `sectigo.com` but the actual deployed validation infrastructure (plus every reseller KB and the Namecheap response examples) use comodoca.com. * `compute_csrhash_dcv(csr_pem, domain)` lives in ca.rs and returns the existing `DcvChallenge::Dns01` shape so DcvBackend plugs in unchanged. * `submit()` adds a third branch after the two legacy shapes: detect ApproverEmail=CNAMECSRHASH (case-insensitive), compute the record from the CSR, return it. Backward-compatible with the TXT/HostName flows. * When NONE of the three shapes match, the failing response is now dumped at debug level so an operator with `RUST_LOG=debug` can file an actionable bug report without re-curling Namecheap. Tests: 4 new in `backends::namecheap::ca::tests`: * shape (host prefix `_`, MD5 32-char uppercase hex, target zone comodoca.com, SHA256 split into two 32-char lowercase labels, TTL = DCV_TTL_SECONDS) * domain parameter is authoritative, not the CSR's CN * deterministic for the same CSR (idempotency) * invalid PEM input returns Error::Ca with `CSR PEM parse` text Workspace deps: md-5, sha2, hex (RustCrypto stack, ~no transitive bloat). rota-daemon Cargo.toml pulls them in. Sources for the algorithm: * Sectigo Domain Control Validation spec v1.09 * Xolphin "Calculate CSR Hash" reference implementation * GoGetSSL Sectigo DCV methods wiki * CentralNic Sectigo Hash Generation KB Closes the gap that surfaced when the rota deploy on aur0 hit "missing DCV record fields" against active oneiriq.com (SSL ID 32542562) and oneiric.dev (SSL ID 31420556) reissue calls. --- Cargo.lock | 3 + Cargo.toml | 3 + crates/rota-daemon/Cargo.toml | 3 + .../rota-daemon/src/backends/namecheap/ca.rs | 186 +++++++++++++++++- 4 files changed, 188 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eea4937..fbef6d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4131,11 +4131,13 @@ dependencies = [ "chrono", "clap", "futures", + "hex", "instant-acme", "k8s-openapi", "kube", "lettre", "maud", + "md-5", "oneiriq-surql", "pem", "prometheus", @@ -4146,6 +4148,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2 0.10.9", "surrealdb", "tempfile", "time", diff --git a/Cargo.toml b/Cargo.toml index b2b0fb9..0c288a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,9 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } x509-parser = "0.16" time = "0.3" +md-5 = "0.10" +sha2 = "0.10" +hex = "0.4" instant-acme = "0.8" pem = "3" oneiriq-surql = { version = "0.2", default-features = false, features = ["client-rustls"] } diff --git a/crates/rota-daemon/Cargo.toml b/crates/rota-daemon/Cargo.toml index 416145a..c1b97ea 100644 --- a/crates/rota-daemon/Cargo.toml +++ b/crates/rota-daemon/Cargo.toml @@ -35,11 +35,13 @@ async-trait = { workspace = true } axum = { workspace = true } chrono = { workspace = true } clap = { workspace = true } +hex = { workspace = true } instant-acme = { workspace = true } k8s-openapi = { workspace = true } kube = { workspace = true } lettre = { workspace = true } maud = { workspace = true } +md-5 = { workspace = true } pem = { workspace = true } prometheus = { workspace = true } quick-xml = { workspace = true } @@ -48,6 +50,7 @@ reqwest = { workspace = true } rusqlite = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } tower = { workspace = true } diff --git a/crates/rota-daemon/src/backends/namecheap/ca.rs b/crates/rota-daemon/src/backends/namecheap/ca.rs index de9afb0..9a5700f 100644 --- a/crates/rota-daemon/src/backends/namecheap/ca.rs +++ b/crates/rota-daemon/src/backends/namecheap/ca.rs @@ -10,16 +10,23 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; +use md5::{Digest, Md5}; use rota_core::backend::{CABackend, ChallengeKind, DcvChallenge, IssuedCert}; use rota_core::secrets::redact; use rota_core::{Error, Result}; -use tracing::{info, warn}; +use sha2::Sha256; +use tracing::{debug, info, warn}; use super::client::NamecheapClient; const POLL_INTERVAL: Duration = Duration::from_secs(30); const POLL_DEADLINE: Duration = Duration::from_secs(60 * 30); // 30 min const DCV_TTL_SECONDS: u32 = 300; +/// Sectigo's deployed DCV target zone for the CSR-hash CNAME flow. +/// Sectigo's marketing pages occasionally cite `sectigo.com`, but the +/// actual validation infrastructure (and every reseller KB plus the +/// Namecheap response examples) uses `comodoca.com`. +const SECTIGO_DCV_TARGET_ZONE: &str = "comodoca.com"; #[derive(Debug, Clone)] pub struct NamecheapCa { @@ -69,15 +76,24 @@ impl CABackend for NamecheapCa { async fn submit( &self, - _domains: &[String], + domains: &[String], csr_pem: &str, _preferred_kinds: &[ChallengeKind], ) -> Result> { // Namecheap's reissue command: submit the CSR + DNS-DCV election. - // The response carries either an ``/`` pair (CNAME - // validation) or a ``/`` pair depending on the - // CA tier. We surface whichever shape we get back as a Dns01 - // challenge; the caller's DcvBackend handles the publish. + // The response carries one of three shapes: + // 1. ``/``: TXT-record DCV (legacy Sectigo + // flow on some products). + // 2. ``/``: CNAME-record DCV with the values + // returned inline (older Comodo flow). + // 3. `CNAMECSRHASH` and NO record + // fields: modern Sectigo flow where the CNAME is computed + // locally from the CSR per Sectigo DCV spec v1.09. Most + // Namecheap-issued PositiveSSL certs use this in 2026+. + // + // We try the explicit-record shapes first because they're cheap + // string lookups, then fall through to the CSR-hash compute when + // the response indicates `CNAMECSRHASH`. // // `preferred_kinds` is ignored: Namecheap reissue only supports // DNS-01 over their API. If the operator pairs a Namecheap CA @@ -97,7 +113,6 @@ impl CABackend for NamecheapCa { .await?; resp.ensure_ok()?; - // Try TXT-style first (newer Sectigo flow), fall back to CNAME-style. let (record_name, record_value) = if let (Some(name), Some(value)) = (resp.first_text("TxtName"), resp.first_text("TxtValue")) { @@ -109,7 +124,26 @@ impl CABackend for NamecheapCa { // TXT record from the trait surface; backends that only accept // CNAMEs will reject downstream and we'll widen the trait then. (name, target) + } else if resp + .first_text("ApproverEmail") + .map(|v| v.eq_ignore_ascii_case("CNAMECSRHASH")) + .unwrap_or(false) + { + let domain = domains.first().ok_or_else(|| { + Error::Ca("namecheap reissue: at least one domain is required for CSR-hash DCV".into()) + })?; + let challenge = compute_csrhash_dcv(csr_pem, domain)?; + info!(domain = %domain, "namecheap reissue accepted, csr-hash dcv computed"); + return Ok(vec![challenge]); } else { + // None of the three known response shapes matched. Dump the + // response at debug level so an operator with `RUST_LOG=debug` + // can file an actionable bug report without re-curling + // Namecheap by hand. + debug!( + ?resp, + "namecheap reissue response missing DCV record fields" + ); return Err(Error::Ca( "namecheap reissue response missing DCV record fields".into(), )); @@ -168,3 +202,141 @@ impl NamecheapCertInfo { && (self.status.eq_ignore_ascii_case("active") || self.status.eq_ignore_ascii_case("issued")) } } + +/// Compute the Sectigo CNAME-CSR-Hash DCV record from a PEM-encoded +/// CSR per Sectigo's "Domain Control Validation" spec v1.09. +/// +/// The algorithm is purely deterministic from the DER-encoded CSR +/// bytes, so no network round-trip is needed once rota has the CSR +/// it submitted to Namecheap. The result plugs into the existing +/// `DcvChallenge::Dns01` trait surface; a `DcvBackend` (Namecheap or +/// Cloudflare) publishes the CNAME and Sectigo's resolver picks it +/// up the same way it does for the explicit-response flows. +/// +/// Format produced: +/// * Host: `_.` +/// * Target: `..comodoca.com` +/// +/// The SHA256 hex (64 chars) is split with one `.` after the 32nd +/// char so neither label exceeds DNS's 63-octet limit. The MD5 hex +/// stays uppercase to match the published spec. +fn compute_csrhash_dcv(csr_pem: &str, domain: &str) -> Result { + let der = pem::parse(csr_pem) + .map_err(|e| Error::Ca(format!("namecheap CSR PEM parse: {e}")))? + .into_contents(); + let md5_hex_upper = hex::encode_upper(Md5::digest(&der)); + let sha256_hex = hex::encode(Sha256::digest(&der)); + let (sha256_first, sha256_second) = sha256_hex.split_at(32); + Ok(DcvChallenge::Dns01 { + record_name: format!("_{md5_hex_upper}.{domain}"), + record_value: format!("{sha256_first}.{sha256_second}.{SECTIGO_DCV_TARGET_ZONE}"), + ttl: DCV_TTL_SECONDS, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_csr_pem() -> String { + // Random key per call: we can't pin exact hash values, but the + // OUTPUT SHAPE (label lengths, hex case, zone suffix) is what + // Sectigo's spec pins down, so structural assertions are enough. + let key = rcgen::KeyPair::generate().unwrap(); + let params = rcgen::CertificateParams::new(vec!["example.com".to_owned()]).unwrap(); + let csr = params.serialize_request(&key).unwrap(); + csr.pem().unwrap() + } + + #[test] + fn cnamecsrhash_record_has_expected_shape() { + let csr_pem = fixture_csr_pem(); + let challenge = compute_csrhash_dcv(&csr_pem, "example.com").unwrap(); + let DcvChallenge::Dns01 { + record_name, + record_value, + ttl, + } = challenge + else { + panic!("expected Dns01"); + }; + + assert!( + record_name.starts_with('_'), + "host must start with `_`: {record_name}" + ); + assert!( + record_name.ends_with(".example.com"), + "host must end with the domain: {record_name}" + ); + let md5_label = record_name + .strip_prefix('_') + .unwrap() + .strip_suffix(".example.com") + .unwrap(); + assert_eq!( + md5_label.len(), + 32, + "MD5 hex should be 32 chars: {md5_label}" + ); + assert!( + md5_label + .chars() + .all(|c| c.is_ascii_digit() || ('A'..='F').contains(&c)), + "MD5 hex must be uppercase: {md5_label}" + ); + + assert!( + record_value.ends_with(".comodoca.com"), + "target zone must be comodoca.com: {record_value}" + ); + let sha_part = record_value.strip_suffix(".comodoca.com").unwrap(); + let labels: Vec<&str> = sha_part.split('.').collect(); + assert_eq!( + labels.len(), + 2, + "SHA256 must split across two labels: {record_value}" + ); + assert_eq!(labels[0].len(), 32); + assert_eq!(labels[1].len(), 32); + assert!( + labels[0] + .chars() + .chain(labels[1].chars()) + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)), + "SHA256 hex must be lowercase: {record_value}" + ); + + assert_eq!(ttl, DCV_TTL_SECONDS); + } + + #[test] + fn cnamecsrhash_uses_supplied_domain_not_csr_cn() { + let csr_pem = fixture_csr_pem(); + let challenge = compute_csrhash_dcv(&csr_pem, "different.example.org").unwrap(); + let DcvChallenge::Dns01 { record_name, .. } = challenge else { + panic!("expected Dns01"); + }; + assert!( + record_name.ends_with(".different.example.org"), + "compute_csrhash_dcv treats `domain` as authoritative: {record_name}" + ); + } + + #[test] + fn cnamecsrhash_is_deterministic_for_same_csr() { + let csr_pem = fixture_csr_pem(); + let a = compute_csrhash_dcv(&csr_pem, "example.com").unwrap(); + let b = compute_csrhash_dcv(&csr_pem, "example.com").unwrap(); + assert_eq!(format!("{a:?}"), format!("{b:?}")); + } + + #[test] + fn cnamecsrhash_rejects_invalid_pem() { + let err = compute_csrhash_dcv("not a real PEM", "example.com").unwrap_err(); + assert!( + err.to_string().contains("CSR PEM parse"), + "error should name the parse failure: {err}" + ); + } +}