diff --git a/Cargo.lock b/Cargo.lock index fbef6d6..eea4937 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4131,13 +4131,11 @@ dependencies = [ "chrono", "clap", "futures", - "hex", "instant-acme", "k8s-openapi", "kube", "lettre", "maud", - "md-5", "oneiriq-surql", "pem", "prometheus", @@ -4148,7 +4146,6 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2 0.10.9", "surrealdb", "tempfile", "time", diff --git a/Cargo.toml b/Cargo.toml index 0c288a9..b2b0fb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,9 +37,6 @@ 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 c1b97ea..416145a 100644 --- a/crates/rota-daemon/Cargo.toml +++ b/crates/rota-daemon/Cargo.toml @@ -35,13 +35,11 @@ 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 } @@ -50,7 +48,6 @@ 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 9a5700f..669d933 100644 --- a/crates/rota-daemon/src/backends/namecheap/ca.rs +++ b/crates/rota-daemon/src/backends/namecheap/ca.rs @@ -10,11 +10,9 @@ 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 sha2::Sha256; use tracing::{debug, info, warn}; use super::client::NamecheapClient; @@ -22,11 +20,6 @@ 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 { @@ -76,24 +69,15 @@ 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 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`. + // The response carries either an ``/`` pair (CNAME + // validation) or a ``/`` pair depending on the + // CA tier. Both pairs land inside `` blocks so the + // unescape path matters; see `xml::ApiResponse::first_text`. // // `preferred_kinds` is ignored: Namecheap reissue only supports // DNS-01 over their API. If the operator pairs a Namecheap CA @@ -124,22 +108,13 @@ 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. + // 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. We landed this diagnostic the + // hard way: the legacy code returned this exact error string + // for a different reason (CDATA blocks not unwrapped) and the + // missing dump cost an avoidable round trip. debug!( ?resp, "namecheap reissue response missing DCV record fields" @@ -202,141 +177,3 @@ 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}" - ); - } -} diff --git a/crates/rota-daemon/src/backends/namecheap/xml.rs b/crates/rota-daemon/src/backends/namecheap/xml.rs index 806fcf7..1ae6dfd 100644 --- a/crates/rota-daemon/src/backends/namecheap/xml.rs +++ b/crates/rota-daemon/src/backends/namecheap/xml.rs @@ -66,6 +66,14 @@ impl ApiResponse { Ok(Event::Text(e)) if inside => { return Some(e.unescape().ok()?.into_owned()); } + // Namecheap wraps DCV record values in `` so + // operator-supplied values (DNS labels with dots, URLs, etc.) + // pass through unescaped. quick-xml fires Event::CData for + // these, not Event::Text. Handling both keeps the helper + // robust to either encoding. + Ok(Event::CData(e)) if inside => { + return String::from_utf8(e.into_inner().into_owned()).ok(); + } Ok(Event::End(e)) if e.local_name().as_ref() == element.as_bytes() => { inside = false; } @@ -186,6 +194,35 @@ mod tests { ); } + #[test] + fn first_text_unwraps_cdata() { + // Namecheap's actual reissue response wraps the DCV CNAME values + // in CDATA blocks. quick-xml fires Event::CData for these, not + // Event::Text, so the parser must handle both. + let body = r#" + + + + + + + + + + + +"#; + let resp = parse_response(body).unwrap(); + assert_eq!( + resp.first_text("HostName").as_deref(), + Some("_6958EA56A4FE23DDF2C3EDA7B9B956A5.oneiric.dev") + ); + assert_eq!( + resp.first_text("Target").as_deref(), + Some("46513AD29B078AF908AD3CDF354A8599.6CD5AE645BCCE1FAA847D98385AFF6CE.69ff68dc5168c.comodoca.com") + ); + } + #[test] fn parses_error_envelope() { let body = r#"