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: 3 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
3 changes: 3 additions & 0 deletions crates/rota-daemon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down
186 changes: 179 additions & 7 deletions crates/rota-daemon/src/backends/namecheap/ca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -69,15 +76,24 @@ 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 either an `<HostName>`/`<Target>` pair (CNAME
// validation) or a `<TxtName>`/`<TxtValue>` 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. `<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`.
//
// `preferred_kinds` is ignored: Namecheap reissue only supports
// DNS-01 over their API. If the operator pairs a Namecheap CA
Expand All @@ -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"))
{
Expand All @@ -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(),
));
Expand Down Expand Up @@ -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: `_<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}"
);
}
}
Loading