Skip to content

feat(namecheap): support Sectigo CNAMECSRHASH DCV method#36

Merged
albedosehen merged 1 commit into
mainfrom
feat/sectigo-cnamecsrhash-dcv
May 9, 2026
Merged

feat(namecheap): support Sectigo CNAMECSRHASH DCV method#36
albedosehen merged 1 commit into
mainfrom
feat/sectigo-cnamecsrhash-dcv

Conversation

@albedosehen
Copy link
Copy Markdown
Contributor

Closes the gap that blocked rota's first real renewal on aur0 against oneiriq.com (SSL ID 32542562) and oneiric.dev (SSL ID 31420556).

Why this exists

Modern Sectigo PositiveSSL certs (the default Namecheap issues 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's published "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:   _<md5_hex>.<domain>
Target: <sha256_hex[0..32]>.<sha256_hex[32..64]>.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 (every reseller KB plus Namecheap's own response examples) uses comodoca.com.

Changes

  • compute_csrhash_dcv(csr_pem, domain) returns the existing DcvChallenge::Dns01 shape so DcvBackend plugs in unchanged.
  • NamecheapCa::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.
  • When NONE of the three response 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:

  • host prefix _, MD5 32-char uppercase hex, target zone comodoca.com, SHA256 split into two 32-char lowercase labels, TTL matches DCV_TTL_SECONDS
  • the 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

Total: rota-core 40 / rota-daemon 110 (was 106) / rota-cli 5 — all passing locally.

Deps

md-5, sha2, hex (RustCrypto stack, minimal transitive footprint) added at the workspace level and pulled into rota-daemon.

Sources

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:   _<md5_hex>.<domain>
  Target: <sha256_hex[0..32]>.<sha256_hex[32..64]>.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.
@albedosehen albedosehen merged commit e7a945e into main May 9, 2026
1 check passed
@albedosehen albedosehen deleted the feat/sectigo-cnamecsrhash-dcv branch May 9, 2026 16:25
albedosehen added a commit that referenced this pull request May 9, 2026
…ute (#37)

The reissue response embeds DCV record values inside <![CDATA[...]]>
blocks. quick-xml fires Event::CData for these (not Event::Text), so
the existing first_text() helper silently returned None and
NamecheapCa::submit() errored with "missing DCV record fields"
even though the response carried the right values all along.

The actual response shape, captured via the debug! dump landed in
PR #36:

  <DNSDCValidation ValueAvailable="true">
    <DNS domain="oneiric.dev">
      <HostName><![CDATA[_<MD5>.oneiric.dev]]></HostName>
      <Target><![CDATA[<SHA256_FIRST32>.<SHA256_LAST32>.<UNIQUE>.comodoca.com]]></Target>
    </DNS>
  </DNSDCValidation>

Two changes:

* xml::ApiResponse::first_text now handles Event::CData alongside
  Event::Text. Single test pinning the real (CDATA-wrapped) DCV
  shape against the parser.

* Revert the CNAMECSRHASH local-compute path from PR #36. It was
  predicated on the canonical Sectigo spec (which says target =
  <SHA256_FIRST32>.<SHA256_LAST32>.comodoca.com), but the deployed
  Namecheap response includes a unique-per-order label between the
  SHA256 split and comodoca.com (e.g. "69ff68dc5168c"). Local
  compute can't produce that without an API round-trip, so the
  function would have generated non-resolving CNAMEs if any caller
  ever reached it. The branch never fired in practice anyway because
  ssl.reissue responses use HostName/Target, not ApproverEmail.
  Drop compute_csrhash_dcv + its 4 tests + md-5/sha2/hex deps; the
  CDATA unwrap alone gets the renewal pipeline past this gate.

Keep the debug! dump on the missing-DCV-fields path: it's how this
bug was diagnosed and any future deviation will surface the same
way.
albedosehen added a commit that referenced this pull request May 9, 2026
rota's `get_info` was looking for `<CertificateReturned>` element
text and `<CACertificate>` element text. Neither matches Namecheap's
actual `ssl.getInfo&Returncertificate=true` response, which carries
`CertificateReturned` as an ATTRIBUTE on `<Certificates>` and packs
PEMs in nested `<Certificate>` elements:

  <Certificates CertificateReturned="true" ReturnType="INDIVIDUAL">
    <Certificate><![CDATA[LEAF_PEM]]></Certificate>
    <CaCertificates>
      <Certificate Type="INTERMEDIATE">
        <Certificate><![CDATA[INTERMEDIATE_1_PEM]]></Certificate>
      </Certificate>
      ...
    </CaCertificates>
  </Certificates>

Result: cert_pem and chain_pem both empty, `is_issued()` false,
polling never terminates even when status==active. So PR #40's
chain-follow lands on the right SSL ID but `await_issuance` still
hangs at the extraction step. Found by extracting the cert manually
out of band when `getInfo` returned status=active for oneiric.dev's
in-flight order: rota's parser yielded empty strings even though
the PEMs were sitting right there in the response.

Fix: new `ApiResponse::pem_blocks(label)` method scans the raw
response for `-----BEGIN <label>-----`...`-----END <label>-----`
armor and returns each block in document order. `get_info` calls
`pem_blocks("CERTIFICATE")`; first block is the leaf, rest are the
chain (concatenated with newlines). The CSR present in the same
response is safely skipped because its label is "CERTIFICATE
REQUEST" and `BEGIN CERTIFICATE-----` doesn't substring-match
`BEGIN CERTIFICATE REQUEST-----`.

This is the 6th and (hopefully) final layer in the rota+Namecheap
end-to-end renewal pipeline, after PRs #36 (reverted), #37 (CDATA
unwrap), #38 (DnsCname variant), #39 (lowercase HostName), #40
(ReplacedBy chain + Status XML path). Tests: 3 new in xml::tests
covering the leaf+chain extraction, the CSR-skip rule, and the
empty-input edge case. Total daemon test count 111 (was 108).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant