Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
3 changes: 0 additions & 3 deletions crates/rota-daemon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down
185 changes: 11 additions & 174 deletions crates/rota-daemon/src/backends/namecheap/ca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,16 @@ 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;

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 {
Expand Down Expand Up @@ -76,24 +69,15 @@ impl CABackend for NamecheapCa {

async fn submit(
&self,
domains: &[String],
_domains: &[String],
csr_pem: &str,
_preferred_kinds: &[ChallengeKind],
) -> Result<Vec<DcvChallenge>> {
// Namecheap's reissue command: submit the CSR + DNS-DCV election.
// The response carries one of three shapes:
// 1. `<TxtName>`/`<TxtValue>`: TXT-record DCV (legacy Sectigo
// flow on some products).
// 2. `<HostName>`/`<Target>`: CNAME-record DCV with the values
// returned inline (older Comodo flow).
// 3. `<ApproverEmail>CNAMECSRHASH</ApproverEmail>` 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 `<HostName>`/`<Target>` pair (CNAME
// validation) or a `<TxtName>`/`<TxtValue>` pair depending on the
// CA tier. Both pairs land inside `<![CDATA[...]]>` 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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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: `_<MD5_HEX_UPPERCASE>.<domain>`
/// * Target: `<SHA256_HEX_FIRST32>.<SHA256_HEX_LAST32>.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<DcvChallenge> {
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}"
);
}
}
37 changes: 37 additions & 0 deletions crates/rota-daemon/src/backends/namecheap/xml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<![CDATA[...]]>` 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;
}
Expand Down Expand Up @@ -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#"<?xml version="1.0"?>
<ApiResponse Status="OK">
<CommandResponse>
<SSLReissueResult ID="34351741" IsSuccess="true">
<DNSDCValidation ValueAvailable="true">
<DNS domain="oneiric.dev">
<HostName><![CDATA[_6958EA56A4FE23DDF2C3EDA7B9B956A5.oneiric.dev]]></HostName>
<Target><![CDATA[46513AD29B078AF908AD3CDF354A8599.6CD5AE645BCCE1FAA847D98385AFF6CE.69ff68dc5168c.comodoca.com]]></Target>
</DNS>
</DNSDCValidation>
</SSLReissueResult>
</CommandResponse>
</ApiResponse>"#;
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#"<?xml version="1.0" encoding="utf-8"?>
Expand Down
Loading