Skip to content

V2 spec follow-ups: SSL.com DCV, reissue endpoint, revoke fallback, token retry (0094, 0095) - #40

Open
spbsoluble wants to merge 11 commits into
feat/v2-consolidatedfrom
feat/v2-spec-followups
Open

spbsoluble wants to merge 11 commits into
feat/v2-consolidatedfrom
feat/v2-spec-followups

Conversation

@spbsoluble

@spbsoluble spbsoluble commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #39. Implements issues/0094-v2-spec-followups-plan.md items 1-5 and issues/0095 (V2 token transport detail and retry). It also covers the UnresolvedStatusFallbacks=5 investigation. Item 6 (OV organization representative) is deferred.

Items

1. DCV for SSL.com-validated domains (5e84e8c)

  • Lookup. Before DCV, each pending V2 domain is looked up with GET /domains?search=<fqdn>&exactMatch=true.
  • SSL.com domains (validatedBy: SSLCOM). The plugin reads SSL.com's record from GET /domains/{id}/dcv and publishes that exact recordName/recordValue. DcvTxtRecordTemplate is not used on this path. It then calls POST /domains/{id}/dcv/verify {optionId} and cleans up.
  • Only dns-txt options are used. The IAnyCAPlugin 3.3 IDomainValidator.StageValidation(key, value) has no record-type argument, so a CNAME can't be staged. A domain offered only dns-cname or http-url stays pending with a warning.
  • Failures leave the order pending and never cancel it: a 502, an unknown shape, a 422 with PENDING/UNDER_REVIEW/FAILED/EXPIRED, or no DNS provider.
  • Fail-open lookup. If the lookup fails or finds no row, the order takes the existing emSign flow, unchanged.
  • UCC orders route each domain by its own row, and share the staging, propagation wait, verify and cleanup phases.
  • Logging. Record values are never logged.

2. Revoke reason privilege-withdrawn (5a30826)

  • What changed live. CERTInext now rejects privilege-withdrawn, with new wording: 422 "Revocation reason privilege-withdrawn is not allowed.", with no EMS code.
  • Why that broke the fallback. The fallback matched only "Invalid Revoke Reason ID", so a reason-9 revoke failed with no retry.
  • The fix. The first attempt still sends privilege-withdrawn. A rejection in either wording now retries once as cessation-of-operation.

3. Docs: revoke reasons (56633e2). The spec note now gives the 2.0.0 SSL/TLS enum: 9 values, no privilege-withdrawn, and aa-compromise included.

4. prevettingToken (f7766e9). The JSON name now matches the spec. Nothing sets the token today, so this is wire-neutral; a serialization test pins the spelling.

5. Reissue via POST /ssl-certificates/{orderId}/reissue (8486405)

  • When. A V2 SSL/TLS Reissue, or a RenewOrReissue whose prior certificate is unexpired and expires after RenewalWindowDays. That's V1's reissue boundary.
  • What it sends. The prior order is resolved from PriorCertSN, then /reissue is called with mode: rekey, the new CSR, and UCC additionalDomains. It always uses rekey: Command gives no signal that only the SANs changed.
  • The derived order. Its id (for example 3527789913R001) becomes the CARequestID, and the normal post-create flow runs on it.
  • New setting V2ReissueRevokePrevious (Boolean, default false). When on, the call sends revokePrevious: true, revokeReason: superseded, and the revoke intent is audit-logged first. It's wired through the constants, config, annotation, ValidateCAConnectionInfo and the manifest.
  • Fallback to a new order (and the result says so): no PriorCertSN, no matching gateway order, or a 404/422 from /reissue.
  • No fallback on a 5xx, a lost response, or a 2xx without an orderId. These fail with "do not resubmit".
  • Unchanged: Private PKI and Document Signer.
  • Order-id audit. Ids are handled as opaque strings throughout (DTOs, sync matching, gateway lookups, the bodyless-revoke guard, family resolution with SSL first), so no code change was needed for non-numeric ids.

0095. Token transport failure (07d119a)

  • Retry. A V2 token request with no HTTP response is retried twice (1 s, then 3 s). HTTP answers, 400/401/403 included, are final. Order creates are still never retried.
  • Transport detail. Token failures, and any V2 call with no response, now log TransportDetail=: RestSharp's ResponseStatus plus the exception chain. That text passes through RedactCredentials, the configured secret values are replaced outright, and the length is capped. A short form goes into the exception message.
  • Message order. ThrowOnV2Failure keeps HTTP 0. first, because IsTransportLevelCsrFailure parses the first HTTP n. in the message.

Sync: untrackable report rows (ae97214, a9d4ef6)

  • Skipped, not errors. A row that /reports/orders lists but Track Order finds in no family is now skipped with one Information line and counted as UntrackableListedOrders. Every On Hold | Setup Pending row behaves this way. These rows used to log an error with a stack trace and count toward the sync's 25% error-rate abort.
  • Unmapped pairs in the summary. The summary now names every unmapped orderStatus | certificateStatus pair, with counts (UnmappedReportStatuses=[...]). CERTInext documents none of this text (Finding 35), so a new value now shows up in ordinary logs.

Also included: 1782375 adds the live probes and a result-message tidy; af91cf7 documents the probe flags.

Testing

  • dotnet build -c Release --no-incremental gives 0 warnings with DcvSupport true and false.
  • CERTInext.Tests passes 1296/1296 (baseline 1236, +60).
  • WireMock (real client): the domain DCV endpoints, plus reissue end to end: golden rekey body, revokePrevious on/off, UCC additionalDomains, 404/422 fallback, no fallback on 5xx or an unreadable 201, the RenewOrReissue window, Private PKI unchanged, and a derived id round-tripped through enroll, GetSingleRecord and Sync.
  • Moq plugin flow: SSL.com detection; txt-vs-cname selection; exact record staging; verify 200, 422 UNDER_REVIEW and 502; GetDcv 502; VALIDATED; emSign and failed-lookup paths; UCC with mixed validators.
  • Token retry: WireMock's faults still send a 200 status line, so the tests use a loopback TCP proxy that resets the connection. Enroll, sync and connector validation each succeed with two token attempts. A permanent drop is tried three times, with no secret in the message. 400/401/403 are tried once.

Live results (sandbox, each run once)

  • SSL.com DCV: not verifiable on sandbox. The lookup works (HTTP 200), and the row id field is domainId. The test domain has no validatedBy (emSign), and GET /domains?validatedBy=SSLCOM returns 0 rows.

  • Revoke, per reason (sent to our own order; CERTInext checks the reason before the order state):

    Live answer Reasons
    422 "Invalid Revoke Reason ID" unspecified, ca-compromise, certificate-hold, aa-compromise, remove-from-crl
    422 "...is not allowed." (new wording) privilege-withdrawn
    Accepted affiliation-changed, cessation-of-operation, key-compromise, superseded
  • Reissue, V2ReissueRevokePrevious=false (two approved runs):

    • 3527789913 gave 3527789913R001; the HTTP code wasn't captured on that run. 4669599214 gave HTTP 201, 4669599214R001, status: issued.
    • Neither derived order has a certificate yet (Approved by System).
    • Track Order finds them under ssl-certificates, and /reports/orders lists them.
    • The source orders are unchanged.
  • Full plugin sync (read-only, group 3162337784): Errors=0, UntrackableListedOrders=6, all of them On Hold | Setup Pending. 272 issued orders came back with bodies, and 541 pending.

  • kfclab gateway-certinext (net8, DcvSupport=false, this branch's build; the DLL hash was verified in the pod):

    • Enrollment on AnyCA_DV SSL: order 6881488167 was created (201) and went to issued, but the certificate isn't generated (422 EMS-1165). Command's workflow timed out at 60 s during the ~55 s pickup (an existing Known Limitation). Gateway row 1999 is pending.
    • Renew inside the window (race-10, 90-day RenewalWindowDays): RenewOrReissue correctly took the renewal path, placing new order 2195712881.
    • Reissue from Command. With the DV SSL template's RenewalWindowDays lowered to 30 in the gateway config (restored to the previous config afterwards), a Command renew of race-6 went Command → plugin → POST /reissue → 201 (Reissue=True) and created 5234549875R001 (gateway row 2002). The reissue call held for 25.8 s, and with the ~55 s pickup on top, Command's 60 s workflow timed out (see open question 5).
    • Derived id through real gateway sync. Gateway row 2001 is 3527789913R001, which the gateway's own sync imported.
    • Command bug, not the plugin. A Command renew of a certificate with no template (race-8, TemplateId: null, imported by sync) fails inside Command with HTTP 500, a NullReferenceException in EnrollmentSecurityService.IsUserAllowedEnrollmentPattern, before any gateway call.

UnresolvedStatusFallbacks=5

  • Which orders. A read-only probe over all 1429 /reports/orders rows found that the only orderStatus | certificateStatus pair the mapping can't place is On Hold | Setup Pending. Six orders have it account-wide (842): 3549334354, 3643952534, 4958277715, 1257971154 and 6534374548 from 2026-10-16, plus 2973146273 from 2026-06-11, which the gateway's group scope presumably excludes.
  • What Track says. Track Order returns 404 in every family for all six. Each one therefore counts under both UnresolvedStatusFallbacks and Errors.
  • No status mapping. The meaning is undocumented, and Track can't see these orders. The sync now skips them without counting them as errors (see above).
  • Rejected remap. The probe also tracked sample rows whose two fields disagree. Order Accepted | Rejected due to Order Cancellation tracks as pending-dcv, not cancelled, so it was not remapped. Order Fulfilled | Pending for Approver (1 row) tracks as issued, but the plugin maps it to pending; recorded, not changed.
  • Findings. Recorded in the CERTInext support-questions list (Finding 33 update; new Finding 34 for the revoke wording).

Orders left at CERTInext (sandbox stall; can't be revoked or cancelled yet)

1288714496, 3527789913R001, 4669599214R001, 5234549875R001, 6881488167, 2195712881. All four are issued with no certificate generated. Revoke answers "Certificate Request still being processed", and an issued order can't be cancelled. Run CERTINEXT_V2_CLEANUP_ORDERS=... once the sandbox generates certificates again.

Deferred

  • Item 6, OV organization representative. It needs five template parameters, OV-only and EMS-1321 phone validation, new personal-data redaction keys, and a status log. That's beyond "small", and it touches personal-data handling, so it should be its own PR.

Open questions

  1. Is a V2 SSL.com domain row's validatedBy present on GET /domains list rows (not just GET /dcv)? The spec doesn't type the list row, and the sandbox has none to check.
  2. Should RenewOrReissue inside the renewal window also use /reissue, rather than a new order? It would keep the subscription and avoid a new billable order, but V1's boundary was kept.
  3. Is a per-pending-domain GET /domains on every DCV pass acceptable? It's bounded by the per-pass DCV caps. Caching per pass is an option.
  4. CERTInext: should On Hold | Setup Pending be shown as pending or failed, and which field is authoritative when the report and Track disagree? (Finding 33.)
  5. Pickup budget versus Command's 60 s. The pickup counts its ~55 s from when it starts, not from when the enrollment started. Create (1-2 s) plus pickup already reaches about 60 s, and reissue (26 s) plus pickup is about 81 s. So any certificate that isn't ready in time fails in Command, even though the order exists, which invites resubmission and duplicate orders. Suggested fix: a deadline measured from enrollment start (about 50 s).

spbsoluble and others added 11 commits November 13, 2026 16:53
…endpoints (0094 item 1)

Each pending V2 domain is looked up with GET /domains?search=&exactMatch=true. For
validatedBy=SSLCOM the plugin reads SSL.com's record from GET /domains/{id}/dcv, publishes
its dns-txt recordName/recordValue (not DcvTxtRecordTemplate), verifies with
POST /domains/{id}/dcv/verify {optionId}, and cleans up. dns-cname/http-url can't be staged
through IAnyCAPlugin 3.3's TXT-only IDomainValidator, so those domains stay pending with a
warning. 502s, unknown shapes and 422 PENDING/UNDER_REVIEW leave the order pending for sync;
nothing is cancelled. A failed lookup or emSign row keeps the order-level flow. UCC orders
route each domain by its own row.

Live (sandbox): the /domains row id is domainId; the account has no SSLCOM domains, so the
SSL.com path is unit-tested only.
…tion; match CERTInext's newer reason-rejection wording (0094 item 2)

The 2.0.0 spec dropped privilege-withdrawn from the SSL/TLS revoke enum, and live (2026-11-13)
now rejects it with 422 "Revocation reason privilege-withdrawn is not allowed." - a different
wording from the "Invalid Revoke Reason ID" the fallback matched, so a reason-9 revoke failed
with no retry. The first attempt still sends privilege-withdrawn; a rejection in either wording
now retries once with cessation-of-operation. Opt-in live tests: privilege-withdrawn acceptance
on an own order, and a per-reason acceptance probe.
…094 item 3)

The spec's SSL/TLS enum now has 9 values including aa-compromise and no privilege-withdrawn;
the bullet said 8 and that aa-compromise was Private PKI/Document Signer only.
…revettingToken (0094 item 4)

SslOrganizationRef names it prevettingToken; the DTO sent preVettingToken. Nothing sets the
token today, so this is wire-neutral; a serialization test pins the spelling.
…e; V2ReissueRevokePrevious (0094 item 5)

A V2 SSL/TLS Reissue, or a RenewOrReissue whose prior certificate is unexpired and outside
RenewalWindowDays (V1's reissue boundary), resolves the prior order from PriorCertSN and calls
/reissue in rekey mode with the new CSR and UCC additionalDomains. The derived order id
(e.g. 1984857369R001) becomes the CARequestID and the normal post-create flow (track,
agreement, DCV, pickup) runs on it. update-sans is never used: Command gives no signal that
only SANs changed.

New connector setting V2ReissueRevokePrevious (Boolean, default false; constants, config,
annotation, ValidateCAConnectionInfo, manifest): sends revokePrevious=true with
revokeReason=superseded, audit-logged before the call.

No prior order, or a 404/422, falls back to a new order and the result message says so. A
5xx, lost response or 2xx without an order id throws with the do-not-resubmit wording and
places nothing. Private PKI/Document Signer keep their behaviour. Order ids were already
handled as opaque strings everywhere (audited); a WireMock test round-trips a derived id
through enroll, GetSingleRecord and Sync. Opt-in live test (not yet run):
CERTINEXT_V2_LIFECYCLE_REISSUE_ENDPOINT=1 + CERTINEXT_V2_REISSUE_SOURCE_ORDER_ID.
… transport detail (0095)

A token request that gets no HTTP response is retried twice (1 s, 3 s); it is idempotent and
creates nothing. HTTP answers (400/401/403 included) are final, and order creates are still
never retried. The failure log and exception now carry TransportDetail (RestSharp
ResponseStatus plus the exception chain's types/messages, passed through RedactCredentials
with the configured secret values replaced and capped) instead of only HttpStatus=0.
LogV2ApiFailure/ThrowOnV2Failure, the inline-CSR create and the reissue do the same for any V2
call with no response; ThrowOnV2Failure keeps 'HTTP 0.' first so IsTransportLevelCsrFailure
still reads status 0.

Tests drive a real dropped connection through a loopback TCP proxy (WireMock's faults still
send a 200 status line): enroll, sync and connector validation each succeed with two token
attempts; a permanent drop tries three times with no secret in the message; 400/401/403 are
tried once.
… acceptance; tidy reissue result message

Sync_UnresolvedReportStatuses_Probe (read-only) lists every /reports/orders status pair, the
rows the mapping can't place (UnresolvedStatusFallbacks) and a Track Order sample of rows whose
two fields disagree. Live: the only unmapped pair is 'On Hold | Setup Pending' (6 orders, Track
404 in every family), so no mapping change; 'Order Accepted | Rejected due to Order
Cancellation' tracks as pending-dcv, so it is not remapped either (Finding 33 update).
The reissue live test now routes logs before building the plugin, and the reissue note is
joined to the result message with a sentence break.
…ad of counting them as errors

Every 'On Hold | Setup Pending' row on the sandbox falls back to Track Order, which finds the
order in no family. Each one logged an error with a stack trace and counted toward the
25% error-rate abort. Such rows are now skipped for the pass with one Information line and
counted as UntrackableListedOrders in the summary; a later pass tracks them again. Nothing is
emitted for them (no evidence the order failed). The reissue success log now carries the
HTTP status. Adds a read-only live full-sync summary probe.
…summary

CERTInext documents none of the report's orderStatus/certificateStatus display strings
(Finding 35), so the only way to learn new ones is to observe them. The summary line now
carries UnmappedReportStatuses=[pair (count); ...] at Information instead of leaving each
to a per-row Debug line.
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