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}" + ); + } +}