Skip to content

fix(enroll): UCC SANs never reached CERTInext — additionalDomains sent empty (1.1 port) - #22

Open
spbsoluble wants to merge 2 commits into
release-1.1from
fix/v1.1-ucc-san-dnsname
Open

fix(enroll): UCC SANs never reached CERTInext — additionalDomains sent empty (1.1 port)#22
spbsoluble wants to merge 2 commits into
release-1.1from
fix/v1.1-ucc-san-dnsname

Conversation

@spbsoluble

Copy link
Copy Markdown
Collaborator

Summary

Port of the UCC SAN fix to the 1.1 line. Original: #21 (stacked on #20, targeting release-1.0).

Certificates enrolled through a UCC product came back holding only the CN, even though the requested SANs were on the CSR and in the SAN data Command supplied. A customer reported it as "UCC SANs not completely populating the certificate although SANs are included on the CSR" — reasonably read as the CA stripping them. The names were being dropped inside this plugin.

The SAN code was identical across release-1.0, release-1.1 and main, so 1.1 has the bug in full.

Root cause

The AnyCA REST Gateway keys its SAN dictionary dnsname, but MapSanType only recognized dns. So:

  1. Every DNS SAN was typed "dnsname".
  2. BuildAdditionalDomains kept entries only where Type == "dns"all SANs filtered out.
  3. AdditionalDomains became null, and [JsonIgnore(WhenWritingNull)] removed additionalDomains from the order body entirely.
  4. CERTInext built the order from domainName (the CN) alone.

Confirmed in a customer gateway log — note the key:

Enrollment attempt started. ... ProfileId=848,
  SANs=dnsname:CLAUDIOTEST20.ucsd.edu; dnsname:CLAUDIOTEST20.ad.ucsd.edu

The CSR did not compensate, because CERTInext ignores the CSR's subjectAltName extension outright (measured below).

Why no test caught it: the only test touching this path used Type = "DNS", which maps cleanly and works. No test used the real gateway key.

Measured CERTInext behaviour

The old code encoded three assumptions that had never been tested. SanSubmissionProbeTests (new, opt-in) measures them against the live API — sandbox-us, product 844 / OV SSL UCC — by placing one order per variant and reading back the domain set the CA registered via TrackOrder's domainVerification keys.

Probe Result
CN + extra1.<cn> via additionalDomains Both registered — this is the field that works
CSR carries CN + extra2.<cn>, additionalDomains omitted Only the CN registeredCERTInext ignores CSR SANs. The customer-facing root cause.
CN repeated inside additionalDomains Accepted; CA collapses it to one
email:san-probe@example.com Accepted — registered verbatim as an order domain
ip:192.0.2.10 Accepted — registered verbatim as an order domain
uri:https://san-probe.example.com/x Accepted — registered verbatim as an order domain

Two assumptions were wrong:

  • Non-DNS values are not rejected. The DNS-only filter assumed the CA would refuse them. It does not — it registers them as "domains", so the order is created and then cannot pass validation, rather than failing up front. Comments and log text say this instead of claiming rejection.
  • CN de-duplication is not required — the CA collapses it itself. Ours is documented as defence in depth.

Changes

  • MapSanType recognizes what the gateway actually sends: dnsname, rfc822name, ipaddress, uniformresourceidentifier. Short forms still work.
  • BuildSanList unions gateway-supplied SANs with SANs parsed from the CSR (BouncyCastle, per project crypto policy), de-duplicating on type+value case-insensitively. Non-throwing: an unparseable CSR falls back to the gateway set. Parsing the CSR is not redundant with sending it — the CA won't read those names, so re-submitting via additionalDomains is the only way a CSR-only SAN reaches the certificate.
  • BuildAdditionalDomains no longer filters to DNS-only. Everything requested is submitted; silently dropping names the subscriber asked for is the worse failure. Excludes the value already going out as domainName.
  • Renew path: renewals previously submitted no SANs at all, and took domainName from the prior order's requestorName (not a domain). RenewCertificateRequest now carries Subject + Sans; the renewal order derives domainName from the subject CN, with the old value as a logged fallback.
  • Logging: PlaceOrderAsync now logs domainName and additionalDomains. The absence of outbound domain logging is precisely what made this read as CA-side stripping.

Difference from #21

One adaptation, in test code only — the fix itself is byte-identical:

  • SanSubmissionTests.BuildPlugin() drops the PickupRetries = 0 config. Synchronous certificate pickup is a 1.0.x hotfix feature and CERTInextConfig has no PickupRetries on this line, so the plugin is constructed with the injected client alone.

Behaviour change to be aware of

Non-DNS SANs (IP / email / URI) previously vanished silently and the certificate issued without them. They are now submitted, so such an enrollment produces an order that parks pending instead of issuing and needs manual cancellation. Intentional — a visibly stuck order beats a certificate quietly missing requested names — and the plugin logs a Warning naming the offending SANs.

Tests

Release, DCV (1.1 default) : 222/222 passed   (net10.0)
Release, -p:DcvSupport=false : 193/193 passed   (net10.0)
Code warnings               : 0 (both variants + integration project)

9 new unit tests drive plugin.Enroll through a real CERTInextClient against WireMock and assert on the JSON actually posted — a test of the mapping function alone would not have caught this bug, since the mapping "worked" and the loss happened in its interaction with the downstream filter. The live probe is opt-in behind CERTINEXT_SAN_PROBE=1.

Review notes

  • Targets release-1.1 as requested; nothing committed to main. main and release-1.1 are source-identical across CERTInext/, CERTInext.Tests/ and CERTInext.IntegrationTests/ (empty diff), so this covers the main content too and can flow forward on the normal release merge.
  • The renew-path fix is adjacent scope, included because it is the same defect class and a half-fix would have left renewals broken. Happy to split it out.

…t empty

Certificates enrolled through a UCC product came back holding only the CN, even
though the requested SANs were present on the CSR and in the SAN data Command
supplied. The names were being dropped inside the plugin, not by the CA.

Root cause: the AnyCA REST Gateway keys its SAN dictionary "dnsname", but
MapSanType only recognized "dns". Every DNS SAN was therefore typed "dnsname",
which failed the DNS-only test in BuildAdditionalDomains, so
certificateInformation.additionalDomains was null and JsonIgnore-WhenWritingNull
removed the field from the order body entirely. Confirmed against a customer
gateway log:

  Enrollment attempt started. ... SANs=dnsname:CLAUDIOTEST20.ucsd.edu;
                                       dnsname:CLAUDIOTEST20.ad.ucsd.edu

The CSR did not compensate, because CERTInext ignores the CSR's subjectAltName
extension outright — measured, see below.

Changes:

* MapSanType now recognizes the spellings the gateway actually sends: dnsname,
  rfc822name, ipaddress, uniformresourceidentifier (the short forms still work).

* BuildSanList unions the gateway-supplied SANs with the SANs parsed out of the
  CSR (BouncyCastle, per the project crypto policy), de-duplicating on
  type+value case-insensitively. Parsing the CSR is not redundant with sending
  it: CERTInext will not read those names itself, so re-submitting them through
  additionalDomains is the only way a CSR-only SAN reaches the certificate. CSR
  parsing is non-throwing — an unparseable CSR falls back to the gateway set.

* BuildAdditionalDomains no longer filters to DNS-only. Every requested SAN is
  submitted; discarding the non-DNS ones issued certificates quietly missing
  names the subscriber asked for, which is the worse failure. It also excludes
  the value already going out as domainName so the CN is not submitted twice.

* Renewals carried no SANs at all and took their primary domain from the prior
  order's requestorName. RenewCertificateRequest now carries Subject + Sans, and
  the renewal order derives domainName from the subject CN with the old value
  as a logged fallback.

* PlaceOrderAsync now logs domainName and additionalDomains. The absence of any
  outbound domain logging is what made this look like CA-side stripping: the
  gateway log recorded the SANs Command supplied and nothing about what was put
  on the wire.

Measured CERTInext behaviour (SanSubmissionProbeTests, sandbox-us, product 844
OV SSL UCC) — these replace assumptions the old code encoded but never tested:

  * additionalDomains is what puts extra names on the order (CN + extra1 →
    both registered).
  * CERTInext IGNORES CSR SANs. A CSR carrying two DNS names with
    additionalDomains omitted produced an order with only the CN registered.
    This is the customer-facing root cause.
  * Non-DNS values are NOT rejected, contrary to what the DNS-only filter
    assumed. An email address, an IPv4 literal and an https URI were each
    accepted and registered verbatim as order domains, so such an order is
    created and then cannot pass validation rather than failing up front. The
    plugin warns accordingly.
  * Repeating the CN inside additionalDomains is accepted and collapsed by the
    CA, so our de-duplication is defence in depth rather than a requirement.

Tests: 9 new unit tests drive plugin.Enroll through a real client against
WireMock and assert on the JSON actually posted — a test of the mapping function
alone would not have caught this, since the mapping "worked" and the loss
happened in its interaction with the downstream filter. The live probe is
opt-in behind CERTINEXT_SAN_PROBE=1.

Port note (release-1.1): the unit-test helper drops the PickupRetries=0 config
used on the 1.0.1 branch. Synchronous certificate pickup is a 1.0.x hotfix
feature and CERTInextConfig has no PickupRetries on this line, so the plugin is
constructed with the injected client alone. The fix itself is unchanged.
The probe ran against sandbox-us, but the comments and the non-DNS warning read as
though the behaviour were established generally. The customer this fix is for is on
production, so the distinction matters.

* The non-DNS finding (CERTInext accepts an email/IP/URI verbatim as an order domain
  rather than rejecting it) is explicitly sandbox-only and flagged unverified on
  production. The operator-facing warning no longer promises a parked order — it names
  the offending SANs and says the order will either be rejected or fail validation,
  noting what the sandbox did.

* The CN-collapse finding is likewise marked sandbox-only, which strengthens rather than
  weakens the case for de-duplicating on our side: we should not depend on undocumented
  CA behaviour we have not seen in production.

* The CSR-SAN finding — CERTInext ignores the CSR's subjectAltName entirely — is noted as
  corroborated by production independently of the probe: the report that prompted this
  work was a production UCC order whose CSR carried the SANs and whose certificate came
  back holding only the CN.

* Probe header now says how to re-run against production, and warns that product
  numbering is per-account (Constants.Products holds defaults, not guarantees).

No functional change.
@spbsoluble

Copy link
Copy Markdown
Collaborator Author

Status update: this PR has the base fix only, not the full-review hardening

Since this PR was opened, the companion PR into release-1.0 (#21) went through 11 rounds of adversarial full-review, which found and fixed real defects beyond the original SAN bug — most substantially a full rewrite of the DCV per-domain retry/cleanup loop (PerformDcvIfNeededAsync in CERTInext/CERTInextCAPlugin.cs), plus fixes in CERTInextClient.cs's ExecuteWithRetryAsync, several audit-log sanitization gaps, and a SubmitNonDnsSans config escape hatch. See #21's commit history (f6fbc80..0cd840a) for the full list.

This branch does not have those fixes. Porting them turned out to be more than a mechanical cherry-pick: release-1.1 independently added CNAME-delegation support (DcvFollowCnameDelegation) to the exact same PerformDcvIfNeededAsync function that all 11 rounds rewrote. Both changes touch the same lines with different, compatible intent (mine changes how staging failures are handled; 1.1's changes what key is used to resolve a DNS provider), so reconciling them needs a careful hand-merge, not a bulk patch apply — I did not want to rush that under time pressure and risk silently reintroducing one of the 11 fixed bugs.

I also found that porting the surrounding support code alone (the SubmitNonDnsSans config property, CleanupValidationTimeoutSeconds constant, updated test helpers) without the CERTInextCAPlugin.cs/CERTInextClient.cs logic that actually uses them would leave this branch with inert, unused scaffolding — worse than the current state — so I backed that out rather than commit it.

What this PR currently has: the original UCC-SAN fix (MapSanType recognizing dnsname, the CSR-SAN fallback, the renew-path SAN fix) — which correctly fixes the customer-reported bug. It does not have the DCV-loop hardening, the cancellation/RestSharp fixes, or the log-sanitization sweep from #21's review.

Recommendation: land #21 first, then redo this port as its own focused task once release-1.0 is settled — the merge may be more tractable then, or worth doing as a deliberate rebase rather than a cherry-pick chain.

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