Skip to content

Add support for EV, OV Wildcard, and DV Wildcard multi-name certificate products - #18

Open
bhillkeyfactor wants to merge 36 commits into
release-1.2from
feature/ev-ov-dv-multiname-certs
Open

bhillkeyfactor wants to merge 36 commits into
release-1.2from
feature/ev-ov-dv-multiname-certs

Conversation

@bhillkeyfactor

Copy link
Copy Markdown
Contributor

Summary

  • Adds three new CSC TrustedSecure certificate products: EV Multiple Names (type 7), OV Wildcard Multiple Names (type 8), and DV Wildcard Multiple Names (type 9), per CSC certificate type table.
  • Type 7 sends both evCertificateDetails and subjectAlternativeNames since it is EV plus multi-name; types 8 and 9 send subjectAlternativeNames only.
  • Updated integration-manifest.json, docsource/configuration.md, and CHANGELOG.md.

Test plan

  • dotnet build succeeds with no new errors
  • Verify enrollment against each new product/template in a test AnyGateway REST instance

bhillkeyfactor and others added 30 commits September 9, 2026 14:23
…te products

CSC TrustedSecure EV, Multiple Names (7), CSC TrustedSecure OV Wildcard,
Multiple Names (8), and CSC TrustedSecure DV Wildcard, Multiple Names (9)
per CSC's certificate type table. Type 7 requires both EvCertificateDetails
and SubjectAlternativeNames since it is EV plus multi-name; 8 and 9 only
need SubjectAlternativeNames.
Introduce a step-oriented FlowLogger for tracing Enroll, Revoke, and
Synchronize pipelines end to end, and add logging throughout
RequestManager (previously had none) and CscGlobalClient (missing
error/warning branches, wrong logger category on CscGlobalClient).
…s unset

Only look up the optional additional-SAN-emails field when the domain
control validation method is EMAIL, and use TryGetValue instead of the
indexer so a missing/blank value no longer throws.
…no 'dnsname' key

UC certificate enrollments with zero SANs supplied threw KeyNotFoundException
from the sans["dnsname"] indexer. Use TryGetValue and treat a missing key as
no SANs instead of throwing.
Registration/Renewal/Reissue/Revoke calls only handled CSC Global's
400 error-body case; any other failure status (e.g. 404) fell through
to deserializing an empty/unexpected body, leaving Result and
RegistrationError both null and causing a downstream
NullReferenceException in RequestManager instead of a clear error.
Add null and bounds guards throughout the enrollment, revoke, and sync
paths: CSC Global responses (Result/RegistrationError/Status may be
null even on a 200), optional ProductParameters lookups that were
using the strict indexer, CARequestID substring truncation, and
config/dictionary lookups in Initialize and the CscGlobalClient
constructor. Replaces silent NREs with clear, logged failures.
Previously the CSC Global list request filtered to status=in=(ACTIVE,REVOKED)
and SyncCertificates only forwarded GENERATED/REVOKED records, so any
pending or failed request was never reported back to Command on any
sync cycle. Command eventually treats a request it hasn't seen in
recent syncs as outdated and tries to prune it, which can trigger an
internal Command cleanup bug for requests with no staged private key.
Now every request CSC Global returns is forwarded to Command each
sync, with certificate content attached only when actually issued or
revoked.
…ng sync

CSC Global renamed its product line at some point; its sync/list API
now returns current names (e.g. "CSC TrustedSecure DV") while this
plugin's ProductIDs/GetCertificateType still use the legacy names it
shipped with (e.g. "CSC TrustedSecure Domain Validated SSL"). Syncing
a cert back set ProductID directly from CSC's current name, which
never matched Command's Certificate Profile/Template mappings (keyed
on the legacy names) for 7 of the 10 product types. Add a reverse
lookup so synced certificates resolve to the same ProductID used
during enrollment.
Drop the legacy product-name compatibility layer (GetCertificateType's
duplicate/old-name cases, the CscCertificateTypeToProductId sync mapping)
and use CSC's certificate type names directly everywhere: CSC TrustedSecure
OV/OV Wildcard/OV Multiple Names/EV/DV/DV Wildcard/DV Multiple Names plus
the three new EV/OV Wildcard/DV Wildcard Multiple Names products. There is
no template mapping persisted in the gateway database, so no migration is
needed, but existing Command Certificate Templates must have their
Template Short Name updated to the new names.
TemplateSync defaulted to off and was never turned on, so template sync
never ran. There is no reason to keep it opt-in, so always resolve
synced certificates back to their CSC certificate type/template and
drop the gateway registration setting.
Matches the pattern used in hydrantid-caplugin: pass the resolved value
through as-is rather than substituting a fallback string that looks
like a real product name.
Add a CSCGlobalCAPlugin.Tests project modeled on hydrantid-caplugin's
test setup (xUnit, Moq, coverlet). Covers RequestManager, FlowLogger,
CscGlobalClient (via a fake HttpMessageHandler), and CSCGlobalCAPlugin's
orchestration logic (Synchronize, Enroll, Revoke, GetSingleRecord,
GetEndEntityCertificate, annotations).

Rework needed for testability:
- CSCGlobalCAPlugin now resolves its ICscGlobalClient through an
  internal ClientFactory delegate instead of constructing one eagerly
  in Initialize, so tests can substitute a mock.
- CscGlobalClient gained an internal constructor overload that accepts
  an HttpMessageHandler, so HTTP calls can be intercepted without a
  real network client.
- Added AssemblyInfo.cs with InternalsVisibleTo for the test project.

Bugs found and fixed while writing tests:
- MultiNameCertificateTypes was missing certificate type "6" (CSC
  TrustedSecure DV, Multiple Names), so that product never got
  SubjectAlternativeNames populated despite its name.
- The prior-cert-serial-number extraction in Enroll() gated on the
  lowercase key "priorcertsn" but read the properly-cased
  "PriorCertSN", so priorSn was effectively never populated and
  Renew/Reissue enrollments always failed with "no prior certificate
  serial number was supplied".

Also fixed a blocking .Result call in
CscGlobalClient.SubmitCertificateListRequestAsync to use await.
The gateway host now runs on .NET 10 as well as .NET 6/8, so build the
plugin against all three. The net8.0 package set (Keyfactor.PKI 8.1.1,
IAnyCAPlugin 3.1.0) is reused for net10.0 since those libraries are
forward-compatible with the newer runtime.
Existing Certificate Templates in Command may still reference the
pre-1.2.0 CSC product names (e.g. "CSC TrustedSecure UC Certificate").
Add a legacy name -> canonical name alias table and resolve it in
ValidateProductInfo and RequestManager.GetCertificateType so both old
and new names continue to work during enrollment.
CSC Global rejects a multi-name registration if any subjectAlternativeNames
entry is missing domainControlValidation. GetSubjectAlternativeNames could
return a SAN with a null DomainControlValidation when the domain didn't
match any address in "Addtl Sans Comma Separated DVC Emails" (or when that
field wasn't set at all), causing enrollment to fail. Both the EMAIL
no-match case and the CNAME case now mirror the primary CN's DCV method/
email instead of returning null or a hardcoded empty value.
Previously, Enroll failures gave the requester only a terse message
(e.g. CSC Global's "Open order in progress"), with no indication of
what the plugin actually attempted before hitting that error. Every
failure path in Enroll now prepends flow.GetSummary() to StatusMessage,
matching the pattern used in acme-provider-caplugin.

Also:
- An exception thrown mid-enrollment is now caught and returned as a
  failed EnrollmentResult (with flow summary + error detail) instead of
  bubbling up as an unhandled exception that Command has to wrap itself.
- Fixed several early-return failure branches reporting
  Status = 30 (INPROCESS) instead of the correct FAILED status.
- Fixed the reissue "one click not available" message incorrectly
  saying "Renew" instead of "Reissue".
LogHandler.FlattenException includes the full stack trace, which was
getting embedded both in the flow summary's failed step line and in
the trailing error text, making the message shown to the requester in
Command needlessly long. Use e.Message for the flow step and the
final error line; the full flattened exception still goes to the log.
CSC Global returns "price.total": null when a certificate cannot be
reissued (e.g. it's not in an active status). Price.Total being a
non-nullable decimal caused Newtonsoft to throw a
JsonSerializationException while parsing the response, which surfaced
to the requester as a raw deserialization error instead of the actual
RegistrationError/order status CSC was trying to report.
CSC Global's renewal/reissue responses never include an issued
certificate - the request is only submitted and still needs domain
control validation (CNAME record or email confirmation) before CSC
actually issues the certificate. GetRenewResponse and GetReIssueResult
were unconditionally returning Status = GENERATED with no Certificate
set, which crashed the gateway host (NullReferenceException in
PemUtilities.PEMCertificateToDER) when it tried to parse a certificate
that didn't exist.

Both now return EXTERNALVALIDATION on success, matching new enrollment,
and populate EnrollmentContext with the CNAME/email DCV instructions
via a shared BuildDcvEnrollmentContext helper (previously only wired
up for new enrollment). Also fixed GetRenewResponse never setting
CARequestID on success.
The legacy-name backward compat tests only covered new enrollment and
ValidateProductInfo. GetRenewalRequest, GetReissueRequest, and the full
Enroll RenewOrReissue path had no test proving a Certificate Template
still configured with a pre-1.2.0 product name resolves correctly.
The int-literal initializer was dead (every switch branch, including
default, already assigns a Keyfactor.PKI EndEntityStatus value), and
was the last place in the plugin where a status value wasn't backed
by the library enum. Every Status assignment elsewhere already uses
(int)EndEntityStatus.X.
Adds a data-driven test covering all 10 CSC certificate products with
realistic domain scenarios, grounded in Sectigo's own Multi-Domain/UCC
and Multi-Domain Wildcard product documentation (CSC resells Sectigo
behind the scenes): SANs on the Multiple Names products are exercised
with unrelated domains (not just subdomains of one base domain), and
the wildcard multi-name types are exercised with wildcard SANs for
unrelated domains and with the base-domain-plus-wildcard-SAN pairing
Sectigo's docs call out as required for full coverage.
RequestManager.GetSubjectAlternativeNames looked up "Addtl Sans Comma
Separated DVC Emails" (V/C swapped), but the field is registered and
sent by Command as "Addtl Sans Comma Separated DCV Emails" per
EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails. The
lookup never matched, so any SAN on a domain unrelated to the CN
silently fell back to the CN's own DCV email - which has no authority
to validate a different domain. Now reads the same constant used to
register the field, so the two can't drift apart again.

Also updated the one existing test that had (accidentally) encoded
the same typo, matching the buggy behavior instead of the real field
name.
Command's enrollment UI does not surface StatusMessage on a successful
result - only EnrollmentContext (the DCV instructions dictionary) is
shown. Renamed EnrichFailureWithFlowSummary to AttachFlowSummary and
extended it to add a "Flow Summary" entry to EnrollmentContext on
success, alongside whatever DCV entries came back, so the requester
can see what the plugin actually did even when there's nothing to
troubleshoot.
Command's enrollment UI renders EnrollmentContext as a bulleted list
but doesn't respect embedded newlines within a single entry's value,
so the multi-line flow summary was showing up as one unreadable
run-on line. Added FlowLogger.GetSummaryEntries(), which returns one
dictionary entry per step (plus an overview entry) instead of a
single multi-line string, so each step naturally becomes its own
bullet - using the UI's existing list rendering rather than trying to
force line breaks into a string value.
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