From 3e3d5815b96bc9a34faea51440beac5167079923 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:11:23 -0700 Subject: [PATCH 01/71] =?UTF-8?q?feat(enroll):=20synchronous=20certificate?= =?UTF-8?q?=20pickup=20(Sectigo=20parity)=20=E2=80=94=20v1.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After submitting an order, Enroll() polls GetCertificate up to PickupRetries times (default 5), PickupDelay seconds apart (default 10), after a 5s initial delay, so a fast-issuing order returns the certificate in the same enrollment call instead of waiting for the next sync. Mirrors the legacy Sectigo connector's PickUpEnrolledCertificate (~55s max worker-thread occupancy by default). Applied to the new, reissue, and renew paths; both build flavors. PickupRetries=0 disables. Orders not issued within the window are returned pending and imported by a later sync (unchanged). OV/EV are issued asynchronously by the CA and typically exhaust the window; DV / already-approved orders return in-call. --- CERTInext/CERTInextCAPlugin.cs | 136 ++++++++++++++++++++++++++- CERTInext/CERTInextCAPluginConfig.cs | 57 +++++++++++ CERTInext/Constants.cs | 30 ++++++ CHANGELOG.md | 5 + 4 files changed, 227 insertions(+), 1 deletion(-) diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 231f611..df51796 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1170,8 +1170,15 @@ private async Task EnrollNewAsync( } #endif + // Synchronous certificate pickup (Sectigo-parity): poll for the issued certificate so + // a fast-issuing order returns GENERATED + PEM in this same call. No-op for the + // already-issued/failed case and for OV/EV orders that CERTInext issues asynchronously + // — those fall back to the pending result and are imported by the next sync. + var newResult = BuildEnrollmentResult(enrollResp, ep.AutoApprove); + newResult = await PickUpEnrolledCertificateAsync(newResult, enrollResp.Id); + _logger.MethodExit(LogLevel.Debug); - return BuildEnrollmentResult(enrollResp, ep.AutoApprove); + return newResult; } /// @@ -1297,6 +1304,9 @@ private async Task RenewOrReissueAsync( "PriorCARequestID={PriorId}, NewCARequestID={NewId}, Status={Status}", priorCaRequestId, renewResult.CARequestID, renewResult.Status); + // Synchronous certificate pickup (Sectigo-parity), same as the new-enrollment path. + renewResult = await PickUpEnrolledCertificateAsync(renewResult, renewResp.Id); + return renewResult; } else @@ -1868,6 +1878,130 @@ private async Task WaitForDcvVerificationAsync(string orderNumber, IReadOnlyList } } + /// + /// Synchronous certificate pickup — parity with the legacy Sectigo connector's + /// PickUpEnrolledCertificate. After an order is submitted, polls + /// GetCertificate up to PickupRetries times, PickupDelay seconds + /// apart (after a fixed initial delay), so an order that issues quickly is returned + /// GENERATED + PEM in the same enrollment call instead of waiting for the next + /// synchronization. If the certificate has not issued within the budget, the original + /// pending result is returned unchanged and the order is imported by a later sync — + /// behaviour identical to before this feature. + /// + /// Applies to ALL products. CERTInext issues OV/EV asynchronously (organization + /// verification, minutes to hours; confirmed by CERTInext support ticket #162763), so + /// those typically exhaust the budget and fall back to pending; only DV / already-approved + /// orders return in-call. Never throws — any polling error degrades to the pending result. + /// + private async Task PickUpEnrolledCertificateAsync( + EnrollmentResult pendingResult, string orderNumber) + { + // Only a still-pending (external-validation) result can benefit from a pickup poll. + // An already issued/failed/revoked result, or a missing order number, is returned as-is. + if (pendingResult == null + || pendingResult.Status != (int)EndEntityStatus.EXTERNALVALIDATION + || string.IsNullOrWhiteSpace(orderNumber)) + return pendingResult; + + int retries = _config.GetEffectivePickupRetries(); + if (retries <= 0) + { + _logger.LogInformation( + "Synchronous certificate pickup disabled (PickupRetries<=0). Order {OrderNumber} " + + "will be picked up on the next synchronization.", orderNumber); + return pendingResult; + } + + int delaySeconds = _config.GetEffectivePickupDelaySeconds(); + _logger.LogInformation( + "Starting synchronous certificate pickup. OrderNumber={OrderNumber}, PickupRetries={Retries}, " + + "PickupDelaySeconds={Delay} (max ~{Max}s including a {Initial}s initial delay).", + orderNumber, retries, delaySeconds, + Constants.Pickup.InitialDelaySeconds + retries * delaySeconds, Constants.Pickup.InitialDelaySeconds); + + try + { + // Small static delay before the first poll — mirrors the Sectigo connector's + // attempt to let a fast order finish issuing before we start polling at all. + await Task.Delay(TimeSpan.FromSeconds(Constants.Pickup.InitialDelaySeconds)); + + for (int attempt = 1; attempt <= retries; attempt++) + { + try + { + var cert = await _client.GetCertificateAsync(orderNumber); + int disposition = StatusMapper.ToRequestDisposition(cert.Status); + + // Issued: only surface GENERATED when the PEM is actually present — never + // hand Command a body-less "issued" record. A body-less issued state keeps + // polling until the body appears or the budget runs out. + if (disposition == (int)EndEntityStatus.GENERATED + && !string.IsNullOrWhiteSpace(cert.Certificate)) + { + _logger.LogInformation( + "Synchronous pickup complete. OrderNumber={OrderNumber}, SerialNumber={Serial}, " + + "Attempt={Attempt}/{Retries}.", + orderNumber, + string.IsNullOrWhiteSpace(cert.SerialNumber) ? "(none)" : cert.SerialNumber, + attempt, retries); + return new EnrollmentResult + { + CARequestID = string.IsNullOrWhiteSpace(cert.Id) ? orderNumber : cert.Id, + Certificate = cert.Certificate, + Status = (int)EndEntityStatus.GENERATED, + StatusMessage = $"Certificate issued successfully. CERTInext ID: {orderNumber}." + }; + } + + // Terminal non-issued outcomes carry no body and are surfaced immediately. + if (disposition == (int)EndEntityStatus.REVOKED + || disposition == (int)EndEntityStatus.FAILED) + { + _logger.LogInformation( + "Order {OrderNumber} reached terminal status '{Status}' during synchronous pickup.", + orderNumber, cert.Status); + return new EnrollmentResult + { + CARequestID = string.IsNullOrWhiteSpace(cert.Id) ? orderNumber : cert.Id, + Certificate = cert.Certificate, + Status = disposition, + StatusMessage = $"Order {orderNumber} reached status '{cert.Status}' during enrollment pickup." + }; + } + } + catch (Exception ex) + { + // A transient fetch failure consumes an attempt rather than aborting the + // wait; if it never recovers the pending result is returned below. + _logger.LogWarning(ex, + "Pickup GetCertificate failed for order {OrderNumber} (attempt {Attempt}/{Retries}).", + orderNumber, attempt, retries); + } + + // Delay after every attempt (including the last), matching the Sectigo + // connector's pickup cadence so the max-occupancy ceiling is identical. + await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); + } + + _logger.LogInformation( + "Synchronous pickup did not complete within {Retries} attempts for order {OrderNumber}. " + + "Returning pending result; the certificate will be imported by the next synchronization. " + + "CERTInext issues OV/EV asynchronously by design (support ticket #162763).", + retries, orderNumber); + pendingResult.StatusMessage = + $"{pendingResult.StatusMessage} The certificate was not issued within the enrollment-pickup " + + "window; it will be imported by a later synchronization."; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Synchronous pickup failed for order {OrderNumber}. Returning pending result; " + + "sync will pick up the certificate later.", orderNumber); + } + + return pendingResult; + } + /// /// Converts a CERTInext API enrollment/renewal response into the /// expected by the AnyCA gateway. diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 43d0537..baf86c9 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -272,6 +272,29 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = true, Type = "Boolean" }, + [Constants.Config.PickupRetries] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Number of times Enroll() will poll CERTInext to download the certificate after a " + + "successful order submission. If the certificate has not issued within this window it is " + + "picked up during the next synchronization instead. Set to 0 to disable the wait. " + + $"Default: {Constants.Pickup.DefaultRetries}. NOTE: CERTInext issues OV/EV certificates " + + "asynchronously (organization verification, minutes to hours), so those typically exhaust " + + "the wait and are returned pending regardless of this value.", + Hidden = false, + DefaultValue = Constants.Pickup.DefaultRetries, + Type = "Number" + }, + [Constants.Config.PickupDelay] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Number of seconds between certificate-pickup retries. The total number of retries " + + "times this delay (plus a short initial delay) is the maximum time an enrollment call " + + "occupies a Command worker thread. If the duration is too long the request may time out, so " + + $"keep the total well under ~90s. Default: {Constants.Pickup.DefaultDelaySeconds} " + + $"(with default retries this yields a ~{Constants.Pickup.InitialDelaySeconds + Constants.Pickup.DefaultRetries * Constants.Pickup.DefaultDelaySeconds}s ceiling).", + Hidden = false, + DefaultValue = Constants.Pickup.DefaultDelaySeconds, + Type = "Number" + }, [Constants.Config.DcvEnabled] = new PropertyConfigInfo { Comments = "OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) " + @@ -695,6 +718,23 @@ public class CERTInextConfig /// Seconds to wait after publishing the DNS TXT record before calling VerifyDcv. /// Default: 30. /// + /// + /// Number of GetCertificate poll attempts inside Enroll() after an order is + /// submitted, before falling back to a pending result (picked up by the next sync). + /// Mirrors the legacy Sectigo connector's PickupRetries. Set to 0 to disable. + /// Default: 5. + /// + [JsonPropertyName("PickupRetries")] + public int PickupRetries { get; set; } = Constants.Pickup.DefaultRetries; + + /// + /// Seconds between certificate-pickup retries. PickupRetries * PickupDelay (plus a + /// short initial delay) bounds the time an enrollment call occupies a Command worker + /// thread. Mirrors the legacy Sectigo connector's PickupDelay. Default: 10. + /// + [JsonPropertyName("PickupDelay")] + public int PickupDelayInSeconds { get; set; } = Constants.Pickup.DefaultDelaySeconds; + [JsonPropertyName("DcvPropagationDelaySeconds")] public int DcvPropagationDelaySeconds { get; set; } = 30; @@ -782,5 +822,22 @@ public int GetEffectiveDcvWaitForIssuanceSeconds() return envVal; return DcvWaitForIssuanceSeconds >= 0 ? DcvWaitForIssuanceSeconds : 60; } + + /// + /// Effective number of certificate-pickup retries, clamped to + /// [0, ]. 0 disables the synchronous pickup. + /// + public int GetEffectivePickupRetries() + => System.Math.Max(0, System.Math.Min(PickupRetries, Constants.Pickup.MaxRetries)); + + /// + /// Effective seconds between pickup retries, clamped to + /// [1, ]. A non-positive configured value + /// falls back to the default rather than producing a tight busy-loop. + /// + public int GetEffectivePickupDelaySeconds() + => System.Math.Max(1, System.Math.Min( + PickupDelayInSeconds > 0 ? PickupDelayInSeconds : Constants.Pickup.DefaultDelaySeconds, + Constants.Pickup.MaxDelaySeconds)); } } diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 83e6929..abf5188 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -21,6 +21,16 @@ public static class Config public const string Enabled = "Enabled"; public const string IgnoreExpired = "IgnoreExpired"; public const string PageSize = "PageSize"; + + // Synchronous certificate pickup (parity with the legacy Sectigo connector). + // After submitting an order, Enroll() polls GetCertificate up to PickupRetries + // times, PickupDelay seconds apart (after a fixed initial delay), so a fast-issuing + // order returns the issued certificate in the same enrollment call instead of + // waiting for the next synchronization. On timeout the order is returned pending and + // imported by a later sync — behaviour identical to before this feature. + public const string PickupRetries = "PickupRetries"; + public const string PickupDelay = "PickupDelay"; + public const string RequestorName = "RequestorName"; public const string RequestorEmail = "RequestorEmail"; public const string RequestorIsdCode = "RequestorIsdCode"; @@ -268,6 +278,26 @@ public static class RevocationReasonId public const int Default = KeyCompromise; } + public static class Pickup + { + // Defaults mirror the legacy Sectigo connector's PickUpEnrolledCertificate: + // a 5-second initial delay, then up to 5 poll attempts 10 seconds apart, so the + // maximum time an enrollment call occupies a Command worker thread is + // InitialDelaySeconds + DefaultRetries * DefaultDelaySeconds = 5 + 5*10 = 55 seconds. + // Set PickupRetries to 0 to disable the wait entirely (immediate pending return). + public const int DefaultRetries = 5; + public const int DefaultDelaySeconds = 10; + + // Small static delay before the first poll — gives a fast order a chance to finish + // issuing before we poll at all, avoiding a guaranteed-miss first attempt. + public const int InitialDelaySeconds = 5; + + // Safety clamps so a mis-configured connector cannot orphan a worker thread. Command + // abandons enrollment calls well before these bounds; they only backstop absurd input. + public const int MaxRetries = 30; + public const int MaxDelaySeconds = 60; + } + public static class Dcv { // CERTInext dcvMethod values (dcvDetails.dcvMethod in GetDcv / VerifyDcv) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6065cb2..44022a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 1.0.1 + +## Features +- feat(enroll): `Enroll()` now polls for the issued certificate after submitting an order, so fast-issuing (DV / already-approved) orders return in the same call instead of waiting for the next sync. Tunable via `PickupRetries` (default 5, `0` disables) and `PickupDelay` (default 10s); ~55s default ceiling. Orders not issued within the window are returned pending and imported by a later sync, as before. + # 1.0.0 Initial release of the CERTInext (emSign Hub) AnyCA REST Gateway plugin. From 77fe123b6ffc445efa4227294b052978cdf91ffc Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:27:27 -0700 Subject: [PATCH 02/71] chore(enroll): log RequestFormat on the enrollment-start line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RequestFormat is received by Enroll() but was never logged, so logs could not show what Command passes for CSR vs PFX enrollments. Add it to the enrollment-start Information line for diagnostics. Behavior unchanged — the value is still not used for any decision (the gateway treats every enrollment as a CSR-based request). --- CERTInext/CERTInextCAPlugin.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index df51796..52fc364 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -573,10 +573,10 @@ public async Task Enroll( _logger.LogInformation( "Enrollment attempt started. " + - "EnrollmentType={EnrollmentType}, Subject={Subject}, " + + "EnrollmentType={EnrollmentType}, RequestFormat={RequestFormat}, Subject={Subject}, " + "ProfileId={ProfileId}, SANs={SANs}, " + "RequesterName={RequesterName}, RequesterEmail={RequesterEmail}", - enrollmentType, subject, + enrollmentType, requestFormat, subject, ep.ProfileId, sanSummary, ep.RequesterName, ep.RequesterEmail); From 49616cc83e640385c9731ff3da8e5ca4f269fc6b Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:14:46 -0700 Subject: [PATCH 03/71] fix(client): don't retry non-idempotent order/CSR submits on a network timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A network-level timeout on GenerateOrderSSL / SubmitCSR can occur after CERTInext has already received and created the order. The inner HTTP retry re-sent the same request body (same requestTxn), which CERTInext rejected as EMS-947 "Duplicate requestTxn" — failing the enrollment while orphaning the created order. ExecuteWithRetryAsync gains an `idempotent` flag; PlaceOrderAsync and SubmitCsrAsync now submit once (idempotent:false). A transient submit failure and an EMS-947 duplicate are each logged as an explicit no-retry decision and surfaced with a clear, conditional message (if an order was created it is imported by the next sync). Idempotent read calls are unchanged and still retry. --- CERTInext/Client/CERTInextClient.cs | 80 ++++++++++++++++++++++++++--- CHANGELOG.md | 4 ++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 255b65a..edc9970 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -216,7 +216,11 @@ public async Task PlaceOrderAsync( req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); var sw = System.Diagnostics.Stopwatch.StartNew(); - resp = await ExecuteWithRetryAsync(req, ct); + // idempotent:false — order submission is non-idempotent. A network-level + // timeout may occur after CERTInext already created the order, so re-sending the + // same requestTxn would be rejected as EMS-947 and orphan the created order + // Rate-limit retries are still handled below (with a fresh txn). + resp = await ExecuteWithRetryAsync(req, ct, idempotent: false); sw.Stop(); Logger.LogInformation( @@ -232,6 +236,25 @@ public async Task PlaceOrderAsync( $"Authentication failure during certificate order. HTTP {(int)resp.StatusCode}. See gateway logs for details."); } + // Transient/network failure (5xx or no HTTP status) on a non-idempotent submit: + // CERTInext may have already created the order (the response just didn't reach us). + // We deliberately did not retry (see idempotent:false above). Fail clearly instead + // of deserializing an empty body; if the order was created, the next sync imports it. + bool transientFailure = !resp.IsSuccessful + && !((int)resp.StatusCode >= 400 && (int)resp.StatusCode < 500); + if (transientFailure) + { + Logger.LogWarning( + "PlaceOrder received no usable response (HttpStatus={Status}, LatencyMs={Latency}). " + + "Not retrying to avoid a duplicate order (EMS-947). If CERTInext created the order it " + + "will be imported by the next synchronization.", + (int)resp.StatusCode, sw.ElapsedMilliseconds); + throw new Exception( + "CERTInext did not return a usable response to the order submission. If the order was " + + "created it will be imported by the next synchronization — do not resubmit immediately. " + + "See gateway logs for details."); + } + result = DeserializeOrThrow(resp, "place order"); if (result.Meta != null && !result.Meta.IsSuccess) @@ -259,6 +282,29 @@ public async Task PlaceOrderAsync( continue; // retry } + // EMS-947 "Duplicate requestTxn": CERTInext already received an order for this + // transaction. With the non-idempotent-retry fix above this should no longer be + // caused by our own retry, but if it still surfaces the order exists on the CA + // side and will be imported by the next sync — say so, not a generic failure. + bool isDuplicateTxn = + string.Equals(result.Meta.ErrorCode, "EMS-947", StringComparison.OrdinalIgnoreCase) + || (result.Meta.ErrorMessage?.IndexOf("Duplicate requestTxn", StringComparison.OrdinalIgnoreCase) >= 0); + if (isDuplicateTxn) + { + // Log the classification decision itself (parity with the transient-failure + // branch above) so an auditor sees the plugin deliberately treated this as a + // benign duplicate rather than a hard failure. + Logger.LogWarning( + "PlaceOrder classified {ErrorCode} as a duplicate transaction (not a hard failure). " + + "Path={Path}, HttpStatus={Status}, LatencyMs={Latency}. If an order exists for this " + + "transaction it will be imported by the next synchronization.", + result.Meta.ErrorCode, Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds); + throw new Exception( + "CERTInext reported a duplicate order transaction (EMS-947). If an order was created " + + "for this transaction it will be imported by the next synchronization — do not resubmit " + + "immediately. See gateway logs for details."); + } + throw new Exception( $"CERTInext order failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " + "See gateway logs for details."); @@ -300,7 +346,9 @@ public async Task SubmitCsrAsync(SubmitCsrRequest request, CancellationToken ct req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); var sw = System.Diagnostics.Stopwatch.StartNew(); - var resp = await ExecuteWithRetryAsync(req, ct); + // idempotent:false — submitting a CSR is non-idempotent; do not resend on a network + // timeout (the first attempt may have been received). See PlaceOrderAsync. + var resp = await ExecuteWithRetryAsync(req, ct, idempotent: false); sw.Stop(); Logger.LogInformation( @@ -310,6 +358,17 @@ public async Task SubmitCsrAsync(SubmitCsrRequest request, CancellationToken ct if (!resp.IsSuccessful) { LogApiFailure(Constants.Api.SubmitCsrPath, resp); + // Parity with PlaceOrderAsync: a transient/network failure on this non-idempotent + // submit was NOT retried, so record that decision (the CSR may already have been + // received). 4xx client errors fall through to the generic failure below. + bool transientFailure = !((int)resp.StatusCode >= 400 && (int)resp.StatusCode < 500); + if (transientFailure) + { + Logger.LogWarning( + "SubmitCSR received no usable response (HttpStatus={Status}, LatencyMs={Latency}); not retrying " + + "(non-idempotent). If CERTInext already received the CSR, do not resubmit immediately.", + (int)resp.StatusCode, sw.ElapsedMilliseconds); + } throw new Exception($"CERTInext SubmitCSR failed. HTTP {(int)resp.StatusCode}. See gateway logs for details."); } @@ -1213,14 +1272,23 @@ private async Task GetOrRefreshTokenAsync(CancellationToken ct) /// attempts, retrying on HTTP 5xx and network-level failures (no status code). /// 4xx responses are returned immediately — client errors will not be resolved /// by retrying. + /// + /// When is false the request is sent exactly + /// once and transient failures are NOT retried. This is required for non-idempotent + /// order-submission calls: a network-level timeout can occur *after* CERTInext has + /// already received and created the order, so re-sending the same body (same + /// requestTxn) is rejected as "Duplicate requestTxn" (EMS-947) and orphans the + /// order the first attempt actually created. /// private async Task ExecuteWithRetryAsync( RestRequest req, CancellationToken ct, - int maxAttempts = 3) + int maxAttempts = 3, + bool idempotent = true) { + int attempts = idempotent ? maxAttempts : 1; RestResponse resp = null; - for (int attempt = 1; attempt <= maxAttempts; attempt++) + for (int attempt = 1; attempt <= attempts; attempt++) { resp = await _http.ExecuteAsync(req, ct); @@ -1229,11 +1297,11 @@ private async Task ExecuteWithRetryAsync( if (resp.IsSuccessful || isClientError) return resp; - if (attempt < maxAttempts) + if (attempt < attempts) { Logger.LogWarning( "CERTInext API returned {Status} on attempt {Attempt}/{Max} — retrying...", - (int)resp.StatusCode, attempt, maxAttempts); + (int)resp.StatusCode, attempt, attempts); } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 44022a4..e53dcd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Features - feat(enroll): `Enroll()` now polls for the issued certificate after submitting an order, so fast-issuing (DV / already-approved) orders return in the same call instead of waiting for the next sync. Tunable via `PickupRetries` (default 5, `0` disables) and `PickupDelay` (default 10s); ~55s default ceiling. Orders not issued within the window are returned pending and imported by a later sync, as before. +- chore(enroll): The enrollment-start log line now includes `RequestFormat` for diagnostics. + +## Bug Fixes +- fix(client): Order submission (`GenerateOrderSSL`) and CSR submission are no longer auto-retried on a network-level timeout. Because a timeout can occur after the CA has already created the order, re-sending the same transaction was being rejected as a duplicate (`EMS-947 "Duplicate requestTxn"`), failing the enrollment while orphaning the created order. Non-idempotent submissions now run once; if the CA created the order it is imported by the next synchronization. A duplicate-transaction response is also now reported with a clear, actionable message. (Idempotent read calls are unaffected and still retry.) # 1.0.0 From e8e47391ec26262f6fb8c0d03c12e1356a32f474 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:23:46 -0700 Subject: [PATCH 04/71] fix(client): enrich orphaned-order warnings + SubmitCSR transient guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compliance follow-ups (both Low): - Add DomainName as a non-sensitive correlation key to the PlaceOrder transient and EMS-947 warnings so an orphaned order can be tied to its enrollment under concurrency (requestTxn is deliberately NOT logged — it is part of the authKey preimage). - SubmitCSR now carries the "may already have been received; do not resubmit" guidance in the thrown exception on a transient failure, for parity with PlaceOrderAsync (previously only in the log line). --- CERTInext/Client/CERTInextClient.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index edc9970..668c4a3 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -245,10 +245,10 @@ public async Task PlaceOrderAsync( if (transientFailure) { Logger.LogWarning( - "PlaceOrder received no usable response (HttpStatus={Status}, LatencyMs={Latency}). " + + "PlaceOrder received no usable response (DomainName={Domain}, HttpStatus={Status}, LatencyMs={Latency}). " + "Not retrying to avoid a duplicate order (EMS-947). If CERTInext created the order it " + "will be imported by the next synchronization.", - (int)resp.StatusCode, sw.ElapsedMilliseconds); + request.OrderDetails?.CertificateInformation?.DomainName, (int)resp.StatusCode, sw.ElapsedMilliseconds); throw new Exception( "CERTInext did not return a usable response to the order submission. If the order was " + "created it will be imported by the next synchronization — do not resubmit immediately. " + @@ -296,9 +296,9 @@ public async Task PlaceOrderAsync( // benign duplicate rather than a hard failure. Logger.LogWarning( "PlaceOrder classified {ErrorCode} as a duplicate transaction (not a hard failure). " + - "Path={Path}, HttpStatus={Status}, LatencyMs={Latency}. If an order exists for this " + - "transaction it will be imported by the next synchronization.", - result.Meta.ErrorCode, Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds); + "DomainName={Domain}, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}. If an order exists " + + "for this transaction it will be imported by the next synchronization.", + result.Meta.ErrorCode, request.OrderDetails?.CertificateInformation?.DomainName, Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds); throw new Exception( "CERTInext reported a duplicate order transaction (EMS-947). If an order was created " + "for this transaction it will be imported by the next synchronization — do not resubmit " + @@ -368,6 +368,11 @@ public async Task SubmitCsrAsync(SubmitCsrRequest request, CancellationToken ct "SubmitCSR received no usable response (HttpStatus={Status}, LatencyMs={Latency}); not retrying " + "(non-idempotent). If CERTInext already received the CSR, do not resubmit immediately.", (int)resp.StatusCode, sw.ElapsedMilliseconds); + // Parity with PlaceOrderAsync: carry the actionable guidance into the surfaced + // exception, not only the log line. + throw new Exception( + "CERTInext did not return a usable response to the CSR submission. If the CSR was received " + + "it will take effect — do not resubmit immediately. See gateway logs for details."); } throw new Exception($"CERTInext SubmitCSR failed. HTTP {(int)resp.StatusCode}. See gateway logs for details."); } From 6ae12b762884f17483f09a11e21c721433103ba5 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:28:07 -0700 Subject: [PATCH 05/71] =?UTF-8?q?fix(enroll):=20harden=20synchronous=20pic?= =?UTF-8?q?kup=20=E2=80=94=20DCV=20gating,=20wait=20ceiling,=20audit=20log?= =?UTF-8?q?ging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-driven refinements to the v1.0.1 synchronous-pickup feature: - Skip the pickup poll when the DCV path already owns the in-call issuance wait, so the two never stack and a cancelled/rejected order is not re-polled for the full window (fixes a regression that broke the terminal-order guard). - Cap total in-call pickup wait at 180s regardless of how PickupRetries and PickupDelay are configured, so an aggressive combination can't exceed Command's enrollment timeout. - Log a terminal FAILED at Error and REVOKED at Warning; trace each poll at Debug; distinguish "all polls errored" from "still pending" in the timeout summary; include OrderNumber in the CSR transient-failure warning; surface a pending result that has no order number to poll instead of skipping silently. - Default pickup off in the unit-test fixtures and add targeted pickup tests (disabled / issued / terminal / budget-exhausted). Both flavors build clean (0 warnings); DCV 199/199, no-DCV 176/176. --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 22 ++-- CERTInext.Tests/CERTInextCAPluginTests.cs | 107 ++++++++++++++++- CERTInext/CERTInextCAPlugin.cs | 119 ++++++++++++++++--- CERTInext/CERTInextCAPluginConfig.cs | 10 +- CERTInext/Client/CERTInextClient.cs | 7 +- CERTInext/Constants.cs | 12 +- CHANGELOG.md | 5 +- 7 files changed, 248 insertions(+), 34 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index 837ae8d..d812074 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -35,7 +35,8 @@ private static CERTInextConfig DcvConfig( int propagationDelaySeconds = 1, int timeoutMinutes = 1, int dcvWaitForChallengeSeconds = 0, - int dcvWaitForIssuanceSeconds = 0) => + int dcvWaitForIssuanceSeconds = 0, + int pickupRetries = 0) => new CERTInextConfig { DcvEnabled = enabled, @@ -45,7 +46,12 @@ private static CERTInextConfig DcvConfig( // behaviour and run fast. Tests that exercise the new wait paths can opt // in with a positive value (see WaitsForChallenge_ToAppear / WaitsForIssuance). DcvWaitForChallengeSeconds = dcvWaitForChallengeSeconds, - DcvWaitForIssuanceSeconds = dcvWaitForIssuanceSeconds + DcvWaitForIssuanceSeconds = dcvWaitForIssuanceSeconds, + // Disable the synchronous pickup poll by default (same reasoning as the wait + // budgets above): the DCV path owns issuance for these tests, and a DCV-disabled + // or no-factory case that ends on a pending result must not pay the real pickup + // Task.Delay loop. The dedicated pickup tests live in CERTInextCAPluginTests. + PickupRetries = pickupRetries }; private static Mock NewMock() => @@ -437,17 +443,19 @@ public async Task Dcv_Skipped_WhenOrderStatusIdIsTerminal_EvenIfDcvValidated(str }); var validator = new FakeDomainValidator(); - // Issuance-wait budget > 0 so a wrong-path entry would manifest as a - // GetCertificate call we DON'T expect. + // Issuance-wait budget > 0 AND pickup ENABLED (pickupRetries > 0) so a wrong-path + // entry would manifest as a GetCertificate call we DON'T expect — this test must + // fail if either the DCV issuance-wait guard OR the synchronous-pickup gate + // (dcvIssuanceWaitRan) regresses and starts polling a cancelled/rejected order. var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), - DcvConfig(dcvWaitForIssuanceSeconds: 10)); + DcvConfig(dcvWaitForIssuanceSeconds: 10, pickupRetries: 5)); await Enroll(plugin); mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never, - "Enroll must not enter WaitForIssuanceAfterDcvAsync when the order is " + - "cancelled/rejected, even if DCV happens to be in a 'validated' state"); + "Enroll must not enter WaitForIssuanceAfterDcvAsync OR the synchronous pickup poll " + + "when the order is cancelled/rejected, even if DCV happens to be in a 'validated' state"); validator.StagedRecords.Should().BeEmpty( "DCV staging must not run for a cancelled/rejected order"); } diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index 3ec5df1..7064b44 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -31,8 +31,20 @@ public class CERTInextCAPluginTests // Helpers // --------------------------------------------------------------------------- + // Pickup is disabled by default in the broad fixture (PickupRetries=0) — mirroring how + // DcvConfig defaults its wait budgets to 0 — so tests that don't care about the + // synchronous pickup don't pay its real Task.Delay-based poll. Tests that DO exercise + // pickup opt in via BuildPluginWithPickup. private static CERTInextCAPlugin BuildPlugin(ICERTInextClient client) => - new CERTInextCAPlugin(client); + new CERTInextCAPlugin(client, new CERTInextConfig { PickupRetries = 0 }); + + // Pickup-enabled fixture for the synchronous-pickup tests. PickupDelay is clamped to a + // 1s floor and the loop adds a fixed 5s initial delay, so these tests are intentionally + // a few seconds each. + private static CERTInextCAPlugin BuildPluginWithPickup( + ICERTInextClient client, int retries, int delaySeconds = 1) => + new CERTInextCAPlugin(client, + new CERTInextConfig { PickupRetries = retries, PickupDelayInSeconds = delaySeconds }); private static Mock NewMock() => new Mock(MockBehavior.Strict); @@ -345,6 +357,99 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval() result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); } + // --------------------------------------------------------------------------- + // Synchronous certificate pickup (Sectigo parity) + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_Disabled_WhenPickupRetriesZero_ReturnsPendingWithoutPolling() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 0); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null, + productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never, "PickupRetries=0 must disable the synchronous pickup poll"); + } + + [Fact] + public async Task Pickup_ReturnsIssuedCert_WhenOrderIssuesDuringPoll() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + // The order finishes issuing by the time we poll: GetCertificate reports issued + PEM. + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 2); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null, + productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().NotBeNullOrEmpty("a synchronously-picked-up cert must carry its PEM"); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.AtLeastOnce); + } + + [Fact] + public async Task Pickup_SurfacesTerminalStatus_WhenOrderRevokedDuringPoll() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.RevokedCertRecord()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 3); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null, + productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.REVOKED, + "a terminal status observed during pickup is surfaced immediately, not polled to exhaustion"); + } + + [Fact] + public async Task Pickup_ReturnsPending_WhenOrderNeverIssuesWithinBudget() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + // Every poll still reports pending — the budget is exhausted and Enroll returns the + // pending result for a later sync to complete. + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingCertRecord()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 1); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null, + productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.AtLeastOnce, "an enabled pickup must actually poll before giving up"); + } + [Fact] public async Task Enroll_New_Throws_WhenProfileIdNotSet() { diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 52fc364..b04c051 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1105,12 +1105,38 @@ private async Task EnrollNewAsync( var enrollResp = await _client.EnrollCertificateAsync(enrollReq); + // Whether the DCV block below took ownership of the in-call issuance wait for this + // order. Declared outside the #if so both build flavors compile the pickup gate the + // same way (it simply stays false on the no-DCV build). When true, the synchronous + // pickup poll is skipped: on the DCV build the DCV path already owns the issuance + // decision — it either ran WaitForIssuanceAfterDcvAsync itself, deferred to another + // in-flight caller, or determined the order is terminal / not yet validated — so a + // second stacked poll would either double the wait or burn the budget polling an + // order that can never issue in-call (regression guard: a cancelled/rejected order + // must not be re-polled here after DCV already short-circuited it). + bool dcvIssuanceWaitRan = false; + #if SUPPORTS_DCV // DCV: run domain validation if enabled, the factory was injected, and the // order was accepted (not immediately failed). string orderNumber = enrollResp.Id; if (_domainValidatorFactory != null && _config.DcvEnabled && !string.IsNullOrEmpty(orderNumber)) { + // DCV owns the in-call issuance wait for this order from here on: every exit from + // this block (duplicate in-flight, DCV-validated + issuance poll, terminal order, + // or challenge-not-yet-exposed) is a decision the pickup poll must not second-guess. + // Set before any await so it holds on every path out of the block. + // + // This is intentionally coarse — keyed on "the DCV subsystem engaged for this order", + // not on "a DCV wait is actively running". The one case it over-defers is an order + // whose pending domains are all assigned to a non-DNS-01 method (HTTP/email): DCV does + // no work, yet pickup is skipped. That is an accepted trade: this plugin only drives + // DNS-01, so such orders depend on out-of-band validation and would not issue within + // the ~55s pickup window anyway — the next sync completes them. Distinguishing that + // sub-case from the terminal/cancelled case (which MUST skip pickup) would require a + // richer PerformDcvIfNeededAsync result and risk re-opening the terminal-order regression. + dcvIssuanceWaitRan = true; + // SOX CC7.3: bound the entire DCV flow with a hard timeout so a stuck // DNS provider or extreme propagation delay cannot hold a gateway worker // thread indefinitely. Configurable via DcvTimeoutMinutes (config or @@ -1175,7 +1201,7 @@ private async Task EnrollNewAsync( // already-issued/failed case and for OV/EV orders that CERTInext issues asynchronously // — those fall back to the pending result and are imported by the next sync. var newResult = BuildEnrollmentResult(enrollResp, ep.AutoApprove); - newResult = await PickUpEnrolledCertificateAsync(newResult, enrollResp.Id); + newResult = await PickUpEnrolledCertificateAsync(newResult, enrollResp.Id, dcvIssuanceWaitRan); _logger.MethodExit(LogLevel.Debug); return newResult; @@ -1305,7 +1331,8 @@ private async Task RenewOrReissueAsync( priorCaRequestId, renewResult.CARequestID, renewResult.Status); // Synchronous certificate pickup (Sectigo-parity), same as the new-enrollment path. - renewResult = await PickUpEnrolledCertificateAsync(renewResult, renewResp.Id); + // The renew path never runs an in-call DCV issuance wait, so pickup always applies. + renewResult = await PickUpEnrolledCertificateAsync(renewResult, renewResp.Id, dcvIssuanceWaitRan: false); return renewResult; } @@ -1894,15 +1921,31 @@ private async Task WaitForDcvVerificationAsync(string orderNumber, IReadOnlyList /// orders return in-call. Never throws — any polling error degrades to the pending result. /// private async Task PickUpEnrolledCertificateAsync( - EnrollmentResult pendingResult, string orderNumber) + EnrollmentResult pendingResult, string orderNumber, bool dcvIssuanceWaitRan) { + // The DCV path already owns the in-call issuance wait for this order — running a second + // stacked poll here would double the wait budget (when DCV ran WaitForIssuanceAfterDcvAsync) + // or waste it polling an order DCV already found terminal / not-yet-validated. Defer to + // the pending result; a later sync completes it. + if (dcvIssuanceWaitRan) + return pendingResult; + // Only a still-pending (external-validation) result can benefit from a pickup poll. - // An already issued/failed/revoked result, or a missing order number, is returned as-is. + // An already issued/failed/revoked result is returned as-is. if (pendingResult == null - || pendingResult.Status != (int)EndEntityStatus.EXTERNALVALIDATION - || string.IsNullOrWhiteSpace(orderNumber)) + || pendingResult.Status != (int)EndEntityStatus.EXTERNALVALIDATION) return pendingResult; + // A pending result with no order number cannot be polled — surface the anomaly rather + // than silently returning, so an un-pollable pending state leaves an audit trace. + if (string.IsNullOrWhiteSpace(orderNumber)) + { + _logger.LogWarning( + "Synchronous pickup skipped: a pending enrollment was returned with no order " + + "number to poll. The certificate can only be reconciled by a later synchronization."); + return pendingResult; + } + int retries = _config.GetEffectivePickupRetries(); if (retries <= 0) { @@ -1913,12 +1956,30 @@ private async Task PickUpEnrolledCertificateAsync( } int delaySeconds = _config.GetEffectivePickupDelaySeconds(); + + // Hard ceiling on total in-call occupancy. PickupRetries and PickupDelay are each clamped + // independently, but their product can still reach ~30 min at the extremes — enough to push + // Enroll() past Command's own enrollment timeout. If the configured budget would exceed the + // ceiling, cap the retry count to fit; the remainder is imported by the next synchronization. + int maxPollRetries = Math.Max(1, + (Constants.Pickup.MaxTotalWaitSeconds - Constants.Pickup.InitialDelaySeconds) / delaySeconds); + if (retries > maxPollRetries) + { + _logger.LogInformation( + "Configured pickup budget (PickupRetries={Configured}, PickupDelaySeconds={Delay}) exceeds the " + + "{MaxTotal}s in-call ceiling; capping to {Capped} attempts. The certificate will be imported by " + + "the next synchronization if it has not issued by then.", + retries, delaySeconds, Constants.Pickup.MaxTotalWaitSeconds, maxPollRetries); + retries = maxPollRetries; + } + _logger.LogInformation( "Starting synchronous certificate pickup. OrderNumber={OrderNumber}, PickupRetries={Retries}, " + "PickupDelaySeconds={Delay} (max ~{Max}s including a {Initial}s initial delay).", orderNumber, retries, delaySeconds, Constants.Pickup.InitialDelaySeconds + retries * delaySeconds, Constants.Pickup.InitialDelaySeconds); + int pollErrors = 0; try { // Small static delay before the first poll — mirrors the Sectigo connector's @@ -1932,6 +1993,14 @@ private async Task PickUpEnrolledCertificateAsync( var cert = await _client.GetCertificateAsync(orderNumber); int disposition = StatusMapper.ToRequestDisposition(cert.Status); + // SOC2 CC7.3: record each poll's observed disposition so the issuance + // timeline is reconstructable (how many polls ran, what each returned). + _logger.LogDebug( + "Pickup poll observed status. OrderNumber={OrderNumber}, Attempt={Attempt}/{Retries}, " + + "MappedDisposition={Disposition}, Status='{Status}', BodyPresent={HasBody}.", + orderNumber, attempt, retries, disposition, cert.Status, + !string.IsNullOrWhiteSpace(cert.Certificate)); + // Issued: only surface GENERATED when the PEM is actually present — never // hand Command a body-less "issued" record. A body-less issued state keeps // polling until the body appears or the budget runs out. @@ -1957,9 +2026,19 @@ private async Task PickUpEnrolledCertificateAsync( if (disposition == (int)EndEntityStatus.REVOKED || disposition == (int)EndEntityStatus.FAILED) { - _logger.LogInformation( - "Order {OrderNumber} reached terminal status '{Status}' during synchronous pickup.", - orderNumber, cert.Status); + // SOX/SOC2 CC7.2: an issuance FAILURE must cross the error threshold that + // SIEM issuance-failure rules key on (parity with BuildEnrollmentResult's + // enroll-time FAILED handling); a REVOKED terminal state is a warning. + if (disposition == (int)EndEntityStatus.FAILED) + _logger.LogError( + "Order {OrderNumber} reached terminal FAILED status '{Status}' during " + + "synchronous pickup (attempt {Attempt}/{Retries}).", + orderNumber, cert.Status, attempt, retries); + else + _logger.LogWarning( + "Order {OrderNumber} was REVOKED ('{Status}') during synchronous pickup " + + "(attempt {Attempt}/{Retries}).", + orderNumber, cert.Status, attempt, retries); return new EnrollmentResult { CARequestID = string.IsNullOrWhiteSpace(cert.Id) ? orderNumber : cert.Id, @@ -1973,6 +2052,7 @@ private async Task PickUpEnrolledCertificateAsync( { // A transient fetch failure consumes an attempt rather than aborting the // wait; if it never recovers the pending result is returned below. + pollErrors++; _logger.LogWarning(ex, "Pickup GetCertificate failed for order {OrderNumber} (attempt {Attempt}/{Retries}).", orderNumber, attempt, retries); @@ -1983,11 +2063,22 @@ private async Task PickUpEnrolledCertificateAsync( await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); } - _logger.LogInformation( - "Synchronous pickup did not complete within {Retries} attempts for order {OrderNumber}. " + - "Returning pending result; the certificate will be imported by the next synchronization. " + - "CERTInext issues OV/EV asynchronously by design (support ticket #162763).", - retries, orderNumber); + // SOC1 accuracy: don't attribute non-completion to "OV/EV async by design" when the + // real cause was every poll erroring (e.g. a CA-side TrackOrder outage). Distinguish + // the two so the log reflects what actually happened. + if (pollErrors == retries) + _logger.LogWarning( + "Synchronous pickup exhausted {Retries} attempts for order {OrderNumber} — ALL polls " + + "errored (see preceding warnings). Returning pending result; the next synchronization " + + "will re-attempt retrieval.", + retries, orderNumber); + else + _logger.LogInformation( + "Synchronous pickup did not complete within {Retries} attempts for order {OrderNumber} " + + "({Errors} poll error(s); remainder still pending). Returning pending result; the " + + "certificate will be imported by the next synchronization. CERTInext issues OV/EV " + + "asynchronously by design (support ticket #162763).", + retries, orderNumber, pollErrors); pendingResult.StatusMessage = $"{pendingResult.StatusMessage} The certificate was not issued within the enrollment-pickup " + "window; it will be imported by a later synchronization."; diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index baf86c9..e77ac68 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -286,10 +286,12 @@ public static Dictionary GetCAConnectorAnnotations() }, [Constants.Config.PickupDelay] = new PropertyConfigInfo { - Comments = "OPTIONAL: Number of seconds between certificate-pickup retries. The total number of retries " + - "times this delay (plus a short initial delay) is the maximum time an enrollment call " + - "occupies a Command worker thread. If the duration is too long the request may time out, so " + - $"keep the total well under ~90s. Default: {Constants.Pickup.DefaultDelaySeconds} " + + Comments = "OPTIONAL: Number of seconds between certificate-pickup retries. PickupRetries times this " + + "delay (plus a short initial delay) is the maximum time an enrollment call occupies a Command " + + "worker thread. If the duration is too long the request may time out, so target a total well " + + $"under ~90s. As a safety backstop the plugin additionally caps the effective total at " + + $"{Constants.Pickup.MaxTotalWaitSeconds}s regardless of how PickupRetries/PickupDelay are set, " + + $"reducing the retry count to fit. Default: {Constants.Pickup.DefaultDelaySeconds} " + $"(with default retries this yields a ~{Constants.Pickup.InitialDelaySeconds + Constants.Pickup.DefaultRetries * Constants.Pickup.DefaultDelaySeconds}s ceiling).", Hidden = false, DefaultValue = Constants.Pickup.DefaultDelaySeconds, diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 668c4a3..c6ad56b 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -365,9 +365,10 @@ public async Task SubmitCsrAsync(SubmitCsrRequest request, CancellationToken ct if (transientFailure) { Logger.LogWarning( - "SubmitCSR received no usable response (HttpStatus={Status}, LatencyMs={Latency}); not retrying " + - "(non-idempotent). If CERTInext already received the CSR, do not resubmit immediately.", - (int)resp.StatusCode, sw.ElapsedMilliseconds); + "SubmitCSR received no usable response (OrderNumber={OrderNumber}, HttpStatus={Status}, " + + "LatencyMs={Latency}); not retrying (non-idempotent). If CERTInext already received the CSR, " + + "do not resubmit immediately.", + request.OrderDetails?.OrderNumber, (int)resp.StatusCode, sw.ElapsedMilliseconds); // Parity with PlaceOrderAsync: carry the actionable guidance into the surfaced // exception, not only the log line. throw new Exception( diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index abf5188..4510286 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -292,10 +292,18 @@ public static class Pickup // issuing before we poll at all, avoiding a guaranteed-miss first attempt. public const int InitialDelaySeconds = 5; - // Safety clamps so a mis-configured connector cannot orphan a worker thread. Command - // abandons enrollment calls well before these bounds; they only backstop absurd input. + // Per-factor safety clamps so a single mis-typed value cannot produce a tight busy-loop + // or an absurd per-attempt delay. These bound each knob independently; the *product* + // (retries * delay) is bounded separately by MaxTotalWaitSeconds below. public const int MaxRetries = 30; public const int MaxDelaySeconds = 60; + + // Hard ceiling on total in-call pickup occupancy (initial delay + retries * delay). + // The per-factor clamps above still permit a ~1805s product at the extremes, which could + // push Enroll() past Command's enrollment timeout; PickUpEnrolledCertificateAsync caps the + // effective retry count so the total never exceeds this. Kept comfortably under a typical + // enrollment timeout while leaving room for the documented ~90s default guidance. + public const int MaxTotalWaitSeconds = 180; } public static class Dcv diff --git a/CHANGELOG.md b/CHANGELOG.md index e53dcd8..d971478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,10 @@ # 1.0.1 ## Features -- feat(enroll): `Enroll()` now polls for the issued certificate after submitting an order, so fast-issuing (DV / already-approved) orders return in the same call instead of waiting for the next sync. Tunable via `PickupRetries` (default 5, `0` disables) and `PickupDelay` (default 10s); ~55s default ceiling. Orders not issued within the window are returned pending and imported by a later sync, as before. -- chore(enroll): The enrollment-start log line now includes `RequestFormat` for diagnostics. +- **Faster enrollment for quickly-issued certificates.** Enrollment now waits briefly for the certificate and returns it in the same request when it issues fast (DV and already-approved orders), instead of always waiting for the next synchronization. Two new optional settings control the wait: `PickupRetries` (default 5; set to `0` to disable) and `PickupDelay` (default 10 seconds) — about a 55-second wait by default, with a built-in ceiling so it can't run long enough to time out the enrollment. Orders that don't issue in that window — including OV/EV, which CERTInext validates asynchronously over minutes to hours — return pending and are imported by a later sync, exactly as before. Works with or without DNS-based DCV. ## Bug Fixes -- fix(client): Order submission (`GenerateOrderSSL`) and CSR submission are no longer auto-retried on a network-level timeout. Because a timeout can occur after the CA has already created the order, re-sending the same transaction was being rejected as a duplicate (`EMS-947 "Duplicate requestTxn"`), failing the enrollment while orphaning the created order. Non-idempotent submissions now run once; if the CA created the order it is imported by the next synchronization. A duplicate-transaction response is also now reported with a clear, actionable message. (Idempotent read calls are unaffected and still retry.) +- **No more duplicate or orphaned orders after a network timeout.** Order and CSR submissions are no longer retried after a network timeout. A timeout can happen *after* the CA has already accepted the request, so the automatic retry was being rejected as a duplicate — failing the enrollment and leaving an orphaned order behind. These requests now run once; if the order was created it is imported by the next synchronization, and duplicate responses are reported with clear, actionable guidance. (Read-only calls are unaffected and still retry.) # 1.0.0 From f499f65993b20f79b27a039c2d8b8e726507c2ec Mon Sep 17 00:00:00 2001 From: spb <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:15:33 -0700 Subject: [PATCH 06/71] =?UTF-8?q?fix(enroll):=20UCC=20SANs=20never=20reach?= =?UTF-8?q?ed=20CERTInext=20=E2=80=94=20additionalDomains=20sent=20empty?= =?UTF-8?q?=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(enroll): UCC SANs never reached CERTInext — additionalDomains sent 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. * docs(enroll): scope the CERTInext SAN measurements to the sandbox 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. * fix(enroll): address full-review round 1 — DCV strand, ASN.1 debris, log injection Six confirmed findings from the five gating lenses, collapsing into three defects plus an upgrade-safety gap. 1. Undrainable pending domains stranded the valid ones (correctness, medium). Submitting non-DNS SANs means CERTInext registers them verbatim as order domains, so an email/URI SAN turns up as a domainVerification key that fails PerformDcvIfNeededAsync's FQDN check. That check threw for the whole order, before staging anything — and the exception escapes Enroll, which has no catch, after the order was already placed. Result: failed enrollment, orphaned order at the CA, no TXT record staged for the valid domains beside it, and every later Synchronize/GetSingleRecord retry re-threw into TryRunDcvDuringSyncAsync's catch-and-return-false, so the order could never progress. Invalid domains are now excluded (still LogError, so the audit trail keeps the signal) and the rest of the order proceeds. Same treatment where no DNS provider resolves for a domain, which is where an IP-literal SAN dead-ends: it clears the FQDN regex but no zone can match it. The genuine "no DNS provider deployed" misconfiguration still throws — distinguished by nothing on the order resolving at all — so Dcv_Throws_WhenNoProviderForDomain keeps its meaning. This is the file's own stated principle, already written at the EMS-956 branch: do not throw out of DCV for a condition that leaves the order legitimately pending. 2. GeneralNameToValue emitted ASN.1 debris (correctness + security + api-compat). Its default branch returned BouncyCastle's stringification, so a UPN otherName from a Windows-generated CSR was submitted as "[1.3.6.1.4.1.311.20.2.3, [CONTEXT 0]svc@corp.example.com]" and a directoryName as "CN=host.example.com,O=Acme" — in a domain-name field, contradicting the method's own doc comment and breaking orders that previously succeeded. These now return null. They are genuinely unrepresentable as a domain, unlike a well-formed IP/email/URI SAN, which we still submit on purpose. Skipping is not silent: ExtractSanEntriesFromCsr reports the skipped GeneralName tags and BuildSanList warns with them. 3. Log injection in the new audit sinks (security, low, CWE-117). SAN values come from the CSR and Command's dictionary — i.e. the requester — and were interpolated into three new log lines unescaped. Structured templates stop format-string abuse but not embedded CRLF, and NLog's text layout does not escape it, so a requester could forge audit records in the very lines added to make the submitted SAN set auditable. Added SanitizeForLog and applied it at all sinks. Deliberately a logging-only scrub: the value submitted to the CA is unchanged. 4. Upgrade safety (api-compat, medium). Submitting non-DNS SANs flips affected enrollments from "issues, silently incomplete" to "parks pending", with no way back short of downgrading the plugin. Added the SubmitNonDnsSans connector setting (default true — current behaviour) to restore DNS-only submission, and a CHANGELOG upgrade note, since the review's residual concern was process rather than logic. Also applied the endorsed advisory: RenewCertificateAsync parsed the subject twice; hoisted to one call, matching what the sibling method already does. Tests: 12 added — two DCV tests proving a non-FQDN domain and an unresolvable domain each leave their co-tenant staged and issuing, an otherName/directoryName test asserting no ASN.1 debris reaches the posted JSON, a SanitizeForLog theory, a CRLF-does-not-break-enrollment test, and SubmitNonDnsSans on/default coverage. Release, no-DCV: 195/195. Release, DCV: 220/220. Zero code warnings in both. * fix(enroll): address full-review round 2 — false pending-domain invariant, TXT leak Four confirmed findings. 1+2. The round-1 misconfiguration throw assumed "the CN is always a pending domain too" — false whenever CERTInext has cached a prior DCV validation for the CN/parent domain (a case this same method already special-cases earlier, at the aggregate/per-domain "already validated" check). When that happens the CN drops out of pendingDomains, and an order carrying only a non-DNS SAN alongside it hit the "nothing on the order resolves a provider" branch and threw — reopening the exact orphaned/stranded-order failure round 1 fixed, just narrowed to this input shape. The check now asks the right question: does ANY domain on the order — pending or already validated — resolve a DNS provider? If yes, a provider is clearly deployed and working, so this is the bad-SAN case (defer, don't throw). Only if nothing on the whole order resolves is it the genuine "no provider deployed" misconfiguration. 4. The TXT-staging loop's throw/defer sites (GetDcv failure, empty token, stage failure, EMS-956 not-ready) were all outside the try/finally that owns cleanup. Round 1 made multi-domain staging the normal case by finally submitting every SAN — before, a UCC order's SANs never reached CERTInext at all, so an order rarely had more than one pending domain. A later domain's failure now orphans every TXT record already published for the earlier domains in the same order, permanently — nothing else in the codebase ever calls CleanupValidation for them. The staging loop is now wrapped so any exit — exception or the not-yet-ready deferral — cleans up whatever was already staged first. 3. BuildAdditionalDomains' new duplicate-collapse debug log was the one sink in the diff that skipped the round-1 SanitizeForLog scrub. Applied. Also applied all 4 endorsed advisory simplifications: collapsed SanTypeFromGeneralNameTag + GeneralNameToValue into one GeneralNameToSanEntry (the split had four dead branches — a type mapping for tags whose value always came back null); moved the twice-duplicated SanitizeForLog into a shared internal LogSanitizer.Strip (Models/LogSanitizer.cs); nested BuildSanList's two non-DNS branches under one `nonDns.Count > 0` test instead of two; hoisted the repeated SAN-list log rendering into a local. Tests: 2 added (cached-CN-plus-unresolvable-SAN must defer without throwing; a second domain's stage failure must clean up the first domain's TXT record). SanitizeForLog's reflection-based test now calls LogSanitizer.Strip directly (it's internal, not private, and InternalsVisibleTo already covers the test project). Release, no-DCV: 195/195. Release, DCV: 222/222. Zero code warnings in both. * fix(enroll): address full-review round 3 — never throw out of DCV staging, CSR fallback not union All three dispositions carried forward from round 2 were broken by this round's adjudicator (real, not accepted), plus 8 more findings collapsing into the same three root causes. 1. PerformDcvIfNeededAsync's per-domain isolation (rounds 1-2) covered only the validator-resolution-null case. A GetDcv failure, an empty DCV token, and a StageValidation failure all still threw and aborted the WHOLE order — exactly the orphaned/stranded-order failure the isolation exists to prevent, just narrower. Confirmed reachable via the non-DNS SANs this PR submits by design (an IP-literal SAN clears the FQDN filter and reaches GetDcv; its live behavior there is unmeasured — my round-2 "measured" claim was based on a Moq stub asserting my own assumption, not the live API). Separately, the post-loop misconfiguration throw ("no DNS provider configured") could fire on an ordinary non-DNS Subject CN with no config escape hatch: SubmitNonDnsSans only filters the returned SAN list, never `subject`/`domainName`, so an IP-format CN reaches this path unfiltered. Fix: every per-domain failure in the staging loop (GetDcv error, empty token, no resolvable validator, StageValidation throwing or returning failure) is now LogError + skip-this-domain-and-continue. Nothing in the loop throws for an input- or API-driven reason any more. The only remaining "abort the whole pass" case is EMS-956 (DCV not yet exposed at the CA) — an order-readiness condition, not a per-domain one, so it still defers immediately rather than isolating per domain. The post-loop misconfiguration throw is gone; "nothing could be staged" now always defers to the next sync cycle with a LogError naming every skipped domain and why, rather than sometimes throwing depending on which domain failed or what else was on the order. 2. BuildSanList's CSR union (rounds 1-2) let a signed CSR's own SAN extension reintroduce names regardless of what Command's SAN dictionary supplied. External research against Keyfactor Command's documented enrollment-pattern behavior found no evidence Command enforces SAN policy by narrowing a signed CSR's embedded SANs before calling Enroll — reconciliation between an externally-generated CSR and Command's SAN data is documented as plugin/CA-configuration-dependent, not Command-enforced. A subscriber's own CSR routinely carries more names than an enrollment pattern computed, and the union let all of them through. Fix: the CSR is now a fallback, consulted only when Command supplies no SAN data at all (the case the original UCC-SAN-drop customer defect actually needed). When Command supplies any SAN entries, the CSR's own SAN extension is ignored entirely — the gateway dictionary is authoritative, not merely first. 3. Three findings on BuildSanList's logging: (a) the "N SAN(s) ... have been added to the order" line fired for CSR-fallback entries before the SubmitNonDnsSans=false filter removed exactly those entries two lines later — a false claim in the same call; (b) the "Resolved N SAN(s)" provenance line had the same before/after-filter mismatch; (c) two throw sites (empty token, stage failure) had no preceding structured log before the bare cleanup-and-rethrow wrapper caught them — moot now that neither throws, since both are LogError'd before being skipped. Fixed by reordering: apply the SubmitNonDnsSans filter first, log the resolved set and CSR-fallback provenance from the final, already-filtered result. Also fixed on the same pass: RenewCertificateAsync's "no usable CN" warning logged the prior order's RequestorName fallback unsanitized — the one sink in this diff that had skipped LogSanitizer.Strip. Tests: rewrote 6 existing DCV tests whose names and assertions pinned the old throw behavior (Dcv_Throws_* → Dcv_SkipsAndDefers_*, including reversing Dcv_Rethrows_When_GetDcv_FailsWithUnrelatedError's stated intent, and rewriting the round-2 TXT-leak test since a skipped domain no longer needs mid-call cleanup — the good domain now just completes its normal lifecycle). Rewrote the CSR-union test into CsrOnlySans_AreIgnored_WhenGatewaySuppliesAnyEntries. Added one test for the log-ordering fix's underlying data flow (CSR-fallback non-DNS SAN genuinely absent from the wire when SubmitNonDnsSans=false, not just mis-described in the log). Release, no-DCV: 196/196. Release, DCV: 223/223. Zero code warnings in both. * fix(enroll): address full-review round 4 — regex $ quirk, cancellation, CSR-fallback edge case Six confirmed findings, a clean round: 0 dismissed, 0 inconclusive. 1. The FQDN validation regex used ^...$, and in .NET's default (non-Multiline) mode $ matches immediately before a single trailing '\n', not only at the true end of the string — so "evil.com\n" passed as "valid" and reached several unsanitized-relative-to-siblings log sinks further down the same method (Staging/Triggering/cleanup/verified/rejected lines). Round 1 fixed log injection at other sinks in this file, but this one slipped through because the domain LOOKED validated. Fixed at the source: the regex now anchors with \A/\z (absolute string bounds regardless of trailing newlines), so a value with any trailing control character is rejected by the FQDN gate itself. Also applied LogSanitizer.Strip at every remaining domain/hostname sink in PerformDcvIfNeededAsync and WaitForDcvVerificationAsync for defense in depth and consistency with the sibling error-path logs that already had it. Worth noting for the record: this path is reachable for ANY order the account has at EXTERNALVALIDATION via Synchronize/GetSingleRecord, not only ones this plugin's own Enroll call placed — TrackOrder's domainVerification keys for an externally-created order are never passed through this plugin's own outbound Trim() calls, so those calls (correct for the outbound path) do not protect this inbound one. 2. SubmitNonDnsSans — the toggle that decides whether a certificate can issue silently missing requested SAN names — was never included in the Initialize startup config-dump log, unlike every sibling setting (DcvEnabled, DcvTxtRecordTemplate, IgnoreExpired, PageSize) that log line exists to make auditable. Added. 3+4+5. The generic per-domain `catch (Exception ex)` blocks around GetDcvAsync and StageValidation also caught OperationCanceledException/ TaskCanceledException raised by the shared DcvTimeoutMinutes-bound cancellation token, mislabeling a genuine timeout as an ordinary per-domain CA/DNS-provider failure in the skippedDomains audit summary — directly contradicting the outer catch's own comment, which claimed to be the handler for exactly this case but could never actually see it, since the inner catches intercepted it first. Both per-domain catch sites now re-throw OperationCanceledException explicitly before their generic Exception clause, so cancellation reaches the outer catch. That outer catch previously logged nothing before cleaning up and rethrowing — with neither EnrollNewAsync nor Enroll adding a catch of their own, an unanticipated failure during the synchronous Enroll-time DCV path left no plugin-emitted audit record at all. Added a LogError there. 6. BuildSanList's CSR-fallback trigger was "the gateway SAN dictionary computed to zero added entries" (fromGateway == 0), which cannot distinguish a null/absent dictionary from a non-null dictionary whose only keys map to empty arrays. An enrollment pattern that runs and deliberately computes zero SANs for a request is a policy decision this plugin must respect — round 3's fallback-over-union redesign existed specifically to stop CSR names overriding Command's SAN policy, and this edge case reopened exactly that. The trigger now checks `san == null` directly: only the literal absence of a dictionary engages the CSR fallback. Tests: 4 added — a trailing-newline domain routed to invalidDomains rather than reaching GetDcv; a cancellation during GetDcv propagating rather than being reported as a skipped-domain failure; and a non-null, all-empty-array gateway SAN dictionary correctly suppressing the CSR fallback (CN-only result, not backfilled from the CSR). Release, no-DCV: 197/197. Release, DCV: 226/226. Zero code warnings in both. * fix(enroll): address full-review round 5 — cancellation swallowed by RestSharp, unsanitized DomainName log Two confirmed findings; the first invalidated round 4's own cancellation fix in a way only a real-HTTP-level test could catch. 1. Round 4 added `catch (OperationCanceledException) { throw; }` guards ahead of the generic per-domain catches in PerformDcvIfNeededAsync, to stop a DCV timeout from being mislabeled as an ordinary per-domain failure. That guard is correct but dead for the real trigger: CERTInextClient is built with ThrowOnAnyError=false, so when the shared cancellation token fires mid-call, RestSharp catches HttpClient.SendAsync's cancellation internally and returns a non-throwing, unsuccessful RestResponse instead of propagating OperationCanceledException. DeserializeOrThrow then wraps that into a plain Exception — which lands in the generic catch, not the new guard, and gets logged and reported as "GetDcv failed" for whatever domain happened to be in flight. Round 4's regression test only proved the plugin-side logic works when a Moq mock is told to throw OperationCanceledException directly — which the real client never does, so it gave false confidence. Fixed at the actual source: ExecuteWithRetryAsync (the one place in the client that holds `ct`) now calls ct.ThrowIfCancellationRequested() immediately after the HTTP call, before any retry or error-wrapping logic sees the response. This fixes every caller of ExecuteWithRetryAsync, not just GetDcvAsync — the same swallow-and-wrap otherwise applies to VerifyDcvAsync, TrackOrderAsync, and everything else that goes through it. 2. PlaceOrderAsync's transient-failure and duplicate-transaction warning logs interpolated the requester-derived DomainName without LogSanitizer.Strip, inconsistent with a sibling log statement three lines above in the same method that already sanitizes the identical field. Fixed both. Also applied the one endorsed advisory: CleanupPartialStagingAsync (added in round 2 for early-exit paths) duplicated the pre-existing try/finally cleanup loop almost line-for-line; both now share CleanupOneStagedValidationAsync, parameterized by a log-context string for the one place their wording differs. Left as-is: a safely-dismissed finding about Enroll()'s original "Enrollment attempt started" log (pre-existing, outside this diff) not being sanitized — the adjudicator tried to break that as out-of-scope and could not. Tests: added GetDcvAsync_ThrowsOperationCanceled_WhenCancellationTokenIsCancelled in CERTInextClientTests.cs — against the REAL client and a real (local) WireMock HTTP call with a pre-cancelled token, not a mock told to throw whatever type is asked for. This is the test shape round 4 was missing. Release, no-DCV: 198/198. Release, DCV: 227/227. Zero code warnings in both. * fix(enroll): address full-review round 6 — cleanup reuses cancelled token, no correlation ID Two confirmed findings, both in code from earlier rounds. 1. The DCV-timeout cleanup path (added round 2, hardened round 3) calls CleanupValidation with the same `ct` the operation was just cancelled by. Any IDomainValidator that forwards its token into its own HTTP calls — the reference CloudflareDomainValidator in this repo does exactly that — throws immediately on an already-cancelled token and never attempts the delete. So the one cleanup path specifically built to handle a DCV timeout is the one most likely to silently no-op in exactly that scenario, leaving a published TXT record behind with only a Warning logged ("may require manual removal"). CleanupOneStagedValidationAsync (deduplicated in round 5) now calls CleanupValidation with CancellationToken.None. This is a best-effort compensating action — removing a record we already published — and it must run regardless of why we're cleaning up, including when `ct` itself is the reason. 2. BuildSanList's provenance/resolution log lines (the exact logging this diff added specifically to close "the blind spot that hid the original defect") carried no Subject, unlike nearly every other enrollment-path log line in this file, which repeats Subject={Subject} per line rather than relying on any log-scope mechanism (there is none in this codebase). Under concurrent enrollments, an auditor could not attribute either line back to a specific request. Threaded `subject` through BuildSanList's signature and both call sites, added to all five of its log statements. Advisory noted, not acted on: the file's original "Enrollment attempt started" log doesn't sanitize Subject/SANs either — round 5's adjudicator already tried to break accepting that as pre-existing/out-of-scope and could not, so it stands. Tests: added Dcv_CleanupAfterCancellation_UsesCancellationTokenNone_NotTheAmbientToken, which asserts CleanupValidation's token argument is exactly CancellationToken.None (not merely "not visibly cancelled during a fast test run", which the real DcvTimeoutMinutes-bound token can't be driven to within a unit test) — proving the code passes the literal value regardless of the ambient token's state. Release, no-DCV: 198/198. Release, DCV: 228/228. Zero code warnings in both. * fix(enroll): address full-review round 7 — cleanup call unbounded, subject unsanitized in BuildSanList Three confirmed findings; two are the same root cause (severity high + medium, same location) and the third is a direct consequence of round 6's own fix. 1+3. Round 6's CancellationToken.None fix for the cleanup call over-corrected: it stopped the compensating CleanupValidation call from reusing an already-cancelled token, but in doing so removed its timeout bound entirely — at every one of its three call sites, including the routine, always-runs finally-block cleanup on the ordinary successful-DCV path, which was never cancellation-related to begin with. This directly contradicts the method's own documented SOX CC7.3 guarantee that the DCV flow is hard-timeout-bounded so a stuck DNS provider cannot hold a gateway worker request open indefinitely. A hanging network call inside any third-party IDomainValidator's CleanupValidation would now block forever. Fixed with a fresh, independently-bounded token instead of either extreme: CleanupOneStagedValidationAsync now creates its own CancellationTokenSource with a new Constants.Dcv.CleanupValidationTimeoutSeconds (60s) ceiling for each cleanup call. Not cancelled going in (so a cooperative validator still gets a real chance to run, closing round 6's original gap), but still bounded (closing this round's regression on top of it). 2. BuildSanList's six Subject={Subject} log lines (added last round for audit-trail correlation) logged the requester-controlled Subject DN raw, while every OTHER requester-controlled value in the same function (domain, SAN value, hostname) already goes through LogSanitizer.Strip — an inconsistency introduced within this diff's own new code, unlike the file's pre-existing "Enrollment attempt started" log (which round 5's adjudicator confirmed is legitimately out of scope, being unrelated pre-existing code). Wrapped all six. Also applied the one endorsed advisory: two new DCV test helpers built three byte-for-byte-identical DomainVerificationDetail JSON blocks; extracted a one-line DcvDetail(dcvStatus) helper. Updated the round-6 regression test to match: it asserted the cleanup token equals CancellationToken.None exactly, which is no longer true. Now asserts the two properties that actually matter — IsCancellationRequested is false (not reusing the cancelled ambient token) and CanBeCanceled is true (still bounded, not CancellationToken.None). Release, no-DCV: 198/198. Release, DCV: 228/228. Zero code warnings in both. * fix(enroll): address full-review round 8 — stale gateway count in log, cancellation swallows audit line Two confirmed findings, low/medium severity — cosmetic/observability rather than functional. 1. BuildSanList's own "Resolved N SAN(s)" log line reported a stale, pre-filter FromGatewayRequest count alongside the post-filter Total — reproducing the exact self-contradicting-audit-trail defect class round 3 already fixed once, but only for the CSR-fallback count (fromCsrKept), not the gateway count. With SubmitNonDnsSans=false and a gateway SAN dictionary mixing DNS and non-DNS entries, the line could read "Resolved 1 SAN(s) ... FromGatewayRequest=3" — 3 does not reconcile to 1. Fixed by computing the gateway count post-filter too: gateway- and CSR-sourced entries are mutually exclusive by construction (the CSR fallback only ever runs when the gateway supplied nothing at all), so `result.Count - fromCsrKept` is exactly the right post-filter gateway count. Removed the now-fully-superseded pre-filter `fromGateway` variable. 2. ExecuteWithRetryAsync's cancellation check (added round 5) throws before any caller reaches its own per-call audit line (Method/Path/HttpStatus/ LatencyMs). A DCV-timeout cancellation landing mid-flight on a CERTInext call therefore left no per-call record anywhere — only a coarser, order-level "unexpected failure" log with no domain/endpoint/status/ latency, since the per-domain cancellation catches in PerformDcvIfNeededAsync deliberately re-throw without logging (to avoid mislabeling a timeout as a per-domain failure). Fixed by logging Method/Path/HttpStatus/ResponseStatus/LatencyMs right at the cancellation-detection point inside ExecuteWithRetryAsync itself — the one place that reliably sees every cancellation regardless of which of its ~10 callers is in flight — before throwing. Also applied the one endorsed advisory: SanSubmissionTests.cs's two CSR builders (GenerateCsrPem, GenerateCsrPemWithGeneralNames) duplicated ~20 lines of BouncyCastle CSR-construction boilerplate; GenerateCsrPem is now a one-line delegator to GenerateCsrPemWithGeneralNames. Tests: added one regression test for the stale-count fix, pinning the payload-level data the log line is computed from (only the DNS entry survives SubmitNonDnsSans=false filtering out of a 3-entry mixed gateway dict) — there is no log-capture seam in this codebase (ILogger comes from a fixed LogHandler.GetClassLogger() field, not an injectable dependency), so the log line's exact text cannot be asserted directly, and the cancellation- logging fix has no independently observable test surface for the same reason (round 5's existing cancellation test already covers the only externally-visible behavior — the exception type — unchanged by this fix). Release, no-DCV: 199/199. Release, DCV: 229/229. Zero code warnings in both. * fix(enroll): address full-review round 9 — TXT cleanup unbounded in aggregate across domains One confirmed root cause (found independently by two lenses), continuing the round 6→7 pattern: round 7 fixed each cleanup call's own timeout bound, but running those calls one after another meant the bound was per-call, not in aggregate. Both the early-exit cleanup (CleanupPartialStagingAsync) and the routine, always-runs finally-block cleanup iterated staged domains sequentially. A UCC/multi-SAN order (the exact feature this diff exists to support) with N staged domains could hold the calling request open for up to N x CleanupValidationTimeoutSeconds if the DNS provider was merely slow — not even hung — on every delete: a realistic degraded-provider condition, not a contrived one. For a large SAN count this can exceed DcvTimeoutMinutes itself, contradicting the method's own SOX CC7.3 "the entire DCV flow is hard-timeout-bounded" comment for exactly the multi-domain case this whole fix chain has been hardening. Fixed by running the per-domain cleanup calls concurrently (Task.WhenAll) instead of sequentially, at both call sites. Each call keeps its own independent 60s bound from round 7; running them concurrently means the wall-clock time for the whole batch is bounded by the slowest single call, not the sum — these are independent per-domain operations on different hostnames/records with no shared mutable state, so there is nothing for concurrent execution to race on. Also applied both endorsed advisories: three new test-only IDomainValidator/ IDomainValidatorFactory implementations (PartiallyFailingDomainValidator, TokenCapturingDomainValidator, SelectiveDomainValidatorFactory) each re-implemented boilerplate the pre-existing FakeDomainValidator/ FakeDomainValidatorFactory already provided. Extended those two shared fakes instead (ShouldFail predicate + CleanupTokens capture on the validator; an optional resolvableDomain filter on the factory) and deleted the three duplicates, updating call sites. Tests: added a timing-based regression test proving cleanup for 3 domains completes in close to one cleanup delay's worth of wall time, not three — verified it actually catches the regression by temporarily reverting the fix locally (confirmed FAIL at ~4.5s) before restoring it (confirmed PASS at ~3s), so the threshold is proven discriminating, not just a number that happens to pass. Extended FakeDomainValidator with a configurable CleanupDelay to make this possible; its two List fields needed a lock now that cleanup calls can genuinely run concurrently (StagedRecords did not, since nothing awaits with a real yield point before writing to it). Release, no-DCV: 199/199. Release, DCV: 230/230. Zero code warnings in both. * fix(enroll): address full-review round 11 — check-after-await cancellation race, Subject sanitization (issue 0008) Round 11 confirmed both round-10 out-of-scope dispositions for real (both landed in safelyDismissed — the Sync N+1 pattern and the broader Subject-unsanitized claim are genuinely pre-existing, untouched by this diff) and surfaced one new, real defect plus one endorsed simplification. 1. ExecuteWithRetryAsync (round 5) checked ct.IsCancellationRequested / called ct.ThrowIfCancellationRequested() BEFORE checking whether the just-completed HTTP call actually succeeded. A CancellationTokenSource's timer callback and the awaited HTTP task's completion are not mutually synchronized, so it's possible for the call to genuinely succeed (the response already fully arrived) while `ct` independently flips to cancelled in the same instant — a real check-after-await race, not a fabricated one. Hitting it discarded a genuine CERTInext success and reported OperationCanceledException instead: for VerifyDcv specifically, that would abort PerformDcvIfNeededAsync's loop before WaitForDcvVerificationAsync ever ran, and its finally block would delete the just-staged TXT record even though CERTInext had genuinely received the verify trigger — a self-inflicted DCV failure out of an actual success. Fixed by checking resp.IsSuccessful (or the 4xx client-error case) BEFORE the cancellation check, so a call that completed successfully is returned regardless of the token's state at that instant. Only a call that did NOT succeed goes on to ask "was that because of cancellation?" No dedicated regression test: reproducing this exact race deterministically requires the HTTP response to fully complete before the cancellation timer fires by mere ticks — round 5's own test already shows a pre-cancelled token makes RestSharp report the call as Aborted, not Successful, so a straightforward pre-cancel test cannot exercise this specific ordering. A test that could would need either a flaky real-timing race or refactoring the HTTP-call/cancellation-check split into an independently testable unit, which is more machinery than this ordering fix warrants. 2. Issue 0008 (filed after round 10, per user request): Subject={Subject} was logged raw at ~13 sites across Enroll's own audit log, Revoke, Synchronize, and RenewOrReissueAsync — all pre-existing, confirmed untouched by this diff, and confirmed pre-existing again by round 11's adjudicator — but folded into this PR anyway per explicit instruction rather than left for a separate PR. Wrapped every site in LogSanitizer.Strip, plus the SANs={SANs} (sanSummary) argument on the "Enrollment attempt started" line, which had the identical unsanitized- raw-dictionary gap for the same reason. Also applied the one endorsed advisory: BuildSanList repeated the exact `LogSanitizer.Strip(string.Join("; ", X.Select(...)))` SAN-formatting expression three times; extracted a local FormatSans(...) helper alongside the method's existing Add(...) local-function pattern. Release, no-DCV: 199/199. Release, DCV: 230/230. Zero code warnings in both. * fix(enroll): don't report GENERATED with no certificate body CERTInext can mark an order auto-approved (certificateStatusId 15) before the certificate bytes are actually generated. The immediate GetCertificate after order placement then fails, but the legacy client still reported Status=issued with Certificate=null, and BuildEnrollmentResult trusted that status over the missing body — handing the gateway framework a GENERATED result with no PEM, which crashes CertificateConverterFactory.FromPEM downstream (confirmed against a live support escalation, UCSD order 5435716354). Demote GENERATED to EXTERNALVALIDATION whenever the certificate body is missing, matching the invariant PickUpEnrolledCertificateAsync already enforces on its own GENERATED branch. * docs(changelog): trim 1.0.1 entries to plain, concise bullets The 1.0.1 section had ballooned into multi-sentence paragraphs per bullet. Cut each down to the essential fact for a customer skimming release notes; no information dropped, just the padding. --- .../SanSubmissionProbeTests.cs | 391 +++++++++ CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 541 +++++++++++- CERTInext.Tests/CERTInextCAPluginTests.cs | 28 + CERTInext.Tests/CERTInextClientTests.cs | 36 + CERTInext.Tests/FakeDomainValidator.cs | 61 +- CERTInext.Tests/MockCertificateData.cs | 14 + CERTInext.Tests/SanSubmissionTests.cs | 707 ++++++++++++++++ CERTInext/API/CertificateRequest.cs | 17 + CERTInext/CERTInextCAPlugin.cs | 780 +++++++++++++++--- CERTInext/CERTInextCAPluginConfig.cs | 30 + CERTInext/Client/CERTInextClient.cs | 147 +++- CERTInext/Constants.cs | 12 + CERTInext/Models/LogSanitizer.cs | 33 + CHANGELOG.md | 10 +- 14 files changed, 2652 insertions(+), 155 deletions(-) create mode 100644 CERTInext.IntegrationTests/SanSubmissionProbeTests.cs create mode 100644 CERTInext.Tests/SanSubmissionTests.cs create mode 100644 CERTInext/Models/LogSanitizer.cs diff --git a/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs b/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs new file mode 100644 index 0000000..23681d1 --- /dev/null +++ b/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs @@ -0,0 +1,391 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 +// +// Probe: establish empirically how CERTInext treats the SAN/domain fields on +// GenerateOrderSSL. Written because the plugin's original behaviour encoded three +// assumptions that were never measured: +// +// A. certificateInformation.additionalDomains is the field that puts extra names on +// the certificate (so a UCC order that omits it yields a CN-only certificate). +// B. additionalDomains accepts DNS names only, so a non-DNS SAN is rejected by the CA. +// C. Repeating the primary domainName inside additionalDomains is harmful (duplicate +// domain / consumes the UCC allowance), so it should be de-duplicated. +// +// None of these had a test. This probe answers them against the live API by placing one +// order per variant and reading back the domain set CERTInext actually registered, via +// TrackOrder's domainVerification block (keys are the domains on the order). That is +// ground truth for "which names did the CA put on this order" without waiting for DCV +// and issuance to complete. +// +// --------------------------------------------------------------------------------------- +// MEASURED RESULTS — SANDBOX ONLY: sandbox-us, account 4951571271, product 844 (OV SSL UCC), +// 2026-08-12. (Product 840 / DV UCC is not enabled on that account: "Invalid Product Code".) +// +// These are sandbox observations. Re-run against production before treating B or C as +// settled there — point ~/.env_certinext at the production account and set +// CERTINEXT_SAN_PROBE_PRODUCTS to a UCC code that account can actually order (product +// numbering is per-account; the codes in Constants.Products are defaults, not guarantees). +// Finding A and the CSR-SAN result below are separately corroborated by production: the +// customer report that prompted this work was a production UCC order whose CSR carried the +// SANs and whose issued certificate held only the CN. +// +// A. CONFIRMED. additionalDomains is what puts extra names on the order. Submitting +// CN + extra1. registered BOTH domains. +// +// B. DISPROVEN. Non-DNS values are NOT rejected. An email address, an IPv4 literal and +// an https:// URI were each accepted at placement AND registered as order domains +// ("san-probe@example.com", "192.0.2.10", "https://san-probe.example.com/x" all came +// back as domainVerification keys). So the CA does not validate the field's contents +// at order time; such an order is created and then cannot pass DCV, rather than +// failing cleanly up front. +// +// C. PARTLY DISPROVEN. Repeating the primary domainName inside additionalDomains is +// accepted and CERTInext collapses it itself — the order came back with the CN +// registered once. De-duplicating on our side is therefore belt-and-braces, not a +// correctness requirement. +// +// Root cause of the customer-reported "UCC SANs not populating": CERTInext IGNORES the +// subjectAltName extension in the CSR. A CSR carrying CN + extra2., submitted with +// additionalDomains omitted, registered ONLY the CN. SANs must be sent in +// additionalDomains or they do not reach the certificate, no matter what the CSR says. +// --------------------------------------------------------------------------------------- +// +// Opt-in: this places real orders against whatever account ~/.env_certinext points at. +// +// set -a; . ~/.env_certinext; set +a +// export CERTINEXT_SAN_PROBE=1 +// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \ +// --filter "FullyQualifiedName~SanSubmissionProbeTests" \ +// --logger "console;verbosity=detailed" > /tmp/sanprobe.log 2>&1 +// +// (xUnit buffers ITestOutputHelper output until the test ends — read the report at the tail.) + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class SanSubmissionProbeTests : IClassFixture + { + private const string OptInFlag = "CERTINEXT_SAN_PROBE"; + + /// + /// Comma-separated product codes to probe. Defaults to the Multi-Domain (UCC) codes, + /// because additional domains are only meaningful on a UCC product — a single-domain + /// product (e.g. 842 = OV SSL) registers the CN and nothing else no matter what + /// additionalDomains contains, which makes it useless as a probe target. + /// + private const string ProductCodesFlag = "CERTINEXT_SAN_PROBE_PRODUCTS"; + private const string DefaultProductCodes = "840,844"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _out; + + public SanSubmissionProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _out = output; + } + + // ------------------------------------------------------------------------- + // CSR generation (BouncyCastle — project crypto policy) + // ------------------------------------------------------------------------- + + /// + /// Generates a PKCS#10 CSR for , optionally carrying a + /// subjectAltName extension (via the PKCS#9 extensionRequest attribute) holding + /// . The SAN-bearing form is what lets this probe ask + /// whether CERTInext reads SANs out of the CSR at all. + /// + private static string GenerateCsrPem(string cn, params string[] dnsSans) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + Asn1Set attributes = null; + if (dnsSans != null && dnsSans.Length > 0) + { + var names = new GeneralNames( + dnsSans.Select(d => new GeneralName(GeneralName.DnsName, d)).ToArray()); + + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, critical: false, extValue: names); + + attributes = new DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, + new DerSet(extGen.Generate()))); + } + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attributes, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + // ------------------------------------------------------------------------- + // One probe variant + // ------------------------------------------------------------------------- + + private sealed class ProbeOutcome + { + public string ProductCode; + public string Label; + public bool Accepted; + public string OrderNumber; + public string Detail; + /// Domains CERTInext registered on the order, per TrackOrder. + public List RegisteredDomains = new List(); + /// Names we asked CERTInext to put on the order, for comparison. + public List RequestedDomains = new List(); + + /// + /// True when the rejection was "Invalid Product Code" — the product simply is not + /// enabled on this account, which is not a data point about SAN handling. + /// + public bool ProductUnavailable; + } + + /// + /// Places one order and reads back the domain set CERTInext registered for it. + /// drives certificateInformation.additionalDomains; + /// drives the SAN extension inside the CSR. They are + /// varied independently on purpose — that separation is the whole point of the probe. + /// + private async Task ProbeAsync( + string productCode, + string label, + Func> sansFactory, + string[] csrSans) + { + var outcome = new ProbeOutcome { ProductCode = productCode, Label = label }; + + var client = new CERTInextClient(_fixture.Config); + string cn = $"sanprobe-{DateTime.UtcNow:yyyyMMddHHmmssfff}.{SafeLabel(label)}.example.com"; + + var sans = sansFactory?.Invoke(cn); + outcome.RequestedDomains = sans == null + ? new List() + : sans.Select(s => $"{s.Type}:{s.Value}").ToList(); + + var req = new EnrollCertificateRequest + { + Csr = GenerateCsrPem(cn, csrSans == null ? null : csrSans.Select(s => Format(s, cn)).ToArray()), + Subject = $"CN={cn}", + Sans = sans, + ProfileId = productCode, + RequesterName = _fixture.RequestorName, + RequesterEmail = _fixture.RequestorEmail + }; + + try + { + var resp = await client.EnrollCertificateAsync(req); + outcome.Accepted = true; + outcome.OrderNumber = resp?.Id; + outcome.Detail = $"OrderNumber={resp?.Id} Status={resp?.Status}"; + } + catch (Exception ex) + { + outcome.Accepted = false; + outcome.Detail = ex.Message; + outcome.ProductUnavailable = + ex.Message.IndexOf("Invalid Product Code", StringComparison.OrdinalIgnoreCase) >= 0; + return outcome; + } + + // Read back which domains the CA actually put on the order. + try + { + var track = await client.TrackOrderAsync(outcome.OrderNumber); + var entries = track.OrderDetails?.DomainVerification?.GetDomainEntries(); + if (entries != null) + outcome.RegisteredDomains = entries.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList(); + } + catch (Exception ex) + { + outcome.Detail += $" | TrackOrder failed: {ex.Message}"; + } + + return outcome; + } + + /// Substitutes the generated CN into a variant's placeholder template. + private static string Format(string template, string cn) => template.Replace("{cn}", cn); + + private static string SafeLabel(string label) => + new string(label.ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()) + .Trim('-'); + + // ------------------------------------------------------------------------- + // The probe + // ------------------------------------------------------------------------- + + [SkippableFact] + public async Task Probe_SanSubmissionBehaviour() + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.IfNot( + Environment.GetEnvironmentVariable(OptInFlag) == "1", + $"Set {OptInFlag}=1 to run this probe — it places real orders on the configured account."); + + var variants = new List<(string Label, Func> Sans, string[] CsrSans)> + { + // 1. Assumption A, positive control: additionalDomains carries an extra DNS + // name. If the extra name comes back registered, additionalDomains works. + ("dns-extra-via-additionalDomains", + cn => new List + { + new SanEntry { Type = "dns", Value = cn }, + new SanEntry { Type = "dns", Value = $"extra1.{cn}" } + }, + new[] { "{cn}", "extra1.{cn}" }), + + // 2. Assumption A, the actual bug: CSR carries both names, additionalDomains + // is omitted entirely. This is what v1.0.1 sent for every UCC enrollment. + // If only the CN comes back registered, the CA does NOT read CSR SANs and + // the diagnosis is confirmed. + ("csr-sans-only-no-additionalDomains", + _ => null, + new[] { "{cn}", "extra2.{cn}" }), + + // 3. Assumption C: primary domainName repeated inside additionalDomains. + // Does the CA reject it, or silently collapse it? + ("cn-duplicated-in-additionalDomains", + cn => new List + { + new SanEntry { Type = "dns", Value = cn }, + new SanEntry { Type = "dns", Value = cn } + }, + new[] { "{cn}" }), + + // 4-6. Assumption B: non-DNS values in additionalDomains. Rejected, ignored, + // or accepted? Each is submitted alongside a valid DNS name so a rejection + // is attributable to the non-DNS value rather than an empty domain set. + ("nondns-email-in-additionalDomains", + cn => new List + { + new SanEntry { Type = "dns", Value = cn }, + new SanEntry { Type = "email", Value = "san-probe@example.com" } + }, + new[] { "{cn}" }), + + ("nondns-ip-in-additionalDomains", + cn => new List + { + new SanEntry { Type = "dns", Value = cn }, + new SanEntry { Type = "ip", Value = "192.0.2.10" } + }, + new[] { "{cn}" }), + + ("nondns-uri-in-additionalDomains", + cn => new List + { + new SanEntry { Type = "dns", Value = cn }, + new SanEntry { Type = "uri", Value = "https://san-probe.example.com/x" } + }, + new[] { "{cn}" }), + }; + + string[] productCodes = + (Environment.GetEnvironmentVariable(ProductCodesFlag) ?? DefaultProductCodes) + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.Trim()) + .Where(p => p.Length > 0) + .ToArray(); + + var results = new List(); + foreach (string productCode in productCodes) + { + bool unavailable = false; + foreach (var (label, sans, csrSans) in variants) + { + var outcome = await ProbeAsync(productCode, label, sans, csrSans); + results.Add(outcome); + + // Don't burn five more orders proving the same product code is not + // enabled on this account. + if (outcome.ProductUnavailable) + { + unavailable = true; + break; + } + + // Throttle: the sandbox rate-limits order bursts (~16 orders / 10 s). + await Task.Delay(1500); + } + + if (unavailable) + _out.WriteLine($"(product {productCode} is not enabled on this account — skipped)"); + } + + _out.WriteLine("=== CERTInext SAN submission probe ==="); + _out.WriteLine($"ProductCodes probed : {string.Join(", ", productCodes)}"); + _out.WriteLine($"(fixture default : {_fixture.ProductCode})"); + _out.WriteLine(""); + + foreach (var group in results.GroupBy(r => r.ProductCode)) + { + _out.WriteLine($"--- ProductCode {group.Key} ---"); + foreach (var r in group) + { + _out.WriteLine($"[{(r.Accepted ? "ACCEPTED" : "REJECTED")}] {r.Label}"); + _out.WriteLine($" requested (additionalDomains): {(r.RequestedDomains.Count > 0 ? string.Join(", ", r.RequestedDomains) : "(field omitted)")}"); + _out.WriteLine($" detail : {r.Detail}"); + _out.WriteLine($" registeredDomains (TrackOrder): {(r.RegisteredDomains.Count > 0 ? string.Join(", ", r.RegisteredDomains) : "(none reported)")}"); + _out.WriteLine(""); + } + } + + _out.WriteLine("=== How to read this ==="); + _out.WriteLine("registeredDomains is TrackOrder's domainVerification key set — the domains"); + _out.WriteLine("CERTInext put on the order. Compare it against 'requested':"); + _out.WriteLine("(1) vs (2): if (1) registers the extra name and (2) does not, then"); + _out.WriteLine(" additionalDomains is required and CSR SANs alone are ignored."); + _out.WriteLine("(3) : whether repeating the CN is rejected or collapsed."); + _out.WriteLine("(4)-(6) : whether non-DNS values are rejected, ignored, or accepted"); + _out.WriteLine(" AT PLACEMENT TIME. An order accepted here can still be"); + _out.WriteLine(" rejected later during validation/approval."); + + // The probe reports; it does not assert a specific CA behaviour, because its purpose + // is to discover what that behaviour is. What must hold is that at least one UCC + // product was actually exercised — otherwise the run proved nothing and should not + // read as a pass. + var usable = results + .Where(r => !r.ProductUnavailable) + .GroupBy(r => r.ProductCode) + .ToList(); + + Skip.If( + usable.Count == 0, + "None of the probed product codes are enabled on this account " + + $"({string.Join(", ", productCodes)}). Set {ProductCodesFlag} to a Multi-Domain (UCC) " + + "code this account can order."); + + foreach (var group in usable) + { + var control = group.First(r => r.Label == "dns-extra-via-additionalDomains"); + Assert.True( + control.Accepted, + $"Positive control failed on product {group.Key} — could not place even a " + + $"plain DNS UCC order: {control.Detail}"); + } + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index d812074..b45f0ac 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -527,7 +527,7 @@ public async Task SyncDcvRetry_DoesSingleShotTrackOrder_WhenChallengeNotReady() // --------------------------------------------------------------------------- [Fact] - public async Task Dcv_Throws_WhenNoProviderForDomain() + public async Task Dcv_SkipsAndDefers_WhenNoProviderForDomain() { var mock = NewMock(); mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) @@ -539,17 +539,19 @@ public async Task Dcv_Throws_WhenNoProviderForDomain() mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) .ReturnsAsync(MockCertificateData.DcvTokenResponse()); - // Factory returns null → no DNS provider configured + // Factory returns null → no DNS provider configured. Regression: this used to throw and + // fail the whole order — including when the "unresolvable" domain was actually just a + // non-DNS Subject CN with no config-level way to prevent the throw (SubmitNonDnsSans only + // filters the SAN list, not the subject). Now it is logged loudly and deferred instead. var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator: null)); Func act = () => Enroll(plugin); - await act.Should().ThrowAsync() - .WithMessage("*No DNS provider plugin is configured*"); + await act.Should().NotThrowAsync(); } [Fact] - public async Task Dcv_Throws_WhenStageValidationFails() + public async Task Dcv_SkipsAndDefers_WhenStageValidationFails() { var mock = NewMock(); mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) @@ -566,10 +568,12 @@ public async Task Dcv_Throws_WhenStageValidationFails() Func act = () => Enroll(plugin); - await act.Should().ThrowAsync() - .WithMessage("*Failed to stage DNS validation*DNS zone not writable*"); + // Regression: a StageValidation failure used to throw and fail the whole order. Now it + // is logged loudly and the domain is skipped/deferred — this is the only pending domain, + // so nothing gets staged and the order defers to the next sync cycle. + await act.Should().NotThrowAsync(); - // No VerifyDcv call — failed before reaching that step + // No VerifyDcv call — nothing was staged to verify mock.Verify(c => c.VerifyDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -602,7 +606,7 @@ public async Task Dcv_CleanupAlwaysCalled_EvenWhenVerifyDcvThrows() } [Fact] - public async Task Dcv_Throws_WhenGetDcvReturnsNoToken() + public async Task Dcv_SkipsAndDefers_WhenGetDcvReturnsNoToken() { var mock = NewMock(); mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) @@ -619,8 +623,11 @@ public async Task Dcv_Throws_WhenGetDcvReturnsNoToken() Func act = () => Enroll(plugin); - await act.Should().ThrowAsync() - .WithMessage("*GetDcv returned no token*"); + // Regression: an empty token used to throw and fail the whole order. It is now logged + // loudly (LogError) and the domain is skipped — the order defers to the next sync cycle + // rather than failing Enroll with an order already placed at the CA. + await act.Should().NotThrowAsync(); + validator.StagedRecords.Should().BeEmpty("the only pending domain returned no token, so nothing should have been staged"); } // --------------------------------------------------------------------------- @@ -689,11 +696,16 @@ public async Task Dcv_Defers_When_GetDcv_ReturnsInvalidRequestMessage_WithoutEms } [Fact] - public async Task Dcv_Rethrows_When_GetDcv_FailsWithUnrelatedError() + public async Task Dcv_SkipsAndDefers_WhenGetDcvFailsWithUnrelatedError() { - // Tolerance is narrow: a genuine server error (5xx, transport, auth) must still - // bubble up so the gateway treats the enrollment as failed and the operator can - // diagnose. This guards against accidentally swallowing every GetDcv exception. + // Regression: this test used to assert the opposite — that a genuine server error (5xx, + // transport, auth) must bubble up and fail the whole enrollment. That is exactly the + // orphaned-order failure mode: GetDcv's live behavior for a non-DNS order-domain is + // unmeasured (see BuildSanList's sandbox-only caveat), so treating any unrecognized + // GetDcv error as fatal risks failing perfectly good co-tenant DNS domains on the same + // order over one domain's transient or CA-side issue, with the enrollment already + // placed at CERTInext and no catch anywhere above this call. The failure is still loud + // (LogError, with the underlying exception) — it just no longer fails the call. var mock = NewMock(); mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" }); @@ -708,8 +720,8 @@ public async Task Dcv_Rethrows_When_GetDcv_FailsWithUnrelatedError() var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); Func act = () => Enroll(plugin); - await act.Should().ThrowAsync() - .WithMessage("*HTTP 500*"); + await act.Should().NotThrowAsync(); + validator.StagedRecords.Should().BeEmpty("the only pending domain's GetDcv call failed, so nothing should have been staged"); } // --------------------------------------------------------------------------- @@ -824,5 +836,500 @@ public async Task Dcv_WaitsForIssuance_AfterDcvVerifies() mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), Times.AtLeast(2), "plugin should have polled at least twice for issuance"); } + + // --------------------------------------------------------------------------- + // Undrainable pending domains must not strand the valid ones on the same order + // --------------------------------------------------------------------------- + + /// Builds a DomainVerificationDetail JsonElement for the given dcvStatus. + private static System.Text.Json.JsonElement DcvDetail(string dcvStatus) => + System.Text.Json.JsonSerializer.SerializeToElement(new DomainVerificationDetail + { + DcvMethod = Constants.Dcv.MethodDnsTxt, + DcvStatus = dcvStatus, + Status = "1" + }); + + /// + /// Builds a TrackOrder response whose domainVerification block lists several pending + /// domains, so tests can mix validatable and unvalidatable keys on one order. + /// + private static TrackOrderResponse DcvPendingTrackResponseMultiDomain( + string orderNumber, params string[] domains) + { + var detail = DcvDetail(Constants.Dcv.StatusPending); + var raw = new Dictionary(); + foreach (string d in domains) + raw[d] = detail; + + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusPending, + RawDomainEntries = raw + } + } + }; + } + + /// + /// Builds a TrackOrder response with one already-validated domain (dcvStatus=1) and one + /// still-pending, unresolvable domain (dcvStatus=0) — the shape CERTInext produces when it + /// has cached a prior DCV validation for the CN while a non-DNS SAN on the same order is + /// still outstanding. + /// + private static TrackOrderResponse DcvMixedStatusTrackResponse( + string validatedDomain, string pendingDomain) + { + var validated = DcvDetail(Constants.Dcv.StatusValidated); + var pending = DcvDetail(Constants.Dcv.StatusPending); + + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + // Aggregate stays pending because one domain still is — this must not take + // the early "already validated" return at the top of the method. + Status = Constants.Dcv.StatusPending, + RawDomainEntries = new Dictionary + { + [validatedDomain] = validated, + [pendingDomain] = pending + } + } + } + }; + } + + /// + /// Regression for the false invariant behind the round-1 fix's own misconfiguration check: + /// "the CN is always a pending domain too" is untrue whenever CERTInext has cached a prior + /// DCV validation for it (a case this same file's cached-validation branch documents), so a + /// non-DNS SAN sharing the order with an already-validated CN must not throw — it must defer + /// to the next sync cycle exactly like the single-domain case does. + /// + [Fact] + public async Task Dcv_CachedCnPlusUnresolvableSan_DefersWithoutThrowing() + { + const string order = MockCertificateData.DcvOrderId; + const string cn = MockCertificateData.DcvDomain; + const string ip = "192.0.2.10"; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvMixedStatusTrackResponse(validatedDomain: cn, pendingDomain: ip)); + + // The IP clears the FQDN regex and reaches GetDcv, per the sandbox-measured shape. + mock.Setup(c => c.GetDcvAsync(order, ip, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken)); + + var validator = new FakeDomainValidator(); + // Resolves for the CN (a real, working DNS provider) but not for the IP literal — the + // scenario that must prove "a provider IS deployed" rather than "nothing is deployed". + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator, resolvableDomain: cn), + DcvConfig()); + + Func act = () => Enroll(plugin); + + await act.Should().NotThrowAsync( + "an unresolvable non-DNS SAN must defer the order to the next sync cycle, not fail " + + "the enrollment — the CN having cached DCV proves a provider is deployed and working, " + + "so this is not the 'nothing is deployed' misconfiguration case"); + + validator.StagedRecords.Should().BeEmpty( + "the only pending domain is unresolvable, so nothing should have been staged"); + } + + /// + /// A non-FQDN pending domain must be skipped, not thrown on. + /// + /// Regression: non-DNS SANs are now submitted to CERTInext, which registers them verbatim + /// as order domains, so an email/URI SAN turns up as a domainVerification key that fails the + /// FQDN check. That check used to throw for the whole order — escaping Enroll (which has no + /// catch) after the order was already placed, so the enrollment failed with an orphaned + /// order and no TXT record was staged for the *valid* domains beside it. Every sync retry + /// re-threw and TryRunDcvDuringSyncAsync swallowed it, so the order could never progress. + /// + [Fact] + public async Task Dcv_NonFqdnPendingDomain_IsSkipped_AndValidDomainStillStaged() + { + const string order = MockCertificateData.DcvOrderId; + const string good = MockCertificateData.DcvDomain; + const string bad = "admin@example.com"; // what an rfc822 SAN comes back as + + var mock = NewMock(); + + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good)); + + // Only the valid domain should ever reach GetDcv/VerifyDcv. MockBehavior.Strict means + // an unexpected call for `bad` fails the test on its own. + mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken)); + mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + // Must not throw — that is the regression. + var result = await Enroll(plugin); + + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good); + validator.StagedRecords.Should().ContainSingle( + "the valid DNS domain must still be staged even though a co-tenant domain is unusable") + .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + + mock.Verify(c => c.GetDcvAsync(order, bad, It.IsAny(), It.IsAny()), + Times.Never, "a non-FQDN domain must never be sent to GetDcv"); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + } + + /// + /// Regression: the FQDN validation regex used ^...$ , and in .NET's default (non-Multiline) + /// mode $ matches immediately before a single trailing '\n', not only at the true end of the + /// string. A domain value ending in '\n' therefore passed as "valid" and reached several log + /// sinks unsanitized further down this same method — a CWE-117 log-injection route into the + /// DCV audit trail, reachable via any order visible through Synchronize/GetSingleRecord (not + /// just ones this plugin's own Enroll call placed, since TrackOrder's domainVerification keys + /// for an externally-created order are never trimmed by this plugin). The regex now anchors + /// with \A/\z, which are absolute string-start/end regardless of trailing newlines. + /// + [Fact] + public async Task Dcv_DomainWithTrailingNewline_IsRejectedAsInvalid_AndValidDomainStillStaged() + { + const string order = MockCertificateData.DcvOrderId; + const string good = MockCertificateData.DcvDomain; + const string bad = "evil.example.com\n"; + + var mock = NewMock(); + + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good)); + + // MockBehavior.Strict: an unexpected GetDcv call for `bad` fails the test on its own — + // if the regex fix regressed, this domain would reach GetDcv instead of being rejected + // by the FQDN check before the staging loop even starts. + mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken)); + mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good); + validator.StagedRecords.Should().ContainSingle( + "the valid domain must still be staged even though a co-tenant domain carries a " + + "trailing newline") + .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + + mock.Verify(c => c.GetDcvAsync(order, bad, It.IsAny(), It.IsAny()), + Times.Never, "a domain with a trailing newline must never be sent to GetDcv"); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + } + + /// + /// Regression: the generic per-domain catch blocks around GetDcvAsync and StageValidation + /// used to catch OperationCanceledException along with genuine GetDcv/DNS-provider failures, + /// logging and skipping the domain as an ordinary per-domain failure. A cancellation (the + /// shared DcvTimeoutMinutes-bound token expiring mid-loop) is not that — it must propagate to + /// the outer catch instead, which is the only place that logs it correctly and is the + /// intended timeout-handling path documented at the top of this method's DCV timeout setup. + /// + [Fact] + public async Task Dcv_CancellationDuringGetDcv_PropagatesRatherThanBeingSkippedAsPerDomainFailure() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ThrowsAsync(new OperationCanceledException("DCV timeout budget exceeded")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + + // Must propagate as a cancellation, not be swallowed and reported as "GetDcv failed" in + // the skipped-domains summary while Enroll completes normally. + await act.Should().ThrowAsync(); + } + + /// + /// A pending domain that resolves no DNS provider (an IP-literal SAN passes the FQDN regex + /// but no zone can match it) must likewise be skipped rather than failing the whole order. + /// + [Fact] + public async Task Dcv_DomainWithNoResolvableValidator_IsSkipped_AndValidDomainStillStaged() + { + const string order = MockCertificateData.DcvOrderId; + const string good = MockCertificateData.DcvDomain; + const string ip = "192.0.2.10"; // what an iPAddress SAN comes back as + + var mock = NewMock(); + + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, ip)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good)); + + // The IP literal clears the FQDN filter, so GetDcv IS called for it; the dead end is + // that no validator resolves. Stub it so reaching that point is legitimate. + mock.Setup(c => c.GetDcvAsync(order, It.IsAny(), Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken)); + mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator, resolvableDomain: good), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good); + validator.StagedRecords.Should().ContainSingle( + "only the domain with a resolvable provider should be staged, and it must still be staged") + .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + } + + /// + /// Regression: the compensating cleanup call after an early exit from staging (chiefly the + /// shared DcvTimeoutMinutes-bound token firing mid-loop, which is what this scenario + /// simulates via a domain whose GetDcv call raises OperationCanceledException) must not reuse + /// the same token the operation was cancelled by. A cooperative IDomainValidator that forwards + /// its token into its own HTTP calls (the reference CloudflareDomainValidator in this repo + /// does exactly that) would otherwise throw immediately on an already-cancelled token and + /// never even attempt the delete, silently leaving the TXT record published. + /// + /// CancellationToken.None would fix that but removes the cleanup call's timeout bound + /// entirely — a second, adversarially-found regression on top of the first — so the correct + /// fix is a fresh token with its OWN short timeout: not cancelled going in, but still bounded. + /// + [Fact] + public async Task Dcv_CleanupAfterCancellation_UsesAFreshBoundedToken_NotTheAmbientToken() + { + const string order = MockCertificateData.DcvOrderId; + const string good = "a.example.com"; + const string bad = "b.example.com"; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + mock.Setup(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad)); + + mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-a")); + // Domain 'good' is processed first (Dictionary enumeration order matches insertion order + // in practice for the small dictionaries this test builds); 'bad' then throws, driving the + // outer catch's cleanup of the already-staged 'good' entry. + mock.Setup(c => c.GetDcvAsync(order, bad, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ThrowsAsync(new OperationCanceledException("DCV timeout budget exceeded")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + await act.Should().ThrowAsync(); + + validator.StagedRecords.Should().ContainSingle( + "'good' must have staged before 'bad' threw, for this test to exercise cleanup at all"); + var cleanupToken = validator.CleanupTokens.Should().ContainSingle( + "the staged entry must go through the cancellation cleanup path exactly once").Subject; + + cleanupToken.IsCancellationRequested.Should().BeFalse( + "cleanup is a best-effort compensating action and must run with its own token, " + + "not the already-cancelled ambient one"); + cleanupToken.CanBeCanceled.Should().BeTrue( + "the cleanup call must still be bounded by its own timeout, not unbounded " + + "(CancellationToken.None) — a hanging DNS-provider call must not block forever"); + } + + /// + /// Regression: the routine, always-runs finally-block cleanup used to iterate staged domains + /// sequentially. Each cleanup call already has its own independent + /// CleanupValidationTimeoutSeconds bound, but running them one after another meant that + /// bound was per-call, not in aggregate — a UCC order with N staged domains could hold the + /// calling request open for up to N x the per-call ceiling if the DNS provider was merely + /// slow (not even hung) on every delete, which can exceed DcvTimeoutMinutes itself for a + /// realistic multi-SAN count. Proven here by timing: three domains each with an artificial + /// cleanup delay must complete in close to ONE delay's worth of wall time, not three. + /// + [Fact] + public async Task Dcv_CleanupOfMultipleDomains_RunsConcurrently_NotSequentially() + { + const string order = MockCertificateData.DcvOrderId; + string[] domains = { "a.example.com", "b.example.com", "c.example.com" }; + var cleanupDelay = TimeSpan.FromMilliseconds(800); + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + var verifiedDetail = DcvDetail(Constants.Dcv.StatusValidated); + var verifiedRaw = new Dictionary(); + foreach (string d in domains) verifiedRaw[d] = verifiedDetail; + + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, domains)) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated, + RawDomainEntries = verifiedRaw + } + } + }); + + foreach (string d in domains) + { + mock.Setup(c => c.GetDcvAsync(order, d, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse($"token-{d}")); + mock.Setup(c => c.VerifyDcvAsync(order, d, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + } + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator { CleanupDelay = cleanupDelay }; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + await Enroll(plugin); + sw.Stop(); + + validator.CleanedUpKeys.Should().HaveCount(3, "all three staged domains must be cleaned up"); + + // This flow carries ~2s of fixed overhead unrelated to cleanup (DcvPropagationDelaySeconds + // and WaitForDcvVerificationAsync's poll interval both floor at 1s each — DcvConfig's + // propagationDelaySeconds default is deliberately 1, since 0 falls back to a 30s default + // in PerformDcvIfNeededAsync, not "no delay"). An 800ms-per-domain cleanup delay makes the + // concurrent-vs-sequential gap (≈800ms vs ≈2400ms of cleanup time) large relative to that + // fixed cost and to CI jitter. 4000ms sits well above "fixed overhead + one 800ms delay" + // and well below "fixed overhead + three 800ms delays run one after another". + sw.ElapsedMilliseconds.Should().BeLessThan(4000, + "cleanup for independent domains must run concurrently, not sequentially — " + + "3 domains x 800ms sequential would add roughly 3x this call's actual cleanup time"); + } + + /// + /// Regression: a StageValidation failure on one domain of a multi-domain order must not + /// leave the TXT records already published for the earlier domains orphaned. Before the + /// fix, the staging loop's throw sites were outside the try/finally that owns cleanup, so + /// this was reachable only by accident (pre-fix, a UCC order's SANs never reached CERTInext + /// at all, so an order rarely had more than one pending domain to stage). Submitting every + /// requested SAN makes multi-domain staging the normal case, so this must hold now. + /// + [Fact] + public async Task Dcv_StageFailureOnSecondDomain_DoesNotAbortTheGoodDomain() + { + const string order = MockCertificateData.DcvOrderId; + const string good = "a.example.com"; + const string bad = "b.example.com"; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + // First TrackOrder call (inside PerformDcvIfNeededAsync) sees both domains pending; + // the second (WaitForDcvVerificationAsync's poll after staging/VerifyDcv) sees the one + // domain that actually got staged — 'good' — as verified. + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good)); + + mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-a")); + mock.Setup(c => c.GetDcvAsync(order, bad, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-b")); + + mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator + { + ShouldFail = key => key.Contains(bad, StringComparison.OrdinalIgnoreCase) + }; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + Func act = () => Enroll(plugin); + + // Regression: a StageValidation failure on one domain of a multi-domain order must not + // abort the whole order any more — it did before this fix, which both failed the + // enrollment with an orphaned CERTInext order AND (before an earlier round's fix) + // orphaned the 'good' domain's already-published TXT record. Now the bad domain is + // skipped (logged loudly) and the good domain proceeds through the normal DCV lifecycle. + await act.Should().NotThrowAsync(); + + string goodHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good); + string badHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, bad); + + validator.StagedRecords.Should().ContainSingle( + "only the domain that did not fail to stage should ever have been staged") + .Which.key.Should().Be(goodHostname); + validator.CleanedUpKeys.Should().Contain(goodHostname, + "the good domain completes its normal verify-then-cleanup lifecycle"); + validator.CleanedUpKeys.Should().NotContain(badHostname, + "the bad domain was never staged, so there is nothing to clean up for it"); + } } } diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index 7064b44..5154146 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -357,6 +357,34 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval() result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); } + [Fact] + public async Task Enroll_New_ReturnsPendingStatus_WhenCaReportsIssuedButBodyMissing() + { + // CERTInext can report an "issued"/auto-approved certificateStatusId before the + // certificate bytes actually exist — the immediate GetCertificate download fails + // and the legacy client returns Status="issued" with Certificate=null. Reporting + // GENERATED with no PEM crashes the gateway framework's PEM parser downstream, so + // the plugin must demote this to pending rather than trust the raw status string. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(MockCertificateData.AutoApprovedNoBodyEnrollResponse()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 0); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=test.example.com", + san: null, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.Certificate.Should().BeNullOrEmpty(); + } + // --------------------------------------------------------------------------- // Synchronous certificate pickup (Sectigo parity) // --------------------------------------------------------------------------- diff --git a/CERTInext.Tests/CERTInextClientTests.cs b/CERTInext.Tests/CERTInextClientTests.cs index e473e89..a0ade72 100644 --- a/CERTInext.Tests/CERTInextClientTests.cs +++ b/CERTInext.Tests/CERTInextClientTests.cs @@ -790,6 +790,42 @@ await act.Should().ThrowAsync() .WithMessage("*GetDcv failed*"); } + /// + /// Regression: this client is built with ThrowOnAnyError=false, so RestSharp catches a + /// cancelled HttpClient.SendAsync internally and returns a non-throwing, unsuccessful + /// RestResponse instead of propagating OperationCanceledException. Before this fix, + /// ExecuteWithRetryAsync passed that response straight to DeserializeOrThrow, which wrapped + /// it in a plain Exception — indistinguishable from a genuine API failure. A caller such as + /// PerformDcvIfNeededAsync's per-domain "catch (OperationCanceledException) { throw; }" guard + /// (added specifically to stop a DCV timeout from being mislabeled as an ordinary per-domain + /// failure) could never actually see the real cancellation, because it never arrived as + /// OperationCanceledException in the first place — a gap a Moq-level test of the plugin alone + /// cannot expose, since a mock can be told to throw whatever type is asked for. This test + /// exercises the real client against a real (if local) HTTP call, which is the only way to + /// pin the actual failure mode. + /// + [Fact] + public async Task GetDcvAsync_ThrowsOperationCanceled_WhenCancellationTokenIsCancelled() + { + _server + .Given(Request.Create().WithPath("/GetDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetDcvSuccessJson())); + + var client = BuildClient(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Func act = () => client.GetDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt, cts.Token); + + await act.Should().ThrowAsync( + "a cancelled token must surface as a genuine cancellation, not get wrapped into a " + + "plain Exception that a caller's cancellation-specific catch clause cannot recognize"); + } + [Fact] public async Task GetDcvAsync_Throws_WhenServerReturns401() { diff --git a/CERTInext.Tests/FakeDomainValidator.cs b/CERTInext.Tests/FakeDomainValidator.cs index 6b42475..d3917ec 100644 --- a/CERTInext.Tests/FakeDomainValidator.cs +++ b/CERTInext.Tests/FakeDomainValidator.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -21,10 +22,20 @@ internal sealed class FakeDomainValidator : IDomainValidator /// All keys passed to . public List CleanedUpKeys { get; } = new(); + /// All CancellationTokens passed to . + public List CleanupTokens { get; } = new(); + /// When false, returns a failure result. public bool StageSucceeds { get; init; } = true; - /// Error message returned when is false. + /// + /// When set, overrides on a per-key basis — e.g. + /// key => key.Contains("bad", StringComparison.OrdinalIgnoreCase) to fail only a + /// specific hostname in a multi-domain test while the others still stage successfully. + /// + public Func ShouldFail { get; init; } + + /// Error message returned when a StageValidation call fails. public string StageError { get; init; } = "Stage failed (test stub)"; public void Initialize(IDomainValidatorConfigProvider configProvider) { } @@ -32,18 +43,39 @@ public void Initialize(IDomainValidatorConfigProvider configProvider) { } public Task StageValidation(string key, string value, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - StagedRecords.Add((key, value)); + bool fail = ShouldFail?.Invoke(key) ?? !StageSucceeds; + if (!fail) + StagedRecords.Add((key, value)); + return Task.FromResult(new DomainValidationResult { - Success = StageSucceeds, - ErrorMessage = StageSucceeds ? null : StageError + Success = !fail, + ErrorMessage = fail ? StageError : null }); } - public Task CleanupValidation(string key, CancellationToken cancellationToken) + /// + /// Artificial delay applied inside before completing — lets + /// tests distinguish "cleanup calls run concurrently" (wall time ~= one delay) from + /// "cleanup calls run sequentially" (wall time ~= N x delay). + /// + public TimeSpan CleanupDelay { get; init; } = TimeSpan.Zero; + + // Cleanup calls can genuinely run concurrently (that's what CleanupDelay exists to prove), + // so the two List fields below need a lock — unlike StagedRecords above, which only ever + // sees synchronously-completing calls in practice. + private readonly object _cleanupLock = new(); + + public async Task CleanupValidation(string key, CancellationToken cancellationToken) { - CleanedUpKeys.Add(key); - return Task.FromResult(new DomainValidationResult { Success = true }); + if (CleanupDelay > TimeSpan.Zero) + await Task.Delay(CleanupDelay, cancellationToken); + lock (_cleanupLock) + { + CleanedUpKeys.Add(key); + CleanupTokens.Add(cancellationToken); + } + return new DomainValidationResult { Success = true }; } public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask; @@ -53,15 +85,24 @@ public Task CleanupValidation(string key, CancellationTo /// /// Factory that returns a single pre-configured for every - /// domain. Pass null as the validator to simulate "no DNS provider configured". + /// domain, or only for if set. Pass null as the + /// validator to simulate "no DNS provider configured". /// internal sealed class FakeDomainValidatorFactory : IDomainValidatorFactory { private readonly IDomainValidator _validator; + private readonly string _resolvableDomain; - public FakeDomainValidatorFactory(IDomainValidator validator = null) => _validator = validator; + public FakeDomainValidatorFactory(IDomainValidator validator = null, string resolvableDomain = null) + { + _validator = validator; + _resolvableDomain = resolvableDomain; + } - public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator; + public IDomainValidator ResolveDomainValidator(string domain, string validationType) => + (_resolvableDomain == null || string.Equals(domain, _resolvableDomain, StringComparison.OrdinalIgnoreCase)) + ? _validator + : null; /// The validator this factory returns; exposed for assertions in tests. public IDomainValidator PrimaryValidator => _validator; diff --git a/CERTInext.Tests/MockCertificateData.cs b/CERTInext.Tests/MockCertificateData.cs index ee6644b..7714152 100644 --- a/CERTInext.Tests/MockCertificateData.cs +++ b/CERTInext.Tests/MockCertificateData.cs @@ -294,6 +294,20 @@ public static EnrollCertificateResponse PendingEnrollResponse(string id = null) Message = "Awaiting approval." }; + // Reproduces the CERTInext "auto-approved" race: TrackOrder reports a + // certificateStatusId the client legacy-maps to "issued", but the immediate + // GetCertificate download failed (cert bytes not generated yet), so no PEM + // ever arrived. See issue 0009. + public static EnrollCertificateResponse AutoApprovedNoBodyEnrollResponse(string id = null) => + new EnrollCertificateResponse + { + Id = id ?? CertId1, + Status = "issued", + Certificate = null, + ProfileId = ProfileIdTls, + Message = "Order auto-approved." + }; + // ----------------------------------------------------------------------- // GetCertificate response (object helpers — used by Moq-based plugin tests) // These use the legacy inferred type (LegacyGetCertificateResponse). diff --git a/CERTInext.Tests/SanSubmissionTests.cs b/CERTInext.Tests/SanSubmissionTests.cs new file mode 100644 index 0000000..c305665 --- /dev/null +++ b/CERTInext.Tests/SanSubmissionTests.cs @@ -0,0 +1,707 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Moq; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for UCC SAN submission. + /// + /// The defect these pin down: the AnyCA REST Gateway keys its SAN dictionary + /// dnsname, but MapSanType only recognized dns. Every DNS SAN was + /// therefore typed "dnsname", filtered out by a DNS-only test when building + /// certificateInformation.additionalDomains, and the order reached CERTInext with + /// no additional domains at all — yielding a certificate holding only the CN. Because + /// CERTInext ignores the CSR's subjectAltName extension entirely (measured; see + /// SanSubmissionProbeTests), SANs present on the CSR did not compensate. + /// + /// The end-to-end tests below drive a real against WireMock + /// so they assert on the JSON actually put on the wire, not on an intermediate object. + /// A test that only checked the mapping function would not have caught this bug, since + /// the mapping "worked" — it was the interaction with the downstream filter that lost + /// the names. + /// + public class SanSubmissionTests : IDisposable + { + private readonly WireMockServer _server; + + public SanSubmissionTests() + { + _server = WireMockServer.Start(); + StubHappyEnroll(); + } + + public void Dispose() => _server.Stop(); + + // ----------------------------------------------------------------------- + // Harness + // ----------------------------------------------------------------------- + + private void StubHappyEnroll() + { + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GenerateOrderSuccessJson(MockCertificateData.OrderNumber1))); + + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.TrackOrderIssuedJson(MockCertificateData.OrderNumber1))); + + _server.Given(Request.Create().WithPath("/GetCertificate").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCertificateSuccessJson())); + } + + private CERTInextClient BuildRealClient() => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "test-key", + AccountNumber = "12345", + RequestorName = "Default Requestor", + RequestorEmail = "default@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerPlace = "Austin", + SignerIp = "203.0.113.10", + PageSize = 100 + }); + + /// + /// Plugin wired to a real client pointed at WireMock. PickupRetries = 0 is set on + /// the plugin's own config (not the client's) — that is where the synchronous-pickup + /// budget is read, and leaving it at the default would make every test here sit in a + /// polling loop. + /// + private CERTInextCAPlugin BuildPlugin() => + new CERTInextCAPlugin(BuildRealClient(), new CERTInextConfig { PickupRetries = 0 }); + + private static EnrollmentProductInfo MakeProductInfo(string profileId = "842") => + new EnrollmentProductInfo + { + ProductID = profileId, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProfileId"] = profileId + } + }; + + /// The orderDetails.certificateInformation block actually POSTed. + private JsonElement CapturedCertificateInformation() + { + var posts = _server.LogEntries + .Where(e => e.RequestMessage.Path == "/GenerateOrderSSL") + .ToList(); + posts.Should().HaveCount(1, "exactly one GenerateOrderSSL POST should have been emitted"); + + string body = posts[0].RequestMessage.Body; + body.Should().NotBeNullOrEmpty(); + + return JsonDocument.Parse(body!).RootElement + .GetProperty("orderDetails") + .GetProperty("certificateInformation"); + } + + private static List AdditionalDomains(JsonElement certificateInformation) => + certificateInformation.TryGetProperty("additionalDomains", out var el) + ? el.EnumerateArray().Select(x => x.GetString()).ToList() + : null; + + // ----------------------------------------------------------------------- + // CSR generation (BouncyCastle — project crypto policy) + // ----------------------------------------------------------------------- + + /// + /// Builds a real PKCS#10 CSR for carrying arbitrary + /// in its subjectAltName extension — used to exercise + /// GeneralName types that have no domain-name rendering. + /// + private static string GenerateCsrPemWithGeneralNames(string cn, params GeneralName[] names) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + Asn1Set attributes = null; + if (names != null && names.Length > 0) + { + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, critical: false, + extValue: new GeneralNames(names)); + + attributes = new DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, + new DerSet(extGen.Generate()))); + } + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attributes, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + /// + /// Builds a real PKCS#10 CSR for , optionally carrying a + /// subjectAltName extension holding . + /// + private static string GenerateCsrPem(string cn, params string[] dnsSans) => + GenerateCsrPemWithGeneralNames( + cn, (dnsSans ?? Array.Empty()).Select(d => new GeneralName(GeneralName.DnsName, d)).ToArray()); + + // ======================================================================= + // End-to-end: Command's SAN dictionary → the JSON on the wire + // ======================================================================= + + /// + /// THE regression test. "dnsname" is the key the real gateway sends — verified against + /// a customer gateway log: + /// SANs=dnsname:CLAUDIOTEST20.ucsd.edu; dnsname:CLAUDIOTEST20.ad.ucsd.edu + /// Before the fix, additionalDomains was absent from the body entirely. + /// + [Fact] + public async Task GatewayDnsNameKey_ReachesAdditionalDomains() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "host.example.com", "alt.example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + var certInfo = CapturedCertificateInformation(); + certInfo.GetProperty("domainName").GetString().Should().Be("host.example.com"); + + AdditionalDomains(certInfo).Should().BeEquivalentTo(new[] { "alt.example.com" }, + "the extra SAN must reach additionalDomains, and the CN must not be repeated there"); + } + + /// + /// The short "dns" spelling must keep working — some callers and older hosts use it. + /// + [Fact] + public async Task ShortDnsKey_StillReachesAdditionalDomains() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dns"] = new[] { "alt.example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com" }); + } + + /// + /// SANs present only on the CSR must still reach additionalDomains. CERTInext does not + /// read the CSR's SAN extension, so if we don't forward these the names never appear + /// on the certificate. + /// + [Fact] + public async Task CsrSans_ReachAdditionalDomains_WhenGatewaySuppliesNone() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com", "host.example.com", "fromcsr.example.com"), + subject: "CN=host.example.com", + san: null, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "fromcsr.example.com" }); + } + + /// + /// Union, not either/or: names unique to each source survive and the overlap collapses. + /// + [Fact] + public async Task CsrOnlySans_AreIgnored_WhenGatewaySuppliesAnyEntries() + { + // Regression: this test used to assert the CSR was unioned in on top of whatever the + // gateway supplied. Full-review's security lens found that risky: Command's SAN + // dictionary is how an enrollment pattern's SAN policy is expressed, and a signed CSR — + // usually generated by the subscriber's own tooling, not by Command — can legitimately + // carry more names than that policy allows. Unioning them in would re-introduce a name + // the policy excluded. The CSR is now consulted only as a fallback when the gateway + // supplies nothing at all (see CsrSans_ReachAdditionalDomains_WhenGatewaySuppliesNone). + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com", "host.example.com", "csronly.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "gatewayonly.example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + var domains = AdditionalDomains(CapturedCertificateInformation()); + + domains.Should().BeEquivalentTo(new[] { "gatewayonly.example.com" }, + "the gateway supplied a (non-empty) SAN set, so the CSR's own SAN extension must be " + + "ignored entirely, not merged in on top of it"); + } + + /// + /// Regression: the CSR-fallback trigger used to be "the gateway dictionary computed to zero + /// added entries", which cannot distinguish "Command never populated SAN data" (the case the + /// fallback exists for) from "Command's enrollment pattern ran and deliberately computed + /// zero SANs for this request" (an explicit policy decision this plugin must respect). A + /// non-null dictionary whose only key maps to an empty array is the latter — the fallback + /// must not engage, even though it computes to the same "0 SANs added" outcome as a null + /// dictionary would. + /// + [Fact] + public async Task CsrSans_AreIgnored_WhenGatewaySuppliesNonNullDictWithOnlyEmptyValues() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com", "host.example.com", "csronly.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + // Non-null dictionary, but the key maps to no values — computes to zero added + // SANs, same as san == null would, but it must NOT be treated the same way. + ["dnsname"] = Array.Empty() + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()).Should().BeNull( + "a non-null gateway SAN dictionary that computes to zero entries must be respected " + + "as Command's own decision, not treated as 'Command supplied nothing' and " + + "backfilled from the CSR"); + } + + /// + /// The CN is already submitted as domainName; repeating it in additionalDomains is + /// suppressed. CERTInext collapses it anyway (measured), so this keeps the body matching + /// what we log rather than relying on undocumented CA-side behaviour. + /// + [Fact] + public async Task Cn_IsNotRepeatedInAdditionalDomains() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com", "host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "host.example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + var certInfo = CapturedCertificateInformation(); + certInfo.GetProperty("domainName").GetString().Should().Be("host.example.com"); + AdditionalDomains(certInfo).Should().BeNull( + "with the CN as the only SAN there is nothing left to send, so the field is omitted"); + } + + /// + /// Non-DNS SANs are submitted rather than silently discarded. CERTInext accepts them + /// verbatim (measured) and the resulting order cannot pass validation — a visible + /// failure, deliberately preferred over issuing a certificate that quietly lacks names + /// the subscriber requested. + /// + [Fact] + public async Task NonDnsSans_AreSubmitted_NotDropped() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "alt.example.com" }, + ["ipaddress"] = new[] { "192.0.2.10" }, + ["rfc822name"] = new[] { "admin@example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com", "192.0.2.10", "admin@example.com" }); + } + + /// + /// Regression for a stale-count bug in BuildSanList's own audit log: with a mixed + /// DNS/non-DNS gateway SAN dictionary and SubmitNonDnsSans=false, the "Resolved N SAN(s)" + /// log line reported the pre-filter gateway count (3) alongside the post-filter total (1) — + /// an arithmetic impossibility ("Resolved 1 ... FromGatewayRequest=3"). There is no log- + /// capture seam in this codebase (ILogger comes from a fixed LogHandler.GetClassLogger() + /// field, not an injectable dependency), so this pins the payload-level data the log line is + /// computed from instead: with the non-DNS entries filtered out, exactly the one DNS name + /// must reach additionalDomains — proving the surviving gateway-sourced count is 1, not the + /// pre-filter 3 the stale log line used to claim. + /// + [Fact] + public async Task MixedGatewaySans_SubmitNonDnsSansFalse_OnlyDnsNameSurvivesFiltering() + { + var plugin = new CERTInextCAPlugin( + BuildRealClient(), + new CERTInextConfig { PickupRetries = 0, SubmitNonDnsSans = false }); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "alt.example.com" }, + ["ipaddress"] = new[] { "192.0.2.10" }, + ["rfc822name"] = new[] { "admin@example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com" }, + "only the DNS entry should survive the SubmitNonDnsSans=false filter, out of " + + "3 the gateway supplied"); + } + + /// + /// A CSR we cannot parse must not break enrollment — the gateway-supplied SANs still go. + /// FakeCsrPem is deliberately truncated, so this also guards the many existing + /// tests that pass it. + /// + [Fact] + public async Task UnparseableCsr_DoesNotBlockGatewaySuppliedSans() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "alt.example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com" }); + } + + /// + /// No SANs from either source → the field is omitted rather than emitted as null/empty. + /// + [Fact] + public async Task NoSansAnywhere_OmitsAdditionalDomains() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: null, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()).Should().BeNull(); + } + + // ======================================================================= + // GeneralName types with no domain-name rendering + // ======================================================================= + + /// + /// A UPN otherName and a directoryName must NOT be submitted. + /// + /// Regression: GeneralNameToValue's default branch returned BouncyCastle's ASN.1 + /// stringification, so a Windows-generated CSR carrying a UPN otherName put + /// "[1.3.6.1.4.1.311.20.2.3, [CONTEXT 0]svc@corp.example.com]" into additionalDomains as if + /// it were a domain name — breaking orders that previously succeeded, and contradicting the + /// method's own doc comment. These types cannot become a certificate SAN via a domain-name + /// field at all, which is why they are skipped (with a Warning) rather than submitted the way + /// well-formed IP/email/URI SANs are. + /// + [Fact] + public async Task CsrOtherNameAndDirectoryName_AreNotSubmittedAsDomains() + { + // UPN otherName, as emitted by Windows/AD certificate tooling. + var upn = new GeneralName(GeneralName.OtherName, new DerSequence( + new DerObjectIdentifier("1.3.6.1.4.1.311.20.2.3"), + new DerTaggedObject(true, 0, new DerUtf8String("svc@corp.example.com")))); + + var directoryName = new GeneralName( + GeneralName.DirectoryName, new X509Name("CN=host.example.com,O=Acme")); + + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPemWithGeneralNames( + "host.example.com", + new GeneralName(GeneralName.DnsName, "alt.example.com"), + upn, + directoryName), + subject: "CN=host.example.com", + san: null, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + var domains = AdditionalDomains(CapturedCertificateInformation()); + + domains.Should().BeEquivalentTo(new[] { "alt.example.com" }, + "only the renderable DNS name may be submitted"); + domains.Should().NotContain(d => d.Contains("1.3.6.1.4.1.311.20.2.3"), + "an otherName must never be submitted as an ASN.1 dump"); + domains.Should().NotContain(d => d.Contains("CONTEXT"), + "BouncyCastle ASN.1 debris must never reach the wire"); + domains.Should().NotContain(d => d.StartsWith("CN=", StringComparison.OrdinalIgnoreCase), + "a directoryName must never be submitted as a domain"); + } + + // ======================================================================= + // Log-injection hardening (CWE-117) + // ======================================================================= + + /// + /// SAN values reach the log from the CSR and from Command's SAN dictionary — i.e. from the + /// requester. Structured message templates stop format-string abuse but not embedded + /// newlines, so a value carrying CRLF could forge audit records in the very log lines added + /// to make the submitted SAN set auditable. LogSanitizer is internal (not private) and + /// shared between the plugin and the client, so this is a direct call, not reflection. + /// + [Theory] + [InlineData("evil.example.com\r\nINFO forged record", "evil.example.com\\r\\nINFO forged record")] + [InlineData("a\nb", "a\\nb")] + [InlineData("a\tb", "a\\tb")] + [InlineData("plain.example.com", "plain.example.com")] + [InlineData("", "")] + [InlineData(null, null)] + public void SanitizeForLog_NeutralizesControlCharacters(string input, string expected) + { + var actual = Keyfactor.Extensions.CAPlugin.CERTInext.Models.LogSanitizer.Strip(input); + + actual.Should().Be(expected); + } + + /// + /// A CRLF-bearing SAN must not break enrollment, and the value is still submitted verbatim — + /// the scrub is a logging concern and deliberately does not mutate the payload sent to the CA. + /// + [Fact] + public async Task SanValueWithCrLf_DoesNotBreakEnrollment() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "alt.example.com\r\nforged log line" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().ContainSingle().Which.Should().Contain("alt.example.com"); + } + + // ======================================================================= + // SubmitNonDnsSans escape hatch + // ======================================================================= + + /// + /// Submitting non-DNS SANs flips affected enrollments from "issues, silently missing the + /// name" to "parks pending". SubmitNonDnsSans=false restores the pre-1.0.1 behaviour so an + /// upgraded host has a way back that isn't a plugin downgrade. + /// + [Fact] + public async Task SubmitNonDnsSansFalse_SubmitsDnsNamesOnly() + { + var plugin = new CERTInextCAPlugin( + BuildRealClient(), + new CERTInextConfig { PickupRetries = 0, SubmitNonDnsSans = false }); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "alt.example.com" }, + ["ipaddress"] = new[] { "192.0.2.10" }, + ["rfc822name"] = new[] { "admin@example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com" }, + "with the switch off, only DNS names are submitted"); + } + + /// + /// The switch defaults to true, so the documented default behaviour is pinned independently + /// of any test that sets it explicitly. + /// + [Fact] + public void SubmitNonDnsSans_DefaultsToTrue() + { + new CERTInextConfig().SubmitNonDnsSans.Should().BeTrue(); + } + + /// + /// Regression for a self-contradicting audit record: BuildSanList used to log "N SAN(s) + /// ... have been added to the order" for CSR-fallback entries, then filter exactly those + /// entries back out two lines later when SubmitNonDnsSans is false — a false claim in the + /// same call. The fix reordered the method to filter first and log the final result, which + /// this test exercises functionally: with the gateway supplying nothing (so the CSR fallback + /// engages) and a non-DNS CSR SAN present, SubmitNonDnsSans=false must still result in that + /// name being genuinely absent from the wire, not merely mis-described in the log. + /// + [Fact] + public async Task CsrFallbackNonDnsSan_IsExcluded_WhenSubmitNonDnsSansFalse() + { + var plugin = new CERTInextCAPlugin( + BuildRealClient(), + new CERTInextConfig { PickupRetries = 0, SubmitNonDnsSans = false }); + + await plugin.Enroll( + csr: GenerateCsrPemWithGeneralNames( + "host.example.com", + new GeneralName(GeneralName.DnsName, "host.example.com"), + new GeneralName(GeneralName.DnsName, "alt.example.com"), + new GeneralName(GeneralName.Rfc822Name, "admin@example.com")), + subject: "CN=host.example.com", + san: null, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com" }, + "the CSR-fallback email SAN must be genuinely absent from the order, not just " + + "misreported as present"); + } + + // ======================================================================= + // Renew path — previously submitted no SANs at all + // ======================================================================= + + /// + /// A renewal that goes through the CERTInext renew API must carry the same domain set + /// as a new enrollment, and must take its primary domain from the subject's CN rather + /// than from the prior order's requestor name. + /// + [Fact] + public async Task RenewalRequest_CarriesSubjectAndSans() + { + var clientMock = new Mock(MockBehavior.Loose); + RenewCertificateRequest captured = null; + + clientMock + .Setup(c => c.RenewCertificateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, req, __) => captured = req) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse()); + + var readerMock = new Mock(MockBehavior.Loose); + readerMock + .Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())) + .ReturnsAsync("PRIOR-ORDER-1"); + + // The renewal-window decision reads expiry from the data reader, not from the CA. + // Put the prior cert 10 days out so it lands inside the 30-day window below and the + // renew API path is actually taken. + readerMock + .Setup(r => r.GetExpirationDateByRequestId(It.IsAny())) + .Returns(DateTime.UtcNow.AddDays(10)); + + var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object); + + var productInfo = MakeProductInfo(); + productInfo.ProductParameters["PriorCertSN"] = "AABBCCDDEEFF"; + productInfo.ProductParameters["RenewalWindowDays"] = "30"; + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com", "host.example.com", "alt.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "host.example.com", "alt.example.com" } + }, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.Renew); + + captured.Should().NotBeNull("the renew API path should have been taken"); + + // Bind to a local so the compiler's null-flow analysis is satisfied — a + // FluentAssertions NotBeNull() does not narrow the nullable reference. + RenewCertificateRequest renewReq = captured!; + + renewReq.Subject.Should().Be("CN=host.example.com", + "without the subject the renewal order has no usable primary domain"); + renewReq.Sans.Should().NotBeNull("renewals previously dropped every SAN"); + renewReq.Sans.Select(s => s.Value) + .Should().BeEquivalentTo(new[] { "host.example.com", "alt.example.com" }); + } + } +} diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs index 7f02df0..043b4b5 100644 --- a/CERTInext/API/CertificateRequest.cs +++ b/CERTInext/API/CertificateRequest.cs @@ -622,6 +622,23 @@ public class RenewCertificateRequest [JsonPropertyName("csr")] public string Csr { get; set; } + /// + /// Distinguished name of the certificate being renewed. Supplies the renewal order's + /// primary domain via its CN — without it the renewal falls back to the prior order's + /// requestor name, which is not a domain at all. + /// + [JsonPropertyName("subject")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Subject { get; set; } + + /// + /// SANs to carry onto the renewal order. Renewals previously submitted none, so a + /// renewed UCC certificate came back holding only its primary domain. + /// + [JsonPropertyName("sans")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public System.Collections.Generic.List Sans { get; set; } + [JsonPropertyName("validityDays")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? ValidityDays { get; set; } diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index b04c051..a631606 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -247,14 +247,14 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa "ApiKeyPresent={ApiKeyPresent}, UsernamePresent={UsernamePresent}, " + "PasswordPresent={PasswordPresent}, OAuth2ClientIdPresent={OAuth2ClientIdPresent}, " + "OAuth2ClientSecretPresent={OAuth2ClientSecretPresent}, OAuth2TokenUrlPresent={OAuth2TokenUrlPresent}, " + - "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}, " + + "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}, SubmitNonDnsSans={SubmitNonDnsSans}, " + "DcvEnabled={DcvEnabled}, DcvTxtRecordTemplate={DcvTxtRecordTemplate}, " + "DomainValidatorFactoryInjected={FactoryInjected}", _config.ApiUrl, _config.AuthMode, _config.Enabled, hasApiKey, hasUsername, hasPassword, hasClientId, hasClientSecret, hasTokenUrl, - _config.PageSize, _config.IgnoreExpired, + _config.PageSize, _config.IgnoreExpired, _config.SubmitNonDnsSans, _config.DcvEnabled, _config.DcvTxtRecordTemplate, _domainValidatorFactory != null); @@ -576,15 +576,15 @@ public async Task Enroll( "EnrollmentType={EnrollmentType}, RequestFormat={RequestFormat}, Subject={Subject}, " + "ProfileId={ProfileId}, SANs={SANs}, " + "RequesterName={RequesterName}, RequesterEmail={RequesterEmail}", - enrollmentType, requestFormat, subject, - ep.ProfileId, sanSummary, + enrollmentType, requestFormat, LogSanitizer.Strip(subject), + ep.ProfileId, LogSanitizer.Strip(sanSummary), ep.RequesterName, ep.RequesterEmail); if (string.IsNullOrWhiteSpace(ep.ProfileId)) { _logger.LogError( "Enrollment rejected — ProfileId parameter is missing. Subject={Subject}, EnrollmentType={EnrollmentType}", - subject, enrollmentType); + LogSanitizer.Strip(subject), enrollmentType); throw new Exception($"Template parameter '{Constants.EnrollmentParam.ProfileId}' is required."); } @@ -605,7 +605,7 @@ public async Task Enroll( default: _logger.LogError( "Enrollment rejected — unsupported enrollment type. EnrollmentType={EnrollmentType}, Subject={Subject}", - enrollmentType, subject); + enrollmentType, LogSanitizer.Strip(subject)); throw new NotSupportedException($"Enrollment type '{enrollmentType}' is not supported."); } @@ -617,7 +617,7 @@ public async Task Enroll( "SerialNumber={SerialNumber}, Subject={Subject}, ProfileId={ProfileId}", enrollmentType, result.CARequestID, result.Status, result.Certificate != null ? ExtractSerialFromPem(result.Certificate) : "(pending)", - subject, ep.ProfileId); + LogSanitizer.Strip(subject), ep.ProfileId); _logger.MethodExit(LogLevel.Debug); return result; } @@ -727,7 +727,7 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r _logger.LogWarning( "Revocation skipped — certificate is already revoked. " + "CARequestID={Id}, HexSerialNumber={Serial}, Subject={Subject}", - caRequestID, hexSerialNumber, current.Subject); + caRequestID, hexSerialNumber, LogSanitizer.Strip(current.Subject)); return (int)EndEntityStatus.REVOKED; } @@ -756,7 +756,7 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r "Revocation complete. " + "CARequestID={Id}, HexSerialNumber={Serial}, Subject={Subject}, " + "ReasonCode={ReasonCode}, ReasonString={ReasonString}", - caRequestID, hexSerialNumber, current.Subject, + caRequestID, hexSerialNumber, LogSanitizer.Strip(current.Subject), revocationReason, reasonString); _logger.MethodExit(LogLevel.Debug); return (int)EndEntityStatus.REVOKED; @@ -937,7 +937,8 @@ public async Task Synchronize( status = StatusMapper.ToRequestDisposition(current.Status); _logger.LogDebug( "Sync: refetched order Id={Id} — status={Status}, certBytes={Bytes}, subject={Subject}.", - current.Id, status, current.Certificate?.Length ?? 0, current.Subject); + current.Id, status, current.Certificate?.Length ?? 0, + LogSanitizer.Strip(current.Subject)); } catch (Exception fetchEx) { @@ -970,7 +971,8 @@ public async Task Synchronize( } _logger.LogDebug( "Sync emit: CARequestID={Id}, Status={Status}, CertBytes={CertBytes}, Subject={Subject}", - record.CARequestID, record.Status, record.Certificate?.Length ?? 0, current.Subject); + record.CARequestID, record.Status, record.Certificate?.Length ?? 0, + LogSanitizer.Strip(current.Subject)); blockingBuffer.Add(record, cancelToken); synced++; @@ -1096,7 +1098,7 @@ private async Task EnrollNewAsync( Csr = csr, ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, Subject = subject, - Sans = BuildSanList(san), + Sans = BuildSanList(san, csr, subject), RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, KeyType = string.IsNullOrWhiteSpace(ep.KeyType) ? null : ep.KeyType, @@ -1229,7 +1231,7 @@ private async Task RenewOrReissueAsync( _logger.LogInformation( "Renewal/reissue probe — read PriorCertSN from EnrollmentProductInfo. " + "Subject={Subject}, PriorCertSN={PriorCertSN}, RenewalWindowDays={WindowDays}", - subject, string.IsNullOrWhiteSpace(priorCertSn) ? "(none)" : priorCertSn, + LogSanitizer.Strip(subject), string.IsNullOrWhiteSpace(priorCertSn) ? "(none)" : priorCertSn, ep.RenewalWindowDays); if (string.IsNullOrWhiteSpace(priorCertSn)) @@ -1238,7 +1240,7 @@ private async Task RenewOrReissueAsync( // production log filters and are available for anomaly detection. _logger.LogInformation( "Renewal/reissue has no PriorCertSN — treating as new enrollment. Subject={Subject}", - subject); + LogSanitizer.Strip(subject)); return await EnrollNewAsync(csr, subject, san, ep); } @@ -1259,7 +1261,7 @@ private async Task RenewOrReissueAsync( { _logger.LogInformation( "CARequestID for serial '{SN}' is empty — falling back to new enrollment. Subject={Subject}", - priorCertSn, subject); + priorCertSn, LogSanitizer.Strip(subject)); return await EnrollNewAsync(csr, subject, san, ep); } @@ -1308,11 +1310,16 @@ private async Task RenewOrReissueAsync( _logger.LogInformation( "Renewal via CERTInext renew API started. " + "PriorCARequestID={PriorId}, Subject={Subject}, ProfileId={ProfileId}", - priorCaRequestId, subject, ep.ProfileId); + priorCaRequestId, LogSanitizer.Strip(subject), ep.ProfileId); var renewReq = new RenewCertificateRequest { Csr = csr, + // Renewals go out as a fresh CERTInext order, so they need the same domain + // set as a new enrollment — otherwise a renewed UCC certificate comes back + // holding only its primary domain. + Subject = subject, + Sans = BuildSanList(san, csr, subject), ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, @@ -1340,7 +1347,7 @@ private async Task RenewOrReissueAsync( { _logger.LogInformation( "Certificate '{Id}' is outside the renewal window ({Window} days) — issuing new certificate. Subject={Subject}", - priorCaRequestId, ep.RenewalWindowDays, subject); + priorCaRequestId, ep.RenewalWindowDays, LogSanitizer.Strip(subject)); return await EnrollNewAsync(csr, subject, san, ep); } } @@ -1602,27 +1609,61 @@ private async Task PerformDcvIfNeededAsync( // SOX CC6.1: validate domain names before passing them to the DNS provider plugin // or the CERTInext API. A malformed domain (empty, whitespace, or containing // characters outside the FQDN alphabet) could cause log injection or unexpected - // DNS plugin behaviour. Invalid entries are rejected loudly rather than silently - // skipped so the condition is visible in the audit trail. - foreach (var (domain, _) in pendingDomains) + // DNS plugin behaviour. Invalid entries are rejected loudly — LogError, so the + // condition is visible in the audit trail — but they are EXCLUDED rather than + // thrown on. + // + // Throwing here would fail the whole order: the exception escapes Enroll (which has + // no catch) after the order was already placed at the CA, so the enrollment reports + // failure with an orphaned order, and no TXT record is staged for the *valid* domains + // on the same order. Worse, it is unrecoverable — every later Synchronize / + // GetSingleRecord retry re-enters here, hits the same undrainable domain, and + // TryRunDcvDuringSyncAsync swallows the exception and returns false, so the order sits + // at EXTERNALVALIDATION forever. + // + // This is reachable in normal operation now that non-DNS SANs are submitted to + // CERTInext (see BuildSanList): the CA registers an email/URI SAN verbatim as an order + // domain, and that key is not an FQDN. One such SAN must not strand the DNS names + // alongside it. Same principle the EMS-956 branch below states explicitly: do not throw + // out of DCV for a condition that leaves the order legitimately pending. + var invalidDomains = new List(); + var validPendingDomains = new List>(); + + foreach (var entry in pendingDomains) { - if (string.IsNullOrWhiteSpace(domain)) - throw new InvalidOperationException( - $"TrackOrder returned a blank domain key in domainVerification for order '{orderNumber}'. " + - "Cannot proceed with DCV."); + string domain = entry.Key; - // Allow standard FQDN characters plus wildcard prefix (*.example.com) - if (!System.Text.RegularExpressions.Regex.IsMatch(domain, @"^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$")) - { - _logger.LogError( - "DCV domain name failed validation and will not be processed. OrderNumber={OrderNumber}, Domain={Domain}", - orderNumber, domain); - throw new InvalidOperationException( - $"TrackOrder returned an invalid domain name '{domain}' in domainVerification for order '{orderNumber}'. " + - "Domain names must conform to FQDN syntax."); - } + // Allow standard FQDN characters plus wildcard prefix (*.example.com). + // + // \A/\z, not ^/$: in .NET's default (non-Multiline) mode, $ matches immediately + // before a single trailing '\n', not only at the true end of the string — so + // "evil.com\n" passes a ^...$ version of this regex. \A and \z are absolute + // start/end-of-string anchors regardless of RegexOptions, so a value with any + // trailing control character is correctly rejected here rather than reaching the + // unsanitized-looking-safe domain this validation exists to guarantee. + bool valid = !string.IsNullOrWhiteSpace(domain) + && System.Text.RegularExpressions.Regex.IsMatch( + domain, @"\A(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?\z"); + + if (valid) + validPendingDomains.Add(entry); + else + invalidDomains.Add(string.IsNullOrWhiteSpace(domain) ? "(blank)" : domain); } + if (invalidDomains.Count > 0) + { + _logger.LogError( + "{Count} domain(s) on order {OrderNumber} are not valid FQDNs and cannot be DNS-01 validated: " + + "[{Domains}]. They are skipped so the remaining {ValidCount} domain(s) can still be validated. " + + "This order cannot be issued by CERTInext until these are removed — they usually come from a " + + "non-DNS SAN (IP address, email, URI) that was requested on the enrollment.", + invalidDomains.Count, orderNumber, LogSanitizer.Strip(string.Join(", ", invalidDomains)), + validPendingDomains.Count); + } + + pendingDomains = validPendingDomains; + if (pendingDomains.Count == 0) return false; @@ -1632,62 +1673,256 @@ private async Task PerformDcvIfNeededAsync( var stagedValidations = new List<(string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator)>(); - // Stage DNS TXT records for all pending domains - foreach (var (domain, _) in pendingDomains) + // Domains this pass could not stage, with why — purely for the summary LogError after + // the loop. Every failure mode below is loud (its own LogError, sanitized) before being + // skipped, so nothing here is silent; this list just avoids repeating that detail twice. + var skippedDomains = new List<(string domain, string reason)>(); + + // Set instead of an immediate `return false` inside the loop below, so a not-yet-ready + // deferral goes through the same cleanup as every other exit path — see the try/catch + // around the loop. + bool deferToNextSyncCycle = false; + + // Removes whatever TXT records were already published before an early exit from the + // staging loop. Nothing else in this method cleans up mid-loop: the try/finally further + // down only runs once every pending domain has been staged, so without this, an early + // exit orphans every TXT record already published for the earlier domains in the *same* + // order — permanently, since nothing else in the codebase calls CleanupValidation for + // them. Kept even though every per-domain failure below is now skip-and-continue rather + // than throw: it is the safety net for a genuinely unexpected exception (cancellation, a + // bug, a validator implementation that throws instead of returning a failure result). + // + // Shares its per-entry cleanup logic with the try/finally's own cleanup loop further + // down via CleanupOneStagedValidation — the two call sites differ only in when they run + // (an early exit here vs. always-run-at-the-end there), not in what "clean up one TXT + // record" means. + async Task CleanupPartialStagingAsync() + { + // Concurrent, not sequential: each cleanup call already has its own independent + // CleanupValidationTimeoutSeconds bound (see CleanupOneStagedValidationAsync), but + // running them one after another meant that bound was per-call, not in aggregate — a + // UCC order with N staged domains could hold the calling request open for up to + // N × CleanupValidationTimeoutSeconds if the DNS provider was merely slow (not even + // hung) on every delete, which can exceed DcvTimeoutMinutes itself and defeats the + // "entire DCV flow is hard-timeout-bounded" guarantee for exactly the multi-SAN case + // this diff exists to support. Running them concurrently bounds the wall-clock time + // for the whole batch to the slowest single call, regardless of domain count — these + // are independent per-domain operations (different hostnames/records) with no shared + // mutable state, so there is nothing for concurrent execution to race on. + await Task.WhenAll(stagedValidations.Select(entry => + CleanupOneStagedValidationAsync(entry, " after an early exit from DCV staging"))); + } + + // Shared by CleanupPartialStagingAsync above and the try/finally's own cleanup loop + // below — both mean "remove one already-published TXT record", just at different times + // (an early exit vs. always-run-at-the-end). `context` distinguishes the two in the log + // text without duplicating the try/catch/log structure itself. + async Task CleanupOneStagedValidationAsync( + (string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator) entry, + string context) { - GetDcvResponse dcvResp; + var (domain, hostname, validator) = entry; try { - dcvResp = await _client.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct); - } - catch (Exception ex) when (IsDcvNotYetReady(ex)) - { - // CERTInext occasionally exposes the DCV slot in TrackOrder (so - // domainVerification is populated and dcvStatus="0") before the GetDcv - // endpoint will accept calls for that order — observed as EMS-956 - // "Invalid Request for this API" for several hours after enrollment. - // Treat this as "DCV not ready yet": skip the DCV ceremony for now and - // let the sync-driven retry pick it up on a later cycle. We must NOT - // throw, because that would fail the entire Enroll call and prevent the - // gateway from recording the pending order at all. + // A fresh, independently-bounded token — deliberately neither `ct` nor + // CancellationToken.None. + // + // Not `ct`: this is a best-effort compensating action — removing a TXT record we + // already published — and it must run regardless of WHY we are cleaning up, + // including the case where `ct` itself is the reason (the dominant real trigger + // for the early-exit call site is the shared DcvTimeoutMinutes-bound token firing + // mid-loop, which means `ct` is guaranteed already cancelled there). A + // cooperative IDomainValidator that forwards its token into its own HTTP calls — + // the reference CloudflareDomainValidator in this repo does exactly that — would + // throw immediately on an already-cancelled token and never even attempt the + // delete, silently leaving the record published with only a Warning logged. + // + // Not CancellationToken.None either: this method's own SOX CC7.3 guarantee is + // that the whole DCV flow is hard-timeout-bounded so a stuck DNS provider cannot + // hold a gateway worker thread indefinitely. That bound has to come from + // somewhere for THIS call too — including the routine, always-runs finally-block + // cleanup on the ordinary successful-DCV path, which was never cancellation- + // related to begin with and would otherwise hang forever on a DNS provider + // plugin whose underlying network call stalls. + using var cleanupCts = new CancellationTokenSource( + TimeSpan.FromSeconds(Constants.Dcv.CleanupValidationTimeoutSeconds)); + await validator.CleanupValidation(hostname, cleanupCts.Token); _logger.LogInformation( - "GetDcv not yet accepting calls for order {OrderNumber} domain {Domain} ({Error}). " + - "Deferring DCV to the next sync cycle.", - orderNumber, domain, ex.Message); - return false; + "DNS TXT record cleaned up{Context}. Domain={Domain}, Hostname={Hostname}", + context, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); } catch (Exception ex) { - _logger.LogError(ex, "GetDcv failed for order {OrderNumber} domain {Domain}", orderNumber, domain); - throw; + _logger.LogWarning(ex, + "Failed to clean up DNS TXT record{Context}. Domain={Domain}, Hostname={Hostname}. " + + "May require manual removal.", + context, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); } + } - string token = dcvResp.DcvDetails?.Token; - if (string.IsNullOrWhiteSpace(token)) - throw new InvalidOperationException( - $"GetDcv returned no token for order '{orderNumber}' domain '{domain}'."); + try + { + // Stage DNS TXT records for all pending domains. Every failure below is scoped to + // the one domain that hit it — logged loudly (LogError, so the audit trail carries + // the reason before the domain is dropped) and skipped, never thrown. A throw here + // would abort the WHOLE order after Enroll already placed it at the CA — Enroll has + // no catch around this call, so the exception would escape as a failed enrollment + // with an orphaned CERTInext order, and TryRunDcvDuringSyncAsync would swallow the + // same exception on every later sync retry, leaving the order stuck at + // EXTERNALVALIDATION forever. That is worse than parking the order pending with a + // clear log entry, for EVERY failure shape here — not just the ones distinguishable + // as "bad input" — because nothing downstream ever gets to see or act on the + // exception anyway. This directly caused three real regressions across the first two + // rounds of fixing this file: a GetDcv error or an empty token for a non-DNS SAN + // (submitted on purpose — see BuildSanList) aborted co-tenant DNS domains on the same + // order; a StageValidation failure on domain N+1 orphaned domain N's TXT record; and + // a misconfiguration-detection throw fired on an ordinary non-DNS Subject CN, which + // no setting could prevent since SubmitNonDnsSans only filters the SAN list, not the + // subject. There is no longer a "this must still throw" case in this loop at all. + foreach (var (domain, _) in pendingDomains) + { + GetDcvResponse dcvResp; + try + { + dcvResp = await _client.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct); + } + catch (Exception ex) when (IsDcvNotYetReady(ex)) + { + // CERTInext occasionally exposes the DCV slot in TrackOrder (so + // domainVerification is populated and dcvStatus="0") before the GetDcv + // endpoint will accept calls for that order — observed as EMS-956 + // "Invalid Request for this API" for several hours after enrollment. This is + // an order-readiness condition, not a per-domain one, so unlike every other + // case in this loop it defers the whole pass rather than skipping one domain. + _logger.LogInformation( + "GetDcv not yet accepting calls for order {OrderNumber} domain {Domain} ({Error}). " + + "Deferring DCV to the next sync cycle.", + orderNumber, LogSanitizer.Strip(domain), ex.Message); + deferToNextSyncCycle = true; + break; + } + catch (OperationCanceledException) + { + // The shared, DcvTimeoutMinutes-bound cancellation firing mid-loop. This is + // NOT a per-domain CA/DNS-provider failure — it must not be caught by the + // generic clause below, which would mislabel it as "GetDcv failed" for + // whichever domain happened to be in flight and send an operator chasing the + // wrong cause. Propagate to the outer catch, which logs and cleans up. + throw; + } + catch (Exception ex) + { + // Any other GetDcv failure — genuinely unmeasured against the live API for a + // non-DNS order-domain, which is exactly why this must not be allowed to fail + // the whole order on a guess. Skip just this domain. + _logger.LogError(ex, + "GetDcv failed for order {OrderNumber} domain {Domain}; skipping this domain so the " + + "rest of the order can still be validated.", orderNumber, LogSanitizer.Strip(domain)); + skippedDomains.Add((domain, "GetDcv failed")); + continue; + } - string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) - ? Constants.Dcv.DefaultTxtRecordTemplate - : _config.DcvTxtRecordTemplate; - string hostname = string.Format(template, domain); + string token = dcvResp.DcvDetails?.Token; + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogError( + "GetDcv returned no token for order {OrderNumber} domain {Domain}; skipping this " + + "domain so the rest of the order can still be validated.", + orderNumber, LogSanitizer.Strip(domain)); + skippedDomains.Add((domain, "no DCV token returned")); + continue; + } - var validator = DomainValidatorFactory.ResolveDomainValidator(domain, "dns-01"); - if (validator == null) - throw new InvalidOperationException( - $"No DNS provider plugin is configured for domain '{domain}'. " + - "Ensure the appropriate DNS provider plugin is deployed and configured on the gateway."); + string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) + ? Constants.Dcv.DefaultTxtRecordTemplate + : _config.DcvTxtRecordTemplate; + string hostname = string.Format(template, domain); - _logger.LogInformation( - "Staging DNS TXT record for DCV. OrderNumber={OrderNumber}, Domain={Domain}, Hostname={Hostname}", - orderNumber, domain, hostname); + var validator = DomainValidatorFactory.ResolveDomainValidator(domain, "dns-01"); + if (validator == null) + { + // The canonical case: an IP-literal SAN (or a non-DNS Subject CN) satisfies + // the FQDN regex above but no DNS zone can ever match it. + _logger.LogError( + "No DNS provider plugin resolved for domain '{Domain}' on order {OrderNumber}; " + + "skipping this domain so the rest of the order can still be validated. If this is " + + "a real domain, ensure the appropriate DNS provider plugin is deployed and " + + "configured on the gateway; if it came from a non-DNS SAN (e.g. an IP address) or a " + + "non-DNS Subject CN, remove it from the request.", + LogSanitizer.Strip(domain), orderNumber); + skippedDomains.Add((domain, "no DNS provider resolved")); + continue; + } - var stageResult = await validator.StageValidation(hostname, token, ct); - if (!stageResult.Success) - throw new InvalidOperationException( - $"Failed to stage DNS validation for '{domain}': {stageResult.ErrorMessage}"); + _logger.LogInformation( + "Staging DNS TXT record for DCV. OrderNumber={OrderNumber}, Domain={Domain}, Hostname={Hostname}", + orderNumber, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); + + DomainValidationResult stageResult; + try + { + stageResult = await validator.StageValidation(hostname, token, ct); + } + catch (OperationCanceledException) + { + // Same reasoning as the GetDcv cancellation catch above: not a per-domain + // failure, must reach the outer catch rather than the generic clause below. + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, + "DNS provider plugin threw while staging '{Domain}' for order {OrderNumber}; " + + "skipping this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(domain), orderNumber); + skippedDomains.Add((domain, "DNS provider plugin threw")); + continue; + } + + if (!stageResult.Success) + { + _logger.LogError( + "Failed to stage DNS validation for '{Domain}' on order {OrderNumber}: {Error}. " + + "Skipping this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(domain), orderNumber, LogSanitizer.Strip(stageResult.ErrorMessage)); + skippedDomains.Add((domain, $"stage failed: {stageResult.ErrorMessage}")); + continue; + } + + stagedValidations.Add((domain, hostname, validator)); + } + } + catch (Exception ex) + { + // Nothing in the loop above throws for a per-domain reason any more — this is the + // safety net for a genuinely unexpected failure: cancellation (the shared + // DcvTimeoutMinutes-bound token expiring mid-loop — explicitly re-thrown past the + // per-domain catches above rather than mislabeled as a per-domain failure) or a bug. + // Log before rethrowing: neither caller (EnrollNewAsync's try/finally, or Enroll + // itself) adds a catch, so without a log line here an unanticipated failure on the + // synchronous Enroll-time DCV path would leave no plugin-emitted record at all + // identifying the order or cause — only whatever the gateway host's own unhandled- + // exception logging happens to capture. + _logger.LogError(ex, + "Unexpected failure during DCV staging for order {OrderNumber}; cleaning up any " + + "already-staged TXT records before this propagates.", orderNumber); + await CleanupPartialStagingAsync(); + throw; + } - stagedValidations.Add((domain, hostname, validator)); + if (deferToNextSyncCycle) + { + await CleanupPartialStagingAsync(); + return false; + } + + if (skippedDomains.Count > 0) + { + _logger.LogError( + "{Count} domain(s) on order {OrderNumber} could not be staged for DCV and were skipped: " + + "[{Domains}]. This order cannot be issued by CERTInext until they are resolved.", + skippedDomains.Count, orderNumber, + LogSanitizer.Strip(string.Join(", ", skippedDomains.Select(d => $"{d.domain} ({d.reason})")))); } if (stagedValidations.Count == 0) @@ -1708,7 +1943,8 @@ private async Task PerformDcvIfNeededAsync( foreach (var (domain, hostname, _) in stagedValidations) { _logger.LogInformation( - "Triggering CERTInext DCV verification. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, domain); + "Triggering CERTInext DCV verification. OrderNumber={OrderNumber}, Domain={Domain}", + orderNumber, LogSanitizer.Strip(domain)); await _client.VerifyDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct); } @@ -1719,21 +1955,12 @@ private async Task PerformDcvIfNeededAsync( } finally { - // Always clean up staged DNS records — even on failure - foreach (var (domain, hostname, validator) in stagedValidations) - { - try - { - await validator.CleanupValidation(hostname, ct); - _logger.LogInformation( - "DNS TXT record cleaned up. Domain={Domain}, Hostname={Hostname}", domain, hostname); - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "Failed to clean up DNS TXT record. Domain={Domain}, Hostname={Hostname}", domain, hostname); - } - } + // Always clean up staged DNS records — even on failure. Concurrent, not sequential + // — see CleanupPartialStagingAsync's comment above for why: sequential cleanup made + // the aggregate wall-clock time for this block scale with the number of staged SAN + // domains, unbounded relative to DcvTimeoutMinutes, on this ordinary success path too. + await Task.WhenAll(stagedValidations.Select(entry => + CleanupOneStagedValidationAsync(entry, ""))); } return true; @@ -1860,7 +2087,8 @@ private async Task WaitForDcvVerificationAsync(string orderNumber, IReadOnlyList "DCV verification poll exceeded its internal deadline ({Minutes}min). " + "OrderNumber={OrderNumber}, StillPendingDomains=[{Pending}]. " + "Exiting and leaving TXT records for the caller's finally block to clean up.", - _config.GetEffectiveDcvTimeoutMinutes(), orderNumber, string.Join(",", pending)); + _config.GetEffectiveDcvTimeoutMinutes(), orderNumber, + LogSanitizer.Strip(string.Join(",", pending))); return; } @@ -1893,12 +2121,14 @@ private async Task WaitForDcvVerificationAsync(string orderNumber, IReadOnlyList if (string.Equals(detail.DcvStatus, Constants.Dcv.StatusValidated, StringComparison.Ordinal)) { - _logger.LogInformation("DCV verified by CERTInext. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, domain); + _logger.LogInformation("DCV verified by CERTInext. OrderNumber={OrderNumber}, Domain={Domain}", + orderNumber, LogSanitizer.Strip(domain)); pending.Remove(domain); } else if (string.Equals(detail.DcvStatus, Constants.Dcv.StatusRejected, StringComparison.Ordinal)) { - _logger.LogWarning("DCV rejected by CERTInext. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, domain); + _logger.LogWarning("DCV rejected by CERTInext. OrderNumber={OrderNumber}, Domain={Domain}", + orderNumber, LogSanitizer.Strip(domain)); pending.Remove(domain); } } @@ -2103,6 +2333,16 @@ private EnrollmentResult BuildEnrollmentResult(EnrollCertificateResponse resp, b throw new Exception("CERTInext returned a null enrollment response."); int status = StatusMapper.ToRequestDisposition(resp.Status); + + // CertiNext's "auto-approved"/"downloadable" statuses can arrive before the + // certificate bytes are actually generated — GetCertificate right after order + // placement then fails, leaving resp.Certificate null while resp.Status still + // says issued. Never hand Command a GENERATED result with no PEM (it crashes + // CertificateConverterFactory.FromPEM downstream); demote to pending instead, + // matching the same invariant PickUpEnrolledCertificateAsync already enforces. + if (status == (int)EndEntityStatus.GENERATED && string.IsNullOrWhiteSpace(resp.Certificate)) + status = (int)EndEntityStatus.EXTERNALVALIDATION; + string message; switch (status) @@ -2183,46 +2423,358 @@ private static int MapRevocationReasonStringToCode(string reason) } /// - /// Converts the multi-valued SAN dictionary from the AnyCA gateway into the - /// list expected by the CERTInext API. + /// Builds the list submitted to CERTInext: the multi-valued SAN + /// dictionary the AnyCA gateway hands us, falling back to the subjectAltName extension + /// carried inside the CSR itself only when the gateway supplies nothing at all. + /// + /// Fallback, not union, deliberately: Command's SAN dictionary is the channel through + /// which an enrollment pattern's SAN policy is expressed for this request, and a signed + /// CSR — typically generated by the subscriber's own tooling, not by Command — can + /// legitimately carry more names than that policy allows. Unioning them in would + /// re-introduce a name the policy excluded. The CSR is only consulted when the dictionary + /// argument is null — not merely empty or all-empty-arrays. A non-null dictionary, + /// even one that computes to zero names, means Command's enrollment pattern ran and + /// deliberately produced no SANs for this request; only its literal absence means no + /// policy-derived set exists to defer to. + /// + /// The CSR still matters even though CERTInext ignores its subjectAltName extension + /// outright — measured on the US sandbox in SanSubmissionProbeTests: a CSR + /// carrying two DNS names, submitted with additionalDomains omitted, produced an + /// order with only the CN registered. Production behaves the same way: the customer + /// report that prompted this fix was a production UCC order whose CSR carried the SANs + /// and whose issued certificate held only the CN. So on whichever path populates the + /// gateway dictionary — or, in the fallback case, the CSR — this method is the only way + /// those names reach additionalDomains and therefore the certificate. + /// + /// History (UCC SANs silently dropped): the gateway keys this dictionary + /// dnsname, not dns. did not recognize + /// dnsname, so every DNS SAN was typed "dnsname", filtered out by the + /// DNS-only test in BuildAdditionalDomains, and the order went to CERTInext + /// with no additionalDomains at all. The certificate came back holding only + /// the CN, which reads as the CA stripping SANs supplied on the CSR. /// - private static List BuildSanList(Dictionary san) + private List BuildSanList(Dictionary san, string csr, string subject) { - if (san == null || san.Count == 0) - return null; - var result = new List(); + // Type+value identity, so the same name requested as two different SAN types is + // preserved while an exact repeat across the two sources collapses. + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + // "type|value" keys of entries that came from the CSR fallback, not the gateway + // dictionary — used only to word the provenance log accurately once the final, + // possibly-filtered result is known (see below). + var fromCsrKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Add(string type, string value, bool fromCsr = false) + { + if (string.IsNullOrWhiteSpace(value)) return; + string trimmed = value.Trim(); + string key = $"{type}|{trimmed}"; + if (!seen.Add(key)) return; + result.Add(new SanEntry { Type = type, Value = trimmed }); + if (fromCsr) fromCsrKeys.Add(key); + } - // AnyCA passes SANs keyed by type name (e.g. "Dns", "Ip", "Email", "Uri") - foreach (var kvp in san) - { - string sanType = MapSanType(kvp.Key); - if (kvp.Value == null) continue; + string FormatSans(IEnumerable sans) => + LogSanitizer.Strip(string.Join("; ", sans.Select(s => $"{s.Type}:{s.Value}"))); - foreach (string value in kvp.Value) + // AnyCA passes SANs keyed by type name — the real gateway uses "dnsname", + // "rfc822name", "ipaddress"; MapSanType normalizes the spelling variants. + if (san != null) + { + foreach (var kvp in san) { - if (!string.IsNullOrWhiteSpace(value)) - result.Add(new SanEntry { Type = sanType, Value = value.Trim() }); + string sanType = MapSanType(kvp.Key); + if (kvp.Value == null) continue; + + foreach (string value in kvp.Value) + Add(sanType, value); } } - return result.Count > 0 ? result : null; + // CSR fallback — only when the gateway dictionary is itself absent (san == null), NOT + // merely "computed to zero SAN entries" (i.e. result.Count == 0 at this point). Those + // are different things: + // a non-null dictionary — even an empty one, or one whose keys all map to empty arrays + // — means Command's enrollment pattern ran and deliberately produced no SANs for this + // request, which the CSR fallback must respect rather than override. san == null means + // Command never populated SAN data for this enrollment path at all, which is the one + // case this fallback exists for. Checking "computed to zero" instead of "san is null" + // would let an enrollment pattern that explicitly computes zero SANs still have + // CSR-derived names spliced back in — reopening the policy-reintroduction risk the + // fallback-over-union redesign exists to close. + var skippedCsrTags = new List(); + if (san == null) + { + var csrSans = ExtractSanEntriesFromCsr(csr, out skippedCsrTags); + foreach (var csrSan in csrSans) + Add(csrSan.Type, csrSan.Value, fromCsr: true); + } + + if (skippedCsrTags.Count > 0) + { + // GeneralName types with no domain-name rendering (otherName — e.g. a UPN from a + // Windows-generated CSR — directoryName, x400Address, ediPartyName, registeredID). + // They cannot be expressed in additionalDomains, so they are not forwarded. Warn + // rather than drop silently: the operator needs to know the CSR asked for something + // the certificate will not carry. + _logger.LogWarning( + "{Count} SAN(s) in the CSR use a type that cannot be represented as a domain name " + + "and were not submitted (ASN.1 GeneralName tag(s): {Tags}). CERTInext's " + + "additionalDomains field carries domain names only, so these cannot appear on the " + + "issued certificate. Remove them from the CSR if they are required. Subject={Subject}", + skippedCsrTags.Count, string.Join(", ", skippedCsrTags), LogSanitizer.Strip(subject)); + } + + if (result.Count == 0) + { + _logger.LogDebug( + "No SANs supplied by the gateway and none found in the CSR — submitting the order " + + "with domainName only. Subject={Subject}", LogSanitizer.Strip(subject)); + return null; + } + + // CERTInext's certificateInformation.additionalDomains is a domain-name field, and + // non-DNS SANs are submitted into it deliberately rather than discarded: dropping + // them would issue a certificate silently missing names the subscriber asked for, + // which is the worse failure. + // + // Measured on the US SANDBOX only (SanSubmissionProbeTests, product 844, + // 2026-08-12): CERTInext did NOT reject these at order placement. It accepted the + // order and registered the value verbatim as an order domain — an email address, an + // IP literal and a URI all came back as domainVerification keys. The order then + // cannot pass domain validation, so it parks pending instead of failing fast. + // + // Production is UNVERIFIED for this case and may reject the order outright instead. + // The warning below therefore describes the sandbox outcome as the expected one + // without promising it: either way the operator is told which SANs are the problem, + // which is the part that matters for diagnosis. + // + // This filtering runs BEFORE any of the logging below, and all of that logging is + // computed from `result` as it stands afterward — not from the pre-filter set. A + // prior version of this method logged "resolved" and "added to the order" against the + // pre-filter set and only THEN applied this filter, so with SubmitNonDnsSans=false the + // audit trail could claim a SAN was added when it had in fact just been dropped two + // lines later — a self-contradicting record for the same enrollment. The fix is + // ordering, not new logic: decide what is actually being submitted first, describe + // that. + var nonDns = result.Where(s => !string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (nonDns.Count > 0 && !_config.SubmitNonDnsSans) + { + _logger.LogWarning( + "{Count} requested SAN(s) are not DNS names and are being DROPPED because " + + "SubmitNonDnsSans is false: {Sans}. The order will issue, but the certificate will " + + "NOT contain these names. Set SubmitNonDnsSans back to true to submit them and have " + + "CERTInext surface the problem instead. Subject={Subject}", + nonDns.Count, FormatSans(nonDns), LogSanitizer.Strip(subject)); + + result = result + .Where(s => string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)) + .ToList(); + nonDns = new List(); + + if (result.Count == 0) + return null; + } + + // The blind spot that hid the original defect was that nothing logged what we + // resolved. Log the final, post-filter resolved set and its provenance at Information. + int fromCsrKept = result.Count(s => fromCsrKeys.Contains($"{s.Type}|{s.Value}")); + // Post-filter, not the pre-filter `fromGateway` snapshot: gateway- and CSR-sourced + // entries are mutually exclusive by construction (the CSR fallback only ever runs when + // the gateway supplied nothing at all), so whatever's left in `result` and isn't + // fromCsrKept must be gateway-sourced. Using the pre-filter count here reproduced the + // exact self-contradicting-audit-trail bug this method was already restructured once to + // fix — with SubmitNonDnsSans=false this line could read e.g. "Resolved 1 SAN(s) ... + // FromGatewayRequest=3", an arithmetic impossibility for anyone reconciling counts. + int fromGatewayKept = result.Count - fromCsrKept; + _logger.LogInformation( + "Resolved {Total} SAN(s) for submission. FromGatewayRequest={FromGateway}, " + + "AddedFromCsrFallback={FromCsr}, Sans={Sans}, Subject={Subject}", + result.Count, fromGatewayKept, fromCsrKept, FormatSans(result), + LogSanitizer.Strip(subject)); + + if (fromCsrKept > 0) + { + // Worth a Warning, not Debug: it means Command handed us no SAN data at all for + // this enrollment, which is a gateway/template wiring smell even though the CSR + // fallback recovers it here. + _logger.LogWarning( + "Command supplied no SAN data for this enrollment; {Count} SAN(s) present in the CSR " + + "have been added to the order instead. Review the enrollment pattern / template SAN " + + "configuration. Subject={Subject}", + fromCsrKept, LogSanitizer.Strip(subject)); + } + + if (nonDns.Count > 0) + { + // Reaching this line means SubmitNonDnsSans is true (the false case already + // returned above), so these are being submitted, not dropped. + _logger.LogWarning( + "{Count} requested SAN(s) are not DNS names: {Sans}. CERTInext's additionalDomains " + + "field takes domain names, so this order will either be rejected outright or be " + + "created and then fail domain validation and sit pending — on the US sandbox it was " + + "accepted verbatim and parked pending. They are submitted rather than dropped on " + + "purpose: a visible failure is preferable to a certificate issued without names the " + + "subscriber requested. Remove them from the CSR or the enrollment pattern if the " + + "order should proceed. Subject={Subject}", + nonDns.Count, FormatSans(nonDns), LogSanitizer.Strip(subject)); + } + + return result; } private static string MapSanType(string anyCAType) { switch (anyCAType?.ToLowerInvariant()) { - case "dns": return "dns"; + // "dnsname" is what the AnyCA REST Gateway actually sends; "dns"/"dnsnames" + // are kept for callers and older hosts that use the shorter spelling. + case "dns": + case "dnsname": + case "dnsnames": return "dns"; case "ip": - case "ipaddress": return "ip"; + case "ipaddress": + case "ipaddresses": return "ip"; case "email": - case "rfc822": return "email"; - case "uri": return "uri"; + case "rfc822": + case "rfc822name": return "email"; + case "uri": + case "uniformresourceidentifier": return "uri"; default: return anyCAType?.ToLowerInvariant() ?? "dns"; } } + /// + /// Extracts the subjectAltName entries from a PEM-encoded PKCS#10 CSR. + /// + /// Implemented with BouncyCastle (per the project's crypto policy: all certificate + /// and key handling goes through BouncyCastle, never BCL System.Security.Cryptography). + /// Never throws — an absent, truncated, or otherwise unparseable CSR returns an empty + /// list so enrollment continues on the gateway-supplied SAN data alone. + /// + /// PEM-encoded PKCS#10 request, or null/garbage. + /// + /// ASN.1 GeneralName tag numbers present in the CSR that have no domain-name rendering and + /// were therefore not returned (otherName, directoryName, x400Address, ediPartyName, + /// registeredID, and any malformed IPAddress). Reported so the caller can warn instead of + /// dropping them silently. + /// + private static List ExtractSanEntriesFromCsr(string csrPem, out List skippedTagNumbers) + { + var result = new List(); + skippedTagNumbers = new List(); + if (string.IsNullOrWhiteSpace(csrPem)) + return result; + + try + { + string b64 = csrPem + .Replace("-----BEGIN CERTIFICATE REQUEST-----", string.Empty) + .Replace("-----END CERTIFICATE REQUEST-----", string.Empty) + .Replace("-----BEGIN NEW CERTIFICATE REQUEST-----", string.Empty) + .Replace("-----END NEW CERTIFICATE REQUEST-----", string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim(); + + if (string.IsNullOrWhiteSpace(b64)) + return result; + + var csr = new Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest(Convert.FromBase64String(b64)); + + // SANs live in the PKCS#9 extensionRequest attribute, not the CSR body. + var extensions = csr.GetRequestedExtensions(); + var sanExtension = extensions?.GetExtension( + Org.BouncyCastle.Asn1.X509.X509Extensions.SubjectAlternativeName); + if (sanExtension == null) + return result; + + var names = Org.BouncyCastle.Asn1.X509.GeneralNames.GetInstance(sanExtension.GetParsedValue()); + foreach (var generalName in names.GetNames()) + { + var entry = GeneralNameToSanEntry(generalName); + if (entry != null) + result.Add(entry); + else + skippedTagNumbers.Add(generalName.TagNo); + } + } + catch (Exception ex) + { + // Enrollment must not fail because we could not read the CSR's SANs — the + // gateway-supplied set still applies, and CERTInext validates the CSR itself. + // Debug so an operator diagnosing a missing SAN can see the parse was skipped. + LogHandler.GetClassLogger(typeof(CERTInextCAPlugin)) + .LogDebug(ex, "ExtractSanEntriesFromCsr suppressed CSR parse failure"); + } + + return result; + } + + /// + /// Maps a GeneralName to the this plugin would submit for it, or null + /// for a name whose value cannot be rendered meaningfully — skipped rather than submitted as + /// ASN.1 debris. One switch, not two: a separate tag→type mapping alongside this one used to + /// assign a type string ("directoryname", "registeredid", ...) to tags that always return a + /// null value here anyway, so those branches were dead — the type never reached a caller + /// with no value to pair it with. + /// + private static SanEntry GeneralNameToSanEntry(Org.BouncyCastle.Asn1.X509.GeneralName generalName) + { + string type; + string value; + + switch (generalName.TagNo) + { + case Org.BouncyCastle.Asn1.X509.GeneralName.DnsName: + type = "dns"; + value = Org.BouncyCastle.Asn1.DerIA5String.GetInstance(generalName.Name).GetString(); + break; + + case Org.BouncyCastle.Asn1.X509.GeneralName.Rfc822Name: + type = "email"; + value = Org.BouncyCastle.Asn1.DerIA5String.GetInstance(generalName.Name).GetString(); + break; + + case Org.BouncyCastle.Asn1.X509.GeneralName.UniformResourceIdentifier: + type = "uri"; + value = Org.BouncyCastle.Asn1.DerIA5String.GetInstance(generalName.Name).GetString(); + break; + + case Org.BouncyCastle.Asn1.X509.GeneralName.IPAddress: + type = "ip"; + // Octet string → dotted-quad / RFC 5952 text, so what we submit and log is + // the address the subscriber asked for rather than its hex encoding. + byte[] octets = Org.BouncyCastle.Asn1.Asn1OctetString.GetInstance(generalName.Name).GetOctets(); + value = octets.Length == 4 || octets.Length == 16 + ? new System.Net.IPAddress(octets).ToString() + : null; + break; + + default: + // otherName, directoryName, x400Address, ediPartyName, registeredID. + // + // Deliberately null, not Name.ToString(). BouncyCastle renders these as an + // ASN.1 dump — a UPN otherName from a Windows-generated CSR stringifies to + // "[1.3.6.1.4.1.311.20.2.3, [CONTEXT 0]svc@corp.example.com]" and a + // directoryName to "CN=host.example.com,O=Acme". Submitting that as an entry in + // additionalDomains is not "forwarding the name the subscriber asked for" — it + // is putting ASN.1 debris in a domain-name field, which cannot become a + // certificate SAN under any circumstances and only breaks the order. That is + // different from a well-formed non-DNS SAN (IP/email/URI), which we do submit + // on purpose so nothing the subscriber requested is dropped silently. + // + // Skipped is not silent: BuildSanList warns with the tag numbers so the + // operator can see a SAN was present and not forwarded. + type = null; + value = null; + break; + } + + return string.IsNullOrWhiteSpace(value) ? null : new SanEntry { Type = type, Value = value }; + } + private static string GetStringValue( Dictionary dict, string key, string defaultValue = "") { diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index e77ac68..980a26a 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -256,6 +256,19 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = false, Type = "Boolean" }, + [Constants.Config.SubmitNonDnsSans] = new PropertyConfigInfo + { + Comments = "If true (default), SANs that are not DNS names (IP address, email, URI) are " + + "submitted to CERTInext in additionalDomains along with the DNS names. CERTInext " + + "registers them verbatim as order domains and they cannot pass domain validation, " + + "so such an order will not issue until they are removed — but nothing the " + + "subscriber requested is dropped silently. Set to false to submit DNS names only, " + + "which restores the pre-1.0.1 behaviour: the order issues, but the certificate " + + "will not contain the non-DNS names. Default: true.", + Hidden = false, + DefaultValue = true, + Type = "Boolean" + }, [Constants.Config.PageSize] = new PropertyConfigInfo { Comments = "Number of orders to fetch per page during synchronization. " + @@ -691,6 +704,23 @@ public class CERTInextConfig [JsonPropertyName("IgnoreExpired")] public bool IgnoreExpired { get; set; } = false; + /// + /// Whether non-DNS SANs (IP address, email, URI) are submitted to CERTInext. + /// + /// Defaults to true: nothing the subscriber requested is dropped silently. CERTInext + /// registers such values verbatim as order domains, and they cannot pass domain validation, + /// so the order will not issue until they are removed — a visible failure, deliberately + /// preferred over a certificate quietly missing requested names. + /// + /// Set to false to submit DNS names only, restoring the pre-1.0.1 behaviour where the + /// order issues but the non-DNS names are absent from the certificate. This exists as an + /// upgrade escape hatch: on a host that was issuing certificates for requests carrying an IP + /// or email SAN, the default flips those enrollments from "issues (incomplete)" to "parks + /// pending", and an operator needs a way back that does not involve downgrading the plugin. + /// + [JsonPropertyName("SubmitNonDnsSans")] + public bool SubmitNonDnsSans { get; set; } = true; + [JsonPropertyName("PageSize")] public int PageSize { get; set; } = Constants.Api.DefaultPageSize; diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index c6ad56b..9ecde2b 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -186,9 +186,20 @@ public async Task PlaceOrderAsync( if (request.Meta == null) request.Meta = await BuildMetaAsync(ct); + // The domain set is logged here, at the wire, not just where Command hands it to us. + // A UCC order that silently lost its SANs upstream of this point is otherwise + // indistinguishable in the gateway log from one the CA stripped — reconciling the + // enrollment-start "SANs=" line against this one localizes the loss immediately. + var certInfo = request.OrderDetails?.CertificateInformation; Logger.LogInformation( - "Submitting order to CERTInext. ProductCode={ProductCode}", - request.OrderDetails?.ProductCode); + "Submitting order to CERTInext. ProductCode={ProductCode}, DomainName={DomainName}, " + + "AdditionalDomainCount={AdditionalDomainCount}, AdditionalDomains={AdditionalDomains}", + request.OrderDetails?.ProductCode, + LogSanitizer.Strip(certInfo?.DomainName), + certInfo?.AdditionalDomains?.Count ?? 0, + certInfo?.AdditionalDomains != null && certInfo.AdditionalDomains.Count > 0 + ? LogSanitizer.Strip(string.Join("; ", certInfo.AdditionalDomains)) + : "(none)"); GenerateOrderResponse result = null; RestResponse resp = null; @@ -248,7 +259,8 @@ public async Task PlaceOrderAsync( "PlaceOrder received no usable response (DomainName={Domain}, HttpStatus={Status}, LatencyMs={Latency}). " + "Not retrying to avoid a duplicate order (EMS-947). If CERTInext created the order it " + "will be imported by the next synchronization.", - request.OrderDetails?.CertificateInformation?.DomainName, (int)resp.StatusCode, sw.ElapsedMilliseconds); + LogSanitizer.Strip(request.OrderDetails?.CertificateInformation?.DomainName), + (int)resp.StatusCode, sw.ElapsedMilliseconds); throw new Exception( "CERTInext did not return a usable response to the order submission. If the order was " + "created it will be imported by the next synchronization — do not resubmit immediately. " + @@ -298,7 +310,9 @@ public async Task PlaceOrderAsync( "PlaceOrder classified {ErrorCode} as a duplicate transaction (not a hard failure). " + "DomainName={Domain}, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}. If an order exists " + "for this transaction it will be imported by the next synchronization.", - result.Meta.ErrorCode, request.OrderDetails?.CertificateInformation?.DomainName, Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds); + result.Meta.ErrorCode, + LogSanitizer.Strip(request.OrderDetails?.CertificateInformation?.DomainName), + Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds); throw new Exception( "CERTInext reported a duplicate order transaction (EMS-947). If an order was created " + "for this transaction it will be imported by the next synchronization — do not resubmit " + @@ -775,6 +789,27 @@ public async Task RenewCertificateAsync( throw new KeyNotFoundException($"Cannot renew: prior order '{certificateId}' was not found in CERTInext."); } + // Primary domain for the renewal order. Prefer the CN of the subject Command gave + // us; the prior order's requestorName is only a last resort and is not a domain — + // it is retained solely so an old caller that sets no Subject behaves as before. + // Hoisted: the same parse drives both the domain and the "did we get a CN?" warning, + // mirroring BuildOrderRequestFromLegacyEnrollRequest. + string subjectCn = ExtractCnFromSubject(request.Subject); + + string renewalDomainName = + subjectCn + ?? priorTrack.OrderDetails?.RequestorInformation?.RequestorName + ?? "unknown"; + + if (subjectCn == null) + { + Logger.LogWarning( + "Renewal of order {PriorId} has no usable CN in its subject; falling back to " + + "DomainName='{DomainName}' from the prior order. Verify the renewed certificate's " + + "primary domain.", + certificateId, LogSanitizer.Strip(renewalDomainName)); + } + // We don't have the product code from TrackOrder — build an order using // the config defaults and the CSR from the renewal request. var orderReq = new GenerateOrderSslRequest @@ -794,7 +829,8 @@ public async Task RenewCertificateAsync( SubscriptionDetails = new SubscriptionDetails { Validity = "1" }, CertificateInformation = new CertificateInformation { - DomainName = priorTrack.OrderDetails?.RequestorInformation?.RequestorName ?? "unknown" + DomainName = renewalDomainName, + AdditionalDomains = BuildAdditionalDomains(request.Sans, renewalDomainName) }, Csr = request.Csr, AgreementDetails = BuildDefaultAgreementDetails() @@ -1294,15 +1330,57 @@ private async Task ExecuteWithRetryAsync( { int attempts = idempotent ? maxAttempts : 1; RestResponse resp = null; + var sw = System.Diagnostics.Stopwatch.StartNew(); for (int attempt = 1; attempt <= attempts; attempt++) { resp = await _http.ExecuteAsync(req, ct); - // Success or 4xx client error — return immediately + // Success or 4xx client error — return immediately, checked BEFORE the + // cancellation check below. `_http.ExecuteAsync` already ran to completion by the + // time control reaches this line; whether `ct` has *since* flipped to cancelled is + // a separate, unsynchronized fact (a check-after-await race, not a fabricated one — + // a CancellationTokenSource(TimeSpan) callback and this awaited Task's completion + // are not mutually exclusive events). A deadline (the shared DcvTimeoutMinutes + // budget) firing at essentially the same instant a call genuinely succeeded must not + // discard that success: for VerifyDcv specifically, discarding it here would abort + // PerformDcvIfNeededAsync's loop before WaitForDcvVerificationAsync ever ran, and + // its finally block would delete the just-staged TXT record even though CERTInext + // had genuinely received the verify trigger — turning a real CA-side success into a + // self-inflicted DCV failure. bool isClientError = (int)resp.StatusCode >= 400 && (int)resp.StatusCode < 500; if (resp.IsSuccessful || isClientError) return resp; + // Only for a call that did NOT succeed: this client is built with + // ThrowOnAnyError=false (see the constructor), so a cancelled ct does not surface as + // OperationCanceledException from ExecuteAsync — RestSharp catches + // HttpClient.SendAsync's cancellation internally and returns a non-throwing, + // unsuccessful RestResponse instead. Left unchecked, that response reaches + // DeserializeOrThrow and becomes a plain Exception indistinguishable from a genuine + // API failure — which is exactly how a caller such as PerformDcvIfNeededAsync's + // shared DCV-timeout cancellation was still landing in a generic "GetDcv failed" + // per-domain catch instead of the cancellation-specific one, even after that method + // was hardened to re-throw a real OperationCanceledException past its per-domain + // catches. Surface the true cancellation here, at the one place in the client that + // actually holds `ct`, before any retry or error-wrapping logic sees the response. + // + // Throwing here means every caller's own per-call audit line (Method/Path/HttpStatus/ + // LatencyMs, logged after ExecuteWithRetryAsync returns) never executes for the + // cancelled call — that specific attempt would otherwise vanish from the audit trail + // entirely, leaving only a coarser, order-level "unexpected failure" log with no + // domain/endpoint/status/latency. Log that record here instead, at the one place that + // reliably sees every cancellation regardless of which of ExecuteWithRetryAsync's ~10 + // callers is in flight. + if (ct.IsCancellationRequested) + { + Logger.LogWarning( + "CERTInext API call cancelled: Method={Method}, Path={Path}, HttpStatus={Status}, " + + "ResponseStatus={ResponseStatus}, LatencyMs={Latency}, Attempt={Attempt}/{Max}.", + req.Method, req.Resource, (int)resp.StatusCode, resp.ResponseStatus, + sw.ElapsedMilliseconds, attempt, attempts); + } + ct.ThrowIfCancellationRequested(); + if (attempt < attempts) { Logger.LogWarning( @@ -1398,6 +1476,10 @@ private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollC string requestorIsd = string.IsNullOrWhiteSpace(_config.RequestorIsdCode) ? "1" : _config.RequestorIsdCode; string requestorMobile = _config.RequestorMobileNumber ?? string.Empty; + // Hoisted: additionalDomains is de-duplicated against the primary domain, so both + // fields have to be built from the same value. + string domainName = ExtractCnFromSubject(request.Subject) ?? "unknown"; + return new GenerateOrderSslRequest { // Meta will be set by PlaceOrderAsync @@ -1443,8 +1525,8 @@ private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollC }, CertificateInformation = new CertificateInformation { - DomainName = ExtractCnFromSubject(request.Subject) ?? "unknown", - AdditionalDomains = BuildAdditionalDomains(request.Sans), + DomainName = domainName, + AdditionalDomains = BuildAdditionalDomains(request.Sans, domainName), AutoSecureWww = string.IsNullOrWhiteSpace(_config.AutoSecureWww) ? "0" : _config.AutoSecureWww }, @@ -1506,16 +1588,57 @@ private static string ExtractCnFromSubject(string subject) return null; } - private static List BuildAdditionalDomains(System.Collections.Generic.List sans) + /// + /// Projects the resolved SAN list onto certificateInformation.additionalDomains. + /// + /// Every requested SAN is submitted regardless of type. Filtering to DNS-only (the + /// original behaviour) issued certificates quietly missing names the subscriber had + /// requested, which is the worse failure; the caller warns about the non-DNS entries + /// before we get here. + /// + /// is the value already going out as the order's primary + /// domain, and Command normally includes the CN in the SAN set as well. On the US + /// sandbox CERTInext was measured to collapse that repetition itself + /// (SanSubmissionProbeTests: CN submitted twice came back registered once), but that is + /// undocumented and unverified against production — which is exactly why we exclude it + /// here rather than relying on CA-side de-duplication. It also keeps the submitted body + /// matching what we log. + /// + private List BuildAdditionalDomains( + System.Collections.Generic.List sans, + string domainName) { if (sans == null || sans.Count == 0) return null; + var domains = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + bool haveDomainName = !string.IsNullOrWhiteSpace(domainName); + if (haveDomainName) + seen.Add(domainName.Trim()); + + int duplicates = 0; foreach (var san in sans) { - if (string.Equals(san.Type, "dns", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(san.Value)) - domains.Add(san.Value); + if (san == null || string.IsNullOrWhiteSpace(san.Value)) continue; + + string value = san.Value.Trim(); + if (!seen.Add(value)) + { + duplicates++; + continue; + } + domains.Add(value); } + + if (duplicates > 0) + { + Logger.LogDebug( + "Collapsed {Count} duplicate SAN value(s) out of additionalDomains " + + "(already submitted as domainName '{DomainName}', or repeated in the SAN set).", + duplicates, LogSanitizer.Strip(domainName)); + } + return domains.Count > 0 ? domains : null; } diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 4510286..e3dc989 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -20,6 +20,7 @@ public static class Config public const string AuthMode = "AuthMode"; public const string Enabled = "Enabled"; public const string IgnoreExpired = "IgnoreExpired"; + public const string SubmitNonDnsSans = "SubmitNonDnsSans"; public const string PageSize = "PageSize"; // Synchronous certificate pickup (parity with the legacy Sectigo connector). @@ -323,6 +324,17 @@ public static class Dcv // Override via the DcvTxtRecordTemplate connector config field. public const string DefaultTxtRecordTemplate = "_emsign-validation.{0}"; + // Independent bound for a single CleanupValidation (TXT-record removal) call. This is + // deliberately its own fixed ceiling, not a fraction of DcvTimeoutMinutes and not the + // ambient DCV-flow cancellation token: cleanup is a best-effort compensating action that + // must get a real chance to run even when the operation it's cleaning up after was + // itself cancelled (the ambient token would already be cancelled at that point), but it + // still must not be allowed to hang the calling gateway request forever if a DNS + // provider plugin's underlying network call stalls. 60s comfortably covers a single + // DELETE-shaped call under normal conditions (the reference CloudflareDomainValidator's + // HttpClient default alone is 100s) without risking an indefinite hang. + public const int CleanupValidationTimeoutSeconds = 60; + // Defaults for the DCV-during-sync bounds (issue 0002). public const int DefaultSyncMaxOrderAgeHours = 24; public const int DefaultSyncMaxPerPass = 50; diff --git a/CERTInext/Models/LogSanitizer.cs b/CERTInext/Models/LogSanitizer.cs new file mode 100644 index 0000000..d341f09 --- /dev/null +++ b/CERTInext/Models/LogSanitizer.cs @@ -0,0 +1,33 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Models +{ + /// + /// Neutralizes control characters before a requester-controlled value is interpolated into a + /// log message. + /// + /// SAN values reach the log from the CSR and from Command's SAN dictionary, i.e. from the + /// requester. Structured message templates stop format-string abuse but not embedded newlines, + /// and NLog's text layout does not escape them — so an unsanitized value can forge additional, + /// well-formed-looking records in the gateway log (CWE-117). That matters here specifically + /// because these log lines exist to make the submitted SAN set auditable; a forged line could + /// assert a different SAN set than the one actually sent. + /// + /// Shared between CERTInextCAPlugin and Client.CERTInextClient — both sanitize the + /// same kind of value at their respective log sinks, so this used to be defined twice, byte- + /// identical, one per class. + /// + internal static class LogSanitizer + { + internal static string Strip(string value) + { + if (string.IsNullOrEmpty(value)) return value; + return value + .Replace("\r", "\\r") + .Replace("\n", "\\n") + .Replace("\t", "\\t"); + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d971478..11bd7b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,16 @@ # 1.0.1 ## Features -- **Faster enrollment for quickly-issued certificates.** Enrollment now waits briefly for the certificate and returns it in the same request when it issues fast (DV and already-approved orders), instead of always waiting for the next synchronization. Two new optional settings control the wait: `PickupRetries` (default 5; set to `0` to disable) and `PickupDelay` (default 10 seconds) — about a 55-second wait by default, with a built-in ceiling so it can't run long enough to time out the enrollment. Orders that don't issue in that window — including OV/EV, which CERTInext validates asynchronously over minutes to hours — return pending and are imported by a later sync, exactly as before. Works with or without DNS-based DCV. +- **Faster enrollment for quickly-issued certificates.** Enrollment now waits briefly and returns the certificate in the same request when it issues fast, instead of always waiting for the next sync. Configurable via `PickupRetries` (default 5, `0` disables) and `PickupDelay` (default 10s). Orders that don't issue in time (e.g. OV/EV) return pending and are picked up by the next sync, as before. ## Bug Fixes -- **No more duplicate or orphaned orders after a network timeout.** Order and CSR submissions are no longer retried after a network timeout. A timeout can happen *after* the CA has already accepted the request, so the automatic retry was being rejected as a duplicate — failing the enrollment and leaving an orphaned order behind. These requests now run once; if the order was created it is imported by the next synchronization, and duplicate responses are reported with clear, actionable guidance. (Read-only calls are unaffected and still retry.) +- **UCC certificates no longer come back with only the common name.** The gateway sends SANs under the key `dnsname`, which the plugin didn't recognize, so orders went out with an empty domain list. SANs are now read from every key the gateway sends, plus from the CSR itself. +- **Renewals no longer lose their SANs.** Renewals were submitted with no additional domains and the wrong primary domain; both now come from the certificate being renewed. +- **Enrollment no longer fails on an order CERTInext auto-approves before it finishes issuing.** The plugin used to report these as issued with no certificate attached, which the gateway rejected. It now returns pending and picks up the certificate once CERTInext finishes issuing it. + +## Upgrade Notes +- **Non-DNS SANs (IP, email, URI) are now submitted instead of silently dropped.** CERTInext can't validate them, so such an order won't issue until the SAN is removed. Set `SubmitNonDnsSans` to `false` to restore the old drop-silently behavior. +- **No more duplicate or orphaned orders after a network timeout.** Order/CSR submissions no longer auto-retry after a timeout, since the CA may have already created the order. If it was created, the next sync imports it. # 1.0.0 From 947ae686ebcdec5268d3083059cccbe745b05f8e Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:27:12 -0700 Subject: [PATCH 07/71] docs: refresh docsource against current code docsource hadn't been touched since v1.0.0; a full freshness pass against current source found several stale/wrong claims and fixed them: - Order Lifecycle status-code table didn't match StatusMapper.cs (several codes were in the wrong bucket, several real codes weren't listed). - GroupNumber description omitted its per-order use (delegationInformation), contradicting a note three lines below it. - AutoApprove and DefaultProductCode were documented as doing things the code doesn't do; corrected to describe actual behavior (both filed as separate GitHub issues, not fixed here). - ~15 real config properties (OrganizationNumber, TechnicalContact*, SubmitNonDnsSans, PickupRetries/PickupDelay, DcvWaitFor*Seconds, DcvSyncMax*, etc.) existed in code with no mention anywhere in docs. - Enrollment/sync sequence diagrams in architecture.md were silent on DCV and synchronous certificate pickup entirely. - development.md's product test-coverage table cited a removed test file and hardcoded requestNumbers documented elsewhere as non-portable; replaced with a pointer to `make probe-products` and TESTING.md. --- docsource/architecture.md | 26 +++++++++++++++++++-- docsource/configuration.md | 46 ++++++++++++++++++++++++++++++-------- docsource/development.md | 30 +++++++------------------ 3 files changed, 69 insertions(+), 33 deletions(-) diff --git a/docsource/architecture.md b/docsource/architecture.md index 93ac459..7fc4135 100644 --- a/docsource/architecture.md +++ b/docsource/architecture.md @@ -113,6 +113,8 @@ sequenceDiagram **Expired certificates:** The `IgnoreExpired` connector setting controls whether expired certificates are included in synchronization. When enabled, expired certificates are silently skipped and will not appear in the Keyfactor Command inventory. +**DCV-during-sync:** on a DCV-enabled build, each sync pass also drives DNS-01 validation forward for pending DV orders that are still waiting on it, bounded by `DcvSyncMaxOrderAgeHours` (skip orders older than this) and `DcvSyncMaxPerPass` (cap how many are attempted per pass), so a large backlog of stalled pending orders can't slow down every sync. + --- ## Certificate Enrollment @@ -134,13 +136,27 @@ sequenceDiagram Plugin->>API: Place certificate order\n(CSR, domain, organization details,\nsubscriber agreement, requestor info) API-->>Plugin: Order accepted — order number assigned + opt DNS-01 DCV build, DCV enabled, and this order requires it + Plugin->>Plugin: Publish DNS TXT challenge\nvia the configured DNS provider plugin + Plugin->>API: Ask CERTInext to verify the record + API-->>Plugin: Domain validated (or still pending —\nfalls through to the pending path below) + end + Plugin->>API: Check order status API-->>Plugin: Order status and certificate details alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned - else Certificate pending approval - Plugin-->>CMD: Pending — Command will pick it up\nduring the next synchronization + else Certificate pending or not yet downloadable + loop Certificate-pickup retries\n(bounded, ~55s by default — PickupRetries/PickupDelay) + Plugin->>API: Poll for the certificate + API-->>Plugin: Status and certificate, if ready + end + alt Certificate became available during pickup + Plugin-->>CMD: Certificate ready — PEM returned + else Still not available + Plugin-->>CMD: Pending — Command will pick it up\nduring the next synchronization + end else Order rejected by CERTInext Plugin-->>CMD: Enrollment failed — see gateway logs end @@ -148,12 +164,18 @@ sequenceDiagram Plugin->>Plugin: Record enrollment outcome in audit log\n(order number, serial number, status) ``` +**DCV:** on a DCV-enabled build, DNS-01 validation runs inline for DV orders that require it, bounded by `DcvTimeoutMinutes`. When DCV isn't enabled, isn't built into this host, or the order doesn't require it, this step is skipped entirely and the order proceeds straight to the pending/pickup path like any other asynchronously-issued order. + +**Synchronous certificate pickup:** if the certificate isn't available immediately (a fresh order, or DCV that just validated but hasn't finished generating the PEM), `Enroll()` polls CERTInext a bounded number of times (`PickupRetries` × `PickupDelay`, capped at a 180s ceiling) before giving up and returning pending. This lets a fast-issuing certificate (DV, or an already-approved order) come back in the same enrollment call instead of always waiting for the next sync. OV/EV orders validate asynchronously over minutes to hours and typically exhaust this window regardless. + ### Renewal When Command initiates a renewal, the plugin checks whether the existing certificate is within the configured renewal window. If it is, the prior order record is used as context for the new request. If it is outside the window (or the prior certificate cannot be located), the plugin falls back to issuing a new certificate. > **Note:** CERTInext does not have a dedicated certificate renewal endpoint. Both renewal and reissuance paths submit a new `GenerateOrderSSL` order. The distinction affects how Keyfactor Command tracks the certificate record, not what is sent to CERTInext. +> **Note:** If the prior-order lookup itself throws (rather than cleanly returning "not found" — e.g. a transient database error), the plugin falls back to issuing a new certificate rather than failing the enrollment. + ```mermaid flowchart TD A([Renewal requested]) --> B{Prior certificate\nserial number\nprovided?} diff --git a/docsource/configuration.md b/docsource/configuration.md index 41c872e..d80216b 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -9,6 +9,7 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi * New certificate enrollment (new keys and certificate). * Certificate renewal — submits a new `GenerateOrderSSL` order when the prior certificate is within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). * Certificate reissuance (new keys with the same or updated subject/SANs) when outside the renewal window or no prior certificate is found. + * Synchronous certificate pickup — a fast-issuing order (DV, or already-approved) can return the certificate in the same enrollment call instead of always waiting for the next sync, via `PickupRetries`/`PickupDelay`. * Certificate Revocation: * Request revocation of a previously issued certificate using any RFC 5280 CRL reason code. * Supported authentication modes for calls to the CERTInext API: @@ -91,7 +92,9 @@ Before enrolling certificates, the Keyfactor Command server must trust the CERTI ## CA Configuration -The following fields are presented in the Keyfactor Command Management Portal when creating or editing the CERTInext CA connector. All fields marked **Required** must be provided before the connector can be saved in an enabled state. +The following fields are presented in the Keyfactor Command Management Portal when creating or editing the CERTInext CA connector. + +> Note: the connector's own save-time validation only enforces `ApiUrl`, `AccountNumber`, and the credential fields for the selected `AuthMode`. Other fields marked **Required** below are required by CERTInext for a successful order — the connector will save without them, but enrollment will fail or the order will be parked pending until they're set. | Field | Required / Optional | Description | Where to find it | Example | |---|---|---|---|---| @@ -108,15 +111,30 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `RequestorMobileNumber` | Optional | Requestor mobile number (digits only, no country code). Included in the `requestorInformation` block. | N/A | `5551234567` | | `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. | Use the physical city where the signer is located. | `Austin` | | `SignerIp` | Required | Public IP address of the host accepting the subscriber agreement. Required by CERTInext for all orders. | Use the outbound IP of the AnyCA Gateway host, or the IP of the workstation from which the agreement was accepted. | `203.0.113.10` | -| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | -| `DefaultProductCode` | Optional | Default numeric product code to use when no product code is set on the certificate template. If omitted and the template also has no product code, enrollment will fail. Product codes are provisioned per account by eMudhra — contact your eMudhra account representative to obtain the numeric codes available to your account. | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | +| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `delegationInformation.groupNumber` on every SSL order. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | +| `OrganizationNumber` | Optional, strongly recommended for OV/EV and faster DV | Numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this number, telling CERTInext to skip its manual organization-vetting queue. Without it, orders may sit in `Pending System RA` for extended manual review (observed: tens of hours). | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | +| `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` on every SSL order. Each defaults to the corresponding `Requestor*` field when blank. Some product configurations require a technical point of contact to be present; omitting it can cause CERTInext to park orders awaiting manual completion of the field. | N/A | *(defaults to Requestor fields)* | +| `AccountingModel` | Optional | CERTInext billing model sent in `orderDetails.accountingModel`. `2` = credit-based (most accounts). `1` = cash model. Default: `2`. | N/A | `2` | +| `EmailNotifications` | Optional | Whether CERTInext sends lifecycle-event emails to the requestor. `1` = enabled, `0` = silent (recommended for gateway-driven orders). Default: `0`. | N/A | `0` | +| `SubscriptionValidityYears` | Optional | Connector-level default validity in years for SSL orders (`1`, `2`, or `3`). Overridden per template by the `ValidityYears` enrollment parameter. Default: `1`. | N/A | `1` | +| `SubscriptionAutoRenew` | Optional | Whether CERTInext should auto-renew certificates issued through this connector. `0` = disabled (recommended — renewal is driven by Keyfactor Command), `1` = enabled. Default: `0`. | N/A | `0` | +| `SubscriptionRenewCriteriaDays` | Optional | Days before expiry at which CERTInext auto-renews. Only honored when `SubscriptionAutoRenew` is `1`. Default: `30`. | N/A | `30` | +| `AutoSecureWww` | Optional | If `1`, CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. Default: `0`. | N/A | `0` | +| `SubmitNonDnsSans` | Optional | If `true` (default), SANs that aren't DNS names (IP address, email, URI) are submitted to CERTInext instead of silently dropped. CERTInext can't validate them, so such an order won't issue until they're removed. Set to `false` to restore the pre-1.0.1 behavior of submitting DNS names only. Default: `true`. | N/A | `true` | +| `DefaultProductCode` | Optional, but effectively required if you use renewals | Numeric product code used for **renewals only** — CERTInext's `TrackOrder` doesn't return the prior order's product code, so the renewal path sends this value verbatim, ignoring the template's `ProductCode`/`ProfileId`. If left blank, renewals go out with an empty product code. Has **no effect on new enrollments** — the `ProductCode`/`ProfileId` template resolution never falls back to it. See [issue tracking this](https://github.com/Keyfactor/certinext-caplugin/issues/26). | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | +| `PickupRetries` | Optional | Number of times `Enroll` polls CERTInext for the certificate after a successful order submission, before returning pending and leaving pickup to the next sync. Set to `0` to disable the wait. OV/EV orders validate asynchronously (minutes to hours) and typically exhaust this wait regardless of the value. Default: `5`. | N/A | `5` | +| `PickupDelay` | Optional | Seconds between certificate-pickup retries. `PickupRetries × PickupDelay` (plus a short initial delay) bounds how long an enrollment call occupies a Command worker thread — capped at a 180s ceiling regardless of how the two are set (aim for well under ~90s in practice, so the call doesn't run long enough to trip Command's own timeout). Default: `10` (a ~55s ceiling with default `PickupRetries`). | N/A | `10` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | | `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | -| `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | +| `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Applies only to the `Enroll()`-time DCV path — DCV driven during sync uses its own fixed 3-second delay. Default: `30`. | N/A | `30` | | `DcvTimeoutMinutes` | Optional | Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) before cancelling the enrollment. Can also be set via the `CERTINEXT_DCV_TIMEOUT_MINUTES` environment variable; the environment variable takes precedence when both are set. Default: `10`. | N/A | `10` | +| `DcvWaitForChallengeSeconds` | Optional | How long `Enroll()` waits for CERTInext to expose the DCV challenge after order placement, before giving up and deferring to the next sync. Set to `0` to disable the wait. Can also be set via `CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS`. Default: `60`. | N/A | `60` | +| `DcvWaitForIssuanceSeconds` | Optional | How long `Enroll()` waits for CERTInext to finish generating the certificate after DCV verifies. Set to `0` to disable the wait. Can also be set via `CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS`. Default: `60`. | N/A | `60` | +| `DcvSyncMaxOrderAgeHours` | Optional | During synchronization, only pending DV orders younger than this many hours are driven through DCV, so a large backlog of old/abandoned pending orders doesn't slow down every sync pass. Set to `0` to disable the age filter. Default: `24`. | N/A | `24` | +| `DcvSyncMaxPerPass` | Optional | Maximum number of pending DV orders driven through DCV in a single sync pass. Set to `0` to disable the cap. Default: `50`. | N/A | `50` | > Note: `AccountNumber` and group-level identifiers are distinct values. The `AccountNumber` is your top-level user account identifier. CERTInext groups (cost centers or departments) each have their own `groupNumber`, which is passed per-order and is separate from any organization number displayed on the Organizations page. @@ -130,11 +148,11 @@ In the Keyfactor Command Management Portal, navigate to **Certificate Templates* | Parameter | Required / Optional | Type | Description | Example / Default | |---|---|---|---|---| -| `ProductCode` | Optional | String | Override the numeric CERTInext product code for this template. Product codes are provisioned per account by eMudhra — obtain the correct code from `GetProductDetails` for your account. Set this explicitly when targeting the sandbox environment or when the connector `DefaultProductCode` should not apply to this template. See the [Product Codes](#product-codes) section for the sandbox/production lookup table. | DV SSL: `842` (sandbox) or `838` (production) | +| `ProductCode` | Optional | String | Override the numeric CERTInext product code for this template. Product codes are provisioned per account by eMudhra — obtain the correct code from `GetProductDetails` for your account. If omitted, the built-in default code for the selected product name is used (see [Product Codes](#product-codes)). Set this explicitly when targeting the sandbox environment or a non-standard code. | DV SSL: `842` (sandbox) or `838` (production) | | `ProfileId` | Deprecated | String | Legacy alias for `ProductCode`. Accepted for backward compatibility — if `ProductCode` is not set, `ProfileId` is used in its place. New templates should use `ProductCode`. | `838` | | `ValidityYears` | Optional | Number | Subscription validity period in years: `1`, `2`, or `3`. Default: `1`. CERTInext certificates are issued within a subscription term at up to 390 days per certificate, with free renewals within the term. | `1` | | `ValidityDays` | Deprecated | Number | Legacy validity field. If set, the value is divided by 365 and rounded up to derive a year count. New templates should use `ValidityYears`. | `365` | -| `AutoApprove` | Optional | Boolean | If `true`, the gateway will attempt automatic approval of certificates returned in a pending-approval state. Only set this if your CERTInext product is configured with automatic approval. Default: `false`. | `false` | +| `AutoApprove` | Optional | Boolean | **Currently has no effect** — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. See [issue tracking this](https://github.com/Keyfactor/certinext-caplugin/issues/25). | `false` | | `RequesterName` | Optional | String | Per-template override for the requestor name. When set, overrides the connector-level `RequestorName` for orders using this template. | `Keyfactor Automation` | | `RequesterEmail` | Optional | String | Per-template override for the requestor email address. When set, overrides the connector-level `RequestorEmail` for orders using this template. | `pki-admin@example.com` | | `RenewalWindowDays` | Optional | Number | Number of days before certificate expiration within which a renewal is attempted instead of a reissue. Default: `90`. | `90` | @@ -226,6 +244,15 @@ authKey = SHA256(accessKey + requestTs + requestTxnId) Where `requestTs` is the ISO 8601 timestamp and `requestTxnId` is a unique transaction UUID generated per request. The raw access key is never transmitted — only the derived hash is sent. This computation happens automatically on every outbound call. When `AuthMode` is `OAuth`, the gateway obtains a bearer token via the configured client credentials flow and injects it into the `meta` block instead. +### HTTP Timeout + +Every CERTInext API call (enroll, sync, revoke) shares one HTTP client with a fixed 120-second +request timeout. This is hardcoded and is not exposed as a connector setting or environment +variable — it cannot be changed without modifying the plugin. If a call doesn't return within 120 +seconds, the plugin aborts it and the operation fails; a non-idempotent call (e.g. order placement) +is not retried afterward, since CERTInext may have already created the order — see +[Synchronization](#synchronization) to reconcile such orders on a later pass. + ### Enrollment Decision Logic When the gateway calls `Enroll`, the plugin selects between three paths based on the enrollment type and the age of the prior certificate: @@ -244,9 +271,10 @@ The `GenerateOrderSSL` API requires an `additionalInformation.remarks` field in CERTInext orders pass through several internal status stages before a certificate is issued. The plugin maps these to Keyfactor enrollment statuses as follows: -- **Issued** (status 9, 20) → certificate returned immediately. -- **Pending approval** (status 2, 8, 15, 24) → enrollment returns a pending status to Command. If `AutoApprove` is enabled on the template, the plugin attempts automatic approval before returning. -- **Rejected / cancelled** (status 4, 5, 13, 14) → enrollment fails with an error. +- **Issued** (status `7`, `9`, `12`, `15`, `20`, `23`) → certificate returned immediately (status `12`, expired, is retained in inventory as issued rather than treated as a failure). +- **Pending approval** (status `1`, `2`, `4`, `6`, `16`, `17`, `24`) → enrollment returns a pending status to Command. `Enroll()` polls briefly for the certificate (see `PickupRetries`/`PickupDelay`) before falling back to pending. +- **Revoked** (status `22`) → certificate marked revoked. +- **Rejected / cancelled** (status `3`, `5`, `8`, `13`, `14`, `18`, `19`, `21`, or any unrecognized code) → enrollment fails with an error. The gateway polls the `TrackOrder` endpoint during sync to pick up certificates that were approved after the initial enrollment call. diff --git a/docsource/development.md b/docsource/development.md index 14c6cff..2f7ab02 100644 --- a/docsource/development.md +++ b/docsource/development.md @@ -114,26 +114,12 @@ See `CERTInext.IntegrationTests/INTEGRATION_TESTING.md` for a full description o ## Product Integration Test Coverage -The table below records live draft-order results against the Production — India instance. Orders were placed with `saveAndHold:"1"` so no billing, DCV, or CA issuance was triggered. Tests are in `CERTInext.IntegrationTests/DraftOrderTests.cs`. +`DraftOrderTests.cs` (and `TrackOrderTests.cs`) previously recorded live draft-order results here, but both were removed: they asserted specific `requestNumber` values hardcoded from one developer's account, which don't exist on any other account and so failed everywhere else. Their intent — verifying draft-order and track-order semantics — is now covered by `LifecycleTests`, which creates its own order and asserts on it without relying on account-specific identifiers. -| Product | Code | Test Status | requestNumber | Notes | -|---|---|---|---|---| -| DV SSL | `838` | ✓ Tested | 4572531551 | Base domain; no extra fields required beyond base set | -| DV SSL Wildcard | `839` | ✓ Tested | 9149755266 | CSR CN must be `*.domain`; `domainName` must also use wildcard format | -| DV SSL UCC | `840` | ✓ Tested | 1611445122 | `certificateInformation.additionalDomains` array required | -| DV SSL Wildcard UCC | `841` | ✗ Blocked | — | EMS-918: "Additional Information cannot be empty" — required fields for this product not yet identified | -| OV SSL | `842` | ✓ Tested | 5546366498 | Requires `locality` and `postalCode` in `certificateInformation` | -| OV SSL Wildcard | `843` | ✗ Not tested | — | Draft order not yet placed | -| OV SSL UCC | `844` | ✗ Not tested | — | Draft order not yet placed | -| OV SSL Wildcard UCC | `845` | ✗ Blocked | — | EMS-918: "Additional Information cannot be empty" — required fields for this product not yet identified | -| EV SSL | `846` | ✓ Tested | 3932332114 | Requires `contractSignerInfo`, `certificateApproverInfo`, non-empty `streetAddress2`, `companyRegistrationNumber` | -| EV SSL UCC | `847` | ✗ Blocked | — | EMS-918: "Additional Information cannot be empty" — required fields for this product not yet identified | -| DV SSL 1 Month | N/A | ✗ Not supported | — | Visible in portal but not returned by `GetProductDetails` API; no product code available. Not supported by plugin. | -| DV SSL Wildcard 1 Month | N/A | ✗ Not supported | — | Visible in portal but not returned by `GetProductDetails` API; no product code available. Not supported by plugin. | -| emSign Intranet SSL | `100` | ✗ Not tested | — | EMS-1162: not provisioned on this account type | -| IGTF Host | `104` | ✗ Not tested | — | EMS-1162: not provisioned on this account type | -| S/MIME | `894` | ✗ Not tested | — | EMS-1162: not provisioned on this account type | -| Natural Person Doc Signer | `825` | ✗ Not tested | — | EMS-1162: not provisioned on this account type | -| Legal Entity Doc Signer | `819` | ✗ Not tested | — | EMS-1162: not provisioned on this account type | - -Products returning EMS-1162 require special provisioning by eMudhra that is not included on a standard SSL/TLS account. The plugin code supports submitting orders for any product code; whether the order is accepted depends on what is provisioned for your account. +Product codes are provisioned per account by eMudhra and are not portable across accounts (see the [Product Codes](configuration.md#product-codes) section in configuration.md). To discover which codes and required fields apply to *your* account: + +```bash +make probe-products +``` + +This places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports which return a `requestNumber` (valid/provisioned) versus an error (invalid or not provisioned). See `CERTInext.IntegrationTests/TESTING.md` for the current, account-specific findings and expected test results. From 5d2d15422b16c71d6df8d29f92a0d8db8ede90fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 13 Aug 2026 17:27:45 +0000 Subject: [PATCH 08/71] docs: auto-generate README and documentation [skip ci] --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ff91ab1..f672ee1 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi * New certificate enrollment (new keys and certificate). * Certificate renewal — submits a new `GenerateOrderSSL` order when the prior certificate is within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). * Certificate reissuance (new keys with the same or updated subject/SANs) when outside the renewal window or no prior certificate is found. + * Synchronous certificate pickup — a fast-issuing order (DV, or already-approved) can return the certificate in the same enrollment call instead of always waiting for the next sync, via `PickupRetries`/`PickupDelay`. * Certificate Revocation: * Request revocation of a previously issued certificate using any RFC 5280 CRL reason code. * Supported authentication modes for calls to the CERTInext API: @@ -159,11 +160,11 @@ In the Keyfactor Command Management Portal, navigate to **Certificate Templates* | Parameter | Required / Optional | Type | Description | Example / Default | |---|---|---|---|---| -| `ProductCode` | Optional | String | Override the numeric CERTInext product code for this template. Product codes are provisioned per account by eMudhra — obtain the correct code from `GetProductDetails` for your account. Set this explicitly when targeting the sandbox environment or when the connector `DefaultProductCode` should not apply to this template. See the [Product Codes](#product-codes) section for the sandbox/production lookup table. | DV SSL: `842` (sandbox) or `838` (production) | +| `ProductCode` | Optional | String | Override the numeric CERTInext product code for this template. Product codes are provisioned per account by eMudhra — obtain the correct code from `GetProductDetails` for your account. If omitted, the built-in default code for the selected product name is used (see [Product Codes](#product-codes)). Set this explicitly when targeting the sandbox environment or a non-standard code. | DV SSL: `842` (sandbox) or `838` (production) | | `ProfileId` | Deprecated | String | Legacy alias for `ProductCode`. Accepted for backward compatibility — if `ProductCode` is not set, `ProfileId` is used in its place. New templates should use `ProductCode`. | `838` | | `ValidityYears` | Optional | Number | Subscription validity period in years: `1`, `2`, or `3`. Default: `1`. CERTInext certificates are issued within a subscription term at up to 390 days per certificate, with free renewals within the term. | `1` | | `ValidityDays` | Deprecated | Number | Legacy validity field. If set, the value is divided by 365 and rounded up to derive a year count. New templates should use `ValidityYears`. | `365` | -| `AutoApprove` | Optional | Boolean | If `true`, the gateway will attempt automatic approval of certificates returned in a pending-approval state. Only set this if your CERTInext product is configured with automatic approval. Default: `false`. | `false` | +| `AutoApprove` | Optional | Boolean | **Currently has no effect** — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. See [issue tracking this](https://github.com/Keyfactor/certinext-caplugin/issues/25). | `false` | | `RequesterName` | Optional | String | Per-template override for the requestor name. When set, overrides the connector-level `RequestorName` for orders using this template. | `Keyfactor Automation` | | `RequesterEmail` | Optional | String | Per-template override for the requestor email address. When set, overrides the connector-level `RequestorEmail` for orders using this template. | `pki-admin@example.com` | | `RenewalWindowDays` | Optional | Number | Number of days before certificate expiration within which a renewal is attempted instead of a reissue. Default: `90`. | `90` | @@ -238,7 +239,9 @@ If your CERTInext account has OAuth enabled, you can use OAuth client credential ## CA Configuration -The following fields are presented in the Keyfactor Command Management Portal when creating or editing the CERTInext CA connector. All fields marked **Required** must be provided before the connector can be saved in an enabled state. +The following fields are presented in the Keyfactor Command Management Portal when creating or editing the CERTInext CA connector. + +> Note: the connector's own save-time validation only enforces `ApiUrl`, `AccountNumber`, and the credential fields for the selected `AuthMode`. Other fields marked **Required** below are required by CERTInext for a successful order — the connector will save without them, but enrollment will fail or the order will be parked pending until they're set. | Field | Required / Optional | Description | Where to find it | Example | |---|---|---|---|---| @@ -255,15 +258,30 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `RequestorMobileNumber` | Optional | Requestor mobile number (digits only, no country code). Included in the `requestorInformation` block. | N/A | `5551234567` | | `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. | Use the physical city where the signer is located. | `Austin` | | `SignerIp` | Required | Public IP address of the host accepting the subscriber agreement. Required by CERTInext for all orders. | Use the outbound IP of the AnyCA Gateway host, or the IP of the workstation from which the agreement was accepted. | `203.0.113.10` | -| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | -| `DefaultProductCode` | Optional | Default numeric product code to use when no product code is set on the certificate template. If omitted and the template also has no product code, enrollment will fail. Product codes are provisioned per account by eMudhra — contact your eMudhra account representative to obtain the numeric codes available to your account. | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | +| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `delegationInformation.groupNumber` on every SSL order. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | +| `OrganizationNumber` | Optional, strongly recommended for OV/EV and faster DV | Numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this number, telling CERTInext to skip its manual organization-vetting queue. Without it, orders may sit in `Pending System RA` for extended manual review (observed: tens of hours). | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | +| `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` on every SSL order. Each defaults to the corresponding `Requestor*` field when blank. Some product configurations require a technical point of contact to be present; omitting it can cause CERTInext to park orders awaiting manual completion of the field. | N/A | *(defaults to Requestor fields)* | +| `AccountingModel` | Optional | CERTInext billing model sent in `orderDetails.accountingModel`. `2` = credit-based (most accounts). `1` = cash model. Default: `2`. | N/A | `2` | +| `EmailNotifications` | Optional | Whether CERTInext sends lifecycle-event emails to the requestor. `1` = enabled, `0` = silent (recommended for gateway-driven orders). Default: `0`. | N/A | `0` | +| `SubscriptionValidityYears` | Optional | Connector-level default validity in years for SSL orders (`1`, `2`, or `3`). Overridden per template by the `ValidityYears` enrollment parameter. Default: `1`. | N/A | `1` | +| `SubscriptionAutoRenew` | Optional | Whether CERTInext should auto-renew certificates issued through this connector. `0` = disabled (recommended — renewal is driven by Keyfactor Command), `1` = enabled. Default: `0`. | N/A | `0` | +| `SubscriptionRenewCriteriaDays` | Optional | Days before expiry at which CERTInext auto-renews. Only honored when `SubscriptionAutoRenew` is `1`. Default: `30`. | N/A | `30` | +| `AutoSecureWww` | Optional | If `1`, CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. Default: `0`. | N/A | `0` | +| `SubmitNonDnsSans` | Optional | If `true` (default), SANs that aren't DNS names (IP address, email, URI) are submitted to CERTInext instead of silently dropped. CERTInext can't validate them, so such an order won't issue until they're removed. Set to `false` to restore the pre-1.0.1 behavior of submitting DNS names only. Default: `true`. | N/A | `true` | +| `DefaultProductCode` | Optional, but effectively required if you use renewals | Numeric product code used for **renewals only** — CERTInext's `TrackOrder` doesn't return the prior order's product code, so the renewal path sends this value verbatim, ignoring the template's `ProductCode`/`ProfileId`. If left blank, renewals go out with an empty product code. Has **no effect on new enrollments** — the `ProductCode`/`ProfileId` template resolution never falls back to it. See [issue tracking this](https://github.com/Keyfactor/certinext-caplugin/issues/26). | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | +| `PickupRetries` | Optional | Number of times `Enroll` polls CERTInext for the certificate after a successful order submission, before returning pending and leaving pickup to the next sync. Set to `0` to disable the wait. OV/EV orders validate asynchronously (minutes to hours) and typically exhaust this wait regardless of the value. Default: `5`. | N/A | `5` | +| `PickupDelay` | Optional | Seconds between certificate-pickup retries. `PickupRetries × PickupDelay` (plus a short initial delay) bounds how long an enrollment call occupies a Command worker thread — capped at a 180s ceiling regardless of how the two are set (aim for well under ~90s in practice, so the call doesn't run long enough to trip Command's own timeout). Default: `10` (a ~55s ceiling with default `PickupRetries`). | N/A | `10` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | | `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | -| `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | +| `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Applies only to the `Enroll()`-time DCV path — DCV driven during sync uses its own fixed 3-second delay. Default: `30`. | N/A | `30` | | `DcvTimeoutMinutes` | Optional | Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) before cancelling the enrollment. Can also be set via the `CERTINEXT_DCV_TIMEOUT_MINUTES` environment variable; the environment variable takes precedence when both are set. Default: `10`. | N/A | `10` | +| `DcvWaitForChallengeSeconds` | Optional | How long `Enroll()` waits for CERTInext to expose the DCV challenge after order placement, before giving up and deferring to the next sync. Set to `0` to disable the wait. Can also be set via `CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS`. Default: `60`. | N/A | `60` | +| `DcvWaitForIssuanceSeconds` | Optional | How long `Enroll()` waits for CERTInext to finish generating the certificate after DCV verifies. Set to `0` to disable the wait. Can also be set via `CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS`. Default: `60`. | N/A | `60` | +| `DcvSyncMaxOrderAgeHours` | Optional | During synchronization, only pending DV orders younger than this many hours are driven through DCV, so a large backlog of old/abandoned pending orders doesn't slow down every sync pass. Set to `0` to disable the age filter. Default: `24`. | N/A | `24` | +| `DcvSyncMaxPerPass` | Optional | Maximum number of pending DV orders driven through DCV in a single sync pass. Set to `0` to disable the cap. Default: `50`. | N/A | `50` | > Note: `AccountNumber` and group-level identifiers are distinct values. The `AccountNumber` is your top-level user account identifier. CERTInext groups (cost centers or departments) each have their own `groupNumber`, which is passed per-order and is separate from any organization number displayed on the Organizations page. @@ -453,6 +471,8 @@ sequenceDiagram **Expired certificates:** The `IgnoreExpired` connector setting controls whether expired certificates are included in synchronization. When enabled, expired certificates are silently skipped and will not appear in the Keyfactor Command inventory. +**DCV-during-sync:** on a DCV-enabled build, each sync pass also drives DNS-01 validation forward for pending DV orders that are still waiting on it, bounded by `DcvSyncMaxOrderAgeHours` (skip orders older than this) and `DcvSyncMaxPerPass` (cap how many are attempted per pass), so a large backlog of stalled pending orders can't slow down every sync. + --- ## Certificate Enrollment @@ -474,13 +494,27 @@ sequenceDiagram Plugin->>API: Place certificate order\n(CSR, domain, organization details,\nsubscriber agreement, requestor info) API-->>Plugin: Order accepted — order number assigned + opt DNS-01 DCV build, DCV enabled, and this order requires it + Plugin->>Plugin: Publish DNS TXT challenge\nvia the configured DNS provider plugin + Plugin->>API: Ask CERTInext to verify the record + API-->>Plugin: Domain validated (or still pending —\nfalls through to the pending path below) + end + Plugin->>API: Check order status API-->>Plugin: Order status and certificate details alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned - else Certificate pending approval - Plugin-->>CMD: Pending — Command will pick it up\nduring the next synchronization + else Certificate pending or not yet downloadable + loop Certificate-pickup retries\n(bounded, ~55s by default — PickupRetries/PickupDelay) + Plugin->>API: Poll for the certificate + API-->>Plugin: Status and certificate, if ready + end + alt Certificate became available during pickup + Plugin-->>CMD: Certificate ready — PEM returned + else Still not available + Plugin-->>CMD: Pending — Command will pick it up\nduring the next synchronization + end else Order rejected by CERTInext Plugin-->>CMD: Enrollment failed — see gateway logs end @@ -488,12 +522,18 @@ sequenceDiagram Plugin->>Plugin: Record enrollment outcome in audit log\n(order number, serial number, status) ``` +**DCV:** on a DCV-enabled build, DNS-01 validation runs inline for DV orders that require it, bounded by `DcvTimeoutMinutes`. When DCV isn't enabled, isn't built into this host, or the order doesn't require it, this step is skipped entirely and the order proceeds straight to the pending/pickup path like any other asynchronously-issued order. + +**Synchronous certificate pickup:** if the certificate isn't available immediately (a fresh order, or DCV that just validated but hasn't finished generating the PEM), `Enroll()` polls CERTInext a bounded number of times (`PickupRetries` × `PickupDelay`, capped at a 180s ceiling) before giving up and returning pending. This lets a fast-issuing certificate (DV, or an already-approved order) come back in the same enrollment call instead of always waiting for the next sync. OV/EV orders validate asynchronously over minutes to hours and typically exhaust this window regardless. + ### Renewal When Command initiates a renewal, the plugin checks whether the existing certificate is within the configured renewal window. If it is, the prior order record is used as context for the new request. If it is outside the window (or the prior certificate cannot be located), the plugin falls back to issuing a new certificate. > **Note:** CERTInext does not have a dedicated certificate renewal endpoint. Both renewal and reissuance paths submit a new `GenerateOrderSSL` order. The distinction affects how Keyfactor Command tracks the certificate record, not what is sent to CERTInext. +> **Note:** If the prior-order lookup itself throws (rather than cleanly returning "not found" — e.g. a transient database error), the plugin falls back to issuing a new certificate rather than failing the enrollment. + ```mermaid flowchart TD A([Renewal requested]) --> B{Prior certificate\nserial number\nprovided?} From a890a7dd0f8e558d174a886abd319df810f2b045 Mon Sep 17 00:00:00 2001 From: spb <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:25:12 -0700 Subject: [PATCH 09/71] fix(enroll): renewal product code, AutoApprove UI text, config log visibility (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(enroll): renewals ignore template product code, correct AutoApprove UI text, log config presence Three independent fixes found during UCSD triage (issues #25, #26, #27): - RenewCertificateAsync built every renewal order from the connector's DefaultProductCode alone, ignoring the template's own ProductCode/ProfileId entirely. Threaded the template's code through RenewCertificateRequest.ProfileId, falling back to DefaultProductCode only when the template doesn't have one (using a blank-check, not ??, since EnrollmentParams.ProductCode never returns null — the same dead-fallback bug that made DefaultProductCode a no-op for new enrollments). - AutoApprove's UI text claimed the plugin attempts automatic approval of pending certificates; no such call exists anywhere in the code. Corrected to say so plainly. - OrganizationNumber, DefaultProductCode, and GroupNumber had zero log visibility, which is what made a stuck-pending-orders question undiagnosable from a support log. Added presence flags to the plugin-initialized log line. --- .../CERTInextCAPluginCoverageTests.cs | 54 +++++++++++++++++++ .../CERTInextClientRequestShapeTests.cs | 54 +++++++++++++++++++ CERTInext/API/CertificateRequest.cs | 9 ++++ CERTInext/CERTInextCAPlugin.cs | 8 +++ CERTInext/CERTInextCAPluginConfig.cs | 4 +- CERTInext/Client/CERTInextClient.cs | 12 +++-- 6 files changed, 136 insertions(+), 5 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs index f684f7d..1a9faa9 100644 --- a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs @@ -259,6 +259,60 @@ public async Task RenewOrReissue_CallsRenewApi_WhenCertWithinRenewalWindow() It.IsAny()), Times.Never); } + // --------------------------------------------------------------------------- + // A1d-2: renewal within window carries the template's product code onto the + // RenewCertificateRequest, not just the connector-level DefaultProductCode. + // Regression for issue #26 / local issues/0012. + // --------------------------------------------------------------------------- + + [Fact] + public async Task RenewOrReissue_CallsRenewApi_UsesTemplateProductCode() + { + var clientMock = NewMock(); + var readerMock = NewReaderMock(); + + // Expiry is 30 days in the future, renewal window is 90 days → within window + DateTime expiry = DateTime.UtcNow.AddDays(30); + + readerMock + .Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())) + .ReturnsAsync(MockCertificateData.CertId1); + + readerMock + .Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1)) + .Returns(expiry); + + clientMock + .Setup(c => c.RenewCertificateAsync( + MockCertificateData.CertId1, + It.Is(r => r.ProfileId == MockCertificateData.ProfileIdClient), + It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse("cert-renewed-002")); + + var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object); + + // ProfileId is a non-default value distinct from the connector's DefaultProductCode. + var productInfo = MakeProductInfo(profileId: MockCertificateData.ProfileIdClient, extras: new Dictionary + { + ["PriorCertSN"] = "AABBCCDDEEFF", + ["RenewalWindowDays"] = "90" + }); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=test.example.com", + san: null, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.RenewOrReissue); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + clientMock.Verify(c => c.RenewCertificateAsync( + MockCertificateData.CertId1, + It.Is(r => r.ProfileId == MockCertificateData.ProfileIdClient), + It.IsAny()), Times.Once); + } + // --------------------------------------------------------------------------- // A1e: PriorCertSN present, cert already expired → new enroll // Semantics: useRenewalApi = expiry > now && expiry <= now + window. diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs index 4e59495..fd61dfb 100644 --- a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs +++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs @@ -288,5 +288,59 @@ public async Task ValidityDays_OnRequest_OverridesConnectorDefault() CapturedOrderBody().GetProperty("subscriptionDetails") .GetProperty("validity").GetString().Should().Be("2"); } + + // ----------------------------------------------------------------------- + // RenewCertificateAsync — productCode resolution (issue #26 / local issues/0012) + // Renewals go out as a fresh GenerateOrderSSL order; the product code must + // come from the template (RenewCertificateRequest.ProfileId) when supplied, + // falling back to the connector's DefaultProductCode only when it is not. + // ----------------------------------------------------------------------- + + [Fact] + public async Task RenewCertificateAsync_ProfileIdSet_UsesTemplateProductCode() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.DefaultProductCode = "connector-default-code"; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "template-product-code", + ValidityDays = 365, + Comment = "Renewal test" + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + CapturedOrderBody().GetProperty("productCode").GetString() + .Should().Be("template-product-code", + "the template's own product code must win over the connector default"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RenewCertificateAsync_ProfileIdBlank_FallsBackToConnectorDefault(string blankProfileId) + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.DefaultProductCode = "connector-default-code"; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = blankProfileId, + ValidityDays = 365, + Comment = "Renewal test" + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + CapturedOrderBody().GetProperty("productCode").GetString() + .Should().Be("connector-default-code", + "a blank ProfileId must fall back to the connector's DefaultProductCode, not an empty string"); + } } } diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs index 043b4b5..c0da0d6 100644 --- a/CERTInext/API/CertificateRequest.cs +++ b/CERTInext/API/CertificateRequest.cs @@ -631,6 +631,15 @@ public class RenewCertificateRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string Subject { get; set; } + /// + /// Template/enrollment product code to submit the renewal order under. Without it, the + /// renewal falls back to the connector-level default product code, which is often unset — + /// leaving renewals to go out under an empty product code regardless of the template used. + /// + [JsonPropertyName("profileId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string ProfileId { get; set; } + /// /// SANs to carry onto the renewal order. Renewals previously submitted none, so a /// renewed UCC certificate came back holding only its primary domain. diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index a631606..76c3cb4 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -240,6 +240,9 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa bool hasClientId = !string.IsNullOrWhiteSpace(_config.OAuth2ClientId); bool hasClientSecret= !string.IsNullOrWhiteSpace(_config.OAuth2ClientSecret); bool hasTokenUrl = !string.IsNullOrWhiteSpace(_config.OAuth2TokenUrl); + bool hasOrganizationNumber = !string.IsNullOrWhiteSpace(_config.OrganizationNumber); + bool hasDefaultProductCode = !string.IsNullOrWhiteSpace(_config.DefaultProductCode); + bool hasGroupNumber = !string.IsNullOrWhiteSpace(_config.GroupNumber); _logger.LogInformation( "CERTInext plugin initialized. " + @@ -247,6 +250,8 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa "ApiKeyPresent={ApiKeyPresent}, UsernamePresent={UsernamePresent}, " + "PasswordPresent={PasswordPresent}, OAuth2ClientIdPresent={OAuth2ClientIdPresent}, " + "OAuth2ClientSecretPresent={OAuth2ClientSecretPresent}, OAuth2TokenUrlPresent={OAuth2TokenUrlPresent}, " + + "OrganizationNumberPresent={OrganizationNumberPresent}, DefaultProductCodePresent={DefaultProductCodePresent}, " + + "GroupNumberPresent={GroupNumberPresent}, " + "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}, SubmitNonDnsSans={SubmitNonDnsSans}, " + "DcvEnabled={DcvEnabled}, DcvTxtRecordTemplate={DcvTxtRecordTemplate}, " + "DomainValidatorFactoryInjected={FactoryInjected}", @@ -254,6 +259,8 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa hasApiKey, hasUsername, hasPassword, hasClientId, hasClientSecret, hasTokenUrl, + hasOrganizationNumber, hasDefaultProductCode, + hasGroupNumber, _config.PageSize, _config.IgnoreExpired, _config.SubmitNonDnsSans, _config.DcvEnabled, _config.DcvTxtRecordTemplate, _domainValidatorFactory != null); @@ -1320,6 +1327,7 @@ private async Task RenewOrReissueAsync( // holding only its primary domain. Subject = subject, Sans = BuildSanList(san, csr, subject), + ProfileId = ep.ProductCode, ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 980a26a..93fb26a 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -444,8 +444,8 @@ public static Dictionary GetTemplateParameterAnnotat }, [Constants.EnrollmentParam.AutoApprove] = new PropertyConfigInfo { - Comments = "OPTIONAL: If true, the gateway will attempt automatic approval of certificates " + - "that are returned in a pending-approval state. Default: false.", + Comments = "Currently has no effect — reserved for future use. The plugin does not call " + + "any approval endpoint against CERTInext regardless of this setting.", Hidden = false, DefaultValue = false, Type = "Boolean" diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 9ecde2b..2fdcc18 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -810,14 +810,20 @@ public async Task RenewCertificateAsync( certificateId, LogSanitizer.Strip(renewalDomainName)); } - // We don't have the product code from TrackOrder — build an order using - // the config defaults and the CSR from the renewal request. + // Prefer the template's own product code (threaded through via request.ProfileId); + // only fall back to the connector-level default when the caller didn't supply one. + // EnrollmentParams.ProductCode never returns null (it returns string.Empty when it + // can't resolve a code), so this must be a blank check, not a null-coalesce — a + // null-coalesce here would make the DefaultProductCode fallback unreachable, the + // same dead-fallback bug that made DefaultProductCode a no-op for new enrollments. var orderReq = new GenerateOrderSslRequest { Meta = await BuildMetaAsync(ct), OrderDetails = new SslOrderDetails { - ProductCode = _config.DefaultProductCode ?? string.Empty, + ProductCode = string.IsNullOrWhiteSpace(request.ProfileId) + ? (_config.DefaultProductCode ?? string.Empty) + : request.ProfileId, SaveAndHold = "0", RequestorInformation = new RequestorInformation { From 00ccdbd60f8c3ee19363fcbc94699b77698d470a Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:51:26 -0700 Subject: [PATCH 10/71] docs(changelog): add PR #28's renewal product-code, AutoApprove, and logging fixes to 1.0.1 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11bd7b5..dff6588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ - **UCC certificates no longer come back with only the common name.** The gateway sends SANs under the key `dnsname`, which the plugin didn't recognize, so orders went out with an empty domain list. SANs are now read from every key the gateway sends, plus from the CSR itself. - **Renewals no longer lose their SANs.** Renewals were submitted with no additional domains and the wrong primary domain; both now come from the certificate being renewed. - **Enrollment no longer fails on an order CERTInext auto-approves before it finishes issuing.** The plugin used to report these as issued with no certificate attached, which the gateway rejected. It now returns pending and picks up the certificate once CERTInext finishes issuing it. +- **Renewals now use the certificate template's product code.** Renewals previously always used the connector's `DefaultProductCode`, which could send an empty product code if that setting was never configured. Renewals now use the template's code, falling back to `DefaultProductCode` only when the template doesn't have one. + +## Chores +- **`OrganizationNumber`, `DefaultProductCode`, and `GroupNumber` are now visible in the startup log.** Whether each is set is now logged alongside the other connector settings, making a misconfigured connector easier to diagnose from logs alone. +- **Corrected the `AutoApprove` template setting's description.** It previously implied the plugin would attempt automatic approval of pending certificates; it does not currently do this. ## Upgrade Notes - **Non-DNS SANs (IP, email, URI) are now submitted instead of silently dropped.** CERTInext can't validate them, so such an order won't issue until the SAN is removed. Set `SubmitNonDnsSans` to `false` to restore the old drop-silently behavior. From 59aa2b2bb86cb37ff6e52c75dc8e3ab48bfb666c Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:41:44 -0700 Subject: [PATCH 11/71] fix(logging): add trace-level payload dumps for enrollment request/response PlaceOrderAsync logs the serialized GenerateOrderSslRequest (including additionalDomains) and TrackOrderAsync logs the raw response body (including domainVerification). Enables UCC SAN debugging when the gateway log level is set to Trace. --- CERTInext/Client/CERTInextClient.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 2fdcc18..f7a814f 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -224,7 +224,9 @@ public async Task PlaceOrderAsync( request.Meta = await BuildMetaAsync(ct); var req = new RestRequest(Constants.Api.GenerateOrderSslPath, Method.Post); - req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); + string jsonBody = JsonSerializer.Serialize(request, GetJsonOptions()); + Logger.LogTrace("PlaceOrderAsync request payload: {Payload}", jsonBody); + req.AddJsonBody(jsonBody); var sw = System.Diagnostics.Stopwatch.StartNew(); // idempotent:false — order submission is non-idempotent. A network-level @@ -433,6 +435,8 @@ public async Task TrackOrderAsync(string orderNumber, Cancel } var result = DeserializeOrThrow(resp, $"track order {orderNumber}"); + Logger.LogTrace("TrackOrderAsync response payload (Order={OrderNumber}): {Payload}", + orderNumber, resp.Content); // A meta status of "0" with errorCode EMS-913 or similar means the order was not found if (result.Meta != null && !result.Meta.IsSuccess) From 1447a5c548b3bb9fab2b93fc621bdf0f23ecc1f2 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:00:56 -0700 Subject: [PATCH 12/71] feat(enroll): port ValidityYears from release-1.1 (PR #22) Lets the template specify subscription validity directly in years (1/2/3) via the ValidityYears enrollment parameter, bypassing the days-to-years ceiling conversion. Precedence: ValidityYears > ValidityDays > config default. --- CERTInext/API/CertificateRequest.cs | 8 ++++++++ CERTInext/CERTInextCAPlugin.cs | 2 ++ CERTInext/Client/CERTInextClient.cs | 14 ++++++++------ CERTInext/Models/EnrollmentParams.cs | 3 +++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs index c0da0d6..29c26cb 100644 --- a/CERTInext/API/CertificateRequest.cs +++ b/CERTInext/API/CertificateRequest.cs @@ -570,6 +570,10 @@ public class EnrollCertificateRequest [JsonPropertyName("csr")] public string Csr { get; set; } + [JsonPropertyName("validityYears")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ValidityYears { get; set; } + [JsonPropertyName("validityDays")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? ValidityDays { get; set; } @@ -648,6 +652,10 @@ public class RenewCertificateRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public System.Collections.Generic.List Sans { get; set; } + [JsonPropertyName("validityYears")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ValidityYears { get; set; } + [JsonPropertyName("validityDays")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? ValidityDays { get; set; } diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 76c3cb4..ff63595 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1103,6 +1103,7 @@ private async Task EnrollNewAsync( { ProfileId = ep.ProfileId, Csr = csr, + ValidityYears = ep.ValidityYears > 0 ? ep.ValidityYears : (int?)null, ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, Subject = subject, Sans = BuildSanList(san, csr, subject), @@ -1328,6 +1329,7 @@ private async Task RenewOrReissueAsync( Subject = subject, Sans = BuildSanList(san, csr, subject), ProfileId = ep.ProductCode, + ValidityYears = ep.ValidityYears > 0 ? ep.ValidityYears : (int?)null, ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index f7a814f..2da09b7 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1474,12 +1474,14 @@ private static LegacyGetCertificateResponse MapOrderReportEntryToLegacy(OrderRep private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollCertificateRequest request) { - // Map ValidityDays → CERTInext's year-based validity. Default 1. - string validityYears = request.ValidityDays.HasValue - ? Math.Ceiling(request.ValidityDays.Value / 365.0).ToString("0") - : (string.IsNullOrWhiteSpace(_config.SubscriptionValidityYears) - ? "1" - : _config.SubscriptionValidityYears); + // ValidityYears takes precedence; ValidityDays is converted to years as a fallback. + string validityYears = request.ValidityYears.HasValue + ? request.ValidityYears.Value.ToString() + : request.ValidityDays.HasValue + ? Math.Ceiling(request.ValidityDays.Value / 365.0).ToString("0") + : (string.IsNullOrWhiteSpace(_config.SubscriptionValidityYears) + ? "1" + : _config.SubscriptionValidityYears); string requestorName = request.RequesterName ?? _config.RequestorName ?? "Keyfactor Gateway"; string requestorEmail = request.RequesterEmail ?? _config.RequestorEmail ?? string.Empty; diff --git a/CERTInext/Models/EnrollmentParams.cs b/CERTInext/Models/EnrollmentParams.cs index 69b662f..07630d0 100644 --- a/CERTInext/Models/EnrollmentParams.cs +++ b/CERTInext/Models/EnrollmentParams.cs @@ -52,6 +52,9 @@ public string ProductCode /// Alias for ProductCode — kept for backward compat. public string ProfileId => ProductCode; + /// Requested subscription validity in years (1, 2, or 3). Takes precedence over ValidityDays. + public int ValidityYears => GetInt(Constants.EnrollmentParam.ValidityYears, 0); + /// Requested validity in days; 0 means "use profile default". public int ValidityDays => GetInt(Constants.EnrollmentParam.ValidityDays, 0); From 6d056df5abe44f068df1e5e0ec937602e1e99c7d Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:28:07 -0700 Subject: [PATCH 13/71] fix(enroll): send V1 GenerateOrderSSL fields where CERTInext reads them - groupNumber and autoSecureWWW move to orderDetails (were under delegationInformation / certificateInformation and ignored by the API). - Drop delegationInformation entirely. - technicalPointOfContact uses pocFirstName/pocLastName/pocEmail/pocIsdCode/ pocMobileNumber; name split from TechnicalContactName (fallback RequestorName); block omitted with a Warning when no email resolves. Remove the hidden TpcIsdCode="1" DTO default. - AutoSecureWww default "0" is now actually sent. - Extract BuildSslOrderDetails shared by new enrollment and renewal, so renewal sends every field and honors connector validity/autoRenew/ emailNotifications/accountingModel plus request ValidityYears/Days/Comment (previously hard-coded validity=1, DTO defaults autoRenew=1/emails=1). - Remove the unproven "prevents Pending System RA" doc claim. --- CERTInext/API/CertificateRequest.cs | 68 +++---- CERTInext/Client/CERTInextClient.cs | 283 ++++++++++++++++++---------- CERTInext/Constants.cs | 2 +- 3 files changed, 224 insertions(+), 129 deletions(-) diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs index 29c26cb..2bf230e 100644 --- a/CERTInext/API/CertificateRequest.cs +++ b/CERTInext/API/CertificateRequest.cs @@ -121,6 +121,25 @@ public class SslOrderDetails [JsonPropertyName("emailNotifications")] public string EmailNotifications { get; set; } = "1"; + /// + /// CERTInext account group (delegation) number the order is placed under. Sent at + /// orderDetails.groupNumber, which is where CERTInext reads it. Omitted when null, + /// in which case CERTInext places the order against the account's default group. + /// + [JsonPropertyName("groupNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string GroupNumber { get; set; } + + /// + /// "1" = CERTInext also secures the www. variant of the primary domain (which then + /// needs its own DCV); "0" = only the supplied domain names. Sent at + /// orderDetails.autoSecureWWW, which is where CERTInext reads it. When omitted, + /// CERTInext applies its own default of "1". + /// + [JsonPropertyName("autoSecureWWW")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string AutoSecureWww { get; set; } + [JsonPropertyName("requestorInformation")] public RequestorInformation RequestorInformation { get; set; } @@ -142,10 +161,6 @@ public class SslOrderDetails [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public OrganizationDetails OrganizationDetails { get; set; } - [JsonPropertyName("delegationInformation")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public DelegationInformation DelegationInformation { get; set; } - [JsonPropertyName("technicalPointOfContact")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TechnicalPointOfContact TechnicalPointOfContact { get; set; } @@ -200,10 +215,8 @@ public class CertificateInformation [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List AdditionalDomains { get; set; } - /// "1" = also secure www variant (default); "0" = disable. - [JsonPropertyName("autoSecureWWW")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string AutoSecureWww { get; set; } + // autoSecureWWW is NOT a certificateInformation field — CERTInext reads it from + // orderDetails. See SslOrderDetails.AutoSecureWww. } public class AgreementDetails @@ -233,36 +246,27 @@ public class OrganizationDetails } /// - /// Routes the order to a specific account group within CERTInext. Required by many - /// accounts even though the V1 docs list it as optional — without it, orders may be - /// placed against the default group and queued for additional review. - /// - public class DelegationInformation - { - [JsonPropertyName("groupNumber")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string GroupNumber { get; set; } - } - - /// - /// Technical point of contact metadata sent with SSL orders. CERTInext uses these - /// fields as the secondary contact for issuance-related notifications. When omitted, - /// some product configurations queue the order in Pending System RA waiting - /// for the field to be populated manually. + /// Technical point of contact sent at orderDetails.technicalPointOfContact on SSL + /// orders. Field names follow the shape CERTInext reads + /// (pocFirstName/pocLastName/pocEmail/pocIsdCode/pocMobileNumber). No defaults are + /// applied here — the order builder resolves every value from connector config. /// public class TechnicalPointOfContact { - [JsonPropertyName("tpcName")] - public string TpcName { get; set; } + [JsonPropertyName("pocFirstName")] + public string PocFirstName { get; set; } + + [JsonPropertyName("pocLastName")] + public string PocLastName { get; set; } - [JsonPropertyName("tpcEmail")] - public string TpcEmail { get; set; } + [JsonPropertyName("pocEmail")] + public string PocEmail { get; set; } - [JsonPropertyName("tpcIsdCode")] - public string TpcIsdCode { get; set; } = "1"; + [JsonPropertyName("pocIsdCode")] + public string PocIsdCode { get; set; } - [JsonPropertyName("tpcMobileNumber")] - public string TpcMobileNumber { get; set; } + [JsonPropertyName("pocMobileNumber")] + public string PocMobileNumber { get; set; } } public class AdditionalInformation diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 2da09b7..23ea6b5 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -820,31 +820,27 @@ public async Task RenewCertificateAsync( // can't resolve a code), so this must be a blank check, not a null-coalesce — a // null-coalesce here would make the DefaultProductCode fallback unreachable, the // same dead-fallback bug that made DefaultProductCode a no-op for new enrollments. + string renewalProductCode = string.IsNullOrWhiteSpace(request.ProfileId) + ? (_config.DefaultProductCode ?? string.Empty) + : request.ProfileId; + + // Same order-details builder as new enrollment, so a renewal sends every field a new + // order does (groupNumber, autoSecureWWW, technicalPointOfContact, organizationDetails, + // remarks) and follows the connector's validity / autoRenew / emailNotifications / + // accountingModel settings plus the ValidityYears/Days/Comment the plugin passes. var orderReq = new GenerateOrderSslRequest { Meta = await BuildMetaAsync(ct), - OrderDetails = new SslOrderDetails - { - ProductCode = string.IsNullOrWhiteSpace(request.ProfileId) - ? (_config.DefaultProductCode ?? string.Empty) - : request.ProfileId, - SaveAndHold = "0", - RequestorInformation = new RequestorInformation - { - RequestorName = request.RequesterName ?? _config.RequestorName, - RequestorEmail = request.RequesterEmail ?? _config.RequestorEmail, - RequestorIsdCode = _config.RequestorIsdCode ?? "1", - RequestorMobileNumber = _config.RequestorMobileNumber ?? string.Empty - }, - SubscriptionDetails = new SubscriptionDetails { Validity = "1" }, - CertificateInformation = new CertificateInformation - { - DomainName = renewalDomainName, - AdditionalDomains = BuildAdditionalDomains(request.Sans, renewalDomainName) - }, - Csr = request.Csr, - AgreementDetails = BuildDefaultAgreementDetails() - } + OrderDetails = BuildSslOrderDetails( + productCode: renewalProductCode, + domainName: renewalDomainName, + sans: request.Sans, + csr: request.Csr, + validityYears: request.ValidityYears, + validityDays: request.ValidityDays, + requesterName: request.RequesterName, + requesterEmail: request.RequesterEmail, + comment: request.Comment) }; var orderResp = await PlaceOrderAsync(orderReq, ct); @@ -1474,20 +1470,6 @@ private static LegacyGetCertificateResponse MapOrderReportEntryToLegacy(OrderRep private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollCertificateRequest request) { - // ValidityYears takes precedence; ValidityDays is converted to years as a fallback. - string validityYears = request.ValidityYears.HasValue - ? request.ValidityYears.Value.ToString() - : request.ValidityDays.HasValue - ? Math.Ceiling(request.ValidityDays.Value / 365.0).ToString("0") - : (string.IsNullOrWhiteSpace(_config.SubscriptionValidityYears) - ? "1" - : _config.SubscriptionValidityYears); - - string requestorName = request.RequesterName ?? _config.RequestorName ?? "Keyfactor Gateway"; - string requestorEmail = request.RequesterEmail ?? _config.RequestorEmail ?? string.Empty; - string requestorIsd = string.IsNullOrWhiteSpace(_config.RequestorIsdCode) ? "1" : _config.RequestorIsdCode; - string requestorMobile = _config.RequestorMobileNumber ?? string.Empty; - // Hoisted: additionalDomains is de-duplicated against the primary domain, so both // fields have to be built from the same value. string domainName = ExtractCnFromSubject(request.Subject) ?? "unknown"; @@ -1495,73 +1477,182 @@ private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollC return new GenerateOrderSslRequest { // Meta will be set by PlaceOrderAsync - OrderDetails = new SslOrderDetails - { - ProductCode = request.ProfileId ?? _config.DefaultProductCode ?? string.Empty, - AccountingModel = string.IsNullOrWhiteSpace(_config.AccountingModel) ? "2" : _config.AccountingModel, - SaveAndHold = "0", - EmailNotifications = string.IsNullOrWhiteSpace(_config.EmailNotifications) ? "0" : _config.EmailNotifications, - - // delegationInformation — routes the order to the configured account group. - // Omitted entirely when GroupNumber is blank (the model JsonIgnore-WhenNull - // handles property absence further down). - DelegationInformation = !string.IsNullOrWhiteSpace(_config.GroupNumber) - ? new DelegationInformation { GroupNumber = _config.GroupNumber } - : null, - - // organizationDetails — declares pre-vetted org when configured. This is the - // single biggest factor in how quickly CERTInext releases an order from - // Pending System RA. When OrganizationNumber is blank we omit the whole - // block (the model is JsonIgnore-WhenNull) so the order falls back to the - // unvetted path — same behavior as the prior plugin builds. - OrganizationDetails = !string.IsNullOrWhiteSpace(_config.OrganizationNumber) - ? new OrganizationDetails - { - PreVetting = "1", - OrganizationNumber = _config.OrganizationNumber - } - : null, + OrderDetails = BuildSslOrderDetails( + productCode: request.ProfileId ?? _config.DefaultProductCode ?? string.Empty, + domainName: domainName, + sans: request.Sans, + csr: request.Csr, + validityYears: request.ValidityYears, + validityDays: request.ValidityDays, + requesterName: request.RequesterName, + requesterEmail: request.RequesterEmail, + comment: request.Comment) + }; + } - RequestorInformation = new RequestorInformation - { - RequestorName = requestorName, - RequestorEmail = requestorEmail, - RequestorIsdCode = requestorIsd, - RequestorMobileNumber = requestorMobile - }, - SubscriptionDetails = new SubscriptionDetails - { - Validity = validityYears, - AutoRenew = string.IsNullOrWhiteSpace(_config.SubscriptionAutoRenew) ? "0" : _config.SubscriptionAutoRenew, - RenewCriteria = string.IsNullOrWhiteSpace(_config.SubscriptionRenewCriteriaDays) ? "30" : _config.SubscriptionRenewCriteriaDays - }, - CertificateInformation = new CertificateInformation - { - DomainName = domainName, - AdditionalDomains = BuildAdditionalDomains(request.Sans, domainName), - AutoSecureWww = string.IsNullOrWhiteSpace(_config.AutoSecureWww) ? "0" : _config.AutoSecureWww - }, - - // technicalPointOfContact — each field falls back to the requestor default - // when its TechnicalContact* counterpart is blank. - TechnicalPointOfContact = new TechnicalPointOfContact - { - TpcName = string.IsNullOrWhiteSpace(_config.TechnicalContactName) ? requestorName : _config.TechnicalContactName, - TpcEmail = string.IsNullOrWhiteSpace(_config.TechnicalContactEmail) ? requestorEmail : _config.TechnicalContactEmail, - TpcIsdCode = string.IsNullOrWhiteSpace(_config.TechnicalContactIsdCode) ? requestorIsd : _config.TechnicalContactIsdCode, - TpcMobileNumber = string.IsNullOrWhiteSpace(_config.TechnicalContactMobileNumber) ? requestorMobile : _config.TechnicalContactMobileNumber - }, - - Csr = request.Csr, - AgreementDetails = BuildDefaultAgreementDetails(), - AdditionalInformation = new AdditionalInformation + /// + /// Builds the orderDetails block of a V1 GenerateOrderSSL body. Shared by new + /// enrollment and renewal so both send the same field set, in the locations CERTInext + /// reads them (orderDetails.groupNumber, orderDetails.autoSecureWWW, + /// orderDetails.technicalPointOfContact.poc*), and both honor the connector's + /// validity / autoRenew / emailNotifications / accountingModel settings. + /// Callers resolve the product code and primary domain themselves (renewal derives them + /// differently from new enrollment). + /// + private SslOrderDetails BuildSslOrderDetails( + string productCode, + string domainName, + List sans, + string csr, + int? validityYears, + int? validityDays, + string requesterName, + string requesterEmail, + string comment) + { + string requestorName = requesterName ?? _config.RequestorName ?? "Keyfactor Gateway"; + string requestorEmail = requesterEmail ?? _config.RequestorEmail ?? string.Empty; + string requestorIsd = string.IsNullOrWhiteSpace(_config.RequestorIsdCode) ? "1" : _config.RequestorIsdCode; + string requestorMobile = _config.RequestorMobileNumber ?? string.Empty; + + return new SslOrderDetails + { + ProductCode = productCode, + AccountingModel = string.IsNullOrWhiteSpace(_config.AccountingModel) ? "2" : _config.AccountingModel, + SaveAndHold = "0", + EmailNotifications = string.IsNullOrWhiteSpace(_config.EmailNotifications) ? "0" : _config.EmailNotifications, + + // orderDetails.groupNumber — routes the order to the configured account group. + // Omitted (JsonIgnore-WhenNull) when GroupNumber is blank. + GroupNumber = string.IsNullOrWhiteSpace(_config.GroupNumber) ? null : _config.GroupNumber, + + // orderDetails.autoSecureWWW — always sent so CERTInext's own default ("1", which + // adds www. and a second DCV) never applies silently. + AutoSecureWww = string.IsNullOrWhiteSpace(_config.AutoSecureWww) ? "0" : _config.AutoSecureWww, + + // organizationDetails — declares pre-vetted org when configured. This is the + // single biggest factor in how quickly CERTInext releases an order from + // Pending System RA. When OrganizationNumber is blank we omit the whole + // block (the model is JsonIgnore-WhenNull) so the order falls back to the + // unvetted path — same behavior as the prior plugin builds. + OrganizationDetails = !string.IsNullOrWhiteSpace(_config.OrganizationNumber) + ? new OrganizationDetails { - Remarks = request.Comment ?? "Issued via Keyfactor Command AnyCA REST Gateway." + PreVetting = "1", + OrganizationNumber = _config.OrganizationNumber } + : null, + + RequestorInformation = new RequestorInformation + { + RequestorName = requestorName, + RequestorEmail = requestorEmail, + RequestorIsdCode = requestorIsd, + RequestorMobileNumber = requestorMobile + }, + SubscriptionDetails = new SubscriptionDetails + { + Validity = ResolveValidityYears(validityYears, validityDays), + AutoRenew = string.IsNullOrWhiteSpace(_config.SubscriptionAutoRenew) ? "0" : _config.SubscriptionAutoRenew, + RenewCriteria = string.IsNullOrWhiteSpace(_config.SubscriptionRenewCriteriaDays) ? "30" : _config.SubscriptionRenewCriteriaDays + }, + CertificateInformation = new CertificateInformation + { + DomainName = domainName, + AdditionalDomains = BuildAdditionalDomains(sans, domainName) + }, + + TechnicalPointOfContact = BuildTechnicalPointOfContact( + requestorName, requestorEmail, requestorIsd, requestorMobile), + + Csr = csr, + AgreementDetails = BuildDefaultAgreementDetails(), + AdditionalInformation = new AdditionalInformation + { + Remarks = comment ?? "Issued via Keyfactor Command AnyCA REST Gateway." } }; } + /// + /// Resolves subscriptionDetails.validity (years). An explicit ValidityYears wins; + /// ValidityDays is rounded up to whole years as a fallback; otherwise the connector's + /// SubscriptionValidityYears (default "1") applies. + /// + private string ResolveValidityYears(int? validityYears, int? validityDays) + { + if (validityYears.HasValue) + return validityYears.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (validityDays.HasValue) + return Math.Ceiling(validityDays.Value / 365.0).ToString("0", System.Globalization.CultureInfo.InvariantCulture); + return string.IsNullOrWhiteSpace(_config.SubscriptionValidityYears) ? "1" : _config.SubscriptionValidityYears; + } + + /// + /// Builds technicalPointOfContact. Each TechnicalContact* field falls back to the + /// matching resolved requestor value when blank; the name is split into + /// pocFirstName/pocLastName via . + /// Returns null (block omitted) when no email resolves — CERTInext validates these fields + /// now that they reach it, and an empty POC email must not start rejecting orders that + /// previously went through. + /// + private TechnicalPointOfContact BuildTechnicalPointOfContact( + string requestorName, string requestorEmail, string requestorIsd, string requestorMobile) + { + string email = string.IsNullOrWhiteSpace(_config.TechnicalContactEmail) + ? requestorEmail + : _config.TechnicalContactEmail; + + if (string.IsNullOrWhiteSpace(email)) + { + Logger.LogWarning( + "Omitting technicalPointOfContact from the SSL order: neither TechnicalContactEmail " + + "nor RequestorEmail resolved to a value. Set TechnicalContactEmail (or RequestorEmail) " + + "in the connector configuration to send a technical point of contact."); + return null; + } + + string name = string.IsNullOrWhiteSpace(_config.TechnicalContactName) + ? requestorName + : _config.TechnicalContactName; + var (first, last) = SplitContactName(name); + + return new TechnicalPointOfContact + { + PocFirstName = first, + PocLastName = last, + PocEmail = email, + PocIsdCode = string.IsNullOrWhiteSpace(_config.TechnicalContactIsdCode) ? requestorIsd : _config.TechnicalContactIsdCode, + PocMobileNumber = string.IsNullOrWhiteSpace(_config.TechnicalContactMobileNumber) ? requestorMobile : _config.TechnicalContactMobileNumber + }; + } + + /// + /// Splits a single contact-name string into first/last name for + /// pocFirstName/pocLastName. The name is trimmed and split on the first run + /// of whitespace: the first token is the first name, the remainder (internal spacing + /// preserved) is the last name. A single-token name is placed in both fields (pending + /// CERTInext guidance on single-name contacts). Null/blank input yields two empty strings. + /// + internal static (string First, string Last) SplitContactName(string name) + { + string trimmed = name?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + return (string.Empty, string.Empty); + + int ws = -1; + for (int i = 0; i < trimmed.Length; i++) + { + if (char.IsWhiteSpace(trimmed[i])) { ws = i; break; } + } + if (ws < 0) + return (trimmed, trimmed); + + string first = trimmed.Substring(0, ws); + string last = trimmed.Substring(ws).TrimStart(); + return (first, last); + } + private AgreementDetails BuildDefaultAgreementDetails() { // SOC1 accuracy-of-processing: the subscriber agreement is a legal artefact diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index e3dc989..809ecba 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -39,7 +39,7 @@ public static class Config public const string SignerPlace = "SignerPlace"; public const string SignerIp = "SignerIp"; - // Technical point-of-contact defaults (TpcName/Email default to Requestor* when blank) + // Technical point-of-contact (each poc* field defaults to its Requestor* value when blank) public const string TechnicalContactName = "TechnicalContactName"; public const string TechnicalContactEmail = "TechnicalContactEmail"; public const string TechnicalContactIsdCode = "TechnicalContactIsdCode"; From 7abcd52dc320a5e349031029beb6e37962d0ca75 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:28:07 -0700 Subject: [PATCH 14/71] test(enroll): pin V1 order shape for enroll and renewal - Assert orderDetails.groupNumber / orderDetails.autoSecureWWW and poc* fields. - Negative asserts on every shape test: no delegationInformation, no certificateInformation.autoSecureWWW, no tpc* property. - Full renewal-shape tests (all fields, connector validity, ValidityDays, connector defaults over DTO defaults). - SplitContactName theory tests; per-field POC fallback and no-email omission. - Synthetic identifiers only; update TESTING.md. --- .../CERTInextClientRequestShapeTests.cs | 356 ++++++++++++++++-- CERTInext.Tests/TESTING.md | 25 +- 2 files changed, 349 insertions(+), 32 deletions(-) diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs index fd61dfb..5854ee8 100644 --- a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs +++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs @@ -143,57 +143,100 @@ public async Task OrganizationNumber_Blank_OmitsOrganizationDetailsBlock() } // ----------------------------------------------------------------------- - // GroupNumber → delegationInformation block + // Legacy (wrong) field placements must never reappear. CERTInext reads + // groupNumber / autoSecureWWW from orderDetails and the technical contact as + // poc* fields; the shapes below were ignored by the API. + // ----------------------------------------------------------------------- + + private static void AssertNoLegacyFieldShapes(JsonElement orderDetails) + { + orderDetails.TryGetProperty("delegationInformation", out _).Should().BeFalse( + "delegationInformation{groupNumber} is not read by CERTInext — groupNumber belongs on orderDetails"); + orderDetails.GetProperty("certificateInformation").TryGetProperty("autoSecureWWW", out _).Should().BeFalse( + "autoSecureWWW is read from orderDetails, not certificateInformation"); + CollectPropertyNames(orderDetails) + .Where(n => n.StartsWith("tpc", StringComparison.Ordinal)) + .Should().BeEmpty("the technical contact uses poc* field names, not tpc*"); + } + + private static System.Collections.Generic.IEnumerable CollectPropertyNames(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var prop in element.EnumerateObject()) + { + yield return prop.Name; + foreach (var nested in CollectPropertyNames(prop.Value)) + yield return nested; + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + foreach (var nested in CollectPropertyNames(item)) + yield return nested; + } + } + + // ----------------------------------------------------------------------- + // GroupNumber → orderDetails.groupNumber // ----------------------------------------------------------------------- [Fact] - public async Task GroupNumber_Set_EmitsDelegationInformation() + public async Task GroupNumber_Set_EmitsOrderDetailsGroupNumber() { StubHappyEnroll(); var cfg = MinimalConfig(); - cfg.GroupNumber = "2171775848"; + cfg.GroupNumber = "1000000001"; await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); var orderDetails = CapturedOrderBody(); - orderDetails.TryGetProperty("delegationInformation", out var delegation).Should().BeTrue(); - delegation.GetProperty("groupNumber").GetString().Should().Be("2171775848"); + orderDetails.GetProperty("groupNumber").GetString().Should().Be("1000000001"); + AssertNoLegacyFieldShapes(orderDetails); } - [Fact] - public async Task GroupNumber_Blank_OmitsDelegationInformation() + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GroupNumber_Blank_OmitsGroupNumber(string blank) { StubHappyEnroll(); var cfg = MinimalConfig(); - cfg.GroupNumber = string.Empty; + cfg.GroupNumber = blank; await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); var orderDetails = CapturedOrderBody(); - orderDetails.TryGetProperty("delegationInformation", out _).Should().BeFalse(); + orderDetails.TryGetProperty("groupNumber", out _).Should().BeFalse(); + AssertNoLegacyFieldShapes(orderDetails); } // ----------------------------------------------------------------------- - // technicalPointOfContact — overrides + requestor fallback + // technicalPointOfContact — poc* fields, name split, requestor fallback // ----------------------------------------------------------------------- [Fact] - public async Task TechnicalContact_AllSet_EmitsExplicitValues() + public async Task TechnicalContact_AllSet_EmitsPocFields() { StubHappyEnroll(); var cfg = MinimalConfig(); - cfg.TechnicalContactName = "Jane Smith"; - cfg.TechnicalContactEmail = "tpc@example.com"; + cfg.TechnicalContactName = "Jane Q Smith"; + cfg.TechnicalContactEmail = "poc@example.com"; cfg.TechnicalContactIsdCode = "44"; cfg.TechnicalContactMobileNumber = "5559999999"; await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); - var tpc = CapturedOrderBody().GetProperty("technicalPointOfContact"); - tpc.GetProperty("tpcName").GetString().Should().Be("Jane Smith"); - tpc.GetProperty("tpcEmail").GetString().Should().Be("tpc@example.com"); - tpc.GetProperty("tpcIsdCode").GetString().Should().Be("44"); - tpc.GetProperty("tpcMobileNumber").GetString().Should().Be("5559999999"); + var od = CapturedOrderBody(); + var poc = od.GetProperty("technicalPointOfContact"); + poc.GetProperty("pocFirstName").GetString().Should().Be("Jane"); + poc.GetProperty("pocLastName").GetString().Should().Be("Q Smith"); + poc.GetProperty("pocEmail").GetString().Should().Be("poc@example.com"); + poc.GetProperty("pocIsdCode").GetString().Should().Be("44"); + poc.GetProperty("pocMobileNumber").GetString().Should().Be("5559999999"); + AssertNoLegacyFieldShapes(od); } [Fact] @@ -209,17 +252,70 @@ public async Task TechnicalContact_AllBlank_FallsBackToRequestorDefaults() await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); - var tpc = CapturedOrderBody().GetProperty("technicalPointOfContact"); - tpc.GetProperty("tpcName").GetString().Should().Be(cfg.RequestorName); - tpc.GetProperty("tpcEmail").GetString().Should().Be(cfg.RequestorEmail); - tpc.GetProperty("tpcIsdCode").GetString().Should().Be(cfg.RequestorIsdCode); - tpc.GetProperty("tpcMobileNumber").GetString().Should().Be(cfg.RequestorMobileNumber); + var poc = CapturedOrderBody().GetProperty("technicalPointOfContact"); + // MinimalConfig RequestorName = "Default Requestor" + poc.GetProperty("pocFirstName").GetString().Should().Be("Default"); + poc.GetProperty("pocLastName").GetString().Should().Be("Requestor"); + poc.GetProperty("pocEmail").GetString().Should().Be(cfg.RequestorEmail); + poc.GetProperty("pocIsdCode").GetString().Should().Be(cfg.RequestorIsdCode); + poc.GetProperty("pocMobileNumber").GetString().Should().Be(cfg.RequestorMobileNumber); + } + + [Fact] + public async Task TechnicalContact_SingleTokenName_FillsFirstAndLast() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.TechnicalContactName = " Operations "; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var poc = CapturedOrderBody().GetProperty("technicalPointOfContact"); + poc.GetProperty("pocFirstName").GetString().Should().Be("Operations"); + poc.GetProperty("pocLastName").GetString().Should().Be("Operations"); + } + + [Fact] + public async Task TechnicalContact_PerFieldFallback_MixesOverridesAndRequestorValues() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.TechnicalContactName = string.Empty; // → requestor name + cfg.TechnicalContactEmail = "poc@example.com"; // override + cfg.TechnicalContactIsdCode = string.Empty; // → requestor ISD + cfg.TechnicalContactMobileNumber = "5551112222"; // override + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var poc = CapturedOrderBody().GetProperty("technicalPointOfContact"); + poc.GetProperty("pocFirstName").GetString().Should().Be("Default"); + poc.GetProperty("pocLastName").GetString().Should().Be("Requestor"); + poc.GetProperty("pocEmail").GetString().Should().Be("poc@example.com"); + poc.GetProperty("pocIsdCode").GetString().Should().Be(cfg.RequestorIsdCode); + poc.GetProperty("pocMobileNumber").GetString().Should().Be("5551112222"); + } + + [Fact] + public async Task TechnicalContact_NoEmailResolved_OmitsBlock() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.RequestorEmail = string.Empty; + cfg.TechnicalContactEmail = " "; + cfg.TechnicalContactName = "Jane Smith"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var od = CapturedOrderBody(); + od.TryGetProperty("technicalPointOfContact", out _).Should().BeFalse( + "a POC with no email would now be validated by CERTInext — omit it rather than reject the order"); + AssertNoLegacyFieldShapes(od); } // ----------------------------------------------------------------------- // SSL order body defaults — AccountingModel / EmailNotifications / // SubscriptionAutoRenew / SubscriptionRenewCriteriaDays / - // SubscriptionValidityYears / AutoSecureWww + // SubscriptionValidityYears / AutoSecureWww (→ orderDetails.autoSecureWWW) // ----------------------------------------------------------------------- [Fact] @@ -245,7 +341,8 @@ public async Task SslBodyDefaults_AreEmitted_FromCustomConnectorValues() sub.GetProperty("autoRenew").GetString().Should().Be("1"); sub.GetProperty("renewCriteria").GetString().Should().Be("60"); - od.GetProperty("certificateInformation").GetProperty("autoSecureWWW").GetString().Should().Be("1"); + od.GetProperty("autoSecureWWW").GetString().Should().Be("1"); + AssertNoLegacyFieldShapes(od); } [Fact] @@ -266,9 +363,26 @@ public async Task SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched() sub.GetProperty("autoRenew").GetString().Should().Be("0"); sub.GetProperty("renewCriteria").GetString().Should().Be("30"); - od.GetProperty("certificateInformation").GetProperty("autoSecureWWW").GetString().Should().Be("0"); + od.GetProperty("autoSecureWWW").GetString().Should().Be("0", + "the documented AutoSecureWww default of 0 must actually be sent, or CERTInext applies its own default of 1"); + AssertNoLegacyFieldShapes(od); } + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task AutoSecureWww_Blank_SendsZero(string blank) + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.AutoSecureWww = blank; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + CapturedOrderBody().GetProperty("autoSecureWWW").GetString().Should().Be("0"); + } + + // ----------------------------------------------------------------------- // ValidityDays request-parameter still overrides the connector default // ----------------------------------------------------------------------- @@ -342,5 +456,195 @@ public async Task RenewCertificateAsync_ProfileIdBlank_FallsBackToConnectorDefau .Should().Be("connector-default-code", "a blank ProfileId must fall back to the connector's DefaultProductCode, not an empty string"); } + + // ----------------------------------------------------------------------- + // RenewCertificateAsync — full order-details shape. Renewal shares the + // new-enrollment builder, so it must send every field a new order sends and + // follow the connector's validity / autoRenew / emailNotifications / + // accountingModel settings (previously hard-coded validity="1" and DTO + // defaults autoRenew="1" / emailNotifications="1", and omitted groupNumber, + // autoSecureWWW, technical contact, organizationDetails and remarks). + // ----------------------------------------------------------------------- + + private static CERTInextConfig FullyConfiguredConfig() + { + var cfg = MinimalConfig(); + cfg.GroupNumber = "1000000001"; + cfg.OrganizationNumber = "2000000002"; + cfg.AccountingModel = "1"; + cfg.EmailNotifications = "0"; + cfg.SubscriptionValidityYears = "3"; + cfg.SubscriptionAutoRenew = "0"; + cfg.SubscriptionRenewCriteriaDays = "60"; + cfg.AutoSecureWww = "0"; + cfg.TechnicalContactName = "Pat Example"; + cfg.TechnicalContactEmail = "poc@example.com"; + cfg.TechnicalContactIsdCode = "44"; + cfg.TechnicalContactMobileNumber = "5553334444"; + return cfg; + } + + [Fact] + public async Task RenewCertificateAsync_SendsFullOrderDetails_FromConnectorConfig() + { + StubHappyEnroll(); + var cfg = FullyConfiguredConfig(); + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + Subject = "CN=renew.example.com,O=Example", + ProfileId = "842", + Sans = new System.Collections.Generic.List + { + new SanEntry { Type = "dns", Value = "renew.example.com" }, + new SanEntry { Type = "dns", Value = "alt.example.com" } + }, + ValidityYears = 2, + RequesterName = "Renew Requester", + RequesterEmail = "renew@example.com", + Comment = "Renewed via Keyfactor Command. Prior ID: ORD-AAA-111." + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var od = CapturedOrderBody(); + od.GetProperty("productCode").GetString().Should().Be("842"); + od.GetProperty("accountingModel").GetString().Should().Be("1"); + od.GetProperty("saveAndHold").GetString().Should().Be("0"); + od.GetProperty("emailNotifications").GetString().Should().Be("0"); + od.GetProperty("groupNumber").GetString().Should().Be("1000000001"); + od.GetProperty("autoSecureWWW").GetString().Should().Be("0"); + + var org = od.GetProperty("organizationDetails"); + org.GetProperty("preVetting").GetString().Should().Be("1"); + org.GetProperty("organizationNumber").GetString().Should().Be("2000000002"); + + var requestor = od.GetProperty("requestorInformation"); + requestor.GetProperty("requestorName").GetString().Should().Be("Renew Requester"); + requestor.GetProperty("requestorEmail").GetString().Should().Be("renew@example.com"); + requestor.GetProperty("requestorIsdCode").GetString().Should().Be(cfg.RequestorIsdCode); + requestor.GetProperty("requestorMobileNumber").GetString().Should().Be(cfg.RequestorMobileNumber); + + var sub = od.GetProperty("subscriptionDetails"); + sub.GetProperty("validity").GetString().Should().Be("2", "the plugin-supplied ValidityYears must win"); + sub.GetProperty("autoRenew").GetString().Should().Be("0"); + sub.GetProperty("renewCriteria").GetString().Should().Be("60"); + + var ci = od.GetProperty("certificateInformation"); + ci.GetProperty("domainName").GetString().Should().Be("renew.example.com"); + ci.GetProperty("additionalDomains").EnumerateArray().Select(e => e.GetString()) + .Should().Equal("alt.example.com"); + + var poc = od.GetProperty("technicalPointOfContact"); + poc.GetProperty("pocFirstName").GetString().Should().Be("Pat"); + poc.GetProperty("pocLastName").GetString().Should().Be("Example"); + poc.GetProperty("pocEmail").GetString().Should().Be("poc@example.com"); + poc.GetProperty("pocIsdCode").GetString().Should().Be("44"); + poc.GetProperty("pocMobileNumber").GetString().Should().Be("5553334444"); + + od.GetProperty("csr").GetString().Should().Be(MockCertificateData.FakeCsrPem); + od.GetProperty("agreementDetails").GetProperty("acceptAgreement").GetString().Should().Be("1"); + od.GetProperty("additionalInformation").GetProperty("remarks").GetString() + .Should().Be("Renewed via Keyfactor Command. Prior ID: ORD-AAA-111."); + + AssertNoLegacyFieldShapes(od); + } + + [Fact] + public async Task RenewCertificateAsync_NoValidityOnRequest_UsesConnectorValidity() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.SubscriptionValidityYears = "3"; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + Subject = "CN=renew.example.com", + ProfileId = "842" + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + CapturedOrderBody().GetProperty("subscriptionDetails").GetProperty("validity").GetString() + .Should().Be("3", "renewal must no longer hard-code validity=1"); + } + + [Fact] + public async Task RenewCertificateAsync_ValidityDays_ConvertsToYears() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.SubscriptionValidityYears = "1"; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + Subject = "CN=renew.example.com", + ProfileId = "842", + ValidityDays = 730 + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + CapturedOrderBody().GetProperty("subscriptionDetails").GetProperty("validity").GetString() + .Should().Be("2"); + } + + [Fact] + public async Task RenewCertificateAsync_ConfigUntouched_UsesConnectorDefaultsNotDtoDefaults() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + Subject = "CN=renew.example.com", + ProfileId = "842" + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var od = CapturedOrderBody(); + od.GetProperty("accountingModel").GetString().Should().Be("2"); + od.GetProperty("emailNotifications").GetString().Should().Be("0", + "the connector default (0) must apply, not the DTO default (1)"); + od.GetProperty("subscriptionDetails").GetProperty("autoRenew").GetString().Should().Be("0", + "the connector default (0) must apply, not the DTO default (1)"); + od.GetProperty("subscriptionDetails").GetProperty("renewCriteria").GetString().Should().Be("30"); + od.GetProperty("autoSecureWWW").GetString().Should().Be("0"); + od.TryGetProperty("groupNumber", out _).Should().BeFalse(); + od.TryGetProperty("organizationDetails", out _).Should().BeFalse(); + od.GetProperty("technicalPointOfContact").GetProperty("pocEmail").GetString() + .Should().Be(cfg.RequestorEmail); + od.GetProperty("additionalInformation").GetProperty("remarks").GetString() + .Should().NotBeNullOrWhiteSpace(); + AssertNoLegacyFieldShapes(od); + } + + // ----------------------------------------------------------------------- + // SplitContactName — TechnicalContactName → pocFirstName / pocLastName + // ----------------------------------------------------------------------- + + [Theory] + [InlineData("Jane Smith", "Jane", "Smith")] + [InlineData("Jane Q Smith", "Jane", "Q Smith")] + [InlineData(" Jane Smith ", "Jane", "Smith")] + [InlineData("Jane\tSmith", "Jane", "Smith")] + [InlineData("Jane Q Smith", "Jane", "Q Smith")] + [InlineData("Operations", "Operations", "Operations")] + [InlineData(" Operations ", "Operations", "Operations")] + [InlineData("", "", "")] + [InlineData(" ", "", "")] + [InlineData(null, "", "")] + public void SplitContactName_SplitsOnFirstWhitespaceRun(string input, string expectedFirst, string expectedLast) + { + var (first, last) = CERTInextClient.SplitContactName(input); + + first.Should().Be(expectedFirst); + last.Should().Be(expectedLast); + } } } diff --git a/CERTInext.Tests/TESTING.md b/CERTInext.Tests/TESTING.md index e56c35a..ec4f04e 100644 --- a/CERTInext.Tests/TESTING.md +++ b/CERTInext.Tests/TESTING.md @@ -181,13 +181,26 @@ blocks depending on connector configuration. |------|-----------| | `OrganizationNumber_Set_EmitsPreVettedOrganizationDetails` | Body includes `organizationDetails.preVetting="1"` and the configured `organizationNumber` | | `OrganizationNumber_Blank_OmitsOrganizationDetailsBlock` | Body omits `organizationDetails` entirely | -| `GroupNumber_Set_EmitsDelegationInformation` | Body includes `delegationInformation.groupNumber` | -| `GroupNumber_Blank_OmitsDelegationInformation` | Body omits `delegationInformation` | -| `TechnicalContact_AllSet_EmitsExplicitValues` | Body includes `technicalPointOfContact` with the configured values | -| `TechnicalContact_AllBlank_FallsBackToRequestorDefaults` | Body includes `technicalPointOfContact` fields derived from `RequestorName`/`RequestorEmail` | -| `SslBodyDefaults_AreEmitted_FromCustomConnectorValues` | Custom connector-level defaults appear in the order body | -| `SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched` | Default values are emitted without throwing when optional config fields are omitted | +| `GroupNumber_Set_EmitsOrderDetailsGroupNumber` | Body includes `orderDetails.groupNumber`; no `delegationInformation` | +| `GroupNumber_Blank_OmitsGroupNumber` | Body omits `orderDetails.groupNumber` (null/empty/whitespace) | +| `TechnicalContact_AllSet_EmitsPocFields` | `technicalPointOfContact` carries `pocFirstName`/`pocLastName` (split from `TechnicalContactName`), `pocEmail`, `pocIsdCode`, `pocMobileNumber`; no `tpc*` fields | +| `TechnicalContact_AllBlank_FallsBackToRequestorDefaults` | Each `poc*` field falls back to the matching `Requestor*` value | +| `TechnicalContact_SingleTokenName_FillsFirstAndLast` | A single-token name goes into both `pocFirstName` and `pocLastName` | +| `TechnicalContact_PerFieldFallback_MixesOverridesAndRequestorValues` | Fallback is per field, not all-or-nothing | +| `TechnicalContact_NoEmailResolved_OmitsBlock` | `technicalPointOfContact` is omitted (with a Warning) when no email resolves | +| `SslBodyDefaults_AreEmitted_FromCustomConnectorValues` | Custom connector-level defaults appear in the order body, incl. `orderDetails.autoSecureWWW` | +| `SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched` | Default values are emitted when optional config fields are untouched; `orderDetails.autoSecureWWW="0"` is sent | +| `AutoSecureWww_Blank_SendsZero` | Blank `AutoSecureWww` still sends `orderDetails.autoSecureWWW="0"` | | `ValidityDays_OnRequest_OverridesConnectorDefault` | `ValidityDays` template parameter overrides the connector `SubscriptionValidityYears` | +| `RenewCertificateAsync_ProfileIdSet_UsesTemplateProductCode` | Renewal uses the template product code over the connector default | +| `RenewCertificateAsync_ProfileIdBlank_FallsBackToConnectorDefault` | Blank renewal `ProfileId` falls back to `DefaultProductCode` | +| `RenewCertificateAsync_SendsFullOrderDetails_FromConnectorConfig` | Renewal body carries every field a new order does (groupNumber, autoSecureWWW, organizationDetails, requestor, subscription, SANs, `poc*`, CSR, agreement, remarks) | +| `RenewCertificateAsync_NoValidityOnRequest_UsesConnectorValidity` | Renewal validity comes from `SubscriptionValidityYears`, not a hard-coded `1` | +| `RenewCertificateAsync_ValidityDays_ConvertsToYears` | Renewal `ValidityDays` is rounded up to whole years | +| `RenewCertificateAsync_ConfigUntouched_UsesConnectorDefaultsNotDtoDefaults` | Renewal uses connector defaults (`autoRenew="0"`, `emailNotifications="0"`), not DTO defaults | +| `SplitContactName_SplitsOnFirstWhitespaceRun` | Name split: trim, first whitespace run separates first/last; single token fills both; blank → empty | + +Every enroll and renewal shape test also asserts the legacy (ignored-by-CERTInext) placements are absent: no `delegationInformation`, no `certificateInformation.autoSecureWWW`, no `tpc*` property anywhere in `orderDetails`. --- From dc59d6c1e9294677af1631e7e19154de11841444 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:27:22 -0700 Subject: [PATCH 15/71] docs(config): name the GenerateOrderSSL fields CERTInext actually reads GroupNumber -> orderDetails.groupNumber, TechnicalContact* -> technicalPointOfContact.poc* (name split on first whitespace), AutoSecureWww -> orderDetails.autoSecureWWW. Drop the unverified claims that a missing group or tech contact parks orders, and note renewals now carry the same order details as new enrollments. --- CERTInext/CERTInextCAPluginConfig.cs | 29 +++++++++++++++------------- docsource/configuration.md | 8 ++++---- integration-manifest.json | 12 ++++++------ 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 93fb26a..c3b5da0 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -47,9 +47,8 @@ public static Dictionary GetCAConnectorAnnotations() { Comments = "OPTIONAL: CERTInext group (delegation) number. " + "When set, it is included in GetProductDetails requests AND in the " + - "`delegationInformation.groupNumber` field of every SSL order so the order " + - "is routed to the correct account group. Some accounts will queue orders for " + - "additional review when this field is omitted. " + + "`orderDetails.groupNumber` field of every SSL order (new and renewal) so the " + + "order is routed to the configured account group. " + "Available in the CERTInext portal under Delegation → Groups.", Hidden = false, DefaultValue = string.Empty, @@ -73,17 +72,18 @@ public static Dictionary GetCAConnectorAnnotations() }, [Constants.Config.TechnicalContactName] = new PropertyConfigInfo { - Comments = "OPTIONAL: Name sent in the `technicalPointOfContact.tpcName` field of every " + - "SSL order. Defaults to the configured RequestorName when blank. " + - "Some product configurations require a TPoC to be present; omitting it can " + - "cause CERTInext to park orders awaiting manual completion of the field.", + Comments = "OPTIONAL: Technical point of contact name, sent as " + + "`technicalPointOfContact.pocFirstName` / `pocLastName` on every SSL order " + + "(new and renewal). The name is split on the first whitespace: the first word " + + "is the first name and the rest is the last name; a single-word name is sent " + + "in both. Defaults to the configured RequestorName when blank.", Hidden = false, DefaultValue = string.Empty, Type = "String" }, [Constants.Config.TechnicalContactEmail] = new PropertyConfigInfo { - Comments = "OPTIONAL: Email sent in the `technicalPointOfContact.tpcEmail` field of every " + + Comments = "OPTIONAL: Email sent in the `technicalPointOfContact.pocEmail` field of every " + "SSL order. Defaults to the configured RequestorEmail when blank.", Hidden = false, DefaultValue = string.Empty, @@ -91,7 +91,8 @@ public static Dictionary GetCAConnectorAnnotations() }, [Constants.Config.TechnicalContactIsdCode] = new PropertyConfigInfo { - Comments = "OPTIONAL: International dialing code for the TPoC phone number. " + + Comments = "OPTIONAL: International dialing code for the technical contact phone number, " + + "sent as `technicalPointOfContact.pocIsdCode`. " + "Defaults to the configured RequestorIsdCode when blank.", Hidden = false, DefaultValue = string.Empty, @@ -99,7 +100,8 @@ public static Dictionary GetCAConnectorAnnotations() }, [Constants.Config.TechnicalContactMobileNumber] = new PropertyConfigInfo { - Comments = "OPTIONAL: Mobile number for the TPoC (digits only). " + + Comments = "OPTIONAL: Mobile number for the technical contact (digits only), sent as " + + "`technicalPointOfContact.pocMobileNumber`. " + "Defaults to the configured RequestorMobileNumber when blank.", Hidden = false, DefaultValue = string.Empty, @@ -242,9 +244,10 @@ public static Dictionary GetCAConnectorAnnotations() }, [Constants.Config.AutoSecureWww] = new PropertyConfigInfo { - Comments = "OPTIONAL: If \"1\", CERTInext automatically adds the `www.` variant of the " + - "primary domain as an additional SAN. \"0\" = use only the CN/SANs supplied " + - "with the CSR. Default: \"0\".", + Comments = "OPTIONAL: Sent as `orderDetails.autoSecureWWW` on every SSL order (new and " + + "renewal). If \"1\", CERTInext automatically adds the `www.` variant of the " + + "primary domain as an additional SAN, which must also pass domain validation. " + + "\"0\" = use only the CN/SANs supplied with the CSR. Default: \"0\".", Hidden = false, DefaultValue = "0", Type = "String" diff --git a/docsource/configuration.md b/docsource/configuration.md index d80216b..9800004 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -111,15 +111,15 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `RequestorMobileNumber` | Optional | Requestor mobile number (digits only, no country code). Included in the `requestorInformation` block. | N/A | `5551234567` | | `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. | Use the physical city where the signer is located. | `Austin` | | `SignerIp` | Required | Public IP address of the host accepting the subscriber agreement. Required by CERTInext for all orders. | Use the outbound IP of the AnyCA Gateway host, or the IP of the workstation from which the agreement was accepted. | `203.0.113.10` | -| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `delegationInformation.groupNumber` on every SSL order. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | +| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `orderDetails.groupNumber` on every SSL order (new and renewal), so orders are routed to this group. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | | `OrganizationNumber` | Optional, strongly recommended for OV/EV and faster DV | Numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this number, telling CERTInext to skip its manual organization-vetting queue. Without it, orders may sit in `Pending System RA` for extended manual review (observed: tens of hours). | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | -| `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` on every SSL order. Each defaults to the corresponding `Requestor*` field when blank. Some product configurations require a technical point of contact to be present; omitting it can cause CERTInext to park orders awaiting manual completion of the field. | N/A | *(defaults to Requestor fields)* | +| `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` (`pocFirstName`, `pocLastName`, `pocEmail`, `pocIsdCode`, `pocMobileNumber`) on every SSL order, new and renewal. `TechnicalContactName` is split on the first whitespace: the first word becomes `pocFirstName` and the rest `pocLastName`; a single-word name is sent in both. Each field defaults to the corresponding `Requestor*` field when blank. | N/A | *(defaults to Requestor fields)* | | `AccountingModel` | Optional | CERTInext billing model sent in `orderDetails.accountingModel`. `2` = credit-based (most accounts). `1` = cash model. Default: `2`. | N/A | `2` | | `EmailNotifications` | Optional | Whether CERTInext sends lifecycle-event emails to the requestor. `1` = enabled, `0` = silent (recommended for gateway-driven orders). Default: `0`. | N/A | `0` | | `SubscriptionValidityYears` | Optional | Connector-level default validity in years for SSL orders (`1`, `2`, or `3`). Overridden per template by the `ValidityYears` enrollment parameter. Default: `1`. | N/A | `1` | | `SubscriptionAutoRenew` | Optional | Whether CERTInext should auto-renew certificates issued through this connector. `0` = disabled (recommended — renewal is driven by Keyfactor Command), `1` = enabled. Default: `0`. | N/A | `0` | | `SubscriptionRenewCriteriaDays` | Optional | Days before expiry at which CERTInext auto-renews. Only honored when `SubscriptionAutoRenew` is `1`. Default: `30`. | N/A | `30` | -| `AutoSecureWww` | Optional | If `1`, CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. Default: `0`. | N/A | `0` | +| `AutoSecureWww` | Optional | Sent as `orderDetails.autoSecureWWW` on every SSL order, new and renewal. If `1`, CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN, which must also pass domain validation. `0` = only the CN/SANs from the CSR. Default: `0`. Before 1.0.1 this value never reached CERTInext, so its own default (add `www.`) applied. | N/A | `0` | | `SubmitNonDnsSans` | Optional | If `true` (default), SANs that aren't DNS names (IP address, email, URI) are submitted to CERTInext instead of silently dropped. CERTInext can't validate them, so such an order won't issue until they're removed. Set to `false` to restore the pre-1.0.1 behavior of submitting DNS names only. Default: `true`. | N/A | `true` | | `DefaultProductCode` | Optional, but effectively required if you use renewals | Numeric product code used for **renewals only** — CERTInext's `TrackOrder` doesn't return the prior order's product code, so the renewal path sends this value verbatim, ignoring the template's `ProductCode`/`ProfileId`. If left blank, renewals go out with an empty product code. Has **no effect on new enrollments** — the `ProductCode`/`ProfileId` template resolution never falls back to it. See [issue tracking this](https://github.com/Keyfactor/certinext-caplugin/issues/26). | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | @@ -258,7 +258,7 @@ is not retried afterward, since CERTInext may have already created the order — When the gateway calls `Enroll`, the plugin selects between three paths based on the enrollment type and the age of the prior certificate: 1. **New enrollment** — no prior certificate exists. A new `GenerateOrderSSL` request is submitted. -2. **Renewal** — a prior certificate exists and its expiry is within the `RenewalWindowDays` threshold (default: 90 days). A new `GenerateOrderSSL` order is submitted within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). +2. **Renewal** — a prior certificate exists and its expiry is within the `RenewalWindowDays` threshold (default: 90 days). A new `GenerateOrderSSL` order is submitted within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). The renewal order carries the same order details as a new enrollment: group, `AutoSecureWww`, technical contact, organization, validity, `SubscriptionAutoRenew`, `EmailNotifications`, and remarks. 3. **Reissue** — a prior certificate exists but is outside the renewal window. A new `GenerateOrderSSL` order is placed with the updated CSR/subject, replacing the prior certificate under a new subscription. The `RenewalWindowDays` template parameter controls the renewal/reissue boundary per certificate template. diff --git a/integration-manifest.json b/integration-manifest.json index 8a6d4ee..6d397ff 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -35,7 +35,7 @@ }, { "name": "GroupNumber", - "description": "OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests AND in the `delegationInformation.groupNumber` field of every SSL order so the order is routed to the correct account group. Some accounts will queue orders for additional review when this field is omitted. Available in the CERTInext portal under Delegation \u2192 Groups." + "description": "OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests AND in the `orderDetails.groupNumber` field of every SSL order (new and renewal) so the order is routed to the configured account group. Available in the CERTInext portal under Delegation \u2192 Groups." }, { "name": "OrganizationNumber", @@ -43,19 +43,19 @@ }, { "name": "TechnicalContactName", - "description": "OPTIONAL: Name sent in the `technicalPointOfContact.tpcName` field of every SSL order. Defaults to the configured RequestorName when blank. Some product configurations require a TPoC to be present; omitting it can cause CERTInext to park orders awaiting manual completion of the field." + "description": "OPTIONAL: Technical point of contact name, sent as `technicalPointOfContact.pocFirstName` / `pocLastName` on every SSL order (new and renewal). The name is split on the first whitespace: the first word is the first name and the rest is the last name; a single-word name is sent in both. Defaults to the configured RequestorName when blank." }, { "name": "TechnicalContactEmail", - "description": "OPTIONAL: Email sent in the `technicalPointOfContact.tpcEmail` field of every SSL order. Defaults to the configured RequestorEmail when blank." + "description": "OPTIONAL: Email sent in the `technicalPointOfContact.pocEmail` field of every SSL order. Defaults to the configured RequestorEmail when blank." }, { "name": "TechnicalContactIsdCode", - "description": "OPTIONAL: International dialing code for the TPoC phone number. Defaults to the configured RequestorIsdCode when blank." + "description": "OPTIONAL: International dialing code for the technical contact phone number, sent as `technicalPointOfContact.pocIsdCode`. Defaults to the configured RequestorIsdCode when blank." }, { "name": "TechnicalContactMobileNumber", - "description": "OPTIONAL: Mobile number for the TPoC (digits only). Defaults to the configured RequestorMobileNumber when blank." + "description": "OPTIONAL: Mobile number for the technical contact (digits only), sent as `technicalPointOfContact.pocMobileNumber`. Defaults to the configured RequestorMobileNumber when blank." }, { "name": "AuthMode", @@ -127,7 +127,7 @@ }, { "name": "AutoSecureWww", - "description": "OPTIONAL: If \"1\", CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. \"0\" = use only the CN/SANs supplied with the CSR. Default: \"0\"." + "description": "OPTIONAL: Sent as `orderDetails.autoSecureWWW` on every SSL order (new and renewal). If \"1\", CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN, which must also pass domain validation. \"0\" = use only the CN/SANs supplied with the CSR. Default: \"0\"." }, { "name": "IgnoreExpired", From 338d4ef52eca6436f19dad5f7883c568ff17719c Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:27:22 -0700 Subject: [PATCH 16/71] chore(scripts): send the corrected V1 order body from dev scripts SSL scripts now send orderDetails.groupNumber / orderDetails.autoSecureWWW and technicalPointOfContact.poc*, with no delegationInformation. generate-order.sh takes AUTO_SECURE_WWW (default 0, matching the plugin). Private PKI probes move groupNumber the same way; that placement is assumed for GenerateOrderPrivatePKI and noted as unconfirmed. --- Makefile | 2 +- scripts/generate-order-149-fresh.sh | 5 +++- scripts/generate-order-igtf.sh | 5 +++- scripts/generate-order-private-pki.sh | 5 +++- scripts/generate-order.sh | 37 +++++++++++++++++++-------- scripts/order_private_pki_minimal.py | 10 +++++--- scripts/probe-products.sh | 15 ++++++++--- scripts/probe_private_pki.py | 5 +++- 8 files changed, 61 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index c2a726f..27026df 100644 --- a/Makefile +++ b/Makefile @@ -471,7 +471,7 @@ show-postman-variables: # probe-private-pki-payloads — Try three payload variants for # GenerateOrderPrivatePKI with product 149. # -# Tests Postman-minimal, +agreementDetails, and +delegationInformation +# Tests Postman-minimal, +agreementDetails, and +orderDetails.groupNumber # to isolate which payload structure the server accepts without EMS-939. # # Optional: DOMAIN=... PRODUCT_CODE=149 SAVE_AND_HOLD=0 diff --git a/scripts/generate-order-149-fresh.sh b/scripts/generate-order-149-fresh.sh index 4a67718..27b5585 100755 --- a/scripts/generate-order-149-fresh.sh +++ b/scripts/generate-order-149-fresh.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash # Optional env var: SAVE_AND_HOLD (default 1) +# groupNumber is sent directly under orderDetails (CERTInext's GenerateOrderSSL +# placement; the old delegationInformation.groupNumber is not read). Placement for +# GenerateOrderPrivatePKI is assumed to match and has not been confirmed live. set -euo pipefail . ~/.env_certinext . "$(dirname "$0")/lib/certinext-auth.sh" @@ -36,7 +39,7 @@ result=$(jq -n \ accountingModel:"2", saveAndHold:$sah, emailNotifications:"0", - delegationInformation:{groupNumber:$grp}, + groupNumber:$grp, requestorInformation:{requestorName:$name, requestorIsdCode:"1",requestorMobileNumber:$mobile, requestorEmail:$email}, diff --git a/scripts/generate-order-igtf.sh b/scripts/generate-order-igtf.sh index 2545180..c4a8412 100755 --- a/scripts/generate-order-igtf.sh +++ b/scripts/generate-order-igtf.sh @@ -2,6 +2,9 @@ # Optional env vars: IGTF_CSR_FILE (default /tmp/certinext-igtf-test.csr), # IGTF_DOMAIN (default test-igtf.example.com), # SAVE_AND_HOLD (default 1) +# groupNumber is sent directly under orderDetails (CERTInext's GenerateOrderSSL +# placement; the old delegationInformation.groupNumber is not read). Placement for +# GenerateOrderPrivatePKI is assumed to match and has not been confirmed live. set -euo pipefail . ~/.env_certinext . "$(dirname "$0")/lib/certinext-auth.sh" @@ -45,7 +48,7 @@ result=$(jq -n \ accountingModel:"2", saveAndHold:$sah, emailNotifications:"0", - delegationInformation:{groupNumber:$grp}, + groupNumber:$grp, requestorInformation:{requestorName:$name, requestorIsdCode:"1",requestorMobileNumber:$mobile, requestorEmail:$email}, diff --git a/scripts/generate-order-private-pki.sh b/scripts/generate-order-private-pki.sh index 82a4944..42093c1 100755 --- a/scripts/generate-order-private-pki.sh +++ b/scripts/generate-order-private-pki.sh @@ -3,6 +3,9 @@ # PRIVATE_PKI_DOMAIN (default test-private-pki.example.com), # PRIVATE_PKI_CSR (default /tmp/certinext-igtf-test.csr), # SAVE_AND_HOLD (default 1) +# groupNumber is sent directly under orderDetails (CERTInext's GenerateOrderSSL +# placement; the old delegationInformation.groupNumber is not read). Placement for +# GenerateOrderPrivatePKI is assumed to match and has not been confirmed live. set -euo pipefail . ~/.env_certinext . "$(dirname "$0")/lib/certinext-auth.sh" @@ -45,7 +48,7 @@ result=$(jq -n \ accountingModel:"2", saveAndHold:$sah, emailNotifications:"0", - delegationInformation:{groupNumber:$grp}, + groupNumber:$grp, requestorInformation:{requestorName:$name, requestorIsdCode:"1",requestorMobileNumber:$mobile, requestorEmail:$email}, diff --git a/scripts/generate-order.sh b/scripts/generate-order.sh index 680b8a6..0464dba 100755 --- a/scripts/generate-order.sh +++ b/scripts/generate-order.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash # Required env var: DOMAIN -# Optional env vars: CSR_FILE, VALIDITY (default 1), SAVE_AND_HOLD (default 1), CODE +# Optional env vars: CSR_FILE, VALIDITY (default 1), SAVE_AND_HOLD (default 1), CODE, +# AUTO_SECURE_WWW (default 0, matches the plugin's AutoSecureWww default) +# +# Body shape mirrors the plugin's GenerateOrderSSL request: groupNumber and +# autoSecureWWW sit directly under orderDetails (no delegationInformation), and +# the technical contact uses technicalPointOfContact.poc* with the name split on +# the first run of whitespace (single-token names go into both first and last). set -euo pipefail . ~/.env_certinext . "$(dirname "$0")/lib/certinext-auth.sh" @@ -9,6 +15,7 @@ DOMAIN="${DOMAIN:-}" CSR_FILE="${CSR_FILE:-}" VALIDITY="${VALIDITY:-1}" SAVE_AND_HOLD="${SAVE_AND_HOLD:-1}" +AUTO_SECURE_WWW="${AUTO_SECURE_WWW:-0}" if [ -z "$DOMAIN" ]; then echo "Usage: DOMAIN= [CSR_FILE=] [VALIDITY=1] [SAVE_AND_HOLD=1] scripts/generate-order.sh" >&2 @@ -26,8 +33,10 @@ if [ -z "$signerIp" ]; then signerIp=$(curl -s https://api.ipify.org); fi mobile="${CERTINEXT_REQUESTOR_MOBILE:-0000000000}" name="${CERTINEXT_REQUESTOR_NAME:-Keyfactor Gateway Test}" +read -r pocFirst pocLast <<< "$name" +if [ -z "${pocLast:-}" ]; then pocLast="$pocFirst"; fi -echo "GenerateOrderSSL domain=$DOMAIN productCode=$CERTINEXT_PRODUCT_CODE validity=$VALIDITY saveAndHold=$SAVE_AND_HOLD signerIp=$signerIp ts=$ts txn=$txn" +echo "GenerateOrderSSL domain=$DOMAIN productCode=$CERTINEXT_PRODUCT_CODE validity=$VALIDITY saveAndHold=$SAVE_AND_HOLD autoSecureWWW=$AUTO_SECURE_WWW signerIp=$signerIp ts=$ts txn=$txn" if [ -n "$CSR_FILE" ] && [ -f "$CSR_FILE" ]; then result=$(jq -n \ @@ -43,6 +52,9 @@ if [ -n "$CSR_FILE" ] && [ -f "$CSR_FILE" ]; then --arg name "$name" \ --arg mobile "$mobile" \ --arg signerIp "$signerIp" \ + --arg asw "$AUTO_SECURE_WWW" \ + --arg pocFirst "$pocFirst" \ + --arg pocLast "$pocLast" \ --rawfile csr "$CSR_FILE" \ '{meta:{ver:$ver,ts:$ts,txn:$txn,accountNumber:$acct,authKey:$auth}, orderDetails:{ @@ -50,15 +62,16 @@ if [ -n "$CSR_FILE" ] && [ -f "$CSR_FILE" ]; then accountingModel:"2", saveAndHold:$sah, emailNotifications:"1", - delegationInformation:{groupNumber:$grp}, + groupNumber:$grp, + autoSecureWWW:$asw, organizationDetails:{preVetting:"1",organizationNumber:$org}, requestorInformation:{requestorName:$name, requestorIsdCode:"91",requestorMobileNumber:$mobile, requestorEmail:$email}, subscriptionDetails:{validity:$validity,autoRenew:"0",renewCriteria:"30"}, - certificateInformation:{domainName:$domain,autoSecureWWW:"1"}, - technicalPointOfContact:{tpcName:$name,tpcEmail:$email, - tpcIsdCode:"91",tpcMobileNumber:$mobile}, + certificateInformation:{domainName:$domain}, + technicalPointOfContact:{pocFirstName:$pocFirst,pocLastName:$pocLast, + pocEmail:$email,pocIsdCode:"91",pocMobileNumber:$mobile}, csr:$csr, agreementDetails:{acceptAgreement:"1",signerName:$name, signerPlace:"Gateway",signerIP:$signerIp}, @@ -80,21 +93,25 @@ else --arg name "$name" \ --arg mobile "$mobile" \ --arg signerIp "$signerIp" \ + --arg asw "$AUTO_SECURE_WWW" \ + --arg pocFirst "$pocFirst" \ + --arg pocLast "$pocLast" \ '{meta:{ver:$ver,ts:$ts,txn:$txn,accountNumber:$acct,authKey:$auth}, orderDetails:{ productCode:$pc, accountingModel:"2", saveAndHold:$sah, emailNotifications:"1", - delegationInformation:{groupNumber:$grp}, + groupNumber:$grp, + autoSecureWWW:$asw, organizationDetails:{preVetting:"1",organizationNumber:$org}, requestorInformation:{requestorName:$name, requestorIsdCode:"91",requestorMobileNumber:$mobile, requestorEmail:$email}, subscriptionDetails:{validity:$validity,autoRenew:"0",renewCriteria:"30"}, - certificateInformation:{domainName:$domain,autoSecureWWW:"1"}, - technicalPointOfContact:{tpcName:$name,tpcEmail:$email, - tpcIsdCode:"91",tpcMobileNumber:$mobile}, + certificateInformation:{domainName:$domain}, + technicalPointOfContact:{pocFirstName:$pocFirst,pocLastName:$pocLast, + pocEmail:$email,pocIsdCode:"91",pocMobileNumber:$mobile}, agreementDetails:{acceptAgreement:"1",signerName:$name, signerPlace:"Gateway",signerIP:$signerIp}, additionalInformation:{remarks:"Issued via Keyfactor Command AnyCA REST Gateway."}}}' \ diff --git a/scripts/order_private_pki_minimal.py b/scripts/order_private_pki_minimal.py index 3157028..5ac44a9 100644 --- a/scripts/order_private_pki_minimal.py +++ b/scripts/order_private_pki_minimal.py @@ -105,7 +105,7 @@ def main(): # ----------------------------------------------------------------------- # Variant 1: Minimal — mirrors Postman body exactly (no agreementDetails, - # no accountingModel, no delegationInformation, no subscriptionDetails) + # no accountingModel, no groupNumber, no subscriptionDetails) # ----------------------------------------------------------------------- print(f"\n=== Variant 1: Minimal (Postman-style) product={args.product} saveAndHold={args.save_and_hold} ===") meta = make_meta(account_num, access_key) @@ -181,15 +181,17 @@ def main(): print(json.dumps(resp2, indent=2)) # ----------------------------------------------------------------------- - # Variant 3: With delegationInformation (groupNumber) + # Variant 3: With orderDetails.groupNumber + # (CERTInext's GenerateOrderSSL placement; delegationInformation.groupNumber + # is not read. Assumed, not yet confirmed live, for GenerateOrderPrivatePKI.) # ----------------------------------------------------------------------- - print(f"\n=== Variant 3: With delegationInformation product={args.product} saveAndHold={args.save_and_hold} ===") + print(f"\n=== Variant 3: With groupNumber product={args.product} saveAndHold={args.save_and_hold} ===") meta = make_meta(account_num, access_key) payload_with_group = { "meta": meta, "orderDetails": { "productCode": args.product, - "delegationInformation": {"groupNumber": group_num}, + "groupNumber": group_num, "requestorInformation": { "requestorName": req_name, "requestorIsdCode": "1", diff --git a/scripts/probe-products.sh b/scripts/probe-products.sh index 4d92fe4..e02a53f 100755 --- a/scripts/probe-products.sh +++ b/scripts/probe-products.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # Optional env var: PROBE_DOMAIN (default test-integration.example.com) # Depends on /tmp/certinext-test.csr being present (run generate-test-csr first). +# Body shape mirrors the plugin's GenerateOrderSSL request (see generate-order.sh): +# orderDetails.groupNumber / orderDetails.autoSecureWWW, technicalPointOfContact.poc*. set -euo pipefail . ~/.env_certinext . "$(dirname "$0")/lib/certinext-auth.sh" @@ -12,6 +14,8 @@ if [ -z "$signerIp" ]; then signerIp=$(curl -s https://api.ipify.org); fi name="${CERTINEXT_REQUESTOR_NAME:-Keyfactor Gateway Test}" mobile="${CERTINEXT_REQUESTOR_MOBILE:-0000000000}" +read -r pocFirst pocLast <<< "$name" +if [ -z "${pocLast:-}" ]; then pocLast="$pocFirst"; fi echo "" echo "=== probe-products: testing SSL/TLS product codes for account $CERTINEXT_ACCOUNT_NUMBER ===" @@ -30,6 +34,8 @@ for code in 842 843 844 845 846 847 848 849 850 851 149; do --arg name "$name" \ --arg mobile "$mobile" \ --arg signerIp "$signerIp" \ + --arg pocFirst "$pocFirst" \ + --arg pocLast "$pocLast" \ --rawfile csr /tmp/certinext-test.csr \ '{meta:{ver:$ver,ts:$ts,txn:$txn,accountNumber:$acct,authKey:$auth}, orderDetails:{ @@ -37,15 +43,16 @@ for code in 842 843 844 845 846 847 848 849 850 851 149; do accountingModel:"2", saveAndHold:"1", emailNotifications:"0", - delegationInformation:{groupNumber:$grp}, + groupNumber:$grp, + autoSecureWWW:"0", organizationDetails:{preVetting:"1",organizationNumber:$org}, requestorInformation:{requestorName:$name, requestorIsdCode:"1",requestorMobileNumber:$mobile, requestorEmail:$email}, subscriptionDetails:{validity:"1",autoRenew:"0",renewCriteria:"30"}, - certificateInformation:{domainName:$domain,autoSecureWWW:"1"}, - technicalPointOfContact:{tpcName:$name,tpcEmail:$email, - tpcIsdCode:"1",tpcMobileNumber:$mobile}, + certificateInformation:{domainName:$domain}, + technicalPointOfContact:{pocFirstName:$pocFirst,pocLastName:$pocLast, + pocEmail:$email,pocIsdCode:"1",pocMobileNumber:$mobile}, csr:$csr, agreementDetails:{acceptAgreement:"1",signerName:$name, signerPlace:"Gateway",signerIP:$signerIp}, diff --git a/scripts/probe_private_pki.py b/scripts/probe_private_pki.py index 841d654..fc5928c 100644 --- a/scripts/probe_private_pki.py +++ b/scripts/probe_private_pki.py @@ -98,7 +98,10 @@ def build_private_pki_payload( "accountingModel": "2", "saveAndHold": save_and_hold, "emailNotifications": "0", - "delegationInformation": {"groupNumber": group_number}, + # orderDetails.groupNumber is CERTInext's GenerateOrderSSL placement + # (delegationInformation.groupNumber is not read); assumed, not yet + # confirmed live, for GenerateOrderPrivatePKI. + "groupNumber": group_number, "requestorInformation": { "requestorName": requestor_name, "requestorIsdCode": "1", From 0556505557d7481c61326e35d0be36f764ca57ca Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:27:22 -0700 Subject: [PATCH 17/71] docs(changelog): note V1 order-body field fixes and upgrade impact in 1.0.1 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dff6588..fc07833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - **Renewals no longer lose their SANs.** Renewals were submitted with no additional domains and the wrong primary domain; both now come from the certificate being renewed. - **Enrollment no longer fails on an order CERTInext auto-approves before it finishes issuing.** The plugin used to report these as issued with no certificate attached, which the gateway rejected. It now returns pending and picks up the certificate once CERTInext finishes issuing it. - **Renewals now use the certificate template's product code.** Renewals previously always used the connector's `DefaultProductCode`, which could send an empty product code if that setting was never configured. Renewals now use the template's code, falling back to `DefaultProductCode` only when the template doesn't have one. +- **`GroupNumber`, `AutoSecureWww`, and the technical contact now reach CERTInext**; they were previously sent in fields CERTInext doesn't read. +- **Renewals now send the full order details** (group, `AutoSecureWww`, technical contact, organization, remarks), the same as a new enrollment. ## Chores - **`OrganizationNumber`, `DefaultProductCode`, and `GroupNumber` are now visible in the startup log.** Whether each is set is now logged alongside the other connector settings, making a misconfigured connector easier to diagnose from logs alone. @@ -16,6 +18,9 @@ ## Upgrade Notes - **Non-DNS SANs (IP, email, URI) are now submitted instead of silently dropped.** CERTInext can't validate them, so such an order won't issue until the SAN is removed. Set `SubmitNonDnsSans` to `false` to restore the old drop-silently behavior. - **No more duplicate or orphaned orders after a network timeout.** Order/CSR submissions no longer auto-retry after a timeout, since the CA may have already created the order. If it was created, the next sync imports it. +- **`www.` is no longer added to orders by default**, because `AutoSecureWww` (default `0`) is now honored; set it to `1` to keep the old behavior. +- **Orders now route to the configured `GroupNumber`**, which previously was not applied to orders. +- **Renewals follow the connector's `SubscriptionAutoRenew`, `EmailNotifications`, and validity settings** instead of fixed 1-year validity with auto-renew and notifications on. # 1.0.0 From bfde454e163fd561a7172ac5f94d5b73be390dcf Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:34:56 -0700 Subject: [PATCH 18/71] fix(logging): redact authKey and requestor PII from V1 payload and error logs (0040) The Trace-level PlaceOrder request dump added in 59aa2b2 logged the serialized GenerateOrderSSL body verbatim, including the replayable meta.authKey digest and requestor/technical-contact/signer PII; the TrackOrder response dump and the LogApiFailure response-body logger were likewise unredacted for PII. Hand-ported from feat/certinextv2 (4029026, 515eb40, 6da53fa), V1 parts only: - New LogSensitiveRequestData connector setting (default false) in config, annotations, integration-manifest.json and docsource. - CERTInextClient.ApplyLoggingRedaction: credentials always scrubbed via RedactCredentials; PII scrubbed via RedactPersonalData unless the flag is on (email masked to j***@domain; email SANs in additionalDomains and domainVerification keys masked via a Utf8JsonReader splice). - Applied to the PlaceOrder and TrackOrder Trace dumps and LogApiFailure (now instance so it can read the flag). - Enrollment-start line drops RequesterName and masks RequesterEmail when off; email SANs masked in the SAN summary, BuildSanList, the V1 AdditionalDomains line and the DCV invalid-domain error. - Startup log records the flag; a Warning is logged when it is on. - Narrow internal test seam (OverrideLoggerForTests) on the static client logger so the real log call sites can be asserted in unit tests. --- CERTInext/CERTInextCAPlugin.cs | 67 +++++-- CERTInext/CERTInextCAPluginConfig.cs | 36 ++++ CERTInext/Client/CERTInextClient.cs | 269 ++++++++++++++++++++++++++- CERTInext/Constants.cs | 4 + CERTInext/Models/LogSanitizer.cs | 106 ++++++++++- CHANGELOG.md | 1 + docsource/configuration.md | 1 + integration-manifest.json | 4 + 8 files changed, 462 insertions(+), 26 deletions(-) diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index ff63595..0f0f643 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -254,7 +254,7 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa "GroupNumberPresent={GroupNumberPresent}, " + "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}, SubmitNonDnsSans={SubmitNonDnsSans}, " + "DcvEnabled={DcvEnabled}, DcvTxtRecordTemplate={DcvTxtRecordTemplate}, " + - "DomainValidatorFactoryInjected={FactoryInjected}", + "DomainValidatorFactoryInjected={FactoryInjected}, LogSensitiveRequestData={LogSensitiveRequestData}", _config.ApiUrl, _config.AuthMode, _config.Enabled, hasApiKey, hasUsername, hasPassword, hasClientId, @@ -263,7 +263,7 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa hasGroupNumber, _config.PageSize, _config.IgnoreExpired, _config.SubmitNonDnsSans, _config.DcvEnabled, _config.DcvTxtRecordTemplate, - _domainValidatorFactory != null); + _domainValidatorFactory != null, _config.LogSensitiveRequestData); // SOC2 CC7.1: surface silent functional downgrades. If DCV is enabled in // config but no factory was injected (e.g. v3.2 gateway host), DCV will be @@ -278,6 +278,19 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa "gateway image that supplies the factory, or set DcvEnabled=false to clear " + "this warning."); } + + // Issue 0040 audit trail: this is the one place that records sensitive-data logging + // was switched on, so a reviewer scanning gateway logs can see exactly when it started + // (and, from the absence of a corresponding line on a later restart, when it stopped). + if (_config.LogSensitiveRequestData) + { + _logger.LogWarning( + "LogSensitiveRequestData=true — this CERTInext connector will write requestor " + + "personal data (name, email, phone, and other organization contact details) and " + + "full CA request/response payloads to the gateway logs. This is intended for " + + "temporary use while verifying a new deployment; turn it back off once " + + "verification is complete."); + } _logger.MethodExit(LogLevel.Debug); } @@ -573,19 +586,37 @@ public async Task Enroll( // SOX / SOC2 CC7.3: log the enrollment attempt with full identifying context // so the event is independently auditable before any API call is made. - string sanSummary = san != null && san.Count > 0 - ? string.Join("; ", san.SelectMany(kvp => (kvp.Value ?? Array.Empty()) - .Select(v => $"{kvp.Key}:{v}"))) - : "(none)"; + // Issue 0040: email-type SAN values are personal data, masked unless + // LogSensitiveRequestData is on; DNS/IP/URI values stay verbatim as audit fields. + // FormatSans also applies LogSanitizer.Strip to the whole summary. + string sanSummary = LogSanitizer.FormatSans(san, _config.LogSensitiveRequestData); - _logger.LogInformation( - "Enrollment attempt started. " + - "EnrollmentType={EnrollmentType}, RequestFormat={RequestFormat}, Subject={Subject}, " + - "ProfileId={ProfileId}, SANs={SANs}, " + - "RequesterName={RequesterName}, RequesterEmail={RequesterEmail}", - enrollmentType, requestFormat, LogSanitizer.Strip(subject), - ep.ProfileId, LogSanitizer.Strip(sanSummary), - ep.RequesterName, ep.RequesterEmail); + // Issue 0040: RequesterName/RequesterEmail are personal data belonging to whoever + // placed the order. Off by default (LogSensitiveRequestData=false) — the name is + // dropped from the line entirely and the email is masked to keep only its domain. + // On, both fields are logged in full, for deployment verification. + if (_config.LogSensitiveRequestData) + { + _logger.LogInformation( + "Enrollment attempt started. " + + "EnrollmentType={EnrollmentType}, RequestFormat={RequestFormat}, Subject={Subject}, " + + "ProfileId={ProfileId}, SANs={SANs}, " + + "RequesterName={RequesterName}, RequesterEmail={RequesterEmail}", + enrollmentType, requestFormat, LogSanitizer.Strip(subject), + ep.ProfileId, sanSummary, + ep.RequesterName, ep.RequesterEmail); + } + else + { + _logger.LogInformation( + "Enrollment attempt started. " + + "EnrollmentType={EnrollmentType}, RequestFormat={RequestFormat}, Subject={Subject}, " + + "ProfileId={ProfileId}, SANs={SANs}, " + + "RequesterEmail={RequesterEmail}", + enrollmentType, requestFormat, LogSanitizer.Strip(subject), + ep.ProfileId, sanSummary, + LogSanitizer.MaskEmail(ep.RequesterEmail)); + } if (string.IsNullOrWhiteSpace(ep.ProfileId)) { @@ -1668,7 +1699,10 @@ private async Task PerformDcvIfNeededAsync( "[{Domains}]. They are skipped so the remaining {ValidCount} domain(s) can still be validated. " + "This order cannot be issued by CERTInext until these are removed — they usually come from a " + "non-DNS SAN (IP address, email, URI) that was requested on the enrollment.", - invalidDomains.Count, orderNumber, LogSanitizer.Strip(string.Join(", ", invalidDomains)), + // An email SAN submitted to V1 comes back verbatim as an order domain; mask it + // unless LogSensitiveRequestData is on (issue 0040). + invalidDomains.Count, orderNumber, + LogSanitizer.FormatUntypedSans(invalidDomains, _config.LogSensitiveRequestData, ", "), validPendingDomains.Count); } @@ -2484,8 +2518,9 @@ void Add(string type, string value, bool fromCsr = false) if (fromCsr) fromCsrKeys.Add(key); } + // Issue 0040: email SAN values masked unless LogSensitiveRequestData is on. string FormatSans(IEnumerable sans) => - LogSanitizer.Strip(string.Join("; ", sans.Select(s => $"{s.Type}:{s.Value}"))); + LogSanitizer.FormatSans(sans, _config.LogSensitiveRequestData); // AnyCA passes SANs keyed by type name — the real gateway uses "dnsname", // "rfc822name", "ipaddress"; MapSanType normalizes the spelling variants. diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index c3b5da0..5178db4 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -288,6 +288,25 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = true, Type = "Boolean" }, + [Constants.Config.LogSensitiveRequestData] = new PropertyConfigInfo + { + Comments = "OPTIONAL diagnostic escape hatch. When true, requestor personal data (name, email, " + + "phone, and other organization contact details) and full CA request/response payloads " + + "are written to the gateway logs: the Trace-level order request/response bodies and the " + + "API-failure response bodies are left unredacted (beyond the credential scrubbing that " + + "always applies), and the Information-level enrollment-attempt log line includes the " + + "requestor's name and email in full. This is meant for temporary use while verifying a " + + "new deployment — confirming exactly what was sent to the CA and that the order " + + "succeeded — and should be turned back off once verification is complete. When false " + + "(default), personal data fields are redacted to '***REDACTED***' (email is masked but " + + "keeps its domain, e.g. 'j***@example.com'), email SAN values in log lines are masked " + + "the same way, and the enrollment log line omits the requester name entirely. " + + "Credentials (access keys, authKey digests, OAuth secrets, tokens) are always redacted " + + "regardless of this setting. Default: false.", + Hidden = false, + DefaultValue = false, + Type = "Boolean" + }, [Constants.Config.PickupRetries] = new PropertyConfigInfo { Comments = "OPTIONAL: Number of times Enroll() will poll CERTInext to download the certificate after a " + @@ -730,6 +749,23 @@ public class CERTInextConfig [JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true; + /// + /// OPTIONAL diagnostic escape hatch. When true, full CA request/response payloads are + /// logged at Trace (beyond the credential scrubbing that always applies), and the + /// enrollment-attempt Information log line includes the requestor's name and email in + /// full. This writes personal data belonging to whoever placed the order — name, email, + /// phone, and other organization contact fields — into the gateway's log files. Intended + /// only for temporary use while verifying a new deployment; turn it back off once + /// verification is complete. When false (default), personal-data fields are replaced with + /// "***REDACTED***" (email values are masked but keep their domain, e.g. + /// "j***@example.com"), email SAN values in log lines are masked the same way, and the + /// enrollment log line omits the requester name entirely. Credentials (access keys, + /// authKey digests, OAuth secrets, tokens) are always redacted regardless of this setting. + /// Default: false. + /// + [JsonPropertyName("LogSensitiveRequestData")] + public bool LogSensitiveRequestData { get; set; } = false; + // ----------------------------------------------------------------------- // DCV — domain control validation via DNS provider plugins // ----------------------------------------------------------------------- diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 23ea6b5..675945d 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -36,7 +36,34 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Client /// public class CERTInextClient : ICERTInextClient, IDisposable { - private static readonly ILogger Logger = LogHandler.GetClassLogger(); + // Not readonly only so unit tests can capture the Trace payload dumps through + // OverrideLoggerForTests (issue 0040). Production code never reassigns it. + private static ILogger Logger = LogHandler.GetClassLogger(); + + /// + /// Test seam (issue 0040): swaps the class-wide logger so a unit test can assert exactly + /// what the request/response payload dumps write. The logger is static and resolved once + /// per process, so swapping LogHandler.Factory cannot reach it. Dispose the + /// returned handle to restore the previous logger. + /// + internal static IDisposable OverrideLoggerForTests(ILogger logger) + { + var prior = Logger; + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + return new LoggerOverride(prior); + } + + private sealed class LoggerOverride : IDisposable + { + private ILogger _prior; + public LoggerOverride(ILogger prior) => _prior = prior; + public void Dispose() + { + if (_prior == null) return; + Logger = _prior; + _prior = null; + } + } private readonly CERTInextConfig _config; private readonly RestClient _http; @@ -197,9 +224,9 @@ public async Task PlaceOrderAsync( request.OrderDetails?.ProductCode, LogSanitizer.Strip(certInfo?.DomainName), certInfo?.AdditionalDomains?.Count ?? 0, - certInfo?.AdditionalDomains != null && certInfo.AdditionalDomains.Count > 0 - ? LogSanitizer.Strip(string.Join("; ", certInfo.AdditionalDomains)) - : "(none)"); + // Untyped by now: an email SAN submitted here is masked unless + // LogSensitiveRequestData is on (issue 0040). + LogSanitizer.FormatUntypedSans(certInfo?.AdditionalDomains, _config.LogSensitiveRequestData)); GenerateOrderResponse result = null; RestResponse resp = null; @@ -225,7 +252,10 @@ public async Task PlaceOrderAsync( var req = new RestRequest(Constants.Api.GenerateOrderSslPath, Method.Post); string jsonBody = JsonSerializer.Serialize(request, GetJsonOptions()); - Logger.LogTrace("PlaceOrderAsync request payload: {Payload}", jsonBody); + // Issue 0040: the body carries the replayable meta.authKey digest (always redacted) + // and requestor/contact PII (redacted unless LogSensitiveRequestData is on). + Logger.LogTrace("PlaceOrderAsync request payload: {Payload}", + ApplyLoggingRedaction(jsonBody, _config.LogSensitiveRequestData)); req.AddJsonBody(jsonBody); var sw = System.Diagnostics.Stopwatch.StartNew(); @@ -436,7 +466,7 @@ public async Task TrackOrderAsync(string orderNumber, Cancel var result = DeserializeOrThrow(resp, $"track order {orderNumber}"); Logger.LogTrace("TrackOrderAsync response payload (Order={OrderNumber}): {Payload}", - orderNumber, resp.Content); + orderNumber, ApplyLoggingRedaction(resp.Content, _config.LogSensitiveRequestData)); // A meta status of "0" with errorCode EMS-913 or similar means the order was not found if (result.Meta != null && !result.Meta.IsSuccess) @@ -1917,6 +1947,228 @@ internal static string RedactCredentials(string body) return body; } + // Exact JSON key names that carry a person's email address on the V1 wire shapes (see + // CERTInext/API/CertificateRequest.cs and CertificateResponse.cs). Every one of these is a + // full, exact key — never a substring of an unrelated key (e.g. "domainName"/ + // "organizationName" do not end in a bare "email" key) — so matching the key by exact + // name cannot cross-contaminate unrelated fields. The bare "email" key is not used by any + // V1 model today; it is kept as defence in depth for CA error/response bodies. + private static readonly string[] PersonalEmailFieldNames = + { + "requestorEmail", "requesterEmail", "tpcEmail", "requestorEmailId", "dcvEmail", "email" + }; + + // Exact JSON key names carrying other person/contact data (name, phone/ISD/mobile, + // designation, signer place/IP). The bare "name"/"phone"/"designation" keys are not + // emitted by any V1 request this plugin logs raw; they are kept as defence in depth for + // CA response/error bodies, where over-redacting a log line costs nothing on the wire. + private static readonly string[] PersonalOtherFieldNames = + { + "requestorName", "requesterName", "tpcName", "signerName", "name", + "requestorIsdCode", "requestorMobileNumber", "requestorDesignation", + "tpcIsdCode", "tpcMobileNumber", "signerPlace", "signerip", "phone", "designation" + }; + + /// + /// Scrubs known person/contact-bearing keys out of a JSON-ish body before it goes into a + /// log line, when LogSensitiveRequestData is off (issue 0040). Covers the V1 + /// requestorInformation / technicalPointOfContact / agreementDetails + /// shapes (requestorName, requestorEmail, requestorIsdCode, + /// requestorMobileNumber, requestorDesignation, tpcName, + /// tpcEmail, tpcIsdCode, tpcMobileNumber, signerName, + /// signerPlace, signerIP/signerIp, the legacy requesterName/ + /// requesterEmail aliases, and the requestorEmailId search filter), plus bare + /// name/email/phone/designation keys as defence in depth. + /// + /// Email values are masked via so the domain stays + /// visible (e.g. "j***@example.com") while the local part is hidden. Every other + /// matched field is replaced outright with "***REDACTED***". Fields that are + /// already blank/empty on the wire are left untouched — there is nothing to redact. + /// + /// Conservative substring/regex pass, same style as — + /// tolerant of whitespace around the JSON key : value separator (including + /// pretty-printed bodies), and anchored on the opening/closing quote of the key so it + /// cannot match a key name as a substring of a longer one. Email SANs inside the + /// additionalDomains array and domainVerification keys are masked afterwards + /// by . Does NOT scrub credentials — that is + /// 's job, applied unconditionally by + /// . Exposed internal for unit testing. + /// + internal static string RedactPersonalData(string body) + { + if (string.IsNullOrEmpty(body)) return body; + + foreach (var key in PersonalEmailFieldNames) + body = RedactJsonField(body, key, LogSanitizer.MaskEmail); + + foreach (var key in PersonalOtherFieldNames) + body = RedactJsonField(body, key, _ => "***REDACTED***"); + + return MaskEmailsInSanContainers(body); + } + + /// + /// Replaces the value of every occurrence of a JSON string field named + /// (case-insensitive, exact key match) with applied to the + /// original value. Leaves already-empty values untouched. Whitespace around the colon and + /// around the key's own quotes is tolerated. + /// + private static string RedactJsonField(string body, string keyName, Func transform) + { + return System.Text.RegularExpressions.Regex.Replace( + body, + $@"(?i)(""{System.Text.RegularExpressions.Regex.Escape(keyName)}""\s*:\s*"")([^""]*)("")", + m => string.IsNullOrEmpty(m.Groups[2].Value) + ? m.Value + : m.Groups[1].Value + transform(m.Groups[2].Value) + m.Groups[3].Value); + } + + // Exact JSON keys whose value is an array of SAN strings. V1 additionalDomains carries every + // requested SAN regardless of type, emails included (non-DNS SANs are submitted unless + // SubmitNonDnsSans=false). "additionalHosts" is not a V1 key; it is kept as defence in + // depth — a DNS name or IP literal never contains '@', so masking can only touch an email. + private static readonly string[] SanArrayFieldNames = { "additionalDomains", "additionalHosts" }; + + // Exact JSON keys whose value is an object keyed by SAN value. The V1 TrackOrder + // domainVerification block is { "": { ... }, "status": "..." }, and an email + // submitted in additionalDomains comes back as one of those keys. + private static readonly string[] SanKeyedObjectFieldNames = { "domainVerification" }; + + /// + /// Masks email addresses (issue 0040) in the two SAN-bearing container shapes the + /// key/value regex in cannot reach: string elements of a + /// array, and property names directly inside a + /// object. Only values containing @ are masked, + /// with . DNS / IP values, every other key, and anything + /// nested deeper inside those containers are left alone. Keys match exactly and + /// case-insensitively, the same as . + /// + /// Uses to find the exact token spans, then splices masked + /// tokens into the original bytes. The rest of the body stays byte-for-byte as it was, with + /// its whitespace and escaping unchanged. A regex cannot follow nesting depth or escaped + /// quotes reliably, and a DOM re-serialize would reformat the whole logged body. Never + /// throws: a body that does not start with {/[ is returned unchanged, and on + /// malformed or truncated JSON the masks found before the fault are still applied. + /// + internal static string MaskEmailsInSanContainers(string body) + { + if (string.IsNullOrEmpty(body)) return body; + if (body.IndexOf('@') < 0 && body.IndexOf("\\u0040", StringComparison.OrdinalIgnoreCase) < 0) + return body; + + int first = 0; + while (first < body.Length && char.IsWhiteSpace(body[first])) first++; + if (first == body.Length || (body[first] != '{' && body[first] != '[')) return body; + + byte[] utf8 = Encoding.UTF8.GetBytes(body); + var edits = new List<(int Start, int Length, string Replacement)>(); + + try + { + var reader = new Utf8JsonReader(utf8, new JsonReaderOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }); + + string pendingProperty = null; + int containerDepth = -1; // CurrentDepth of tokens directly inside the targeted container + bool containerIsArray = false; + + while (reader.Read()) + { + if (containerDepth >= 0) + { + if (reader.CurrentDepth < containerDepth) + { + containerDepth = -1; // the container's own End token + continue; + } + + bool candidate = reader.CurrentDepth == containerDepth && + (containerIsArray + ? reader.TokenType == JsonTokenType.String + : reader.TokenType == JsonTokenType.PropertyName); + if (candidate) + { + string value = reader.GetString(); + if (value != null && value.IndexOf('@') >= 0) + { + // TokenStartIndex is the opening quote; ValueSpan is the raw content. + string masked = reader.ValueIsEscaped + ? JsonSerializer.Serialize(LogSanitizer.MaskEmail(value)) + : "\"" + LogSanitizer.MaskEmail(Encoding.UTF8.GetString(reader.ValueSpan)) + "\""; + edits.Add(((int)reader.TokenStartIndex, reader.ValueSpan.Length + 2, masked)); + } + } + continue; + } + + if (reader.TokenType == JsonTokenType.PropertyName) + { + pendingProperty = reader.GetString(); + continue; + } + + if (pendingProperty != null) + { + if (reader.TokenType == JsonTokenType.StartArray && MatchesAny(SanArrayFieldNames, pendingProperty)) + { + containerDepth = reader.CurrentDepth + 1; + containerIsArray = true; + } + else if (reader.TokenType == JsonTokenType.StartObject && MatchesAny(SanKeyedObjectFieldNames, pendingProperty)) + { + containerDepth = reader.CurrentDepth + 1; + containerIsArray = false; + } + } + pendingProperty = null; + } + } + catch (JsonException) + { + // Malformed or truncated body: keep the masks collected before the fault. Everything + // up to that point was well-formed, so those spans are correct. + } + + if (edits.Count == 0) return body; + + var output = new System.IO.MemoryStream(utf8.Length); + int cursor = 0; + foreach (var (start, length, replacement) in edits) + { + output.Write(utf8, cursor, start - cursor); + byte[] replacementBytes = Encoding.UTF8.GetBytes(replacement); + output.Write(replacementBytes, 0, replacementBytes.Length); + cursor = start + length; + } + output.Write(utf8, cursor, utf8.Length - cursor); + return Encoding.UTF8.GetString(output.GetBuffer(), 0, (int)output.Length); + + static bool MatchesAny(string[] keys, string name) + { + foreach (var key in keys) + if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + } + + /// + /// Applies the standard logging redaction pipeline to a request/response body before it + /// is written to a log line: credentials (including the replayable meta.authKey + /// digest) are always scrubbed via , and personal-data + /// fields are additionally scrubbed via unless + /// is true (issue 0040). Used by the + /// PlaceOrderAsync request dump, the TrackOrderAsync response dump, and + /// LogApiFailure, so the on/off behavior has one place to unit-test. + /// + internal static string ApplyLoggingRedaction(string body, bool logSensitiveRequestData) + { + string redacted = RedactCredentials(body); + return logSensitiveRequestData ? redacted : RedactPersonalData(redacted); + } + /// /// Writes a structured log capturing every diagnostic field available for a /// non-success CERTInext API response — HTTP status, the CERTInext-side error @@ -1944,14 +2196,15 @@ internal static string RedactCredentials(string body) /// so SOX-loggable authentication events match /// the SIEM-alert level convention. /// - private static void LogApiFailure( + // Instance (not static) so it can read _config.LogSensitiveRequestData — see issue 0040. + private void LogApiFailure( string operationContext, RestResponse resp, string errorCode = null, string errorMessage = null, LogLevel level = LogLevel.Warning) { - string sanitizedBody = RedactCredentials(resp?.Content) ?? "(empty)"; + string sanitizedBody = ApplyLoggingRedaction(resp?.Content, _config.LogSensitiveRequestData) ?? "(empty)"; Logger.Log( level, "CERTInext API non-success. Operation={Operation}, HttpStatus={HttpStatus}, " + diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 809ecba..da86063 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -23,6 +23,10 @@ public static class Config public const string SubmitNonDnsSans = "SubmitNonDnsSans"; public const string PageSize = "PageSize"; + // Diagnostic escape hatch — see CERTInextConfig.LogSensitiveRequestData. Off by + // default; only meant for temporary use while verifying a new deployment. + public const string LogSensitiveRequestData = "LogSensitiveRequestData"; + // Synchronous certificate pickup (parity with the legacy Sectigo connector). // After submitting an order, Enroll() polls GetCertificate up to PickupRetries // times, PickupDelay seconds apart (after a fixed initial delay), so a fast-issuing diff --git a/CERTInext/Models/LogSanitizer.cs b/CERTInext/Models/LogSanitizer.cs index d341f09..9ebaf07 100644 --- a/CERTInext/Models/LogSanitizer.cs +++ b/CERTInext/Models/LogSanitizer.cs @@ -1,6 +1,20 @@ // Copyright 2026 Keyfactor -// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. -// At http://www.apache.org/licenses/LICENSE-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; namespace Keyfactor.Extensions.CAPlugin.CERTInext.Models { @@ -29,5 +43,93 @@ internal static string Strip(string value) .Replace("\n", "\\n") .Replace("\t", "\\t"); } + + /// + /// Masks the local part of an email address for logging while preserving the domain + /// (e.g. "j***@example.com"), so an operator can still tell which organization + /// an order came from without seeing exactly who submitted it. Used by both + /// CERTInextCAPlugin and Client.CERTInextClient when + /// LogSensitiveRequestData is off (issue 0040). Values with no @ (blank, + /// malformed, or not actually an email) fall back to a full "***REDACTED***". + /// + internal static string MaskEmail(string value) + { + if (string.IsNullOrEmpty(value)) return value; + int at = value.IndexOf('@'); + if (at <= 0) return "***REDACTED***"; + string domain = value.Substring(at + 1); + return value.Substring(0, 1) + "***@" + domain; + } + + // SAN type spellings (case-insensitive) whose values are email addresses: the gateway's + // "rfc822name" plus the variants CERTInextCAPlugin.MapSanType normalizes to "email". + private static readonly HashSet EmailSanTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "email", "rfc822", "rfc822name" }; + + // SAN types logged verbatim even with LogSensitiveRequestData off: host names, IP + // literals and URIs are audit fields, not personal data (issue 0040 follow-up). + private static readonly HashSet VerbatimSanTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "dns", "dnsname", "dnsnames", + "ip", "ipaddress", "ipaddresses", + "uri", "uniformresourceidentifier" + }; + + /// + /// Returns a single SAN value as it should appear in a log line (issue 0040 follow-up). + /// With on, the value is returned as-is. Off, + /// an email-type SAN (rfc822name and its spelling variants) is masked with + /// , and so is any value containing @ whose type is unknown + /// or null (untyped host lists). DNS, IP and URI values are always returned as-is. + /// Does not ; callers strip the formatted line. + /// + internal static string FormatSanValue(string sanType, string value, bool logSensitiveRequestData) + { + if (logSensitiveRequestData || string.IsNullOrEmpty(value)) return value; + if (sanType != null && EmailSanTypes.Contains(sanType)) return MaskEmail(value); + if (sanType != null && VerbatimSanTypes.Contains(sanType)) return value; + return value.IndexOf('@') >= 0 ? MaskEmail(value) : value; + } + + /// + /// Formats typed SAN entries as "type:value; type:value" for a log line, applying + /// to each value and to the result. + /// Returns "(none)" for a null or empty collection. + /// + internal static string FormatSans( + IEnumerable> sans, bool logSensitiveRequestData) + { + var parts = sans? + .Select(s => $"{s.Key}:{FormatSanValue(s.Key, s.Value, logSensitiveRequestData)}") + .ToList(); + return parts == null || parts.Count == 0 ? "(none)" : Strip(string.Join("; ", parts)); + } + + /// Gateway SAN dictionary overload of . + internal static string FormatSans(Dictionary san, bool logSensitiveRequestData) + => FormatSans( + san?.SelectMany(kvp => (kvp.Value ?? Array.Empty()) + .Select(v => new KeyValuePair(kvp.Key, v))), + logSensitiveRequestData); + + /// Resolved overload of . + internal static string FormatSans(IEnumerable sans, bool logSensitiveRequestData) + => FormatSans( + sans?.Where(s => s != null).Select(s => new KeyValuePair(s.Type, s.Value)), + logSensitiveRequestData); + + /// + /// Formats an untyped list of SAN-derived names (e.g. V1 additionalDomains, or order + /// domains echoed back by the CA) joined by . With no type to go + /// on, any value containing @ is masked when + /// is off. The result is ped; "(none)" for a null or empty list. + /// + internal static string FormatUntypedSans( + IEnumerable values, bool logSensitiveRequestData, string separator = "; ") + { + var parts = values?.Select(v => FormatSanValue(null, v, logSensitiveRequestData)).ToList(); + return parts == null || parts.Count == 0 ? "(none)" : Strip(string.Join(separator, parts)); + } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index fc07833..9116145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - **Renewals now use the certificate template's product code.** Renewals previously always used the connector's `DefaultProductCode`, which could send an empty product code if that setting was never configured. Renewals now use the template's code, falling back to `DefaultProductCode` only when the template doesn't have one. - **`GroupNumber`, `AutoSecureWww`, and the technical contact now reach CERTInext**; they were previously sent in fields CERTInext doesn't read. - **Renewals now send the full order details** (group, `AutoSecureWww`, technical contact, organization, remarks), the same as a new enrollment. +- **Gateway logs no longer contain the `authKey` or requestor personal data by default**; set `LogSensitiveRequestData` to log PII temporarily (credentials stay redacted). ## Chores - **`OrganizationNumber`, `DefaultProductCode`, and `GroupNumber` are now visible in the startup log.** Whether each is set is now logged alongside the other connector settings, making a misconfigured connector easier to diagnose from logs alone. diff --git a/docsource/configuration.md b/docsource/configuration.md index 9800004..d1a9e0e 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -125,6 +125,7 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | +| `LogSensitiveRequestData` | Optional | **Diagnostic escape hatch — off by default.** When `true`, this writes requestor personal data (name, email, phone, and other organization contact details) and full CA request/response payloads to the gateway logs. It's meant for temporary use while verifying a new deployment (confirming exactly what was sent to the CA and that the order succeeded) — turn it back off once verification is complete. When `false` (default), personal-data fields are redacted (email is masked but keeps its domain, e.g. `j***@example.com`) and the enrollment log line omits the requester name entirely. Email SAN values (rfc822Name) in log lines are masked the same way; DNS, IP and URI SANs are always logged in full. Credentials (access keys, `authKey` digests, OAuth secrets, tokens) are always redacted regardless of this setting. Default: `false`. | N/A | `false` | | `PickupRetries` | Optional | Number of times `Enroll` polls CERTInext for the certificate after a successful order submission, before returning pending and leaving pickup to the next sync. Set to `0` to disable the wait. OV/EV orders validate asynchronously (minutes to hours) and typically exhaust this wait regardless of the value. Default: `5`. | N/A | `5` | | `PickupDelay` | Optional | Seconds between certificate-pickup retries. `PickupRetries × PickupDelay` (plus a short initial delay) bounds how long an enrollment call occupies a Command worker thread — capped at a 180s ceiling regardless of how the two are set (aim for well under ~90s in practice, so the call doesn't run long enough to trip Command's own timeout). Default: `10` (a ~55s ceiling with default `PickupRetries`). | N/A | `10` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | diff --git a/integration-manifest.json b/integration-manifest.json index 6d397ff..37de584 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -141,6 +141,10 @@ "name": "Enabled", "description": "Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true." }, + { + "name": "LogSensitiveRequestData", + "description": "OPTIONAL diagnostic escape hatch. When true, enabling it writes requestor personal data (name, email, phone, and other organization contact details) and full CA request/response payloads to the gateway logs. Meant for temporary use while verifying a new deployment — confirming exactly what was sent to the CA and that the order succeeded — and should be turned back off once verification is complete. When false (default), personal-data fields are redacted (email is masked but keeps its domain, e.g. 'j***@example.com') and the enrollment log line omits the requester name entirely. Email SAN values (rfc822Name) in log lines are masked the same way; DNS, IP and URI SANs are always logged in full. Credentials (access keys, authKey digests, OAuth secrets, tokens) are always redacted regardless of this setting. Default: false." + }, { "name": "DcvEnabled", "description": "OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false." From 39f0264bb3f439fc822a77ea9f54cbb7a733f13a Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:35:06 -0700 Subject: [PATCH 19/71] test(logging): regression coverage for LogSensitiveRequestData redaction (0040) V1 parts of feat/certinextv2 6c12174, 55e6673 and e4f8bf1 (V2 order-shape tests dropped), plus ClientPayloadLogRedactionTests: drives CERTInextClient against WireMock and asserts the real PlaceOrder/TrackOrder Trace dumps and the LogApiFailure body never contain the sent authKey, omit PII with the flag off, and include PII (credentials still redacted) with the flag on. Synthetic data. --- .../CERTInextCAPluginAuditLoggingTests.cs | 182 +++++++ .../ClientPayloadLogRedactionTests.cs | 285 ++++++++++ CERTInext.Tests/MaskEmailTests.cs | 54 ++ CERTInext.Tests/RedactPersonalDataTests.cs | 497 ++++++++++++++++++ CERTInext.Tests/SanLogMaskingTests.cs | 277 ++++++++++ .../SensitiveRequestDataConfigTests.cs | 78 +++ 6 files changed, 1373 insertions(+) create mode 100644 CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs create mode 100644 CERTInext.Tests/ClientPayloadLogRedactionTests.cs create mode 100644 CERTInext.Tests/MaskEmailTests.cs create mode 100644 CERTInext.Tests/RedactPersonalDataTests.cs create mode 100644 CERTInext.Tests/SanLogMaskingTests.cs create mode 100644 CERTInext.Tests/SensitiveRequestDataConfigTests.cs diff --git a/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs new file mode 100644 index 0000000..0ffbabd --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs @@ -0,0 +1,182 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040: pins the on/off behavior of the "Enrollment attempt started" audit log line in + /// for the LogSensitiveRequestData connector + /// setting. + /// + /// CERTInextCAPlugin._logger is a per-instance field assigned from + /// LogHandler.GetClassLogger<CERTInextCAPlugin>() at construction time (unlike + /// Client.CERTInextClient.Logger, which is a static readonly field resolved once + /// per process — not swappable after the fact). Swapping + /// before constructing a fresh plugin instance is therefore a genuine, narrow capture seam for + /// this one log line. All tests in this class run in the "LogHandlerFactory-NoParallel" + /// collection (sequential within the class by xUnit default; the named collection also blocks + /// any other class opting into it from interleaving) and restore the original factory in a + /// finally block so the global static mutation can't outlive a single test. + /// + [Collection("LogHandlerFactory-NoParallel")] + public class CERTInextCAPluginAuditLoggingTests + { + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue Messages { get; } = new(); + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Messages); + public void Dispose() { } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _messages; + public CapturingLogger(ConcurrentQueue messages) => _messages = messages; + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => _messages.Enqueue(formatter(state, exception)); + } + } + + private static Mock NewHappyPathMock() + { + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new API.EnrollCertificateResponse + { + Id = "ORD-AUDIT-001", + Status = "issued", + Certificate = MockCertificateData.FakePemCertificate + }); + return mock; + } + + /// + /// Runs once with a freshly-swapped capturing + /// logger factory in place — constructing the plugin only after the swap, so its + /// per-instance _logger field resolves through the capturing factory — and returns + /// every rendered log message the plugin emitted. RequesterName/RequesterEmail are driven + /// through the template parameters that EnrollmentParams.RequesterName/ + /// RequesterEmail read ( / + /// RequesterEmail), matching what the "Enrollment attempt started" line logs. + /// + private static async Task<(ConcurrentQueue Messages, string SubjectMarker)> CaptureEnrollLogMessagesAsync( + bool logSensitiveRequestData, string requesterName, string requesterEmail) + { + var provider = new CapturingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace)); + + // LogHandler.Factory is a shared static — other test classes construct their own + // CERTInextCAPlugin instances concurrently (xUnit parallelizes across collections by + // default) and, purely by coincidence of timing, some of those may resolve their + // _logger through this same swapped factory while it's active, adding unrelated + // "Enrollment attempt started" lines to provider.Messages. A per-call unique subject + // is the only reliable way to pick this call's own line back out of that noise. + string subjectMarker = "audit-" + Guid.NewGuid().ToString("N"); + try + { + LogHandler.Factory = factory; + + var mock = NewHappyPathMock(); + var config = new CERTInextConfig + { + PickupRetries = 0, + LogSensitiveRequestData = logSensitiveRequestData + }; + // Constructed AFTER the factory swap so its _logger field resolves through it. + var plugin = new CERTInextCAPlugin(mock.Object, config); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + [Constants.EnrollmentParam.RequesterName] = requesterName, + [Constants.EnrollmentParam.RequesterEmail] = requesterEmail + } + }; + + await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: $"CN={subjectMarker}.example.com", + san: null, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + finally + { + // LogHandler.Factory is write-only (no getter to save/restore the prior value), + // so reset to the same NullLoggerFactory the class defaults to absent any host + // configuring a real one — matching every other test's ambient (unconfigured) + // logging state. + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + factory.Dispose(); + } + + return (provider.Messages, subjectMarker); + } + + private static string FindEnrollmentAttemptLine(ConcurrentQueue messages, string subjectMarker) + { + foreach (var m in messages) + { + if (m.Contains("Enrollment attempt started") && m.Contains(subjectMarker)) + return m; + } + return null; + } + + [Fact] + public async Task Enroll_LogSensitiveRequestDataFalse_AuditLineOmitsNameAndMasksEmail() + { + var (messages, marker) = await CaptureEnrollLogMessagesAsync( + logSensitiveRequestData: false, requesterName: "Jane Doe", requesterEmail: "jane.doe@example.com"); + + string line = FindEnrollmentAttemptLine(messages, marker); + line.Should().NotBeNull("the enrollment-attempt audit line must always be logged"); + line.Should().NotContain("Jane Doe", "the requester name must be dropped entirely when the flag is off"); + line.Should().NotContain("RequesterName=", "the RequesterName field itself must be absent from the line, not just blanked"); + line.Should().Contain("j***@example.com", "the requester email must be masked but keep its domain"); + line.Should().NotContain("jane.doe@example.com"); + } + + [Fact] + public async Task Enroll_LogSensitiveRequestDataTrue_AuditLineIncludesNameAndEmailInFull() + { + var (messages, marker) = await CaptureEnrollLogMessagesAsync( + logSensitiveRequestData: true, requesterName: "Jane Doe", requesterEmail: "jane.doe@example.com"); + + string line = FindEnrollmentAttemptLine(messages, marker); + line.Should().NotBeNull("the enrollment-attempt audit line must always be logged"); + line.Should().Contain("Jane Doe", "the requester name is logged in full when the flag is on"); + line.Should().Contain("jane.doe@example.com", "the requester email is logged in full when the flag is on"); + } + } +} diff --git a/CERTInext.Tests/ClientPayloadLogRedactionTests.cs b/CERTInext.Tests/ClientPayloadLogRedactionTests.cs new file mode 100644 index 0000000..37f8e09 --- /dev/null +++ b/CERTInext.Tests/ClientPayloadLogRedactionTests.cs @@ -0,0 +1,285 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Microsoft.Extensions.Logging; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040 regression coverage at the real log call sites in : + /// the PlaceOrderAsync Trace request dump (which used to write the replayable + /// meta.authKey digest and requestor PII verbatim), the TrackOrderAsync Trace + /// response dump, and the LogApiFailure response-body logger. Each test drives the client + /// against WireMock and captures what the client actually logged through + /// CERTInextClient.OverrideLoggerForTests. + /// + /// The client logger is process-wide, so other test classes running in parallel may log into + /// the capture while it is installed; every assertion is scoped to lines carrying this call's + /// unique marker. All data is synthetic. + /// + [Collection("CERTInextClientLogger-NoParallel")] + public class ClientPayloadLogRedactionTests : IDisposable + { + private const string RequestorName = "Jane Doe"; + private const string RequestorEmail = "jane.doe@example.com"; + private const string MaskedRequestorEmail = "j***@example.com"; + private const string RequestorMobile = "5551234567"; + private const string TpcEmail = "tech.contact@example.com"; + private const string SignerName = "John Signer"; + private const string EmailSan = "alice@example.com"; + private const string MaskedEmailSan = "a***@example.com"; + + private readonly WireMockServer _server; + + public ClientPayloadLogRedactionTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() => _server.Stop(); + + private sealed class CapturingLogger : ILogger + { + public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => Entries.Enqueue((logLevel, formatter(state, exception))); + } + + private CERTInextClient BuildClient(bool logSensitiveRequestData) => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "synthetic-access-key", + AccountNumber = "9988776655", + LogSensitiveRequestData = logSensitiveRequestData + }); + + private static GenerateOrderSslRequest BuildOrder(string primaryDomain) => new GenerateOrderSslRequest + { + // Meta left null so PlaceOrderAsync computes a real authKey via BuildMetaAsync. + OrderDetails = new SslOrderDetails + { + ProductCode = "842", + RequestorInformation = new RequestorInformation + { + RequestorName = RequestorName, + RequestorMobileNumber = RequestorMobile, + RequestorEmail = RequestorEmail, + RequestorDesignation = "IT Administrator" + }, + CertificateInformation = new CertificateInformation + { + DomainName = primaryDomain, + AdditionalDomains = new List { "www." + primaryDomain, EmailSan } + }, + AgreementDetails = new AgreementDetails { SignerName = SignerName, SignerPlace = "Austin", SignerIp = "203.0.113.10" }, + TechnicalPointOfContact = new TechnicalPointOfContact { TpcName = "Tech Contact", TpcEmail = TpcEmail, TpcMobileNumber = "5559876543" } + } + }; + + private void StubGenerateOrder(string body) => + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json").WithBody(body)); + + /// Returns the meta.authKey the client actually sent on the wire. + private string SentAuthKey() + { + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/GenerateOrderSSL"); + using var doc = JsonDocument.Parse(entry.RequestMessage.Body ?? "{}"); + string authKey = doc.RootElement.GetProperty("meta").GetProperty("authKey").GetString(); + authKey.Should().NotBeNullOrEmpty("precondition: AccessKey mode sends a computed authKey"); + return authKey; + } + + private static async Task> CaptureAsync(string marker, Func act) + { + var logger = new CapturingLogger(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + { + try { await act(); } + catch (Exception) { /* failure-path tests expect a throw; assertions are on the logs */ } + } + return logger.Entries.Where(e => e.Message != null && e.Message.Contains(marker)).ToList(); + } + + // --------------------------------------------------------------------------- + // PlaceOrderAsync Trace request dump + // --------------------------------------------------------------------------- + + [Fact] + public async Task PlaceOrder_TracePayload_FlagOff_OmitsAuthKeyAndPii() + { + string domain = "po-" + Guid.NewGuid().ToString("N") + ".example.com"; + StubGenerateOrder(MockCertificateData.GenerateOrderSuccessJson("ORD-REDACT-1")); + using var client = BuildClient(logSensitiveRequestData: false); + + var lines = await CaptureAsync(domain, () => client.PlaceOrderAsync(BuildOrder(domain))); + string authKey = SentAuthKey(); + + var dump = lines.Where(l => l.Message.StartsWith("PlaceOrderAsync request payload")).ToList(); + dump.Should().ContainSingle(); + dump[0].Level.Should().Be(LogLevel.Trace); + string payload = dump[0].Message; + + payload.Should().NotContain(authKey, "the replayable authKey digest must never be logged"); + payload.Should().Contain("\"authKey\":\"***REDACTED***\""); + payload.Should().NotContain(RequestorName).And.NotContain(RequestorEmail).And.NotContain(RequestorMobile) + .And.NotContain(TpcEmail).And.NotContain(SignerName).And.NotContain(EmailSan); + payload.Should().Contain(MaskedRequestorEmail).And.Contain(MaskedEmailSan).And.Contain("www." + domain); + + lines.Should().NotContain(l => l.Message.Contains(authKey) || l.Message.Contains(EmailSan) || l.Message.Contains(RequestorEmail), + "no client log line for this order may carry the authKey or unmasked email with the flag off"); + } + + [Fact] + public async Task PlaceOrder_TracePayload_FlagOn_IncludesPiiButStillRedactsAuthKey() + { + string domain = "po-" + Guid.NewGuid().ToString("N") + ".example.com"; + StubGenerateOrder(MockCertificateData.GenerateOrderSuccessJson("ORD-REDACT-2")); + using var client = BuildClient(logSensitiveRequestData: true); + + var lines = await CaptureAsync(domain, () => client.PlaceOrderAsync(BuildOrder(domain))); + string authKey = SentAuthKey(); + + string payload = lines.Single(l => l.Message.StartsWith("PlaceOrderAsync request payload")).Message; + + payload.Should().NotContain(authKey, "credentials are redacted regardless of LogSensitiveRequestData"); + payload.Should().Contain("\"authKey\":\"***REDACTED***\""); + payload.Should().Contain(RequestorName).And.Contain(RequestorEmail).And.Contain(RequestorMobile) + .And.Contain(TpcEmail).And.Contain(SignerName).And.Contain(EmailSan); + + lines.Should().NotContain(l => l.Message.Contains(authKey)); + } + + [Fact] + public async Task PlaceOrder_SubmittingOrderLine_MasksEmailSanUnlessFlagOn() + { + string domain = "po-" + Guid.NewGuid().ToString("N") + ".example.com"; + StubGenerateOrder(MockCertificateData.GenerateOrderSuccessJson("ORD-REDACT-3")); + + using (var off = BuildClient(logSensitiveRequestData: false)) + { + var lines = await CaptureAsync(domain, () => off.PlaceOrderAsync(BuildOrder(domain))); + lines.Single(l => l.Message.StartsWith("Submitting order to CERTInext")).Message + .Should().Contain(MaskedEmailSan).And.NotContain(EmailSan); + } + + using (var on = BuildClient(logSensitiveRequestData: true)) + { + var lines = await CaptureAsync(domain, () => on.PlaceOrderAsync(BuildOrder(domain))); + lines.Single(l => l.Message.StartsWith("Submitting order to CERTInext")).Message + .Should().Contain(EmailSan); + } + } + + // --------------------------------------------------------------------------- + // LogApiFailure — CA error body echoing request fields + // --------------------------------------------------------------------------- + + private static string FailureBodyEchoingRequest(string domain) => + "{\"meta\":{\"status\":\"0\",\"errorCode\":\"EMS-100\",\"errorMessage\":\"Validation failed\"," + + "\"authKey\":\"0f1e2d3c4b5a6978synthetic\"}," + + "\"orderDetails\":{\"requestorInformation\":{\"requestorName\":\"" + RequestorName + "\",\"requestorEmail\":\"" + RequestorEmail + "\"}," + + "\"certificateInformation\":{\"domainName\":\"" + domain + "\",\"additionalDomains\":[\"" + EmailSan + "\"]}}}"; + + [Fact] + public async Task PlaceOrder_ApiFailureBody_FlagOff_RedactsCredentialsAndPii() + { + string domain = "fail-" + Guid.NewGuid().ToString("N") + ".example.com"; + StubGenerateOrder(FailureBodyEchoingRequest(domain)); + using var client = BuildClient(logSensitiveRequestData: false); + + var lines = await CaptureAsync(domain, () => client.PlaceOrderAsync(BuildOrder(domain))); + + string failure = lines.Single(l => l.Message.StartsWith("CERTInext API non-success")).Message; + failure.Should().NotContain("0f1e2d3c4b5a6978synthetic") + .And.NotContain(RequestorName).And.NotContain(RequestorEmail).And.NotContain(EmailSan); + failure.Should().Contain(MaskedRequestorEmail).And.Contain(MaskedEmailSan).And.Contain("EMS-100"); + } + + [Fact] + public async Task PlaceOrder_ApiFailureBody_FlagOn_KeepsPiiButRedactsCredentials() + { + string domain = "fail-" + Guid.NewGuid().ToString("N") + ".example.com"; + StubGenerateOrder(FailureBodyEchoingRequest(domain)); + using var client = BuildClient(logSensitiveRequestData: true); + + var lines = await CaptureAsync(domain, () => client.PlaceOrderAsync(BuildOrder(domain))); + + string failure = lines.Single(l => l.Message.StartsWith("CERTInext API non-success")).Message; + failure.Should().NotContain("0f1e2d3c4b5a6978synthetic"); + failure.Should().Contain(RequestorName).And.Contain(RequestorEmail).And.Contain(EmailSan); + } + + // --------------------------------------------------------------------------- + // TrackOrderAsync Trace response dump + // --------------------------------------------------------------------------- + + private void StubTrackOrder(string orderNumber) => + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody( + "{\"meta\":{\"status\":\"1\"},\"orderDetails\":{\"orderNumber\":\"" + orderNumber + "\"," + + "\"orderStatusId\":\"1\",\"certificateStatusId\":\"1\"," + + "\"requestorInformation\":{\"requestorName\":\"" + RequestorName + "\",\"requestorEmail\":\"" + RequestorEmail + "\"}," + + "\"domainVerification\":{\"example.com\":{\"dcvStatus\":\"1\"},\"" + EmailSan + "\":{\"dcvStatus\":\"0\"},\"status\":\"0\"}}}")); + + [Fact] + public async Task TrackOrder_TracePayload_FlagOff_OmitsPii() + { + string order = "ORD-" + Guid.NewGuid().ToString("N"); + StubTrackOrder(order); + using var client = BuildClient(logSensitiveRequestData: false); + + var lines = await CaptureAsync(order, () => client.TrackOrderAsync(order)); + + var dump = lines.Where(l => l.Message.StartsWith("TrackOrderAsync response payload")).ToList(); + dump.Should().ContainSingle(); + dump[0].Level.Should().Be(LogLevel.Trace); + dump[0].Message.Should().NotContain(RequestorName).And.NotContain(RequestorEmail).And.NotContain(EmailSan); + dump[0].Message.Should().Contain(MaskedRequestorEmail).And.Contain(MaskedEmailSan); + } + + [Fact] + public async Task TrackOrder_TracePayload_FlagOn_IncludesPii() + { + string order = "ORD-" + Guid.NewGuid().ToString("N"); + StubTrackOrder(order); + using var client = BuildClient(logSensitiveRequestData: true); + + var lines = await CaptureAsync(order, () => client.TrackOrderAsync(order)); + + lines.Single(l => l.Message.StartsWith("TrackOrderAsync response payload")).Message + .Should().Contain(RequestorName).And.Contain(RequestorEmail).And.Contain(EmailSan); + } + } +} diff --git a/CERTInext.Tests/MaskEmailTests.cs b/CERTInext.Tests/MaskEmailTests.cs new file mode 100644 index 0000000..f3bd3a2 --- /dev/null +++ b/CERTInext.Tests/MaskEmailTests.cs @@ -0,0 +1,54 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040: is the shared email-masking helper used + /// by both CERTInextCAPlugin (the enrollment-attempt Information log line) and + /// Client.CERTInextClient (RedactPersonalData) when LogSensitiveRequestData + /// is off. + /// + public class MaskEmailTests + { + [Theory] + [InlineData("jane.doe@example.com", "j***@example.com")] + [InlineData("a@b.co", "a***@b.co")] + [InlineData("Jane.Doe@Example.COM", "J***@Example.COM")] + public void MaskEmail_KeepsFirstCharacterAndDomain(string input, string expected) + { + LogSanitizer.MaskEmail(input).Should().Be(expected); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void MaskEmail_HandlesNullAndEmpty(string input) + { + LogSanitizer.MaskEmail(input).Should().Be(input); + } + + [Theory] + [InlineData("not-an-email")] + [InlineData("@example.com")] + public void MaskEmail_NoUsableLocalPart_FallsBackToFullRedaction(string input) + { + LogSanitizer.MaskEmail(input).Should().Be("***REDACTED***"); + } + } +} diff --git a/CERTInext.Tests/RedactPersonalDataTests.cs b/CERTInext.Tests/RedactPersonalDataTests.cs new file mode 100644 index 0000000..1a86951 --- /dev/null +++ b/CERTInext.Tests/RedactPersonalDataTests.cs @@ -0,0 +1,497 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040: requestor personal data (name, email, phone, org contact fields) must not + /// appear in gateway logs unless the connector's LogSensitiveRequestData setting is + /// explicitly turned on, and credentials (the meta.authKey digest) must never appear. + /// These tests pin and + /// against realistic V1 order payload JSON, + /// produced by serializing the real request/response models rather than hand-written strings + /// — so a future rename of a JSON property name (which would silently stop the redactor from + /// matching it) fails these tests immediately. All data is synthetic. + /// + public class RedactPersonalDataTests + { + // Mirrors CERTInextClient.GetJsonOptions() (private), so serialized payloads in these + // tests match the real wire shape (case-insensitive property names, nulls omitted). + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private const string SyntheticAuthKey = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0"; + + // --------------------------------------------------------------------------- + // V1 GenerateOrderSSL request — requestorInformation / technicalPointOfContact / + // agreementDetails all carry personal data; certificateInformation/orderDetails don't. + // --------------------------------------------------------------------------- + + private static string BuildV1OrderRequestJson() + { + var request = new GenerateOrderSslRequest + { + Meta = new RequestMeta + { + Ver = "1.0", Ts = "2026-05-22T10:00:00+00:00", Txn = "1234567890", + AccountNumber = "9988776655", AuthKey = SyntheticAuthKey + }, + OrderDetails = new SslOrderDetails + { + ProductCode = "842", + AccountingModel = "2", + SaveAndHold = "0", + EmailNotifications = "0", + RequestorInformation = new RequestorInformation + { + RequestorName = "Jane Doe", + RequestorIsdCode = "1", + RequestorMobileNumber = "5551234567", + RequestorEmail = "jane.doe@example.com", + RequestorDesignation = "IT Administrator" + }, + SubscriptionDetails = new SubscriptionDetails { Validity = "1", AutoRenew = "0", RenewCriteria = "30" }, + CertificateInformation = new CertificateInformation + { + DomainName = "example.com", + AdditionalDomains = new System.Collections.Generic.List { "alt.example.com", "www.example.com" } + }, + AgreementDetails = new AgreementDetails + { + AcceptAgreement = "1", + SignerName = "John Signer", + SignerPlace = "Austin", + SignerIp = "203.0.113.10" + }, + TechnicalPointOfContact = new TechnicalPointOfContact + { + TpcName = "Tech Contact", + TpcEmail = "tech.contact@example.com", + TpcIsdCode = "1", + TpcMobileNumber = "5559876543" + } + } + }; + + return JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + } + + [Fact] + public void RedactPersonalData_V1OrderRequest_RemovesAllPersonFields() + { + string input = BuildV1OrderRequestJson(); + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().NotContain("Jane Doe"); + output.Should().NotContain("jane.doe@example.com"); + output.Should().NotContain("5551234567"); + output.Should().NotContain("IT Administrator"); + output.Should().NotContain("John Signer"); + output.Should().NotContain("Austin"); + output.Should().NotContain("203.0.113.10"); + output.Should().NotContain("Tech Contact"); + output.Should().NotContain("tech.contact@example.com"); + output.Should().NotContain("5559876543"); + } + + [Fact] + public void RedactPersonalData_V1OrderRequest_MasksEmailsKeepingDomain() + { + string output = CERTInextClient.RedactPersonalData(BuildV1OrderRequestJson()); + + output.Should().Contain("\"requestorEmail\":\"j***@example.com\""); + output.Should().Contain("\"tpcEmail\":\"t***@example.com\""); + } + + [Fact] + public void RedactPersonalData_V1OrderRequest_PreservesNonPersonalFields() + { + string output = CERTInextClient.RedactPersonalData(BuildV1OrderRequestJson()); + + output.Should().Contain("\"productCode\":\"842\""); + output.Should().Contain("\"domainName\":\"example.com\""); + output.Should().Contain("alt.example.com"); + output.Should().Contain("www.example.com"); + output.Should().Contain("\"accountNumber\":\"9988776655\""); + output.Should().Contain("\"validity\":\"1\""); + } + + [Fact] + public void RedactPersonalData_V1OrderRequest_RedactsRequestorNameToPlaceholder() + { + string output = CERTInextClient.RedactPersonalData(BuildV1OrderRequestJson()); + + output.Should().Contain("\"requestorName\":\"***REDACTED***\""); + output.Should().Contain("\"requestorMobileNumber\":\"***REDACTED***\""); + output.Should().Contain("\"requestorDesignation\":\"***REDACTED***\""); + output.Should().Contain("\"signerName\":\"***REDACTED***\""); + output.Should().Contain("\"signerPlace\":\"***REDACTED***\""); + output.Should().Contain("\"signerIP\":\"***REDACTED***\""); + output.Should().Contain("\"tpcName\":\"***REDACTED***\""); + output.Should().Contain("\"tpcMobileNumber\":\"***REDACTED***\""); + } + + // --------------------------------------------------------------------------- + // The V1 order body's meta.authKey is a replayable credential — always redacted by + // ApplyLoggingRedaction, whatever LogSensitiveRequestData says. + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ApplyLoggingRedaction_V1OrderRequest_AuthKeyAlwaysRedacted(bool logSensitiveRequestData) + { + string input = BuildV1OrderRequestJson(); + input.Should().Contain(SyntheticAuthKey, "precondition: the serialized body carries meta.authKey"); + + string output = CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData); + + output.Should().NotContain(SyntheticAuthKey); + output.Should().Contain("\"authKey\":\"***REDACTED***\""); + } + + [Fact] + public void ApplyLoggingRedaction_V1OrderRequest_FlagOn_KeepsPersonalDataInFull() + { + string output = CERTInextClient.ApplyLoggingRedaction(BuildV1OrderRequestJson(), logSensitiveRequestData: true); + + output.Should().Contain("Jane Doe"); + output.Should().Contain("jane.doe@example.com"); + output.Should().Contain("5551234567"); + output.Should().Contain("tech.contact@example.com"); + } + + // --------------------------------------------------------------------------- + // Whitespace tolerance — a pretty-printed body must redact identically to a compact one. + // --------------------------------------------------------------------------- + + [Fact] + public void RedactPersonalData_TolerantOfWhitespaceAroundKeyValueSeparator() + { + string input = "{\n \"requestorInformation\" : {\n \"requestorName\" : \"Jane Doe\",\n \"requestorEmail\":\"jane.doe@example.com\"\n }\n}"; + + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().NotContain("Jane Doe"); + output.Should().Contain("j***@example.com"); + } + + [Fact] + public void RedactPersonalData_BareContactKeysInResponseBody_AreRedacted_DefenceInDepth() + { + // No V1 request uses bare name/email/phone/designation keys, but CA error/response + // bodies are not contractually fixed — these are redacted as defence in depth. + string input = "{\"contact\":{\"name\":\"Jane Doe\",\"email\":\"jane.doe@example.com\",\"phone\":\"+15551234567\",\"designation\":\"IT Administrator\"}}"; + + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().Be("{\"contact\":{\"name\":\"***REDACTED***\",\"email\":\"j***@example.com\",\"phone\":\"***REDACTED***\",\"designation\":\"***REDACTED***\"}}"); + } + + // --------------------------------------------------------------------------- + // Edge cases + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + public void RedactPersonalData_HandlesNullAndEmpty(string input) + { + CERTInextClient.RedactPersonalData(input).Should().Be(input); + } + + [Fact] + public void RedactPersonalData_LeavesAlreadyBlankFieldsUntouched() + { + string input = "{\"requestorName\":\"\",\"requestorEmail\":\"\"}"; + CERTInextClient.RedactPersonalData(input).Should().Be(input, + "there is nothing to redact in an already-blank field"); + } + + [Fact] + public void RedactPersonalData_MalformedEmailValue_FallsBackToFullRedaction() + { + string input = "{\"requestorEmail\":\"not-an-email\"}"; + string output = CERTInextClient.RedactPersonalData(input); + output.Should().Be("{\"requestorEmail\":\"***REDACTED***\"}"); + } + + [Fact] + public void RedactPersonalData_DoesNotTouchUnrelatedNameLikeKeys() + { + // domainName / organizationName end in "Name" but are not the exact key "name" — + // the anchored quote-delimited match must not treat them as substrings of "name". + string input = "{\"domainName\":\"example.com\",\"organizationName\":\"Acme Corp\"}"; + CERTInextClient.RedactPersonalData(input).Should().Be(input); + } + + // --------------------------------------------------------------------------- + // Credentials are always redacted, regardless of RedactPersonalData + // --------------------------------------------------------------------------- + + [Fact] + public void RedactPersonalData_DoesNotRedactCredentials_ThatIsRedactCredentialsJob() + { + // RedactPersonalData is deliberately scoped to person/contact fields only; credential + // scrubbing is RedactCredentials's job and is applied unconditionally by + // ApplyLoggingRedaction regardless of this method. + string input = "{\"authKey\":\"deadbeef\",\"requestorName\":\"Jane Doe\"}"; + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().Contain("deadbeef", "RedactPersonalData alone does not scrub credentials"); + output.Should().NotContain("Jane Doe"); + } + + // --------------------------------------------------------------------------- + // ApplyLoggingRedaction — the flag-gated composition used at every log site + // --------------------------------------------------------------------------- + + [Fact] + public void ApplyLoggingRedaction_FlagOff_RedactsBothCredentialsAndPersonalData() + { + string input = "{\"authKey\":\"deadbeef\",\"requestorName\":\"Jane Doe\",\"requestorEmail\":\"jane.doe@example.com\",\"domainName\":\"example.com\"}"; + + string output = CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: false); + + output.Should().NotContain("deadbeef"); + output.Should().NotContain("Jane Doe"); + output.Should().Contain("j***@example.com"); + output.Should().Contain("\"domainName\":\"example.com\""); + } + + [Fact] + public void ApplyLoggingRedaction_FlagOn_RedactsCredentialsOnly_LeavesPersonalDataInFull() + { + string input = "{\"authKey\":\"deadbeef\",\"requestorName\":\"Jane Doe\",\"requestorEmail\":\"jane.doe@example.com\",\"domainName\":\"example.com\"}"; + + string output = CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: true); + + output.Should().NotContain("deadbeef", "credentials must always be redacted, even with the flag on"); + output.Should().Contain("Jane Doe", "personal data is left in full when the flag is on"); + output.Should().Contain("jane.doe@example.com", "personal data is left in full when the flag is on"); + output.Should().Contain("\"domainName\":\"example.com\""); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ApplyLoggingRedaction_HandlesNullAndEmpty(string input) + { + CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: false).Should().Be(input); + CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: true).Should().Be(input); + } + + // --------------------------------------------------------------------------- + // Email SANs inside SAN arrays (V1 additionalDomains carries every SAN type) and V1 + // TrackOrder domainVerification keys, which the key/value regex can't reach. + // --------------------------------------------------------------------------- + + private static string BuildV1OrderRequestJsonWithMixedSans(bool indented) + { + var request = new GenerateOrderSslRequest + { + Meta = new RequestMeta { Ver = "1.0", Ts = "2026-05-22T10:00:00+00:00", Txn = "1234567890", AccountNumber = "9988776655" }, + OrderDetails = new SslOrderDetails + { + ProductCode = "844", + RequestorInformation = new RequestorInformation { RequestorName = "Jane Doe", RequestorEmail = "jane.doe@example.com" }, + CertificateInformation = new CertificateInformation + { + DomainName = "example.com", + AdditionalDomains = new System.Collections.Generic.List { "a.example.com", "alice@example.com", "10.0.0.1" } + } + } + }; + var options = ClientEquivalentJsonOptions(); + options.WriteIndented = indented; + return JsonSerializer.Serialize(request, options); + } + + [Fact] + public void ApplyLoggingRedaction_V1AdditionalDomains_FlagOff_MasksOnlyEmailElement() + { + string output = CERTInextClient.ApplyLoggingRedaction(BuildV1OrderRequestJsonWithMixedSans(indented: false), logSensitiveRequestData: false); + + output.Should().NotContain("alice@"); + output.Should().Contain("\"additionalDomains\":[\"a.example.com\",\"a***@example.com\",\"10.0.0.1\"]"); + output.Should().Contain("\"domainName\":\"example.com\""); + output.Should().Contain("j***@example.com", "the existing key/value redaction still runs"); + } + + [Fact] + public void ApplyLoggingRedaction_V1AdditionalDomains_PrettyPrinted_FlagOff_MasksOnlyEmailElement() + { + string input = BuildV1OrderRequestJsonWithMixedSans(indented: true); + input.Should().Contain("\n", "precondition: the body is pretty-printed"); + + string output = CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: false); + + output.Should().NotContain("alice@"); + output.Should().Contain("\"a***@example.com\""); + output.Should().Contain("\"a.example.com\""); + output.Should().Contain("\"10.0.0.1\""); + // Only the email token changes; the layout of the array is preserved. + string expectedArray = System.Text.RegularExpressions.Regex.Match(input, @"""additionalDomains"":\s*\[[^\]]*\]").Value + .Replace("\"alice@example.com\"", "\"a***@example.com\""); + expectedArray.Should().NotBeEmpty(); + output.Should().Contain(expectedArray); + } + + [Fact] + public void ApplyLoggingRedaction_V1AdditionalDomains_FlagOn_LeavesArrayVerbatim() + { + string input = BuildV1OrderRequestJsonWithMixedSans(indented: false); + + string output = CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: true); + + output.Should().Be(CERTInextClient.RedactCredentials(input)); + output.Should().Contain("\"additionalDomains\":[\"a.example.com\",\"alice@example.com\",\"10.0.0.1\"]"); + } + + [Fact] + public void RedactPersonalData_HandWrittenWhitespaceInSanArray_MasksEmailAndKeepsLayout() + { + string input = "{ \"additionalDomains\" :\n [ \"a.example.com\" ,\n \"alice@example.com\",\"10.0.0.1\" ] }"; + + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().Be("{ \"additionalDomains\" :\n [ \"a.example.com\" ,\n \"a***@example.com\",\"10.0.0.1\" ] }"); + } + + [Fact] + public void RedactPersonalData_SanArrayKeyMatch_IsCaseInsensitive() + { + CERTInextClient.RedactPersonalData("{\"AdditionalDomains\":[\"alice@example.com\"]}") + .Should().Be("{\"AdditionalDomains\":[\"a***@example.com\"]}"); + } + + [Fact] + public void RedactPersonalData_EscapedEmailElement_IsMasked() + { + // Elements using JSON unicode escapes (backslash-u0040 for '@', backslash-u0069 for 'i') + // must still be detected and masked. + CERTInextClient.RedactPersonalData("{\"additionalDomains\":[\"alice\\u0040example.com\",\"al\\u0069ce@example.com\"]}") + .Should().Be("{\"additionalDomains\":[\"a***@example.com\",\"a***@example.com\"]}"); + } + + [Fact] + public void RedactPersonalData_UnrelatedArraysAndValuesWithAt_AreUntouched() + { + // Only the named SAN containers are touched. An '@' in any other array, object key or + // string value is left as it is. + string input = "{\"notifyList\":[\"alice@example.com\"],\"tags\":[\"x@y\"],\"note\":\"ping bob@example.com\"," + + "\"customFields\":{\"owner@example.com\":\"v\"},\"additionalDomains\":[\"a.example.com\"]}"; + + CERTInextClient.RedactPersonalData(input).Should().Be(input); + } + + [Fact] + public void RedactPersonalData_NestedContainersInsideSanArray_AreNotDescendedInto() + { + // Only direct string elements of the array are candidates. + string input = "{\"additionalDomains\":[[\"alice@example.com\"],{\"k\":\"bob@example.com\"},\"carol@example.com\"]}"; + + CERTInextClient.RedactPersonalData(input) + .Should().Be("{\"additionalDomains\":[[\"alice@example.com\"],{\"k\":\"bob@example.com\"},\"c***@example.com\"]}"); + } + + // V1 TrackOrder wire shape per the spec: domainVerification is keyed by domain name, with a + // block-level "status". An email SAN submitted in additionalDomains comes back as one of + // these keys. + private const string V1TrackOrderResponseWithEmailDomainKey = + "{\"meta\":{\"status\":\"1\"},\"orderDetails\":{\"orderStatus\":\"Pending\"," + + "\"domainVerification\":{" + + "\"example.com\":{\"dcvMethod\":\"DNS\",\"dcvStatus\":\"1\",\"status\":\"1\",\"verifiedDate\":\"2026-09-01\",\"caaStatus\":\"1\"}," + + "\"san-probe@example.com\":{\"dcvMethod\":\"\",\"dcvStatus\":\"0\",\"status\":\"1\",\"verifiedDate\":\"\",\"caaStatus\":\"1\"}," + + "\"192.0.2.10\":{\"dcvMethod\":\"\",\"dcvStatus\":\"0\",\"status\":\"1\",\"verifiedDate\":\"\",\"caaStatus\":\"1\"}," + + "\"status\":\"0\"}," + + "\"customFields\":{\"owner@example.com\":\"kept\"}}}"; + + [Fact] + public void ApplyLoggingRedaction_V1TrackOrderDomainVerification_FlagOff_MasksEmailKeyOnly() + { + string output = CERTInextClient.ApplyLoggingRedaction(V1TrackOrderResponseWithEmailDomainKey, logSensitiveRequestData: false); + + output.Should().Be(V1TrackOrderResponseWithEmailDomainKey.Replace("\"san-probe@example.com\":", "\"s***@example.com\":")); + + // The masked body still deserializes into the real DTO and keeps the DNS/IP entries. + var parsed = JsonSerializer.Deserialize(output, ClientEquivalentJsonOptions()); + var domainVerification = parsed?.OrderDetails?.DomainVerification; + domainVerification.Should().NotBeNull(); + domainVerification!.GetDomainEntries().Keys.Should().BeEquivalentTo(new[] { "example.com", "s***@example.com", "192.0.2.10" }); + domainVerification.Status.Should().Be("0"); + } + + [Fact] + public void ApplyLoggingRedaction_V1TrackOrderDomainVerification_FlagOn_Verbatim() + { + CERTInextClient.ApplyLoggingRedaction(V1TrackOrderResponseWithEmailDomainKey, logSensitiveRequestData: true) + .Should().Be(V1TrackOrderResponseWithEmailDomainKey); + } + + [Fact] + public void RedactPersonalData_DomainVerificationPrettyPrinted_MasksEmailKey() + { + string input = "{\n \"domainVerification\" : {\n \"alice@example.com\" : { \"dcvStatus\" : \"0\" },\n \"status\" : \"0\"\n }\n}"; + + CERTInextClient.RedactPersonalData(input) + .Should().Be("{\n \"domainVerification\" : {\n \"a***@example.com\" : { \"dcvStatus\" : \"0\" },\n \"status\" : \"0\"\n }\n}"); + } + + [Theory] + [InlineData("{\"additionalDomains\":[\"a.example.com\",\"alice@example.com\",\"bob@ex")] + [InlineData("{\"additionalDomains\":[")] + [InlineData("{\"additionalDomains\":[\"alice@example.com\"")] + [InlineData("{\"domainVerification\":{\"alice@example.com\":{\"dcvStatus\":")] + [InlineData("{\"additionalDomains\":[\"alice@example.com\",,]} trailing @ garbage")] + [InlineData("{not json at all @ }")] + [InlineData("[\"@\"")] + [InlineData("contact admin@example.com")] + [InlineData("additionalDomains=alice@example.com&x=1")] + [InlineData(" ")] + public void RedactPersonalData_MalformedOrTruncatedBody_DoesNotThrow(string input) + { + System.Func act = () => CERTInextClient.RedactPersonalData(input); + act.Should().NotThrow(); + System.Func act2 = () => CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: false); + act2.Should().NotThrow(); + } + + [Fact] + public void RedactPersonalData_TruncatedBody_MasksElementsSeenBeforeTheFault() + { + // A body cut off mid-array keeps the masks for the complete elements before the cut. + // The partial last element is not a complete token, so it is left as it was. + CERTInextClient.RedactPersonalData("{\"additionalDomains\":[\"a.example.com\",\"alice@example.com\",\"bob@ex") + .Should().Be("{\"additionalDomains\":[\"a.example.com\",\"a***@example.com\",\"bob@ex"); + } + + [Fact] + public void RedactPersonalData_NonJsonBody_IsReturnedUnchanged() + { + string input = "additionalDomains=alice@example.com&x=1"; + CERTInextClient.RedactPersonalData(input).Should().Be(input); + } + } +} diff --git a/CERTInext.Tests/SanLogMaskingTests.cs b/CERTInext.Tests/SanLogMaskingTests.cs new file mode 100644 index 0000000..2bc2104 --- /dev/null +++ b/CERTInext.Tests/SanLogMaskingTests.cs @@ -0,0 +1,277 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040 follow-up: with LogSensitiveRequestData off, email-type SAN values are + /// masked in log lines (); DNS, IP and URI values stay + /// verbatim. With the flag on, everything is logged in full. + /// + public class LogSanitizerFormatSansTests + { + [Theory] + [InlineData("rfc822name")] + [InlineData("RFC822Name")] + [InlineData("rfc822")] + [InlineData("email")] + public void FormatSanValue_FlagOff_EmailType_IsMasked(string type) + => LogSanitizer.FormatSanValue(type, "alice@example.com", false).Should().Be("a***@example.com"); + + [Theory] + [InlineData("rfc822name")] + [InlineData("email")] + [InlineData("otherName")] + [InlineData(null)] + public void FormatSanValue_FlagOn_IsVerbatim(string type) + => LogSanitizer.FormatSanValue(type, "alice@example.com", true).Should().Be("alice@example.com"); + + [Theory] + [InlineData("dnsname", "www.example.com")] + [InlineData("dns", "www.example.com")] + [InlineData("ipaddress", "192.0.2.10")] + [InlineData("ip", "2001:db8::1")] + [InlineData("uri", "https://example.com/path")] + [InlineData("uniformresourceidentifier", "https://user@example.com/")] + public void FormatSanValue_DnsIpUri_VerbatimEitherWay(string type, string value) + { + LogSanitizer.FormatSanValue(type, value, false).Should().Be(value); + LogSanitizer.FormatSanValue(type, value, true).Should().Be(value); + } + + [Theory] + [InlineData("upn")] + [InlineData(null)] + public void FormatSanValue_FlagOff_UnknownTypeWithAt_IsMasked(string type) + => LogSanitizer.FormatSanValue(type, "bob@corp.example.com", false).Should().Be("b***@corp.example.com"); + + [Fact] + public void FormatSanValue_FlagOff_UnknownTypeWithoutAt_IsVerbatim() + => LogSanitizer.FormatSanValue("upn", "host.example.com", false).Should().Be("host.example.com"); + + [Theory] + [InlineData(null)] + [InlineData("")] + public void FormatSanValue_NullOrEmptyValue_ReturnedAsIs(string value) + { + LogSanitizer.FormatSanValue("rfc822name", value, false).Should().Be(value); + LogSanitizer.FormatSanValue(null, value, false).Should().Be(value); + } + + [Fact] + public void FormatSans_Dictionary_FlagOff_MasksOnlyEmail() + { + var san = new Dictionary + { + ["dnsname"] = new[] { "a.example.com", "b.example.com" }, + ["ipaddress"] = new[] { "192.0.2.10" }, + ["rfc822name"] = new[] { "alice@example.com" }, + ["uri"] = new[] { "https://example.com" } + }; + + LogSanitizer.FormatSans(san, false).Should().Be( + "dnsname:a.example.com; dnsname:b.example.com; ipaddress:192.0.2.10; " + + "rfc822name:a***@example.com; uri:https://example.com"); + LogSanitizer.FormatSans(san, true).Should().Contain("rfc822name:alice@example.com"); + } + + [Fact] + public void FormatSans_Dictionary_NullOrEmpty_ReturnsNone() + { + LogSanitizer.FormatSans((Dictionary)null, false).Should().Be("(none)"); + LogSanitizer.FormatSans(new Dictionary(), false).Should().Be("(none)"); + LogSanitizer.FormatSans(new Dictionary { ["dnsname"] = null }, false).Should().Be("(none)"); + } + + [Fact] + public void FormatSans_SanEntries_FlagOff_MasksEmail_SkipsNullEntries() + { + var sans = new List + { + new SanEntry { Type = "dns", Value = "a.example.com" }, + null, + new SanEntry { Type = "email", Value = "alice@example.com" } + }; + + LogSanitizer.FormatSans(sans, false).Should().Be("dns:a.example.com; email:a***@example.com"); + LogSanitizer.FormatSans(sans, true).Should().Be("dns:a.example.com; email:alice@example.com"); + LogSanitizer.FormatSans((IEnumerable)null, false).Should().Be("(none)"); + } + + [Fact] + public void FormatSans_StillStripsControlCharacters() + => LogSanitizer.FormatSans(new Dictionary { ["dnsname"] = new[] { "a.example.com\nforged" } }, false) + .Should().Be("dnsname:a.example.com\\nforged"); + + [Fact] + public void FormatUntypedSans_FlagOff_MasksAtValuesOnly() + { + var values = new[] { "a.example.com", "alice@example.com", "192.0.2.10" }; + LogSanitizer.FormatUntypedSans(values, false).Should().Be("a.example.com; a***@example.com; 192.0.2.10"); + LogSanitizer.FormatUntypedSans(values, true).Should().Be("a.example.com; alice@example.com; 192.0.2.10"); + LogSanitizer.FormatUntypedSans(values, false, ", ").Should().Be("a.example.com, a***@example.com, 192.0.2.10"); + } + + [Fact] + public void FormatUntypedSans_NullOrEmpty_ReturnsNone() + { + LogSanitizer.FormatUntypedSans(null, false).Should().Be("(none)"); + LogSanitizer.FormatUntypedSans(Array.Empty(), false).Should().Be("(none)"); + } + } + + /// + /// Plugin-level log capture for the issue 0040 follow-up: the "Enrollment attempt started" + /// line and BuildSanList's "Resolved N SAN(s)" / "submitted rather than dropped" lines + /// must mask an email SAN with LogSensitiveRequestData off and log it in full with it + /// on. Same swap seam and non-parallel collection as + /// ; only lines carrying this call's unique + /// subject marker are considered. + /// + [Collection("LogHandlerFactory-NoParallel")] + public class SanLogMaskingPluginTests + { + private const string EmailSan = "alice@example.com"; + private const string MaskedEmailSan = "a***@example.com"; + private const string IpSan = "192.0.2.10"; + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue Messages { get; } = new(); + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Messages); + public void Dispose() { } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _messages; + public CapturingLogger(ConcurrentQueue messages) => _messages = messages; + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => _messages.Enqueue(formatter(state, exception)); + } + } + + private static async Task<(List Messages, string Primary, EnrollCertificateRequest Captured)> EnrollV1Async( + bool logSensitiveRequestData) + { + string marker = "sanmask-" + Guid.NewGuid().ToString("N"); + string primary = marker + ".example.com"; + + EnrollCertificateRequest captured = null; + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .Callback((req, _) => captured = req) + .ReturnsAsync(new EnrollCertificateResponse + { + Id = "ORD-SANMASK", Status = "issued", Certificate = MockCertificateData.FakePemCertificate + }); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842" + } + }; + var san = new Dictionary + { + ["dnsname"] = new[] { primary }, + ["ipaddress"] = new[] { IpSan }, + ["rfc822name"] = new[] { EmailSan } + }; + + var provider = new CapturingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace)); + try + { + LogHandler.Factory = factory; + // Constructed AFTER the swap so _logger resolves through the capturing factory. + var plugin = new CERTInextCAPlugin(mock.Object, new CERTInextConfig + { + PickupRetries = 0, + LogSensitiveRequestData = logSensitiveRequestData + }); + await plugin.Enroll(MockCertificateData.FakeCsrPem, $"CN={primary}", san, productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + } + finally + { + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + factory.Dispose(); + } + + return (provider.Messages.Where(m => m != null && m.Contains(marker)).ToList(), primary, captured); + } + + [Fact] + public async Task Enroll_FlagOff_MasksEmailSan_KeepsDnsAndIpVerbatim_WireUnchanged() + { + var (messages, primary, captured) = await EnrollV1Async(logSensitiveRequestData: false); + + var start = messages.Where(m => m.StartsWith("Enrollment attempt started")).ToList(); + start.Should().ContainSingle(); + start[0].Should().Contain($"dnsname:{primary}") + .And.Contain($"ipaddress:{IpSan}") + .And.Contain($"rfc822name:{MaskedEmailSan}") + .And.NotContain(EmailSan); + + var resolved = messages.Where(m => m.StartsWith("Resolved ")).ToList(); + resolved.Should().ContainSingle(); + resolved[0].Should().Contain($"dns:{primary}") + .And.Contain($"ip:{IpSan}") + .And.Contain($"email:{MaskedEmailSan}") + .And.NotContain(EmailSan); + + messages.Should().NotContain(m => m.Contains(EmailSan), + "no plugin log line for this enrollment may carry the unmasked email SAN with the flag off"); + + captured.Should().NotBeNull(); + captured!.Sans.Select(s => s.Value).Should().Contain(EmailSan, + "masking is log-only; the SAN still goes to CERTInext unchanged"); + } + + [Fact] + public async Task Enroll_FlagOn_LogsEmailSanInFull() + { + var (messages, _, captured) = await EnrollV1Async(logSensitiveRequestData: true); + + messages.Where(m => m.StartsWith("Enrollment attempt started")).Should().ContainSingle() + .Which.Should().Contain($"rfc822name:{EmailSan}"); + messages.Where(m => m.StartsWith("Resolved ")).Should().ContainSingle() + .Which.Should().Contain($"email:{EmailSan}"); + messages.Should().NotContain(m => m.Contains(MaskedEmailSan)); + + captured!.Sans.Select(s => s.Value).Should().Contain(EmailSan); + } + } +} diff --git a/CERTInext.Tests/SensitiveRequestDataConfigTests.cs b/CERTInext.Tests/SensitiveRequestDataConfigTests.cs new file mode 100644 index 0000000..78b133b --- /dev/null +++ b/CERTInext.Tests/SensitiveRequestDataConfigTests.cs @@ -0,0 +1,78 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using FluentAssertions; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040: LogSensitiveRequestData is a new opt-in CA connector setting, off by + /// default, that gates whether requestor personal data and full CA request/response bodies + /// are written to gateway logs. + /// + public class SensitiveRequestDataConfigTests + { + [Fact] + public void CERTInextConfig_DefaultsToFalse() + { + new CERTInextConfig().LogSensitiveRequestData.Should().BeFalse( + "sensitive-data logging must be opt-in, not opt-out"); + } + + [Fact] + public void GetCAConnectorAnnotations_ContainsLogSensitiveRequestData() + { + var annotations = CERTInextCAPluginConfig.GetCAConnectorAnnotations(); + + annotations.Should().ContainKey(Constants.Config.LogSensitiveRequestData); + + var annotation = annotations[Constants.Config.LogSensitiveRequestData]; + annotation.Type.Should().Be("Boolean"); + annotation.DefaultValue.Should().Be(false); + annotation.Comments.Should().ContainAll("name", "email", "phone", + "temporary", "Credentials"); + } + + [Fact] + public void Constants_LogSensitiveRequestData_MatchesJsonPropertyName() + { + // The Dictionary key used by the Command UI/connector config must match the + // [JsonPropertyName] on CERTInextConfig for the round-trip through + // JsonSerializer.Serialize(configProvider.CAConnectionData) / + // JsonSerializer.Deserialize in Initialize() to work. + Constants.Config.LogSensitiveRequestData.Should().Be("LogSensitiveRequestData"); + } + + [Fact] + public void CERTInextConfig_DeserializesLogSensitiveRequestData_WhenTrue() + { + string json = "{\"LogSensitiveRequestData\": true}"; + var config = System.Text.Json.JsonSerializer.Deserialize(json); + + config.Should().NotBeNull(); + config!.LogSensitiveRequestData.Should().BeTrue(); + } + + [Fact] + public void CERTInextConfig_DeserializesLogSensitiveRequestData_OmittedField_DefaultsFalse() + { + string json = "{\"ApiUrl\": \"https://ca.example.com\"}"; + var config = System.Text.Json.JsonSerializer.Deserialize(json); + + config.Should().NotBeNull(); + config!.LogSensitiveRequestData.Should().BeFalse(); + } + } +} From 7cdac9047b8ed146b4aaa0e6c692a4148d676041 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:23:02 -0700 Subject: [PATCH 20/71] fix(config): dispose transient CERTInextClient in Validate* methods Move ValidateProductInfo's tempClient allocation after the ProfileId guard so the early throw no longer leaks a RestClient + SemaphoreSlim, and dispose tempClient in the finally blocks of ValidateCAConnectionInfo and ValidateProductInfo. Ported from feat/certinextv2 (70b83b2, db6f33e). --- CERTInext/CERTInextCAPlugin.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 0f0f643..eb4c8ac 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -484,6 +484,7 @@ public async Task ValidateCAConnectionInfo(Dictionary connection tempConfig.OAuthClientSecret = string.Empty; tempConfig.Password = string.Empty; } + tempClient?.Dispose(); } _logger.LogInformation( @@ -499,15 +500,10 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Diction string rawConfig = JsonSerializer.Serialize(connectionInfo); var tempConfig = JsonSerializer.Deserialize(rawConfig); - var tempClient = new CERTInextClient(tempConfig); var params_ = new EnrollmentParams(productInfo); string profileId = params_.ProfileId; - _logger.LogInformation( - "Product/profile validation attempt started. ProfileId={ProfileId}, ProductID={ProductID}", - profileId, productInfo?.ProductID); - if (string.IsNullOrWhiteSpace(profileId)) { _logger.LogWarning( @@ -517,6 +513,14 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Diction $"Template parameter '{Constants.EnrollmentParam.ProfileId}' is required but was not set."); } + _logger.LogInformation( + "Product/profile validation attempt started. ProfileId={ProfileId}, ProductID={ProductID}", + profileId, productInfo?.ProductID); + + // Allocated only after the ProfileId guard so the early-throw path above never + // leaks a RestClient + SemaphoreSlim; disposed in the finally below. + var tempClient = new CERTInextClient(tempConfig); + try { var profiles = await tempClient.GetProfilesAsync(); @@ -561,6 +565,7 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Diction tempConfig.OAuthClientSecret = string.Empty; tempConfig.Password = string.Empty; } + tempClient?.Dispose(); } _logger.LogInformation("Product/profile validation succeeded. ProfileId={ProfileId}", profileId); From 3e822326baa7c503358c0ae61c7a1a662ccac796 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:23:03 -0700 Subject: [PATCH 21/71] test(config): regression test for ValidateProductInfo client allocation order --- CERTInext.Tests/CERTInextCAPluginTests.cs | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index 5154146..883f9b5 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -301,6 +301,37 @@ await act.Should().ThrowAsync() .WithMessage("*ProfileId*required*"); } + [Fact] + public async Task ValidateProductInfo_ProfileIdMissing_ThrowsBeforeClientAllocation() + { + // Regression: the transient CERTInextClient was allocated before the ProfileId + // guard, so the early throw leaked a RestClient + SemaphoreSlim (it sat outside the + // try/finally that disposes it). A null ApiUrl makes the CERTInextClient constructor + // throw (NullReferenceException on ApiUrl.TrimEnd), so if the client is still built + // before the guard this test surfaces that exception instead of the validation error. + var mock = NewMock(); + var plugin = BuildPlugin(mock.Object); + + var productInfo = new EnrollmentProductInfo + { + ProductID = string.Empty, + ProductParameters = new Dictionary() + }; + + var connInfo = new Dictionary + { + ["ApiUrl"] = null, + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "key" + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowExactlyAsync() + .WithMessage("*ProfileId*required*"); + mock.VerifyNoOtherCalls(); + } + // --------------------------------------------------------------------------- // Enroll — New // --------------------------------------------------------------------------- From a15c89e13a1afc95ac2217bb6f25e94b17941f13 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:18:31 -0700 Subject: [PATCH 22/71] fix(enroll): re-throw OperationCanceledException in PickUpEnrolledCertificateAsync catch The inner catch (Exception ex) in the poll loop swallowed OperationCanceledException/TaskCanceledException, violating the .NET cancellation contract. All current callers pass CancellationToken.None so this was latent, but a future refactor passing a real token would silently consume cancellation. Add `if (ex is OperationCanceledException) throw;` before the log-and-continue path. Fixes F6. --- CERTInext/CERTInextCAPlugin.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index eb4c8ac..661a403 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -2329,6 +2329,7 @@ private async Task PickUpEnrolledCertificateAsync( } catch (Exception ex) { + if (ex is OperationCanceledException) throw; // A transient fetch failure consumes an attempt rather than aborting the // wait; if it never recovers the pending result is returned below. pollErrors++; From 8799e7b1edd6062a06eacc6d7cd9db86fdbffb60 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:24:17 -0700 Subject: [PATCH 23/71] test(enroll): regression test for pickup loop not swallowing OperationCanceledException --- CERTInext.Tests/CERTInextCAPluginTests.cs | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index 883f9b5..d93daf9 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -509,6 +509,31 @@ public async Task Pickup_ReturnsPending_WhenOrderNeverIssuesWithinBudget() Times.AtLeastOnce, "an enabled pickup must actually poll before giving up"); } + [Fact] + public async Task Pickup_StopsPolling_WhenGetCertificateThrowsOperationCanceled() + { + // Regression: the per-attempt catch swallowed OperationCanceledException as a + // transient poll error and kept polling. It must escape the poll loop; the outer + // pickup guard still degrades it to the pending result for a later sync. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 3); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null, + productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Once, "cancellation must not be consumed as a retryable poll error"); + } + [Fact] public async Task Enroll_New_Throws_WhenProfileIdNotSet() { From 2bffb579ec58f3c0577089674e27152e8bfda065 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:25:56 -0700 Subject: [PATCH 24/71] test(integration): keep opt-in flags env-only and dispose Cloudflare DCV validator Port of the test-fixture hunks of db6f33e: - IntegrationTestFixture no longer promotes opt-in flags from ~/.env_certinext (CERTINEXT_COMPLETE_PENDING, CERTINEXT_RUN_BULK_TEST, CERTINEXT_ALGO_MATRIX, CERTINEXT_ALGO_MATRIX_DCV, CERTINEXT_SAN_PROBE); they must be exported in the shell. - Promotion loop extracted to PromoteToProcessEnvironment with regression tests. - CloudflareDomainValidator/Factory implement IDisposable; DcvLifecycleTests disposes them. - TESTING.md / INTEGRATION_TESTING.md note the env-only flags. --- .../CloudflareDomainValidator.cs | 10 ++-- .../DcvLifecycleTests.cs | 26 ++++++++--- .../INTEGRATION_TESTING.md | 3 ++ .../IntegrationTestFixture.cs | 43 +++++++++++++++-- .../IntegrationTestFixtureTests.cs | 46 +++++++++++++++++++ CERTInext.IntegrationTests/TESTING.md | 3 ++ 6 files changed, 117 insertions(+), 14 deletions(-) diff --git a/CERTInext.IntegrationTests/CloudflareDomainValidator.cs b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs index 89c01eb..db56616 100644 --- a/CERTInext.IntegrationTests/CloudflareDomainValidator.cs +++ b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs @@ -23,7 +23,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests /// Credentials are read from the : /// CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID. /// - internal sealed class CloudflareDomainValidator : IDomainValidator + internal sealed class CloudflareDomainValidator : IDomainValidator, IDisposable { private const string CfApiBase = "https://api.cloudflare.com/client/v4"; @@ -113,11 +113,13 @@ public async Task CleanupValidation(string key, Cancella public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask; public Dictionary GetDomainValidatorAnnotations() => new(); public string GetValidationType() => "dns-01"; + + public void Dispose() => _http.Dispose(); } - internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory + internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory, IDisposable { - private readonly IDomainValidator _validator; + private readonly CloudflareDomainValidator _validator; public CloudflareDomainValidatorFactory(string apiToken, string zoneId) { @@ -125,5 +127,7 @@ public CloudflareDomainValidatorFactory(string apiToken, string zoneId) } public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator; + + public void Dispose() => _validator.Dispose(); } } diff --git a/CERTInext.IntegrationTests/DcvLifecycleTests.cs b/CERTInext.IntegrationTests/DcvLifecycleTests.cs index 24ba0f1..0c05438 100644 --- a/CERTInext.IntegrationTests/DcvLifecycleTests.cs +++ b/CERTInext.IntegrationTests/DcvLifecycleTests.cs @@ -40,10 +40,11 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests /// CERTINEXT_DCV_DOMAIN=<subdomain to use, e.g. dcv-test.example.com> /// /// - public class DcvLifecycleTests : IClassFixture + public class DcvLifecycleTests : IClassFixture, IDisposable { private readonly IntegrationTestFixture _fixture; private readonly ITestOutputHelper _output; + private readonly List _toDispose = new List(); public DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) { @@ -51,6 +52,13 @@ public DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper outpu _output = output; } + public void Dispose() + { + foreach (var d in _toDispose) + d.Dispose(); + _toDispose.Clear(); + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -69,11 +77,17 @@ private static string GenerateCsrPem(string commonName) + "\n-----END CERTIFICATE REQUEST-----"; } - private IDomainValidatorFactory BuildDnsFactory() => - _fixture.IsCloudflareConfigured - ? (IDomainValidatorFactory)new CloudflareDomainValidatorFactory( - _fixture.CloudflareApiToken, _fixture.CloudflareZoneId) - : new StubDomainValidatorFactory(); + private IDomainValidatorFactory BuildDnsFactory() + { + if (_fixture.IsCloudflareConfigured) + { + var factory = new CloudflareDomainValidatorFactory( + _fixture.CloudflareApiToken, _fixture.CloudflareZoneId); + _toDispose.Add(factory); + return factory; + } + return new StubDomainValidatorFactory(); + } /// /// Runs plugin.Synchronize and returns every record that came out of the diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md index 441f573..73f5c95 100644 --- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md +++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md @@ -59,6 +59,9 @@ The file is parsed line by line: - Each line must be in `KEY=VALUE` format. - Values are not quoted — do not surround values with `"` or `'`. - Real environment variables override file values (useful for CI injection). +- Opt-in flags that place real orders (`CERTINEXT_COMPLETE_PENDING`, `CERTINEXT_RUN_BULK_TEST`, + `CERTINEXT_ALGO_MATRIX`, `CERTINEXT_ALGO_MATRIX_DCV`, `CERTINEXT_SAN_PROBE`) are **ignored** in this file; + they must be exported in the shell (see `IntegrationTestFixture.OptInOnlyFlags`). --- diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs index 8e4f637..58b5878 100644 --- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs +++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs @@ -20,6 +20,26 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests /// public sealed class IntegrationTestFixture : IDisposable { + // --------------------------------------------------------------------------- + // Opt-in guard + // --------------------------------------------------------------------------- + + /// + /// Env-var keys that must be set explicitly in the shell and must NOT be + /// auto-promoted from the env file. These gate tests that place real orders or + /// drive mutating flows, so a developer cannot accidentally arm them by leaving + /// a flag in ~/.env_certinext. Exposed internal for unit-testing. + /// + internal static readonly HashSet OptInOnlyFlags = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "CERTINEXT_COMPLETE_PENDING", + "CERTINEXT_RUN_BULK_TEST", + "CERTINEXT_ALGO_MATRIX", + "CERTINEXT_ALGO_MATRIX_DCV", + "CERTINEXT_SAN_PROBE", + }; + // --------------------------------------------------------------------------- // Credential properties // --------------------------------------------------------------------------- @@ -83,11 +103,7 @@ public IntegrationTestFixture() var env = LoadEnvFile(envPath); - // Promote env-file values into the process environment so that any code - // calling System.Environment.GetEnvironmentVariable() picks them up. - foreach (var kv in env) - if (System.Environment.GetEnvironmentVariable(kv.Key) == null) - System.Environment.SetEnvironmentVariable(kv.Key, kv.Value); + PromoteToProcessEnvironment(env); ApiUrl = GetEnvValue(env, "CERTINEXT_API_URL"); AccessKey = GetEnvValue(env, "CERTINEXT_ACCESS_KEY"); @@ -138,6 +154,23 @@ public void Dispose() { } // Private helpers // --------------------------------------------------------------------------- + /// + /// Promotes env-file values into the process environment so that any code + /// calling picks them up. + /// Variables already set in the process are left untouched. Keys in + /// are deliberately excluded: they must be set + /// explicitly in the shell so a flag left in the file does not arm + /// order-placing tests on every bare dotnet test. + /// Exposed internal for direct unit-testing. + /// + internal static void PromoteToProcessEnvironment(IReadOnlyDictionary values) + { + foreach (var kv in values) + if (Environment.GetEnvironmentVariable(kv.Key) == null + && !OptInOnlyFlags.Contains(kv.Key)) + Environment.SetEnvironmentVariable(kv.Key, kv.Value); + } + /// /// Reads a KEY=VALUE file, stripping blank lines and lines starting with '#'. /// Real environment variables overlay the file so CI overrides always win. diff --git a/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs index 1db8470..97a262e 100644 --- a/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs +++ b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs @@ -5,6 +5,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. +using System; +using System.Collections.Generic; using FluentAssertions; using Xunit; @@ -49,5 +51,49 @@ public void ParseEnvValue_DoesNotStripEmbeddedQuotes() IntegrationTestFixture.ParseEnvValue("foo\"bar\"baz") .Should().Be("foo\"bar\"baz"); } + + [SkippableTheory] + [InlineData("CERTINEXT_COMPLETE_PENDING")] + [InlineData("CERTINEXT_RUN_BULK_TEST")] + [InlineData("CERTINEXT_ALGO_MATRIX")] + [InlineData("CERTINEXT_ALGO_MATRIX_DCV")] + [InlineData("CERTINEXT_SAN_PROBE")] + [InlineData("certinext_complete_pending")] // env-file keys are matched case-insensitively + public void PromoteToProcessEnvironment_DoesNotPromoteOptInFlags(string flag) + { + Skip.If(Environment.GetEnvironmentVariable(flag) != null, + $"{flag} is already set in the process environment; cannot verify it is not promoted."); + + IntegrationTestFixture.PromoteToProcessEnvironment( + new Dictionary { [flag] = "1" }); + + Environment.GetEnvironmentVariable(flag).Should().BeNull( + "opt-in flags must come from the real environment, never from ~/.env_certinext"); + } + + [Fact] + public void PromoteToProcessEnvironment_PromotesOrdinaryKeys_WithoutOverridingRealEnv() + { + string fresh = "CERTINEXT_FIXTURE_TEST_" + Guid.NewGuid().ToString("N"); + string preset = "CERTINEXT_FIXTURE_TEST_" + Guid.NewGuid().ToString("N"); + try + { + Environment.SetEnvironmentVariable(preset, "from-shell"); + + IntegrationTestFixture.PromoteToProcessEnvironment(new Dictionary + { + [fresh] = "from-file", + [preset] = "from-file", + }); + + Environment.GetEnvironmentVariable(fresh).Should().Be("from-file"); + Environment.GetEnvironmentVariable(preset).Should().Be("from-shell"); + } + finally + { + Environment.SetEnvironmentVariable(fresh, null); + Environment.SetEnvironmentVariable(preset, null); + } + } } } diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/TESTING.md index b961130..ad6d9ce 100644 --- a/CERTInext.IntegrationTests/TESTING.md +++ b/CERTInext.IntegrationTests/TESTING.md @@ -94,6 +94,9 @@ The file is parsed line by line: - Each line must be in `KEY=VALUE` format. - Values are not quoted — do not surround values with `"` or `'`. - Real environment variables override file values (useful for CI injection). +- Opt-in flags that place real orders (`CERTINEXT_COMPLETE_PENDING`, `CERTINEXT_RUN_BULK_TEST`, + `CERTINEXT_ALGO_MATRIX`, `CERTINEXT_ALGO_MATRIX_DCV`, `CERTINEXT_SAN_PROBE`) are **ignored** in this file; + they must be exported in the shell (see `IntegrationTestFixture.OptInOnlyFlags`). --- From 84e792db057c7e5f4c7559b3af352937f42011fc Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:39:31 -0700 Subject: [PATCH 25/71] fix(logging): redact renamed technicalPointOfContact poc* keys when LogSensitiveRequestData is off --- CERTInext/Client/CERTInextClient.cs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 675945d..7a9b2c5 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1951,22 +1951,27 @@ internal static string RedactCredentials(string body) // CERTInext/API/CertificateRequest.cs and CertificateResponse.cs). Every one of these is a // full, exact key — never a substring of an unrelated key (e.g. "domainName"/ // "organizationName" do not end in a bare "email" key) — so matching the key by exact - // name cannot cross-contaminate unrelated fields. The bare "email" key is not used by any - // V1 model today; it is kept as defence in depth for CA error/response bodies. + // name cannot cross-contaminate unrelated fields. "pocEmail" is the technicalPointOfContact + // email on the current V1 order shape; the legacy "tpcEmail" and the bare "email" key are + // not emitted by any V1 model today and are kept as defence in depth for CA error/response + // bodies that may echo older field names. private static readonly string[] PersonalEmailFieldNames = { - "requestorEmail", "requesterEmail", "tpcEmail", "requestorEmailId", "dcvEmail", "email" + "requestorEmail", "requesterEmail", "pocEmail", "tpcEmail", "requestorEmailId", "dcvEmail", "email" }; // Exact JSON key names carrying other person/contact data (name, phone/ISD/mobile, - // designation, signer place/IP). The bare "name"/"phone"/"designation" keys are not - // emitted by any V1 request this plugin logs raw; they are kept as defence in depth for - // CA response/error bodies, where over-redacting a log line costs nothing on the wire. + // designation, signer place/IP). The technicalPointOfContact keys are the poc* family + // (pocFirstName/pocLastName/pocIsdCode/pocMobileNumber); the legacy tpc* names and the + // bare "name"/"phone"/"designation" keys are not emitted by any V1 request this plugin + // logs raw; they are kept as defence in depth for CA response/error bodies, where + // over-redacting a log line costs nothing on the wire. private static readonly string[] PersonalOtherFieldNames = { - "requestorName", "requesterName", "tpcName", "signerName", "name", + "requestorName", "requesterName", "signerName", "name", "requestorIsdCode", "requestorMobileNumber", "requestorDesignation", - "tpcIsdCode", "tpcMobileNumber", "signerPlace", "signerip", "phone", "designation" + "pocFirstName", "pocLastName", "pocIsdCode", "pocMobileNumber", + "tpcName", "tpcIsdCode", "tpcMobileNumber", "signerPlace", "signerip", "phone", "designation" }; /// @@ -1974,8 +1979,10 @@ internal static string RedactCredentials(string body) /// log line, when LogSensitiveRequestData is off (issue 0040). Covers the V1 /// requestorInformation / technicalPointOfContact / agreementDetails /// shapes (requestorName, requestorEmail, requestorIsdCode, - /// requestorMobileNumber, requestorDesignation, tpcName, - /// tpcEmail, tpcIsdCode, tpcMobileNumber, signerName, + /// requestorMobileNumber, requestorDesignation, pocFirstName, + /// pocLastName, pocEmail, pocIsdCode, pocMobileNumber, the + /// legacy tpcName/tpcEmail/tpcIsdCode/tpcMobileNumber (defence + /// in depth for CA bodies echoing the old names), signerName, /// signerPlace, signerIP/signerIp, the legacy requesterName/ /// requesterEmail aliases, and the requestorEmailId search filter), plus bare /// name/email/phone/designation keys as defence in depth. From a0df1dfc98279cc234603481887d58a238153d33 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:39:31 -0700 Subject: [PATCH 26/71] test(logging): assert poc* redaction (flag off/on) and migrate tests to Poc* properties --- .../ClientPayloadLogRedactionTests.cs | 23 +++++-- CERTInext.Tests/RedactPersonalDataTests.cs | 64 ++++++++++++++++--- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/CERTInext.Tests/ClientPayloadLogRedactionTests.cs b/CERTInext.Tests/ClientPayloadLogRedactionTests.cs index 37f8e09..c9c529e 100644 --- a/CERTInext.Tests/ClientPayloadLogRedactionTests.cs +++ b/CERTInext.Tests/ClientPayloadLogRedactionTests.cs @@ -48,7 +48,11 @@ public class ClientPayloadLogRedactionTests : IDisposable private const string RequestorEmail = "jane.doe@example.com"; private const string MaskedRequestorEmail = "j***@example.com"; private const string RequestorMobile = "5551234567"; - private const string TpcEmail = "tech.contact@example.com"; + private const string PocEmail = "tech.contact@example.com"; + private const string MaskedPocEmail = "t***@example.com"; + private const string PocFirstName = "Terry"; + private const string PocLastName = "Techcontact"; + private const string PocMobile = "5559876543"; private const string SignerName = "John Signer"; private const string EmailSan = "alice@example.com"; private const string MaskedEmailSan = "a***@example.com"; @@ -100,7 +104,11 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except AdditionalDomains = new List { "www." + primaryDomain, EmailSan } }, AgreementDetails = new AgreementDetails { SignerName = SignerName, SignerPlace = "Austin", SignerIp = "203.0.113.10" }, - TechnicalPointOfContact = new TechnicalPointOfContact { TpcName = "Tech Contact", TpcEmail = TpcEmail, TpcMobileNumber = "5559876543" } + TechnicalPointOfContact = new TechnicalPointOfContact + { + PocFirstName = PocFirstName, PocLastName = PocLastName, PocEmail = PocEmail, + PocIsdCode = "44", PocMobileNumber = PocMobile + } } }; @@ -152,7 +160,12 @@ public async Task PlaceOrder_TracePayload_FlagOff_OmitsAuthKeyAndPii() payload.Should().NotContain(authKey, "the replayable authKey digest must never be logged"); payload.Should().Contain("\"authKey\":\"***REDACTED***\""); payload.Should().NotContain(RequestorName).And.NotContain(RequestorEmail).And.NotContain(RequestorMobile) - .And.NotContain(TpcEmail).And.NotContain(SignerName).And.NotContain(EmailSan); + .And.NotContain(PocEmail).And.NotContain(SignerName).And.NotContain(EmailSan) + .And.NotContain(PocFirstName).And.NotContain(PocLastName).And.NotContain(PocMobile) + .And.NotContain("\"pocIsdCode\":\"44\""); + payload.Should().Contain(MaskedPocEmail).And.Contain("\"pocFirstName\":\"***REDACTED***\"") + .And.Contain("\"pocLastName\":\"***REDACTED***\"").And.Contain("\"pocIsdCode\":\"***REDACTED***\"") + .And.Contain("\"pocMobileNumber\":\"***REDACTED***\""); payload.Should().Contain(MaskedRequestorEmail).And.Contain(MaskedEmailSan).And.Contain("www." + domain); lines.Should().NotContain(l => l.Message.Contains(authKey) || l.Message.Contains(EmailSan) || l.Message.Contains(RequestorEmail), @@ -174,7 +187,9 @@ public async Task PlaceOrder_TracePayload_FlagOn_IncludesPiiButStillRedactsAuthK payload.Should().NotContain(authKey, "credentials are redacted regardless of LogSensitiveRequestData"); payload.Should().Contain("\"authKey\":\"***REDACTED***\""); payload.Should().Contain(RequestorName).And.Contain(RequestorEmail).And.Contain(RequestorMobile) - .And.Contain(TpcEmail).And.Contain(SignerName).And.Contain(EmailSan); + .And.Contain(PocEmail).And.Contain(SignerName).And.Contain(EmailSan) + .And.Contain(PocFirstName).And.Contain(PocLastName).And.Contain(PocMobile) + .And.Contain("\"pocIsdCode\":\"44\""); lines.Should().NotContain(l => l.Message.Contains(authKey)); } diff --git a/CERTInext.Tests/RedactPersonalDataTests.cs b/CERTInext.Tests/RedactPersonalDataTests.cs index 1a86951..efb81e6 100644 --- a/CERTInext.Tests/RedactPersonalDataTests.cs +++ b/CERTInext.Tests/RedactPersonalDataTests.cs @@ -86,10 +86,11 @@ private static string BuildV1OrderRequestJson() }, TechnicalPointOfContact = new TechnicalPointOfContact { - TpcName = "Tech Contact", - TpcEmail = "tech.contact@example.com", - TpcIsdCode = "1", - TpcMobileNumber = "5559876543" + PocFirstName = "Terry", + PocLastName = "Techcontact", + PocEmail = "tech.contact@example.com", + PocIsdCode = "44", + PocMobileNumber = "5559876543" } } }; @@ -110,9 +111,11 @@ public void RedactPersonalData_V1OrderRequest_RemovesAllPersonFields() output.Should().NotContain("John Signer"); output.Should().NotContain("Austin"); output.Should().NotContain("203.0.113.10"); - output.Should().NotContain("Tech Contact"); + output.Should().NotContain("Terry"); + output.Should().NotContain("Techcontact"); output.Should().NotContain("tech.contact@example.com"); output.Should().NotContain("5559876543"); + output.Should().NotContain("\"pocIsdCode\":\"44\""); } [Fact] @@ -121,7 +124,7 @@ public void RedactPersonalData_V1OrderRequest_MasksEmailsKeepingDomain() string output = CERTInextClient.RedactPersonalData(BuildV1OrderRequestJson()); output.Should().Contain("\"requestorEmail\":\"j***@example.com\""); - output.Should().Contain("\"tpcEmail\":\"t***@example.com\""); + output.Should().Contain("\"pocEmail\":\"t***@example.com\""); } [Fact] @@ -148,8 +151,10 @@ public void RedactPersonalData_V1OrderRequest_RedactsRequestorNameToPlaceholder( output.Should().Contain("\"signerName\":\"***REDACTED***\""); output.Should().Contain("\"signerPlace\":\"***REDACTED***\""); output.Should().Contain("\"signerIP\":\"***REDACTED***\""); - output.Should().Contain("\"tpcName\":\"***REDACTED***\""); - output.Should().Contain("\"tpcMobileNumber\":\"***REDACTED***\""); + output.Should().Contain("\"pocFirstName\":\"***REDACTED***\""); + output.Should().Contain("\"pocLastName\":\"***REDACTED***\""); + output.Should().Contain("\"pocIsdCode\":\"***REDACTED***\""); + output.Should().Contain("\"pocMobileNumber\":\"***REDACTED***\""); } // --------------------------------------------------------------------------- @@ -180,6 +185,49 @@ public void ApplyLoggingRedaction_V1OrderRequest_FlagOn_KeepsPersonalDataInFull( output.Should().Contain("jane.doe@example.com"); output.Should().Contain("5551234567"); output.Should().Contain("tech.contact@example.com"); + output.Should().Contain("\"pocFirstName\":\"Terry\""); + output.Should().Contain("\"pocLastName\":\"Techcontact\""); + output.Should().Contain("\"pocIsdCode\":\"44\""); + output.Should().Contain("\"pocMobileNumber\":\"5559876543\""); + output.Should().Contain("\"authKey\":\"***REDACTED***\"", "credentials stay redacted with the flag on"); + output.Should().NotContain(SyntheticAuthKey); + } + + [Fact] + public void ApplyLoggingRedaction_V1OrderRequest_FlagOff_RedactsEveryPocField() + { + string output = CERTInextClient.ApplyLoggingRedaction(BuildV1OrderRequestJson(), logSensitiveRequestData: false); + + output.Should().Contain("\"pocFirstName\":\"***REDACTED***\""); + output.Should().Contain("\"pocLastName\":\"***REDACTED***\""); + output.Should().Contain("\"pocEmail\":\"t***@example.com\""); + output.Should().Contain("\"pocIsdCode\":\"***REDACTED***\""); + output.Should().Contain("\"pocMobileNumber\":\"***REDACTED***\""); + output.Should().NotContain("tech.contact@example.com"); + output.Should().NotContain(SyntheticAuthKey); + } + + [Fact] + public void RedactPersonalData_PrettyPrintedNestedPocBlock_IsRedacted() + { + string input = "{\n \"technicalPointOfContact\" : {\n \"pocFirstName\" : \"Terry\",\n \"pocEmail\" : \"tech.contact@example.com\",\n \"pocMobileNumber\":\"5559876543\"\n }\n}"; + + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().NotContain("Terry").And.NotContain("tech.contact@example.com").And.NotContain("5559876543"); + output.Should().Contain("t***@example.com"); + } + + [Fact] + public void RedactPersonalData_LegacyTpcKeysInResponseBody_AreRedacted_DefenceInDepth() + { + // The V1 request no longer emits tpc* (renamed to poc*), but a CA error body may echo + // the old names, so they stay on the redaction lists. + string input = "{\"tpcName\":\"Tech Contact\",\"tpcEmail\":\"tech.contact@example.com\",\"tpcIsdCode\":\"1\",\"tpcMobileNumber\":\"5559876543\"}"; + + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().Be("{\"tpcName\":\"***REDACTED***\",\"tpcEmail\":\"t***@example.com\",\"tpcIsdCode\":\"***REDACTED***\",\"tpcMobileNumber\":\"***REDACTED***\"}"); } // --------------------------------------------------------------------------- From 7a6cbf7ac1eea4377eda8c2dcaabf2341eadde76 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:40:07 -0700 Subject: [PATCH 27/71] fix(logging): strip CR/LF from RequesterName/RequesterEmail on enrollment-start log line --- CERTInext/CERTInextCAPlugin.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 661a403..e33f5b2 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -609,7 +609,7 @@ public async Task Enroll( "RequesterName={RequesterName}, RequesterEmail={RequesterEmail}", enrollmentType, requestFormat, LogSanitizer.Strip(subject), ep.ProfileId, sanSummary, - ep.RequesterName, ep.RequesterEmail); + LogSanitizer.Strip(ep.RequesterName), LogSanitizer.Strip(ep.RequesterEmail)); } else { @@ -620,7 +620,7 @@ public async Task Enroll( "RequesterEmail={RequesterEmail}", enrollmentType, requestFormat, LogSanitizer.Strip(subject), ep.ProfileId, sanSummary, - LogSanitizer.MaskEmail(ep.RequesterEmail)); + LogSanitizer.MaskEmail(LogSanitizer.Strip(ep.RequesterEmail))); } if (string.IsNullOrWhiteSpace(ep.ProfileId)) From a77d5c403768556c26e7b25a7e4b4c52258c1d20 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:40:11 -0700 Subject: [PATCH 28/71] test(logging): regression tests for CR/LF stripped from requester values in both LogSensitiveRequestData states --- .../CERTInextCAPluginAuditLoggingTests.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs index 0ffbabd..d85fdab 100644 --- a/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs @@ -178,5 +178,41 @@ public async Task Enroll_LogSensitiveRequestDataTrue_AuditLineIncludesNameAndEma line.Should().Contain("Jane Doe", "the requester name is logged in full when the flag is on"); line.Should().Contain("jane.doe@example.com", "the requester email is logged in full when the flag is on"); } + + [Fact] + public async Task Enroll_LogSensitiveRequestDataTrue_CrLfInRequesterValuesIsStripped() + { + // Regression: RequesterName/RequesterEmail were logged raw (log injection via CR/LF), + // unlike subject and SANs which already went through LogSanitizer.Strip. + var (messages, marker) = await CaptureEnrollLogMessagesAsync( + logSensitiveRequestData: true, + requesterName: "Jane\r\nFAKE-LOG-ENTRY name", + requesterEmail: "jane@example.com\r\nFAKE-LOG-ENTRY email"); + + string line = FindEnrollmentAttemptLine(messages, marker); + line.Should().NotBeNull("the enrollment-attempt audit line must always be logged"); + line.Should().NotContain("\r").And.NotContain("\n", + "CR/LF in requester values must not be able to forge a new log line"); + line.Should().Contain("Jane\\r\\nFAKE-LOG-ENTRY name"); + line.Should().Contain("jane@example.com\\r\\nFAKE-LOG-ENTRY email"); + } + + [Fact] + public async Task Enroll_LogSensitiveRequestDataFalse_CrLfInRequesterValuesIsStripped() + { + // Flag off: name is dropped, email is MaskEmail(Strip(email)). The domain part survives + // masking, so CR/LF placed there must still be escaped. + var (messages, marker) = await CaptureEnrollLogMessagesAsync( + logSensitiveRequestData: false, + requesterName: "Jane\r\nFAKE-LOG-ENTRY name", + requesterEmail: "jane@example.com\r\nFAKE-LOG-ENTRY email"); + + string line = FindEnrollmentAttemptLine(messages, marker); + line.Should().NotBeNull("the enrollment-attempt audit line must always be logged"); + line.Should().NotContain("\r").And.NotContain("\n", + "CR/LF in requester values must not be able to forge a new log line"); + line.Should().NotContain("Jane").And.NotContain("FAKE-LOG-ENTRY name"); + line.Should().Contain("j***@example.com\\r\\nFAKE-LOG-ENTRY email"); + } } } From 0deb829218950bbd176a447d32f8db57f815aa21 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:40:16 -0700 Subject: [PATCH 29/71] fix(enroll): omit technicalPointOfContact when the resolved contact name is blank --- CERTInext/Client/CERTInextClient.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 7a9b2c5..db471f9 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1622,9 +1622,10 @@ private string ResolveValidityYears(int? validityYears, int? validityDays) /// Builds technicalPointOfContact. Each TechnicalContact* field falls back to the /// matching resolved requestor value when blank; the name is split into /// pocFirstName/pocLastName via . - /// Returns null (block omitted) when no email resolves — CERTInext validates these fields - /// now that they reach it, and an empty POC email must not start rejecting orders that - /// previously went through. + /// Returns null (block omitted, Warning logged) when no email resolves or when the resolved + /// name yields a blank first name — CERTInext's spec marks the block optional but requires + /// the name and email inside it, and it validates them now that they reach it, so an empty + /// POC email or name must not start rejecting orders that previously went through. /// private TechnicalPointOfContact BuildTechnicalPointOfContact( string requestorName, string requestorEmail, string requestorIsd, string requestorMobile) @@ -1647,6 +1648,15 @@ private TechnicalPointOfContact BuildTechnicalPointOfContact( : _config.TechnicalContactName; var (first, last) = SplitContactName(name); + if (string.IsNullOrWhiteSpace(first)) + { + Logger.LogWarning( + "Omitting technicalPointOfContact from the SSL order: neither TechnicalContactName " + + "nor RequestorName resolved to a value. Set TechnicalContactName (or RequestorName) " + + "in the connector configuration to send a technical point of contact."); + return null; + } + return new TechnicalPointOfContact { PocFirstName = first, From 674b70a71f2bfd38705f21334fa92feb562d2002 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:40:16 -0700 Subject: [PATCH 30/71] test(enroll): technicalPointOfContact omitted for blank name on enroll and renewal --- .../CERTInextClientRequestShapeTests.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs index 5854ee8..f8d5884 100644 --- a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs +++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs @@ -312,6 +312,77 @@ public async Task TechnicalContact_NoEmailResolved_OmitsBlock() AssertNoLegacyFieldShapes(od); } + [Theory] + [InlineData("", "")] + [InlineData(" ", "")] + [InlineData("", " ")] + public async Task TechnicalContact_NoNameResolved_OmitsBlock_OnEnroll(string technicalName, string requestorName) + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.TechnicalContactName = technicalName; + cfg.RequestorName = requestorName; + // Email resolves fine — only the name is missing. + cfg.TechnicalContactEmail = "poc@example.com"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var od = CapturedOrderBody(); + od.TryGetProperty("technicalPointOfContact", out _).Should().BeFalse( + "CERTInext requires the POC name inside the block — omit it rather than send empty first/last names"); + AssertNoLegacyFieldShapes(od); + } + + [Fact] + public async Task TechnicalContact_NoNameResolved_OmitsBlock_OnRenewal() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.TechnicalContactName = string.Empty; + cfg.RequestorName = string.Empty; + cfg.TechnicalContactEmail = "poc@example.com"; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "842", + ValidityDays = 365, + Comment = "Renewal test" + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var od = CapturedOrderBody(); + od.TryGetProperty("technicalPointOfContact", out _).Should().BeFalse( + "renewal shares the enroll builder and must also omit a POC block with no name"); + AssertNoLegacyFieldShapes(od); + } + + [Fact] + public async Task TechnicalContact_RenewalWithRequesterName_EmitsBlockFromRequesterName() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.TechnicalContactName = string.Empty; + cfg.RequestorName = string.Empty; // config blank, but the renewal request supplies a name + cfg.TechnicalContactEmail = "poc@example.com"; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "842", + ValidityDays = 365, + RequesterName = "Renew Requester", + Comment = "Renewal test" + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var poc = CapturedOrderBody().GetProperty("technicalPointOfContact"); + poc.GetProperty("pocFirstName").GetString().Should().Be("Renew"); + poc.GetProperty("pocLastName").GetString().Should().Be("Requester"); + } + // ----------------------------------------------------------------------- // SSL order body defaults — AccountingModel / EmailNotifications / // SubscriptionAutoRenew / SubscriptionRenewCriteriaDays / From ec5aef9db8bd8f04405505e9aaea0fcd29692670 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:41:45 -0700 Subject: [PATCH 31/71] docs(config): describe GroupNumber default-group behavior per CERTInext spec --- CERTInext/CERTInextCAPluginConfig.cs | 6 +++--- docsource/configuration.md | 2 +- integration-manifest.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 5178db4..4b919fb 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -49,6 +49,7 @@ public static Dictionary GetCAConnectorAnnotations() "When set, it is included in GetProductDetails requests AND in the " + "`orderDetails.groupNumber` field of every SSL order (new and renewal) so the " + "order is routed to the configured account group. " + + "When blank, CERTInext uses the account's default group. " + "Available in the CERTInext portal under Delegation → Groups.", Hidden = false, DefaultValue = string.Empty, @@ -567,9 +568,8 @@ public class CERTInextConfig /// /// Optional CERTInext group (delegation) number. When set, it is passed in /// the productDetails.groupNumber field of GetProductDetails - /// requests AND in the delegationInformation.groupNumber field of every - /// SSL order body so the order is routed to the correct account group. Some - /// accounts queue orders for extra review when this field is omitted. + /// requests AND in the orderDetails.groupNumber field of every SSL order + /// (new and renewal). When blank, CERTInext uses the account's default group. /// [JsonPropertyName("GroupNumber")] public string GroupNumber { get; set; } = string.Empty; diff --git a/docsource/configuration.md b/docsource/configuration.md index d1a9e0e..308c544 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -111,7 +111,7 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `RequestorMobileNumber` | Optional | Requestor mobile number (digits only, no country code). Included in the `requestorInformation` block. | N/A | `5551234567` | | `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. | Use the physical city where the signer is located. | `Austin` | | `SignerIp` | Required | Public IP address of the host accepting the subscriber agreement. Required by CERTInext for all orders. | Use the outbound IP of the AnyCA Gateway host, or the IP of the workstation from which the agreement was accepted. | `203.0.113.10` | -| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `orderDetails.groupNumber` on every SSL order (new and renewal), so orders are routed to this group. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | +| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `orderDetails.groupNumber` on every SSL order (new and renewal), so orders are routed to this group. When blank, CERTInext uses the account's default group. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | | `OrganizationNumber` | Optional, strongly recommended for OV/EV and faster DV | Numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this number, telling CERTInext to skip its manual organization-vetting queue. Without it, orders may sit in `Pending System RA` for extended manual review (observed: tens of hours). | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | | `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` (`pocFirstName`, `pocLastName`, `pocEmail`, `pocIsdCode`, `pocMobileNumber`) on every SSL order, new and renewal. `TechnicalContactName` is split on the first whitespace: the first word becomes `pocFirstName` and the rest `pocLastName`; a single-word name is sent in both. Each field defaults to the corresponding `Requestor*` field when blank. | N/A | *(defaults to Requestor fields)* | | `AccountingModel` | Optional | CERTInext billing model sent in `orderDetails.accountingModel`. `2` = credit-based (most accounts). `1` = cash model. Default: `2`. | N/A | `2` | diff --git a/integration-manifest.json b/integration-manifest.json index 37de584..0d381f2 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -35,7 +35,7 @@ }, { "name": "GroupNumber", - "description": "OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests AND in the `orderDetails.groupNumber` field of every SSL order (new and renewal) so the order is routed to the configured account group. Available in the CERTInext portal under Delegation \u2192 Groups." + "description": "OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests AND in the `orderDetails.groupNumber` field of every SSL order (new and renewal) so the order is routed to the configured account group. When blank, CERTInext uses the account's default group. Available in the CERTInext portal under Delegation \u2192 Groups." }, { "name": "OrganizationNumber", From 02ceb4f7874218f9515a350c63aacaa2e45339f8 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:46:22 -0700 Subject: [PATCH 32/71] test(integration): live proof of V1 group placement and no-www SAN for new and renewed orders - Opt-in via real env var CERTINEXT_WS1C_LIVE=1 (refused if set in ~/.env_certinext). - New order and DCV-issued order renewed via RenewCertificateAsync; asserts ListOrders groupNumber and no www. in TrackOrder domain list. - Revokes every created order and re-reads state read-only. --- .../GroupAndWwwLiveTests.cs | 436 ++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs diff --git a/CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs b/CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs new file mode 100644 index 0000000..78cef4b --- /dev/null +++ b/CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs @@ -0,0 +1,436 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.PKI.Enums.EJBCA; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Live-sandbox proof of the V1 GenerateOrderSSL body fix: orderDetails.groupNumber + /// and orderDetails.autoSecureWWW must be honoured by CERTInext for both new enrollment and + /// the renewal path (RenewCertificateAsync, which shares BuildSslOrderDetails). + /// + /// Checks per order: + /// 1. The order appears in GetOrderReport (ListOrders) under the configured group. + /// 2. The TrackOrder domain list contains the requested name and no www. entry. + /// + /// Opt-in: set CERTINEXT_WS1C_LIVE=1 as a REAL environment variable. A value placed in + /// ~/.env_certinext is deliberately ignored (the fixture promotes file values into the + /// process environment, so the file is inspected to refuse that case). Also requires + /// CERTINEXT_GROUP_NUMBER. Every order created is revoked in a finally block and the + /// final state is re-read and printed. + /// + public class GroupAndWwwLiveTests : IClassFixture + { + private const string OptInFlag = "CERTINEXT_WS1C_LIVE"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + public GroupAndWwwLiveTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + // --------------------------------------------------------------------------- + // Gating + helpers + // --------------------------------------------------------------------------- + + private void SkipUnlessOptedIn() + { + IntegrationSkip.IfNotConfigured(_fixture); + + Skip.If(Environment.GetEnvironmentVariable(OptInFlag) != "1", + $"Opt-in: set {OptInFlag}=1 as a real environment variable to place live sandbox orders."); + + string envFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".env_certinext"); + bool inFile = File.Exists(envFile) && File.ReadAllLines(envFile) + .Any(l => l.TrimStart().StartsWith(OptInFlag + "=", StringComparison.Ordinal)); + Skip.If(inFile, $"{OptInFlag} must be a real environment variable, not set in ~/.env_certinext."); + + Skip.If(string.IsNullOrWhiteSpace(_fixture.Config.GroupNumber), + "CERTINEXT_GROUP_NUMBER is required to prove group placement."); + } + + private CERTInextConfig BuildConfig(bool dcvEnabled) + { + var c = _fixture.Config; + return new CERTInextConfig + { + ApiUrl = c.ApiUrl, + AuthMode = c.AuthMode, + ApiKey = c.ApiKey, + AccountNumber = c.AccountNumber, + GroupNumber = c.GroupNumber, + OrganizationNumber = c.OrganizationNumber, + RequestorName = c.RequestorName, + RequestorEmail = c.RequestorEmail, + RequestorIsdCode = c.RequestorIsdCode, + RequestorMobileNumber = c.RequestorMobileNumber, + SignerPlace = c.SignerPlace, + SignerIp = c.SignerIp, + DefaultProductCode = c.DefaultProductCode, + PageSize = c.PageSize, + AutoSecureWww = "0", + DcvEnabled = dcvEnabled, + DcvPropagationDelaySeconds = 5, + DcvTimeoutMinutes = 3, + }; + } + + private static string GenerateCsrPem(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={commonName}"), keyPair.Public, null, keyPair.Private); + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + // Local copy: IntegrationTestData lives in DcvLifecycleTests.cs, which is excluded on non-DCV builds. + private EnrollmentProductInfo DvProductInfo() + { + string code = _fixture.Config.DefaultProductCode ?? Constants.Products.DvSsl; + return new EnrollmentProductInfo + { + ProductID = code, + ProductParameters = new Dictionary + { + ["ProfileId"] = code, + ["ValidityYears"] = "1" + } + }; + } + + private static Dictionary DnsSan(string cn) => + new Dictionary { ["dns"] = new[] { cn } }; + + /// Finds an order in GetOrderReport (today onward first, then unfiltered). + private async Task FindInOrderReportAsync(string orderNumber) + { + foreach (string from in new[] { DateTime.UtcNow.Date.AddDays(-1).ToString("yyyy-MM-dd"), null }) + { + try + { + await foreach (var e in _fixture.Client.ListOrdersAsync(orderDateFrom: from, pageSize: 100)) + if (e.OrderNumber == orderNumber) + return e; + } + catch (Exception ex) when (from != null) + { + _output.WriteLine($" ListOrders(orderDateFrom={from}) failed ({ex.GetType().Name}); retrying unfiltered."); + } + } + return null; + } + + /// + /// Domain names from TrackOrder.domainVerification. Polled briefly because CERTInext may + /// populate the block a few seconds after order placement. + /// + private async Task> GetDomainListAsync(string orderNumber, string mustContain) + { + var names = new List(); + for (int i = 0; i < 8; i++) + { + var track = await _fixture.Client.TrackOrderAsync(orderNumber); + names = track.OrderDetails?.DomainVerification?.RawDomainEntries?.Keys.ToList() ?? new List(); + if (names.Any(n => string.Equals(n, mustContain, StringComparison.OrdinalIgnoreCase))) + break; + await Task.Delay(TimeSpan.FromSeconds(5)); + } + return names; + } + + private void AssertGroupAndNoWww(string label, string orderNumber, OrderReportEntry entry, List domains, string cn) + { + entry.Should().NotBeNull($"{label} order {orderNumber} must be listed by GetOrderReport"); + bool groupMatches = string.Equals(entry!.GroupNumber, _fixture.Config.GroupNumber, StringComparison.Ordinal); + _output.WriteLine($" [{label}] {orderNumber}: ListOrders groupNumber matches configured group = {groupMatches} " + + $"(report groupNumber blank = {string.IsNullOrWhiteSpace(entry.GroupNumber)})"); + _output.WriteLine($" [{label}] {orderNumber}: TrackOrder domains = [{string.Join(", ", domains)}]; " + + $"report domainName = {entry.DomainName}"); + + groupMatches.Should().BeTrue($"{label} order must land in the configured CERTInext group"); + domains.Should().Contain(d => string.Equals(d, cn, StringComparison.OrdinalIgnoreCase), + "the TrackOrder domain list must be populated (otherwise 'no www' is vacuous)"); + domains.Should().NotContain(d => d.StartsWith("www.", StringComparison.OrdinalIgnoreCase), + $"{label} order was placed with AutoSecureWww=0 so no www. SAN may be added"); + } + + /// + /// Revokes every created order (plugin path for issued certs, raw revoke attempt otherwise), + /// then re-reads each order read-only and prints its final state. + /// + private async Task CleanupAsync(CERTInextCAPlugin plugin, IEnumerable orderNumbers) + { + foreach (string id in orderNumbers.Where(o => !string.IsNullOrWhiteSpace(o)).Distinct()) + { + try + { + var before = await _fixture.Client.TrackOrderAsync(id); + int.TryParse(before.OrderDetails?.CertificateStatusId, out int st); + _output.WriteLine($" cleanup {id}: before certificateStatusId={st} ({before.OrderDetails?.CertificateStatus})"); + if (st == Constants.CertificateStatusId.CertificateRevoked) + continue; + + if (st == Constants.CertificateStatusId.CertificateGenerated + || st == Constants.CertificateStatusId.CertificateDownloaded) + { + int rc = await plugin.Revoke(id, string.Empty, 5); + _output.WriteLine($" cleanup {id}: plugin.Revoke returned {rc}"); + } + else + { + // Not issued: the plugin refuses (revocable only when GENERATED). Try the raw + // revoke and record exactly what CERTInext says; do not work around a refusal. + await _fixture.Client.RevokeCertificateAsync(id, new RevokeCertificateRequest + { + Reason = Constants.RevocationReason.CessationOfOperation, + Comment = "ws1c live-test cleanup" + }); + _output.WriteLine($" cleanup {id}: raw RevokeCertificateAsync accepted for non-issued order"); + } + } + catch (Exception ex) + { + _output.WriteLine($" cleanup {id}: REVOKE/CANCEL FAILED -> {ex.GetType().Name}: {Truncate(ex.Message)}"); + } + } + + _output.WriteLine(" --- cleanup verification (fresh read-only TrackOrder) ---"); + foreach (string id in orderNumbers.Where(o => !string.IsNullOrWhiteSpace(o)).Distinct()) + { + try + { + var after = await _fixture.Client.TrackOrderAsync(id); + _output.WriteLine($" verify {id}: orderStatusId={after.OrderDetails?.OrderStatusId} ({after.OrderDetails?.OrderStatus}), " + + $"certificateStatusId={after.OrderDetails?.CertificateStatusId} ({after.OrderDetails?.CertificateStatus})"); + } + catch (Exception ex) + { + _output.WriteLine($" verify {id}: TrackOrder failed -> {ex.GetType().Name}: {Truncate(ex.Message)}"); + } + } + } + + private static string Truncate(string s) => s != null && s.Length > 500 ? s.Substring(0, 500) : s; + + // --------------------------------------------------------------------------- + // Test A: new enrollment (compiles on both DcvSupport variants) + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task NewOrder_AutoSecureWwwOff_LandsInConfiguredGroup_WithNoWwwSan() + { + SkipUnlessOptedIn(); + + string cn = $"ws1c-a-{Guid.NewGuid():N}".Substring(0, 20) + ".scrup.org"; + var plugin = new CERTInextCAPlugin(_fixture.Client, BuildConfig(dcvEnabled: false)); + var created = new List(); + try + { + var result = await plugin.Enroll( + GenerateCsrPem(cn), $"CN={cn}", DnsSan(cn), + DvProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + created.Add(result.CARequestID); + _output.WriteLine($"Enrolled order {result.CARequestID}, status={result.Status}"); + result.CARequestID.Should().NotBeNullOrWhiteSpace(); + + var entry = await FindInOrderReportAsync(result.CARequestID); + var domains = await GetDomainListAsync(result.CARequestID, cn); + AssertGroupAndNoWww("new", result.CARequestID, entry, domains, cn); + } + finally + { + await CleanupAsync(plugin, created); + } + } + +#if SUPPORTS_DCV + // --------------------------------------------------------------------------- + // Test B: new order -> DCV issuance -> renewal through RenewCertificateAsync + // --------------------------------------------------------------------------- + + /// + /// Minimal ICertificateDataReader for the renewal path (the plugin only calls + /// GetRequestIDBySerialNumber and GetExpirationDateByRequestId). Built with DispatchProxy + /// so the integration project needs no mocking package. + /// + public class ReaderProxy : DispatchProxy + { + public string RequestId { get; set; } + public DateTime? Expiry { get; set; } + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + switch (targetMethod.Name) + { + case "GetRequestIDBySerialNumber": return Task.FromResult(RequestId); + case "GetExpirationDateByRequestId": return Expiry; + default: throw new NotSupportedException(targetMethod.Name); + } + } + } + + /// + /// Forwarding decorator over the live client that records which interface methods the plugin + /// called, so the test can prove the renewal went through RenewCertificateAsync rather than + /// silently falling back to a new enrollment. + /// + public class RecordingClientProxy : DispatchProxy + { + public Client.ICERTInextClient Target { get; set; } + public System.Collections.Concurrent.ConcurrentQueue Calls { get; } = + new System.Collections.Concurrent.ConcurrentQueue(); + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + Calls.Enqueue(targetMethod.Name); + try + { + return targetMethod.Invoke(Target, args); + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + } + } + + [SkippableFact] + public async Task IssuedOrder_RenewedViaRenewCertificateAsync_StaysInGroup_WithNoWwwSan() + { + SkipUnlessOptedIn(); + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required to drive DCV to issuance."); + + string cn = $"ws1c-b-{Guid.NewGuid():N}".Substring(0, 20) + ".scrup.org"; + var config = BuildConfig(dcvEnabled: true); + var factory = new CloudflareDomainValidatorFactory(_fixture.CloudflareApiToken, _fixture.CloudflareZoneId); + var recorder = DispatchProxy.Create(); + ((RecordingClientProxy)(object)recorder).Target = _fixture.Client; + var calls = ((RecordingClientProxy)(object)recorder).Calls; + var plugin = new CERTInextCAPlugin(recorder, factory, config); + var reader = DispatchProxy.Create(); + typeof(CERTInextCAPlugin) + .GetField("_certificateDataReader", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(plugin, reader); + + var created = new List(); + try + { + // 1. New DV order, AutoSecureWww=0 + group, DCV-driven. + var first = await plugin.Enroll( + GenerateCsrPem(cn), $"CN={cn}", DnsSan(cn), + DvProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + created.Add(first.CARequestID); + first.CARequestID.Should().NotBeNullOrWhiteSpace(); + _output.WriteLine($"Original order {first.CARequestID}: Enroll status={first.Status}"); + + // 2. Group + domain-list checks on the original order. + AssertGroupAndNoWww("original", first.CARequestID, + await FindInOrderReportAsync(first.CARequestID), + await GetDomainListAsync(first.CARequestID, cn), cn); + + // 3. Drive to issuance (GetSingleRecord re-runs DCV for EXTERNALVALIDATION orders). + await DriveToIssuanceAsync(plugin, first.CARequestID); + var issued = await plugin.GetSingleRecord(first.CARequestID); + _output.WriteLine($"Original order {first.CARequestID}: status after DCV = {issued.Status}"); + issued.Status.Should().Be((int)EndEntityStatus.GENERATED, "the renewal precondition is an issued certificate"); + + // 4. Renew through the plugin's renewal path -> RenewCertificateAsync. + var track = await _fixture.Client.TrackOrderAsync(first.CARequestID); + DateTime.TryParse(track.OrderDetails?.CertificateExpiryDate, out var parsedExpiry); + var proxy = (ReaderProxy)(object)reader; + proxy.RequestId = first.CARequestID; + proxy.Expiry = parsedExpiry == default ? DateTime.UtcNow.AddDays(30) : parsedExpiry.ToUniversalTime(); + + var renewInfo = DvProductInfo(); + renewInfo.ProductParameters["PriorCertSN"] = "ws1c-prior-serial"; + renewInfo.ProductParameters["RenewalWindowDays"] = "800"; // force the renew API branch + + EnrollmentResult renewed; + try + { + renewed = await plugin.Enroll( + GenerateCsrPem(cn), $"CN={cn}", DnsSan(cn), renewInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + } + catch (Exception ex) + { + _output.WriteLine($"RENEWAL REFUSED/FAILED: {ex.GetType().Name}: {Truncate(ex.Message)}"); + throw; + } + + created.Add(renewed.CARequestID); + _output.WriteLine($"Renewal client calls: RenewCertificateAsync x{calls.Count(c => c == "RenewCertificateAsync")}"); + calls.Should().Contain("RenewCertificateAsync", + "the renewal must go through RenewCertificateAsync, not fall back to a fresh enrollment"); + _output.WriteLine($"Renewal order {renewed.CARequestID}: Enroll status={renewed.Status}, " + + $"distinct from original = {renewed.CARequestID != first.CARequestID}"); + renewed.CARequestID.Should().NotBe(first.CARequestID); + + AssertGroupAndNoWww("renewal", renewed.CARequestID, + await FindInOrderReportAsync(renewed.CARequestID), + await GetDomainListAsync(renewed.CARequestID, cn), cn); + + // Let the renewal issue too, so it is cleanly revocable. + await DriveToIssuanceAsync(plugin, renewed.CARequestID); + } + finally + { + await CleanupAsync(plugin, created); + } + } + + private async Task DriveToIssuanceAsync(CERTInextCAPlugin plugin, string orderNumber) + { + for (int pass = 1; pass <= 8; pass++) + { + var rec = await plugin.GetSingleRecord(orderNumber); + _output.WriteLine($" DCV pass {pass}: {orderNumber} status={rec.Status}"); + if (rec.Status == (int)EndEntityStatus.GENERATED) + return; + await Task.Delay(TimeSpan.FromSeconds(20)); + } + } +#endif + } +} From 25ec9e691dd344767c064bf6d8b0f98e92d45cdd Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:55:58 -0700 Subject: [PATCH 33/71] docs(config): describe OrganizationNumber pre-vetting per CERTInext spec --- CERTInext/CERTInextCAPluginConfig.cs | 27 ++++++++++----------------- CERTInext/Client/CERTInextClient.cs | 8 +++----- docsource/configuration.md | 2 +- integration-manifest.json | 2 +- 4 files changed, 15 insertions(+), 24 deletions(-) diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 4b919fb..d090413 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -57,14 +57,11 @@ public static Dictionary GetCAConnectorAnnotations() }, [Constants.Config.OrganizationNumber] = new PropertyConfigInfo { - Comments = "STRONGLY RECOMMENDED for OV/EV and faster DV issuance: numeric " + - "CERTInext organization number for a pre-vetted organization (e.g. " + - "your company's pre-vetted entry). When set, every SSL order is submitted " + - "with `organizationDetails.preVetting=\"1\"` and the configured " + - "`organizationNumber`, telling CERTInext to skip the manual " + - "organization-vetting queue. Without this value, orders are placed without " + - "any organizationDetails block and CERTInext may park them in " + - "`Pending System RA` for extended manual review (observed: tens of hours). " + + Comments = "OPTIONAL, strongly recommended for OV/EV: numeric CERTInext organization " + + "number for a pre-vetted organization. When set, every SSL order is submitted " + + "with `organizationDetails.preVetting=\"1\"` and this `organizationNumber`, so " + + "CERTInext can reuse that organization's pre-verified domains without fresh DCV. " + + "Leave blank to omit `organizationDetails`. " + "Available in the CERTInext portal under Organizations → " + "Pre-vetted Organizations.", Hidden = false, @@ -575,15 +572,11 @@ public class CERTInextConfig public string GroupNumber { get; set; } = string.Empty; /// - /// CERTInext organization number for a pre-vetted organization (e.g. the customer's - /// company). When set, every SSL order is submitted with - /// organizationDetails.preVetting="1" and the configured - /// organizationNumber, telling CERTInext to skip the manual organization - /// vetting queue. Strongly recommended for OV/EV products; significantly speeds - /// up DV issuance because CERTInext otherwise parks orders in Pending System RA - /// for extended manual review (observed tens of hours on the sandbox). - /// Empty by default — the plugin omits the organizationDetails block when - /// this is unset, preserving prior behavior. + /// CERTInext organization number for a pre-vetted organization. When set, every SSL + /// order is submitted with organizationDetails.preVetting="1" and this + /// organizationNumber. Per CERTInext's spec, this lets the order reuse the + /// organization's pre-verified domains without fresh DCV. Empty by default; the + /// organizationDetails block is omitted when unset, preserving prior behavior. /// [JsonPropertyName("OrganizationNumber")] public string OrganizationNumber { get; set; } = string.Empty; diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index db471f9..249e8f0 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1560,11 +1560,9 @@ private SslOrderDetails BuildSslOrderDetails( // adds www. and a second DCV) never applies silently. AutoSecureWww = string.IsNullOrWhiteSpace(_config.AutoSecureWww) ? "0" : _config.AutoSecureWww, - // organizationDetails — declares pre-vetted org when configured. This is the - // single biggest factor in how quickly CERTInext releases an order from - // Pending System RA. When OrganizationNumber is blank we omit the whole - // block (the model is JsonIgnore-WhenNull) so the order falls back to the - // unvetted path — same behavior as the prior plugin builds. + // organizationDetails — declares a pre-vetted organization when configured, which + // lets CERTInext reuse its pre-verified domains (no fresh DCV). Omitted when + // OrganizationNumber is blank (JsonIgnore-WhenNull), same as prior builds. OrganizationDetails = !string.IsNullOrWhiteSpace(_config.OrganizationNumber) ? new OrganizationDetails { diff --git a/docsource/configuration.md b/docsource/configuration.md index 308c544..87ab685 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -112,7 +112,7 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. | Use the physical city where the signer is located. | `Austin` | | `SignerIp` | Required | Public IP address of the host accepting the subscriber agreement. Required by CERTInext for all orders. | Use the outbound IP of the AnyCA Gateway host, or the IP of the workstation from which the agreement was accepted. | `203.0.113.10` | | `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `orderDetails.groupNumber` on every SSL order (new and renewal), so orders are routed to this group. When blank, CERTInext uses the account's default group. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | -| `OrganizationNumber` | Optional, strongly recommended for OV/EV and faster DV | Numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this number, telling CERTInext to skip its manual organization-vetting queue. Without it, orders may sit in `Pending System RA` for extended manual review (observed: tens of hours). | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | +| `OrganizationNumber` | Optional, strongly recommended for OV/EV | Numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this `organizationNumber`, so CERTInext can reuse that organization's pre-verified domains without fresh DCV. Leave blank to omit `organizationDetails`. | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | | `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` (`pocFirstName`, `pocLastName`, `pocEmail`, `pocIsdCode`, `pocMobileNumber`) on every SSL order, new and renewal. `TechnicalContactName` is split on the first whitespace: the first word becomes `pocFirstName` and the rest `pocLastName`; a single-word name is sent in both. Each field defaults to the corresponding `Requestor*` field when blank. | N/A | *(defaults to Requestor fields)* | | `AccountingModel` | Optional | CERTInext billing model sent in `orderDetails.accountingModel`. `2` = credit-based (most accounts). `1` = cash model. Default: `2`. | N/A | `2` | | `EmailNotifications` | Optional | Whether CERTInext sends lifecycle-event emails to the requestor. `1` = enabled, `0` = silent (recommended for gateway-driven orders). Default: `0`. | N/A | `0` | diff --git a/integration-manifest.json b/integration-manifest.json index 0d381f2..64cd4e8 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -39,7 +39,7 @@ }, { "name": "OrganizationNumber", - "description": "STRONGLY RECOMMENDED for OV/EV and faster DV issuance: numeric CERTInext organization number for a pre-vetted organization (e.g. your company's pre-vetted entry). When set, every SSL order is submitted with `organizationDetails.preVetting=\"1\"` and the configured `organizationNumber`, telling CERTInext to skip the manual organization-vetting queue. Without this value, orders are placed without any organizationDetails block and CERTInext may park them in `Pending System RA` for extended manual review (observed: tens of hours). Available in the CERTInext portal under Organizations \u2192 Pre-vetted Organizations." + "description": "OPTIONAL, strongly recommended for OV/EV: numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting=\"1\"` and this `organizationNumber`, so CERTInext can reuse that organization's pre-verified domains without fresh DCV. Leave blank to omit `organizationDetails`. Available in the CERTInext portal under Organizations → Pre-vetted Organizations." }, { "name": "TechnicalContactName", From 8cca70c490273133cb15cdcfec1d5ff2c79a290f Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:04:12 -0700 Subject: [PATCH 34/71] docs(config): correct DefaultProductCode behavior (template code first, default as fallback) --- docsource/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docsource/configuration.md b/docsource/configuration.md index 87ab685..b5ff164 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -121,7 +121,7 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `SubscriptionRenewCriteriaDays` | Optional | Days before expiry at which CERTInext auto-renews. Only honored when `SubscriptionAutoRenew` is `1`. Default: `30`. | N/A | `30` | | `AutoSecureWww` | Optional | Sent as `orderDetails.autoSecureWWW` on every SSL order, new and renewal. If `1`, CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN, which must also pass domain validation. `0` = only the CN/SANs from the CSR. Default: `0`. Before 1.0.1 this value never reached CERTInext, so its own default (add `www.`) applied. | N/A | `0` | | `SubmitNonDnsSans` | Optional | If `true` (default), SANs that aren't DNS names (IP address, email, URI) are submitted to CERTInext instead of silently dropped. CERTInext can't validate them, so such an order won't issue until they're removed. Set to `false` to restore the pre-1.0.1 behavior of submitting DNS names only. Default: `true`. | N/A | `true` | -| `DefaultProductCode` | Optional, but effectively required if you use renewals | Numeric product code used for **renewals only** — CERTInext's `TrackOrder` doesn't return the prior order's product code, so the renewal path sends this value verbatim, ignoring the template's `ProductCode`/`ProfileId`. If left blank, renewals go out with an empty product code. Has **no effect on new enrollments** — the `ProductCode`/`ProfileId` template resolution never falls back to it. See [issue tracking this](https://github.com/Keyfactor/certinext-caplugin/issues/26). | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | +| `DefaultProductCode` | Optional, but effectively required if templates don't set a product code | Numeric product code used for both new enrollments and renewals when the template has no product code (`ProductCode`/`ProfileId`). The template's code always takes precedence. If both are blank, the order is sent with an empty product code. CERTInext's `TrackOrder` doesn't return the prior order's product code, so renewals rely on the template's code or this value. | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | From 95e7e2986683e67a22ee37be352d88d87d9d1671 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:04:16 -0700 Subject: [PATCH 35/71] chore(make): replace hard-coded developer-home script paths with CURDIR/script-relative paths --- Makefile | 6 +++--- scripts/probe-endpoints.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 27026df..131aaf2 100644 --- a/Makefile +++ b/Makefile @@ -453,7 +453,7 @@ get-field-details: FILTER ?= show-postman-bodies: - @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/extract_postman_bodies.py \ + @python3 $(CURDIR)/scripts/extract_postman_bodies.py \ --filter "$(FILTER)" # --------------------------------------------------------------------------- @@ -465,7 +465,7 @@ show-postman-bodies: # --------------------------------------------------------------------------- show-postman-variables: - @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/extract_postman_variables.py + @python3 $(CURDIR)/scripts/extract_postman_variables.py # --------------------------------------------------------------------------- # probe-private-pki-payloads — Try three payload variants for @@ -479,7 +479,7 @@ show-postman-variables: # --------------------------------------------------------------------------- probe-private-pki-payloads: generate-test-csr - @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/order_private_pki_minimal.py \ + @python3 $(CURDIR)/scripts/order_private_pki_minimal.py \ --csr /tmp/certinext-test.csr \ --domain "$(IGTF_DOMAIN)" \ --product "$(PRIVATE_PKI_CODE)" \ diff --git a/scripts/probe-endpoints.sh b/scripts/probe-endpoints.sh index 8b9e64a..4710933 100755 --- a/scripts/probe-endpoints.sh +++ b/scripts/probe-endpoints.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash set -euo pipefail -python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/probe_endpoints.py \ +python3 "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/probe_endpoints.py" \ | while IFS= read -r line; do echo "$line"; done From 50637b4e2b00dac5ec1f82e35a3fefd9734b23d0 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:06:34 -0700 Subject: [PATCH 36/71] fix(enroll): fall back to defaults when product code and signer values are blank --- CERTInext/Client/CERTInextClient.cs | 11 ++++++++--- CHANGELOG.md | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 249e8f0..5375d8c 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1508,7 +1508,11 @@ private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollC { // Meta will be set by PlaceOrderAsync OrderDetails = BuildSslOrderDetails( - productCode: request.ProfileId ?? _config.DefaultProductCode ?? string.Empty, + // Blank (not just null) ProfileId must fall back: the template ProductCode + // resolves to "" when unset, so a null-coalesce would never fire. + productCode: string.IsNullOrWhiteSpace(request.ProfileId) + ? (_config.DefaultProductCode ?? string.Empty) + : request.ProfileId, domainName: domainName, sans: request.Sans, csr: request.Csr, @@ -1711,8 +1715,9 @@ private AgreementDetails BuildDefaultAgreementDetails() return new AgreementDetails { AcceptAgreement = "1", - SignerName = _config.RequestorName ?? "Keyfactor Gateway", - SignerPlace = _config.SignerPlace ?? "Gateway", + // Config strings default to "", so these need blank checks, not null-coalesce. + SignerName = string.IsNullOrWhiteSpace(_config.RequestorName) ? "Keyfactor Gateway" : _config.RequestorName, + SignerPlace = string.IsNullOrWhiteSpace(_config.SignerPlace) ? "Gateway" : _config.SignerPlace, SignerIp = signerIp }; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9116145..c5583c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **Renewals no longer lose their SANs.** Renewals were submitted with no additional domains and the wrong primary domain; both now come from the certificate being renewed. - **Enrollment no longer fails on an order CERTInext auto-approves before it finishes issuing.** The plugin used to report these as issued with no certificate attached, which the gateway rejected. It now returns pending and picks up the certificate once CERTInext finishes issuing it. - **Renewals now use the certificate template's product code.** Renewals previously always used the connector's `DefaultProductCode`, which could send an empty product code if that setting was never configured. Renewals now use the template's code, falling back to `DefaultProductCode` only when the template doesn't have one. +- **New enrollments now use the connector defaults when the template or connector value is blank.** A blank template product code now falls back to `DefaultProductCode`, and a blank `RequestorName`/`SignerPlace` now sends "Keyfactor Gateway"/"Gateway" as the agreement signer instead of an empty value. - **`GroupNumber`, `AutoSecureWww`, and the technical contact now reach CERTInext**; they were previously sent in fields CERTInext doesn't read. - **Renewals now send the full order details** (group, `AutoSecureWww`, technical contact, organization, remarks), the same as a new enrollment. - **Gateway logs no longer contain the `authKey` or requestor personal data by default**; set `LogSensitiveRequestData` to log PII temporarily (credentials stay redacted). From 729e8345b4cfc43e048bfb98bc55ab95eb9627f7 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:06:34 -0700 Subject: [PATCH 37/71] test(enroll): blank product code and signer name/place fall back to defaults --- .../CERTInextClientRequestShapeTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs index f8d5884..2a40589 100644 --- a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs +++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs @@ -474,6 +474,72 @@ public async Task ValidityDays_OnRequest_OverridesConnectorDefault() .GetProperty("validity").GetString().Should().Be("2"); } + // ----------------------------------------------------------------------- + // New enrollment — blank-value fallbacks (local issues/0071). Config/template + // strings default to "", so null-coalesce fallbacks never fired. + // ----------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_ProfileIdBlank_FallsBackToConnectorDefaultProductCode(string blankProfileId) + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.DefaultProductCode = "connector-default-code"; + var req = BasicEnrollRequest(); + req.ProfileId = blankProfileId; + + await BuildClient(cfg).EnrollCertificateAsync(req); + + CapturedOrderBody().GetProperty("productCode").GetString().Should().Be("connector-default-code", + "a blank template ProductCode must fall back to the connector's DefaultProductCode"); + } + + [Fact] + public async Task Enroll_ProfileIdSet_UsesTemplateProductCodeOverConnectorDefault() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.DefaultProductCode = "connector-default-code"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + CapturedOrderBody().GetProperty("productCode").GetString().Should().Be("842"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_SignerNameAndPlaceBlank_FallBackToDefaults(string blank) + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.RequestorName = blank; + cfg.SignerPlace = blank; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var agreement = CapturedOrderBody().GetProperty("agreementDetails"); + agreement.GetProperty("signerName").GetString().Should().Be("Keyfactor Gateway"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Gateway"); + } + + [Fact] + public async Task Enroll_SignerNameAndPlaceConfigured_AreSentVerbatim() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); // RequestorName "Default Requestor", SignerPlace "Austin" + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var agreement = CapturedOrderBody().GetProperty("agreementDetails"); + agreement.GetProperty("signerName").GetString().Should().Be("Default Requestor"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Austin"); + } + // ----------------------------------------------------------------------- // RenewCertificateAsync — productCode resolution (issue #26 / local issues/0012) // Renewals go out as a fresh GenerateOrderSSL order; the product code must From f1a588aa80581f778c5dd39484733bb71a0b7c81 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:04:48 -0700 Subject: [PATCH 38/71] fix(logging): include HTTP status and log redacted body for V1 non-2xx responses (0073) Port of 0044 (V1 part). DeserializeOrThrow's non-2xx branch logged only the generic 'unrecognised error body' text, never the body, and the exception had no status. It now logs the redacted, truncated body through LogApiFailure (ApplyLoggingRedaction: authKey always redacted, PII per LogSensitiveRequestData) and puts the HTTP status in the exception message. The two-argument ExtractErrorMessage used by RevokeOrderAsync is unchanged. --- CERTInext/Client/CERTInextClient.cs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 5375d8c..7398dbe 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1792,14 +1792,17 @@ private List BuildAdditionalDomains( // Deserialization helpers // --------------------------------------------------------------------------- - private static T DeserializeOrThrow(RestResponse resp, string operation) where T : class + // Instance (not static) — calls LogApiFailure, which needs _config.LogSensitiveRequestData. + private T DeserializeOrThrow(RestResponse resp, string operation) where T : class { if (!resp.IsSuccessful) { - string errMsg = ExtractErrorMessage(resp.Content, operation); - Logger.LogError( - "CERTInext API error during '{Operation}': HttpStatus={Status}, Error={Error}", - operation, (int)resp.StatusCode, errMsg); + // Issue 0073 (port of 0044): V1 documents errors only as HTTP-200 meta envelopes, so a + // non-2xx body here is usually not from the V1 application at all (e.g. ApiUrl missing + // the /emSignHub-API/ segment). Log the redacted body and put the HTTP status in the + // message so "See gateway logs for details" has something to point at. + string errMsg = ExtractErrorMessage(resp.Content, operation, (int)resp.StatusCode); + LogApiFailure(operation, resp, errorMessage: errMsg, level: LogLevel.Error); throw new Exception(errMsg); } @@ -2236,10 +2239,12 @@ private void LogApiFailure( Truncate(sanitizedBody, LoggedResponseBodyCapBytes)); } - private static string ExtractErrorMessage(string content, string operation) + internal static string ExtractErrorMessage(string content, string operation, int? httpStatus = null) { + string status = httpStatus.HasValue ? $" (HTTP {httpStatus.Value})" : string.Empty; + if (string.IsNullOrWhiteSpace(content)) - return $"CERTInext returned no body for operation '{operation}'."; + return $"CERTInext returned no body{status} for operation '{operation}'."; if (content.Length > MaxErrorBodyBytes) { @@ -2261,19 +2266,19 @@ private static string ExtractErrorMessage(string content, string operation) if (meta.TryGetProperty("errorMessage", out var em)) errMsg = em.GetString(); if (meta.TryGetProperty("errorCode", out var ec)) errCode = ec.GetString(); if (!string.IsNullOrWhiteSpace(errMsg) || !string.IsNullOrWhiteSpace(errCode)) - return $"CERTInext error during '{operation}': {errMsg ?? errCode} [{errCode}]"; + return $"CERTInext error during '{operation}'{status}: {errMsg ?? errCode} [{errCode}]"; } // Fall back to legacy ApiErrorResponse shape if (doc.RootElement.TryGetProperty("message", out var legacyMsg)) - return $"CERTInext error during '{operation}': {legacyMsg.GetString()}"; + return $"CERTInext error during '{operation}'{status}: {legacyMsg.GetString()}"; } catch { // Fall through to safe generic message } - return $"CERTInext returned an unrecognised error body for operation '{operation}'. " + + return $"CERTInext returned an unrecognised error body{status} for operation '{operation}'. " + "See gateway logs for details."; } From 6267c6c8b3f0c68c8798ef8152a24d84c119aa56 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:06:26 -0700 Subject: [PATCH 39/71] test(logging): cover V1 non-2xx error body logging and redaction (0073) WireMock tests replay the Spring Boot 404 body captured live for 0044 through ListOrdersAsync and GetProductDetailsAsync, pin ExtractErrorMessage output with and without a status, and assert the logged body has authKey always redacted and PII redacted per LogSensitiveRequestData. Adds the 1.0.1 CHANGELOG entry. --- CERTInext.Tests/ExtractErrorMessageTests.cs | 81 +++++++++ .../V1NonSuccessLogRedactionTests.cs | 158 ++++++++++++++++++ CERTInext.Tests/V1NonSuccessResponseTests.cs | 101 +++++++++++ CHANGELOG.md | 1 + 4 files changed, 341 insertions(+) create mode 100644 CERTInext.Tests/ExtractErrorMessageTests.cs create mode 100644 CERTInext.Tests/V1NonSuccessLogRedactionTests.cs create mode 100644 CERTInext.Tests/V1NonSuccessResponseTests.cs diff --git a/CERTInext.Tests/ExtractErrorMessageTests.cs b/CERTInext.Tests/ExtractErrorMessageTests.cs new file mode 100644 index 0000000..d1477f1 --- /dev/null +++ b/CERTInext.Tests/ExtractErrorMessageTests.cs @@ -0,0 +1,81 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0073 (port of 0044): parser-level coverage for CERTInextClient.ExtractErrorMessage, which + /// builds the V1 non-success exception message. + /// + public class ExtractErrorMessageTests + { + private const string Op = "list orders page 1"; + + [Fact] + public void LiveSpringNotFoundBody_WithStatus_ReturnsGenericMessageWithHttpStatus() + { + CERTInextClient.ExtractErrorMessage(V1NonSuccessResponseTests.LiveSpringNotFoundBody, Op, 404) + .Should().Be("CERTInext returned an unrecognised error body (HTTP 404) for operation 'list orders page 1'. " + + "See gateway logs for details."); + } + + [Fact] + public void LiveSpringNotFoundBody_WithoutStatus_KeepsPreviousMessage() + { + // RevokeOrderAsync still calls the two-argument form; its message must not change. + CERTInextClient.ExtractErrorMessage(V1NonSuccessResponseTests.LiveSpringNotFoundBody, Op) + .Should().Be("CERTInext returned an unrecognised error body for operation 'list orders page 1'. " + + "See gateway logs for details."); + } + + [Theory] + [InlineData("Bad Gateway")] + [InlineData("[]")] + public void NonEnvelopeBody_WithStatus_ReturnsGenericMessageWithHttpStatus(string body) + { + CERTInextClient.ExtractErrorMessage(body, Op, 502) + .Should().StartWith("CERTInext returned an unrecognised error body (HTTP 502) for operation"); + } + + [Fact] + public void MetaEnvelope_WithStatus_IncludesStatusAndCaError() + { + const string body = "{\"meta\":{\"status\":\"0\",\"errorCode\":\"EMS-913\",\"errorMessage\":\"Invalid Account Number\"}}"; + + CERTInextClient.ExtractErrorMessage(body, Op, 500) + .Should().Be("CERTInext error during 'list orders page 1' (HTTP 500): Invalid Account Number [EMS-913]"); + } + + [Fact] + public void LegacyMessageBody_WithStatus_IncludesStatusAndMessage() + { + CERTInextClient.ExtractErrorMessage("{\"message\":\"Service Unavailable\"}", Op, 503) + .Should().Be("CERTInext error during 'list orders page 1' (HTTP 503): Service Unavailable"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void EmptyBody_WithStatus_IncludesStatus(string body) + { + CERTInextClient.ExtractErrorMessage(body, Op, 404) + .Should().Be("CERTInext returned no body (HTTP 404) for operation 'list orders page 1'."); + } + } +} diff --git a/CERTInext.Tests/V1NonSuccessLogRedactionTests.cs b/CERTInext.Tests/V1NonSuccessLogRedactionTests.cs new file mode 100644 index 0000000..baadfb8 --- /dev/null +++ b/CERTInext.Tests/V1NonSuccessLogRedactionTests.cs @@ -0,0 +1,158 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Microsoft.Extensions.Logging; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0073 (port of 0044): an unrecognised V1 non-2xx error body is logged so it can be + /// diagnosed, but only after ApplyLoggingRedaction: the authKey is always scrubbed and + /// personal data is scrubbed unless LogSensitiveRequestData is on. Drives the real + /// DeserializeOrThrow call site through WireMock and captures what the client logged via + /// CERTInextClient.OverrideLoggerForTests. All data is synthetic. + /// + [Collection("CERTInextClientLogger-NoParallel")] + public class V1NonSuccessLogRedactionTests : IDisposable + { + private const string AuthKey = "SYNTHETIC-AUTHKEY-0073"; + private const string Email = "jane.doe@example.com"; + private const string Name = "Jane Doe"; + + private readonly WireMockServer _server; + + public V1NonSuccessLogRedactionTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() => _server.Stop(); + + private sealed class CapturingLogger : ILogger + { + public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => Entries.Enqueue((logLevel, formatter(state, exception))); + } + + private CERTInextClient BuildClient(bool logSensitiveRequestData) => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "synthetic-access-key", + AccountNumber = "9988776655", + LogSensitiveRequestData = logSensitiveRequestData + }); + + // Unrecognised shape (no meta.errorMessage/errorCode, no top-level message) that nevertheless + // echoes a credential and personal data, which is the case redaction must cover. + private static string BuildBody(string marker) => + "{\"timestamp\":\"2026-10-02T00:00:00.000+00:00\",\"status\":502,\"error\":\"Bad Gateway\"," + + $"\"trace\":\"{marker}\",\"meta\":{{\"authKey\":\"{AuthKey}\"}}," + + $"\"requestorEmail\":\"{Email}\",\"requestorName\":\"{Name}\"}}"; + + private async Task<(Exception Error, List<(LogLevel Level, string Message)> Lines)> RunAsync( + bool logSensitiveRequestData, int status, string body, string marker) + { + _server.Reset(); + _server.Given(Request.Create().WithPath("/GetProductDetails").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(status) + .WithHeader("Content-Type", "application/json").WithBody(body)); + + var client = BuildClient(logSensitiveRequestData); + var logger = new CapturingLogger(); + Exception error = null; + using (CERTInextClient.OverrideLoggerForTests(logger)) + { + try { await client.GetProductDetailsAsync(); } + catch (Exception ex) { error = ex; } + } + + // The client logger is process-wide; scope to lines carrying this call's marker. + var lines = logger.Entries.Where(e => e.Message != null && e.Message.Contains(marker)).ToList(); + return (error, lines); + } + + [Fact] + public async Task UnrecognisedErrorBody_FlagOff_LogsBodyWithAuthKeyAndPiiRedacted() + { + string marker = "m-" + Guid.NewGuid().ToString("N"); + var (error, lines) = await RunAsync(false, 502, BuildBody(marker), marker); + + error.Should().NotBeNull(); + error.Message.Should().Contain("unrecognised error body (HTTP 502)"); + + var failure = lines.Where(l => l.Message.StartsWith("CERTInext API non-success")).ToList(); + failure.Should().ContainSingle("the unrecognised body must be logged exactly once"); + failure[0].Level.Should().Be(LogLevel.Error); + string msg = failure[0].Message; + msg.Should().Contain("HttpStatus=502").And.Contain("Bad Gateway", "the diagnosable part of the body is kept"); + msg.Should().Contain("\"authKey\":\"***REDACTED***\""); + msg.Should().NotContain(AuthKey).And.NotContain(Email).And.NotContain(Name); + + lines.Should().NotContain(l => l.Message.Contains(AuthKey) || l.Message.Contains(Email) || l.Message.Contains(Name)); + } + + [Fact] + public async Task UnrecognisedErrorBody_FlagOn_KeepsPiiButStillRedactsAuthKey() + { + string marker = "m-" + Guid.NewGuid().ToString("N"); + var (_, lines) = await RunAsync(true, 502, BuildBody(marker), marker); + + var failure = lines.Where(l => l.Message.StartsWith("CERTInext API non-success")).ToList(); + failure.Should().ContainSingle(); + string msg = failure[0].Message; + msg.Should().Contain(Email).And.Contain(Name); + msg.Should().Contain("\"authKey\":\"***REDACTED***\"").And.NotContain(AuthKey); + lines.Should().NotContain(l => l.Message.Contains(AuthKey)); + } + + [Fact] + public async Task NonJsonErrorBody_FormEncodedAuthKey_IsRedactedInLog() + { + string marker = "m-" + Guid.NewGuid().ToString("N"); + string body = $"Bad Gateway {marker} authKey={AuthKey}"; + var (error, lines) = await RunAsync(false, 502, body, marker); + + error.Message.Should().Contain("unrecognised error body (HTTP 502)"); + var failure = lines.Where(l => l.Message.StartsWith("CERTInext API non-success")).ToList(); + failure.Should().ContainSingle(); + failure[0].Message.Should().Contain("Bad Gateway").And.Contain("authKey=***REDACTED***").And.NotContain(AuthKey); + } + + [Fact] + public async Task UnrecognisedErrorBody_ExceptionMessageDoesNotEmbedBody() + { + string marker = "m-" + Guid.NewGuid().ToString("N"); + var (error, _) = await RunAsync(false, 502, BuildBody(marker), marker); + + error.Message.Should().NotContain(AuthKey).And.NotContain(Email).And.NotContain(marker, + "the raw body goes to the (redacted) log only, never into the exception surfaced to Command"); + } + } +} diff --git a/CERTInext.Tests/V1NonSuccessResponseTests.cs b/CERTInext.Tests/V1NonSuccessResponseTests.cs new file mode 100644 index 0000000..14f2ee5 --- /dev/null +++ b/CERTInext.Tests/V1NonSuccessResponseTests.cs @@ -0,0 +1,101 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0073 (port of 0044): a V1 call that gets a non-2xx response whose body is not a CERTInext envelope + /// must surface the HTTP status in the exception, not just "unrecognised error body". + /// + /// The body below is the exact one captured live on 2026-09-29 when the V1 GetOrderReport + /// call was sent to the V2 base URL (ApiUrl without /emSignHub-API/): the host's default + /// Spring Boot 404 body, with no meta and no message. + /// + public class V1NonSuccessResponseTests : IDisposable + { + internal const string LiveSpringNotFoundBody = + "{\"timestamp\":\"2026-09-29T16:05:05.736+00:00\",\"status\":404,\"error\":\"Not Found\",\"path\":\"/GetOrderReport\"}"; + + private readonly WireMockServer _server; + + public V1NonSuccessResponseTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() + { + _server.Stop(); + } + + private CERTInextClient BuildClient() => + new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "test-key", + AccountNumber = "12345", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + PageSize = 100 + }); + + private void StubNotFound(string path) => + _server + .Given(Request.Create().WithPath(path).UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(404) + .WithHeader("Content-Type", "application/json") + .WithBody(LiveSpringNotFoundBody)); + + [Fact] + public async Task ListOrdersAsync_SpringNotFoundBody_ThrowsWithHttpStatus() + { + StubNotFound("/GetOrderReport"); + var client = BuildClient(); + + Func act = async () => + { + await foreach (var _ in client.ListOrdersAsync(pageSize: 5)) + { + } + }; + + await act.Should().ThrowAsync() + .WithMessage("CERTInext returned an unrecognised error body (HTTP 404) for operation 'list orders page 1'. See gateway logs for details."); + } + + [Fact] + public async Task GetProductDetailsAsync_SpringNotFoundBody_ThrowsWithHttpStatus() + { + // Same shared DeserializeOrThrow path, different caller. + StubNotFound("/GetProductDetails"); + var client = BuildClient(); + + Func act = () => client.GetProductDetailsAsync(); + + await act.Should().ThrowAsync() + .WithMessage("*unrecognised error body (HTTP 404) for operation 'get product details'*"); + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index c5583c6..2f1defd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **New enrollments now use the connector defaults when the template or connector value is blank.** A blank template product code now falls back to `DefaultProductCode`, and a blank `RequestorName`/`SignerPlace` now sends "Keyfactor Gateway"/"Gateway" as the agreement signer instead of an empty value. - **`GroupNumber`, `AutoSecureWww`, and the technical contact now reach CERTInext**; they were previously sent in fields CERTInext doesn't read. - **Renewals now send the full order details** (group, `AutoSecureWww`, technical contact, organization, remarks), the same as a new enrollment. +- **Unexpected CERTInext error responses are now diagnosable from the logs.** Non-2xx responses with an unrecognised body now include the HTTP status in the error and log the redacted body (`authKey` always redacted, personal data per `LogSensitiveRequestData`). - **Gateway logs no longer contain the `authKey` or requestor personal data by default**; set `LogSensitiveRequestData` to log PII temporarily (credentials stay redacted). ## Chores From 797f7847bcb48515da141a852b6f570ea9fb2fe4 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:11:45 -0700 Subject: [PATCH 40/71] fix(enroll): honor SignerName/SignerPlace/SignerIp template parameters in agreementDetails Resolve each signer value as template value, then connector value, then built-in default using blank checks, for both new enrollment and renewal. Fixes local issue 0072. --- CERTInext/API/CertificateRequest.cs | 30 +++++++++++++++++++ CERTInext/CERTInextCAPlugin.cs | 6 ++++ CERTInext/Client/CERTInextClient.cs | 45 ++++++++++++++++++++--------- CHANGELOG.md | 1 + 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs index 2bf230e..c0e3806 100644 --- a/CERTInext/API/CertificateRequest.cs +++ b/CERTInext/API/CertificateRequest.cs @@ -602,6 +602,21 @@ public class EnrollCertificateRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string Comment { get; set; } + /// Per-template agreement signer name; blank falls back to the connector value. + [JsonPropertyName("signerName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string SignerName { get; set; } + + /// Per-template agreement signer place; blank falls back to the connector value. + [JsonPropertyName("signerPlace")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string SignerPlace { get; set; } + + /// Per-template agreement signer IP; blank falls back to the connector value. + [JsonPropertyName("signerIp")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string SignerIp { get; set; } + [JsonPropertyName("keyType")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string KeyType { get; set; } @@ -675,6 +690,21 @@ public class RenewCertificateRequest [JsonPropertyName("comment")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string Comment { get; set; } + + /// Per-template agreement signer name; blank falls back to the connector value. + [JsonPropertyName("signerName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string SignerName { get; set; } + + /// Per-template agreement signer place; blank falls back to the connector value. + [JsonPropertyName("signerPlace")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string SignerPlace { get; set; } + + /// Per-template agreement signer IP; blank falls back to the connector value. + [JsonPropertyName("signerIp")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string SignerIp { get; set; } } /// diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index e33f5b2..9d6ba51 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1146,6 +1146,9 @@ private async Task EnrollNewAsync( RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, KeyType = string.IsNullOrWhiteSpace(ep.KeyType) ? null : ep.KeyType, + SignerName = string.IsNullOrWhiteSpace(ep.SignerName) ? null : ep.SignerName, + SignerPlace = string.IsNullOrWhiteSpace(ep.SignerPlace) ? null : ep.SignerPlace, + SignerIp = string.IsNullOrWhiteSpace(ep.SignerIp) ? null : ep.SignerIp, Comment = "Issued via Keyfactor Command AnyCA REST Gateway." }; @@ -1369,6 +1372,9 @@ private async Task RenewOrReissueAsync( ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, + SignerName = string.IsNullOrWhiteSpace(ep.SignerName) ? null : ep.SignerName, + SignerPlace = string.IsNullOrWhiteSpace(ep.SignerPlace) ? null : ep.SignerPlace, + SignerIp = string.IsNullOrWhiteSpace(ep.SignerIp) ? null : ep.SignerIp, Comment = $"Renewed via Keyfactor Command. Prior ID: {priorCaRequestId}." }; diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 7398dbe..9b66dd3 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -870,7 +870,10 @@ public async Task RenewCertificateAsync( validityDays: request.ValidityDays, requesterName: request.RequesterName, requesterEmail: request.RequesterEmail, - comment: request.Comment) + comment: request.Comment, + signerName: request.SignerName, + signerPlace: request.SignerPlace, + signerIp: request.SignerIp) }; var orderResp = await PlaceOrderAsync(orderReq, ct); @@ -1520,7 +1523,10 @@ private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollC validityDays: request.ValidityDays, requesterName: request.RequesterName, requesterEmail: request.RequesterEmail, - comment: request.Comment) + comment: request.Comment, + signerName: request.SignerName, + signerPlace: request.SignerPlace, + signerIp: request.SignerIp) }; } @@ -1542,7 +1548,10 @@ private SslOrderDetails BuildSslOrderDetails( int? validityDays, string requesterName, string requesterEmail, - string comment) + string comment, + string signerName, + string signerPlace, + string signerIp) { string requestorName = requesterName ?? _config.RequestorName ?? "Keyfactor Gateway"; string requestorEmail = requesterEmail ?? _config.RequestorEmail ?? string.Empty; @@ -1598,7 +1607,7 @@ private SslOrderDetails BuildSslOrderDetails( requestorName, requestorEmail, requestorIsd, requestorMobile), Csr = csr, - AgreementDetails = BuildDefaultAgreementDetails(), + AgreementDetails = BuildAgreementDetails(signerName, signerPlace, signerIp), AdditionalInformation = new AdditionalInformation { Remarks = comment ?? "Issued via Keyfactor Command AnyCA REST Gateway." @@ -1695,7 +1704,13 @@ internal static (string First, string Last) SplitContactName(string name) return (first, last); } - private AgreementDetails BuildDefaultAgreementDetails() + /// + /// Builds agreementDetails. Each signer value resolves template value, then + /// connector value, then built-in default, using blank (not just null) checks because + /// both the template parameters and the connector config default to "". + /// + private AgreementDetails BuildAgreementDetails( + string templateSignerName, string templateSignerPlace, string templateSignerIp) { // SOC1 accuracy-of-processing: the subscriber agreement is a legal artefact // and the SignerIp it carries is part of the audit record CERTInext stores. @@ -1703,25 +1718,29 @@ private AgreementDetails BuildDefaultAgreementDetails() // don't break existing deployments (and our enrollment never fails just // because SignerIp is blank), but a missing value emits a Warning so an // auditor sees the misrepresentation as an actionable signal in the gateway log. - string signerIp = _config.SignerIp; - if (string.IsNullOrWhiteSpace(signerIp)) + string signerIp = FirstNonBlank(templateSignerIp, _config.SignerIp); + if (signerIp == null) { Logger.LogWarning( - "Connector config SignerIp is empty — falling back to 127.0.0.1 for the " + - "subscriber agreement. Set the SignerIp config field to the gateway host's " + - "actual public-routable IP so the audit record is accurate."); + "Neither the template SignerIp parameter nor the connector SignerIp config is set — " + + "falling back to 127.0.0.1 for the subscriber agreement. Set SignerIp to the " + + "gateway host's actual public-routable IP so the audit record is accurate."); signerIp = "127.0.0.1"; } return new AgreementDetails { AcceptAgreement = "1", - // Config strings default to "", so these need blank checks, not null-coalesce. - SignerName = string.IsNullOrWhiteSpace(_config.RequestorName) ? "Keyfactor Gateway" : _config.RequestorName, - SignerPlace = string.IsNullOrWhiteSpace(_config.SignerPlace) ? "Gateway" : _config.SignerPlace, + SignerName = FirstNonBlank(templateSignerName, _config.RequestorName) ?? "Keyfactor Gateway", + SignerPlace = FirstNonBlank(templateSignerPlace, _config.SignerPlace) ?? "Gateway", SignerIp = signerIp }; } + private static string FirstNonBlank(string first, string second) + => !string.IsNullOrWhiteSpace(first) ? first + : !string.IsNullOrWhiteSpace(second) ? second + : null; + private static string ExtractCnFromSubject(string subject) { if (string.IsNullOrWhiteSpace(subject)) return null; diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1defd..5d46ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - **Enrollment no longer fails on an order CERTInext auto-approves before it finishes issuing.** The plugin used to report these as issued with no certificate attached, which the gateway rejected. It now returns pending and picks up the certificate once CERTInext finishes issuing it. - **Renewals now use the certificate template's product code.** Renewals previously always used the connector's `DefaultProductCode`, which could send an empty product code if that setting was never configured. Renewals now use the template's code, falling back to `DefaultProductCode` only when the template doesn't have one. - **New enrollments now use the connector defaults when the template or connector value is blank.** A blank template product code now falls back to `DefaultProductCode`, and a blank `RequestorName`/`SignerPlace` now sends "Keyfactor Gateway"/"Gateway" as the agreement signer instead of an empty value. +- **The `SignerName`, `SignerPlace`, and `SignerIp` template parameters now take effect.** They were accepted but ignored; they now override the connector values for the subscriber agreement on both new orders and renewals. - **`GroupNumber`, `AutoSecureWww`, and the technical contact now reach CERTInext**; they were previously sent in fields CERTInext doesn't read. - **Renewals now send the full order details** (group, `AutoSecureWww`, technical contact, organization, remarks), the same as a new enrollment. - **Unexpected CERTInext error responses are now diagnosable from the logs.** Non-2xx responses with an unrecognised body now include the HTTP status in the error and log the redacted body (`authKey` always redacted, personal data per `LogSensitiveRequestData`). From 414a1159835eb0066c725f24600ad5a68f042a34 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:11:45 -0700 Subject: [PATCH 41/71] test(enroll): signer template/connector/default precedence on enroll and renewal --- CERTInext.Tests/CERTInextCAPluginTests.cs | 88 ++++++++++++ .../CERTInextClientRequestShapeTests.cs | 135 ++++++++++++++++++ 2 files changed, 223 insertions(+) diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index d93daf9..b9a5071 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -558,6 +558,56 @@ await act.Should().ThrowAsync() .WithMessage("*ProfileId*required*"); } + [Fact] + public async Task Enroll_New_ThreadsTemplateSignerParamsOntoRequest() + { + var mock = NewMock(); + EnrollCertificateRequest captured = null; + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), + It.IsAny())) + .Callback((r, _) => captured = r) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse()); + + var plugin = BuildPlugin(mock.Object); + await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=test.example.com", null, + MakeProductInfo(extras: new Dictionary + { + ["SignerName"] = "Template Signer", + ["SignerPlace"] = "Template Place", + ["SignerIp"] = "203.0.113.77" + }), + RequestFormat.PKCS10, EnrollmentType.New); + + captured.Should().NotBeNull(); + captured!.SignerName.Should().Be("Template Signer"); + captured.SignerPlace.Should().Be("Template Place"); + captured.SignerIp.Should().Be("203.0.113.77"); + } + + [Fact] + public async Task Enroll_New_BlankTemplateSignerParams_LeaveRequestSignerValuesNull() + { + var mock = NewMock(); + EnrollCertificateRequest captured = null; + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), + It.IsAny())) + .Callback((r, _) => captured = r) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse()); + + var plugin = BuildPlugin(mock.Object); + await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=test.example.com", null, + MakeProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + captured.Should().NotBeNull(); + captured!.SignerName.Should().BeNull(); + captured.SignerPlace.Should().BeNull(); + captured.SignerIp.Should().BeNull(); + } + [Fact] public async Task Enroll_Reissue_AlsoCallsEnrollAsync() { @@ -1155,6 +1205,44 @@ public async Task RenewOrReissue_UsesRenewApi_WhenCertExpiresWithinWindow() "cert expiring in 30 days should use the renewal API (within 90-day window)"); } + [Fact] + public async Task RenewOrReissue_Renewal_ThreadsTemplateSignerParamsOntoRequest() + { + var clientMock = new Mock(MockBehavior.Strict); + var readerMock = new Mock(MockBehavior.Strict); + + readerMock.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())) + .ReturnsAsync(MockCertificateData.CertId1); + readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1)) + .Returns(DateTime.UtcNow.AddDays(30)); + + RenewCertificateRequest captured = null; + clientMock.Setup(c => c.RenewCertificateAsync( + MockCertificateData.CertId1, + It.IsAny(), + It.IsAny())) + .Callback((_, r, _) => captured = r) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse("renewed-01")); + + var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object); + await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=test.example.com", null, + MakeProductInfo(extras: new Dictionary + { + ["PriorCertSN"] = "AABB", + ["RenewalWindowDays"] = "90", + ["SignerName"] = "Template Signer", + ["SignerPlace"] = "Template Place", + ["SignerIp"] = "203.0.113.77" + }), + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + captured.Should().NotBeNull(); + captured!.SignerName.Should().Be("Template Signer"); + captured.SignerPlace.Should().Be("Template Place"); + captured.SignerIp.Should().Be("203.0.113.77"); + } + [Fact] public async Task RenewOrReissue_UsesNewEnroll_WhenCertExpiresOutsideWindow() { diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs index 2a40589..eb9f73c 100644 --- a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs +++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs @@ -540,6 +540,141 @@ public async Task Enroll_SignerNameAndPlaceConfigured_AreSentVerbatim() agreement.GetProperty("signerPlace").GetString().Should().Be("Austin"); } + // ----------------------------------------------------------------------- + // agreementDetails precedence (issue 0072): template value -> connector value -> default. + // Blank (null/""/whitespace) at either level falls through. + // ----------------------------------------------------------------------- + + [Fact] + public async Task Enroll_TemplateSignerValues_WinOverConnectorValues() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); // connector: "Default Requestor" / "Austin" / 203.0.113.10 + var req = BasicEnrollRequest(); + req.SignerName = "Template Signer"; + req.SignerPlace = "Template Place"; + req.SignerIp = "198.51.100.7"; + + await BuildClient(cfg).EnrollCertificateAsync(req); + + var agreement = CapturedOrderBody().GetProperty("agreementDetails"); + agreement.GetProperty("signerName").GetString().Should().Be("Template Signer"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Template Place"); + agreement.GetProperty("signerIP").GetString().Should().Be("198.51.100.7"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_BlankTemplateSignerValues_FallBackToConnectorValues(string blank) + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + var req = BasicEnrollRequest(); + req.SignerName = blank; + req.SignerPlace = blank; + req.SignerIp = blank; + + await BuildClient(cfg).EnrollCertificateAsync(req); + + var agreement = CapturedOrderBody().GetProperty("agreementDetails"); + agreement.GetProperty("signerName").GetString().Should().Be("Default Requestor"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Austin"); + agreement.GetProperty("signerIP").GetString().Should().Be("203.0.113.10"); + } + + [Fact] + public async Task Enroll_TemplateAndConnectorSignerBlank_UseBuiltInDefaults() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.RequestorName = ""; + cfg.SignerPlace = " "; + cfg.SignerIp = ""; + var req = BasicEnrollRequest(); + req.SignerName = " "; + + await BuildClient(cfg).EnrollCertificateAsync(req); + + var agreement = CapturedOrderBody().GetProperty("agreementDetails"); + agreement.GetProperty("signerName").GetString().Should().Be("Keyfactor Gateway"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Gateway"); + agreement.GetProperty("signerIP").GetString().Should().Be("127.0.0.1"); + } + + [Fact] + public async Task Renewal_TemplateSignerValues_WinOverConnectorValues() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "842", + Comment = "Renewal test", + SignerName = "Template Signer", + SignerPlace = "Template Place", + SignerIp = "198.51.100.7" + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var agreement = CapturedOrderBody().GetProperty("agreementDetails"); + agreement.GetProperty("signerName").GetString().Should().Be("Template Signer"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Template Place"); + agreement.GetProperty("signerIP").GetString().Should().Be("198.51.100.7"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Renewal_BlankTemplateSignerValues_FallBackToConnectorThenDefaults(string blank) + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "842", + Comment = "Renewal test", + SignerName = blank, + SignerPlace = blank, + SignerIp = blank + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var agreement = CapturedOrderBody().GetProperty("agreementDetails"); + agreement.GetProperty("signerName").GetString().Should().Be("Default Requestor"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Austin"); + agreement.GetProperty("signerIP").GetString().Should().Be("203.0.113.10"); + } + + [Fact] + public async Task Renewal_TemplateAndConnectorSignerBlank_UseBuiltInDefaults() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.RequestorName = ""; + cfg.SignerPlace = ""; + cfg.SignerIp = ""; + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "842", + Comment = "Renewal test" + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var agreement = CapturedOrderBody().GetProperty("agreementDetails"); + agreement.GetProperty("signerName").GetString().Should().Be("Keyfactor Gateway"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Gateway"); + agreement.GetProperty("signerIP").GetString().Should().Be("127.0.0.1"); + } + // ----------------------------------------------------------------------- // RenewCertificateAsync — productCode resolution (issue #26 / local issues/0012) // Renewals go out as a fresh GenerateOrderSSL order; the product code must From 0fb6c0f6cfca34202b9a39bafe8c9b9a795c820a Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:17:22 -0700 Subject: [PATCH 42/71] docs(changelog): add 1.0.1 validation-leak fix and upgrade notes for tech-contact omission and V1 error log text --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d46ed4..6cd2d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - **Renewals now send the full order details** (group, `AutoSecureWww`, technical contact, organization, remarks), the same as a new enrollment. - **Unexpected CERTInext error responses are now diagnosable from the logs.** Non-2xx responses with an unrecognised body now include the HTTP status in the error and log the redacted body (`authKey` always redacted, personal data per `LogSensitiveRequestData`). - **Gateway logs no longer contain the `authKey` or requestor personal data by default**; set `LogSensitiveRequestData` to log PII temporarily (credentials stay redacted). +- **Connector and template validation no longer leaks an HTTP client per check.** ## Chores - **`OrganizationNumber`, `DefaultProductCode`, and `GroupNumber` are now visible in the startup log.** Whether each is set is now logged alongside the other connector settings, making a misconfigured connector easier to diagnose from logs alone. @@ -25,6 +26,8 @@ - **`www.` is no longer added to orders by default**, because `AutoSecureWww` (default `0`) is now honored; set it to `1` to keep the old behavior. - **Orders now route to the configured `GroupNumber`**, which previously was not applied to orders. - **Renewals follow the connector's `SubscriptionAutoRenew`, `EmailNotifications`, and validity settings** instead of fixed 1-year validity with auto-renew and notifications on. +- **The technical contact is omitted, with a Warning, when no contact name or email resolves**, since CERTInext requires both once the block is sent. +- **The V1 API error log line now reads `CERTInext API non-success. Operation=...`** instead of `CERTInext API error during ...`; update any log alerts keyed on the old text. # 1.0.0 From afd13ecd844b4c0d3ff83fb90c188a8b550c6d2d Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:20:33 -0700 Subject: [PATCH 43/71] test(integration): opt-in live probe for blank RequestorName/TechnicalContactName DV order --- .../BlankRequestorLiveTests.cs | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 CERTInext.IntegrationTests/BlankRequestorLiveTests.cs diff --git a/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs b/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs new file mode 100644 index 0000000..0336cb3 --- /dev/null +++ b/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs @@ -0,0 +1,232 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Live probe: does CERTInext accept a DV order whose requestorInformation.requestorName + /// is empty (connector RequestorName="", TechnicalContactName="")? In that + /// configuration the plugin sends an empty requestorName and omits technicalPointOfContact + /// (blank first name). The test only records CERTInext's answer; it asserts nothing about + /// accept vs. reject so either outcome is a valid finding. + /// + /// Opt-in: set CERTINEXT_BLANK_REQUESTOR_LIVE=1 as a REAL environment variable (a value in + /// ~/.env_certinext is refused). The order, if placed, is revoked/cancelled in a finally + /// block and re-read read-only to confirm. + /// + public class BlankRequestorLiveTests : IClassFixture + { + private const string OptInFlag = "CERTINEXT_BLANK_REQUESTOR_LIVE"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + public BlankRequestorLiveTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + private void SkipUnlessOptedIn() + { + IntegrationSkip.IfNotConfigured(_fixture); + + Skip.If(Environment.GetEnvironmentVariable(OptInFlag) != "1", + $"Opt-in: set {OptInFlag}=1 as a real environment variable to place a live sandbox order."); + + string envFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".env_certinext"); + bool inFile = File.Exists(envFile) && File.ReadAllLines(envFile) + .Any(l => l.TrimStart().StartsWith(OptInFlag + "=", StringComparison.Ordinal)); + Skip.If(inFile, $"{OptInFlag} must be a real environment variable, not set in ~/.env_certinext."); + } + + private CERTInextConfig BuildBlankRequestorConfig() + { + var c = _fixture.Config; + return new CERTInextConfig + { + ApiUrl = c.ApiUrl, + AuthMode = c.AuthMode, + ApiKey = c.ApiKey, + AccountNumber = c.AccountNumber, + GroupNumber = c.GroupNumber, + OrganizationNumber = c.OrganizationNumber, + RequestorName = string.Empty, + TechnicalContactName = string.Empty, + RequestorEmail = c.RequestorEmail, + RequestorIsdCode = c.RequestorIsdCode, + RequestorMobileNumber = c.RequestorMobileNumber, + SignerPlace = c.SignerPlace, + SignerIp = c.SignerIp, + DefaultProductCode = c.DefaultProductCode, + PageSize = c.PageSize, + DcvEnabled = false, + }; + } + + private static string GenerateCsrPem(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={commonName}"), keyPair.Public, null, keyPair.Private); + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + /// Masks anything that looks like an email address, then truncates to 500 chars. + private static string Scrub(string s) + { + if (s == null) return null; + s = Regex.Replace(s, @"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", ""); + return s.Length > 500 ? s.Substring(0, 500) : s; + } + + [SkippableFact] + public async Task DvOrder_WithBlankRequestorNameAndTechnicalContactName_RecordsCertinextResponse() + { + SkipUnlessOptedIn(); + + string cn = $"blankreq-{Guid.NewGuid():N}".Substring(0, 20) + ".scrup.org"; + string code = _fixture.Config.DefaultProductCode ?? Constants.Products.DvSsl; + var productInfo = new EnrollmentProductInfo + { + ProductID = code, + ProductParameters = new Dictionary + { + ["ProfileId"] = code, + ["ValidityYears"] = "1" + } + }; + + var config = BuildBlankRequestorConfig(); + // The client builds the order body from ITS config, so it must be constructed from the + // blank-requestor config (the shared fixture client carries the populated RequestorName). + var client = new CERTInextClient(config); + var plugin = new CERTInextCAPlugin(client, config); + var created = new List(); + + _output.WriteLine($"PROBE: RequestorName=\"\" TechnicalContactName=\"\" ProductCode={code} CN={cn}"); + try + { + try + { + var result = await plugin.Enroll( + GenerateCsrPem(cn), $"CN={cn}", + new Dictionary { ["dns"] = new[] { cn } }, + productInfo, RequestFormat.PKCS10, EnrollmentType.New); + + created.Add(result.CARequestID); + _output.WriteLine($"RESULT: ACCEPTED order={result.CARequestID} status={result.Status} " + + $"statusMessage={Scrub(result.StatusMessage)}"); + + var track = await client.TrackOrderAsync(result.CARequestID); + string storedName = track.OrderDetails?.RequestorInformation?.RequestorName; + _output.WriteLine($"TRACK: orderStatusId={track.OrderDetails?.OrderStatusId} ({track.OrderDetails?.OrderStatus}), " + + $"certificateStatusId={track.OrderDetails?.CertificateStatusId} ({track.OrderDetails?.CertificateStatus}), " + + $"stored requestorName blank={string.IsNullOrWhiteSpace(storedName)}"); + } + catch (Exception ex) + { + _output.WriteLine($"RESULT: REJECTED/FAILED {ex.GetType().Name}: {Scrub(ex.Message)}"); + if (ex.InnerException != null) + _output.WriteLine($" inner {ex.InnerException.GetType().Name}: {Scrub(ex.InnerException.Message)}"); + } + } + finally + { + await CleanupAsync(client, plugin, created); + } + } + + private async Task CleanupAsync(CERTInextClient client, CERTInextCAPlugin plugin, IEnumerable orderNumbers) + { + var ids = orderNumbers.Where(o => !string.IsNullOrWhiteSpace(o)).Distinct().ToList(); + if (ids.Count == 0) + { + _output.WriteLine("CLEANUP: no order was placed; nothing to revoke."); + return; + } + + foreach (string id in ids) + { + try + { + var before = await client.TrackOrderAsync(id); + int.TryParse(before.OrderDetails?.CertificateStatusId, out int st); + _output.WriteLine($"cleanup {id}: before certificateStatusId={st} ({before.OrderDetails?.CertificateStatus})"); + if (st == Constants.CertificateStatusId.CertificateRevoked) + continue; + + if (st == Constants.CertificateStatusId.CertificateGenerated + || st == Constants.CertificateStatusId.CertificateDownloaded) + { + int rc = await plugin.Revoke(id, string.Empty, 5); + _output.WriteLine($"cleanup {id}: plugin.Revoke returned {rc}"); + } + else + { + // Not issued: record exactly what CERTInext says to a raw revoke/cancel. + await client.RevokeCertificateAsync(id, new RevokeCertificateRequest + { + Reason = Constants.RevocationReason.CessationOfOperation, + Comment = "blank-requestor live-test cleanup" + }); + _output.WriteLine($"cleanup {id}: raw RevokeCertificateAsync accepted for non-issued order"); + } + } + catch (Exception ex) + { + _output.WriteLine($"cleanup {id}: REVOKE/CANCEL FAILED -> {ex.GetType().Name}: {Scrub(ex.Message)}"); + } + } + + _output.WriteLine("--- cleanup verification (fresh read-only TrackOrder) ---"); + foreach (string id in ids) + { + try + { + var after = await client.TrackOrderAsync(id); + _output.WriteLine($"verify {id}: orderStatusId={after.OrderDetails?.OrderStatusId} ({after.OrderDetails?.OrderStatus}), " + + $"certificateStatusId={after.OrderDetails?.CertificateStatusId} ({after.OrderDetails?.CertificateStatus})"); + } + catch (Exception ex) + { + _output.WriteLine($"verify {id}: TrackOrder failed -> {ex.GetType().Name}: {Scrub(ex.Message)}"); + } + } + } + } +} From 8847142873f58663bab95577ddd288e721f18bd5 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:31:35 -0700 Subject: [PATCH 44/71] test(enroll): pin and live-capture blank RequestorName/TechnicalContactName GenerateOrderSSL wire body --- .../BlankRequestorLiveTests.cs | 133 ++++++++++- CERTInext.Tests/BlankRequestorWireTests.cs | 210 ++++++++++++++++++ 2 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 CERTInext.Tests/BlankRequestorWireTests.cs diff --git a/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs b/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs index 0336cb3..9fc3ec1 100644 --- a/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs +++ b/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs @@ -1,11 +1,11 @@ // Copyright 2026 Keyfactor -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -13,15 +13,18 @@ // limitations under the License. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CERTInext.API; using Keyfactor.Extensions.CAPlugin.CERTInext.Client; using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; @@ -39,6 +42,15 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests /// (blank first name). The test only records CERTInext's answer; it asserts nothing about /// accept vs. reject so either outcome is a valid finding. /// + /// It also captures the outbound GenerateOrderSSL body (the client's Trace + /// "PlaceOrderAsync request payload" dump, taken with LogSensitiveRequestData=true so + /// the empty requestorName is not masked; meta.authKey is always redacted by the client + /// and the body is never printed) and asserts requestorName is exactly "", no + /// technicalPointOfContact key, and signerName "Keyfactor Gateway". The dump's equivalence to + /// the WireMock-captured POST body is pinned by BlankRequestorWireTests in CERTInext.Tests. + /// For the TrackOrder response it records only blank/non-blank for requestorName (and whether + /// it equals a configured value) and the names of any technical-contact-like JSON keys. + /// /// Opt-in: set CERTINEXT_BLANK_REQUESTOR_LIVE=1 as a REAL environment variable (a value in /// ~/.env_certinext is refused). The order, if placed, is revoked/cancelled in a finally /// block and re-read read-only to confirm. @@ -91,9 +103,72 @@ private CERTInextConfig BuildBlankRequestorConfig() DefaultProductCode = c.DefaultProductCode, PageSize = c.PageSize, DcvEnabled = false, + // Needed so the Trace payload dumps keep the (empty) requestorName verbatim. + // authKey is redacted regardless; the dumps are only inspected, never printed. + LogSensitiveRequestData = true, }; } + private sealed class CapturingLogger : ILogger + { + public ConcurrentQueue Messages { get; } = new(); + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) => Messages.Enqueue(formatter(state, exception)); + } + + private const string PlaceOrderDumpPrefix = "PlaceOrderAsync request payload: "; + + /// Last dump of the given kind whose text contains , parsed; null if absent. + private static JsonDocument LastDump(CapturingLogger logger, string startsWith, string marker) + { + string msg = logger.Messages + .Where(m => m != null && m.StartsWith(startsWith, StringComparison.Ordinal) && m.Contains(marker)) + .LastOrDefault(); + if (msg == null) return null; + string json = msg.Substring(msg.IndexOf('{')); + return JsonDocument.Parse(json); + } + + /// All values of JSON properties named at any depth (case-insensitive). + private static List FindProps(JsonElement e, string name) + { + var hits = new List(); + void Walk(JsonElement x) + { + if (x.ValueKind == JsonValueKind.Object) + foreach (var p in x.EnumerateObject()) + { + if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) hits.Add(p.Value); + Walk(p.Value); + } + else if (x.ValueKind == JsonValueKind.Array) + foreach (var i in x.EnumerateArray()) Walk(i); + } + Walk(e); + return hits; + } + + /// Names of every JSON property (any depth) that looks like a technical-contact field. + private static List TechContactLikeKeys(JsonElement e) + { + var names = new List(); + void Walk(JsonElement x) + { + if (x.ValueKind == JsonValueKind.Object) + foreach (var p in x.EnumerateObject()) + { + if (Regex.IsMatch(p.Name, "technical|poc|contact", RegexOptions.IgnoreCase)) names.Add(p.Name); + Walk(p.Value); + } + else if (x.ValueKind == JsonValueKind.Array) + foreach (var i in x.EnumerateArray()) Walk(i); + } + Walk(e); + return names.Distinct().ToList(); + } + private static string GenerateCsrPem(string commonName) { var keyGen = new RsaKeyPairGenerator(); @@ -138,6 +213,10 @@ public async Task DvOrder_WithBlankRequestorNameAndTechnicalContactName_RecordsC var plugin = new CERTInextCAPlugin(client, config); var created = new List(); + // Process-wide client logger swap (restored on dispose) so the Trace payload dumps can be inspected. + var capture = new CapturingLogger(); + using var loggerOverride = CERTInextClient.OverrideLoggerForTests(capture); + _output.WriteLine($"PROBE: RequestorName=\"\" TechnicalContactName=\"\" ProductCode={code} CN={cn}"); try { @@ -169,6 +248,54 @@ public async Task DvOrder_WithBlankRequestorNameAndTechnicalContactName_RecordsC { await CleanupAsync(client, plugin, created); } + + // ---- Outbound body (captured from the live PlaceOrder call; body itself is never printed) ---- + using var sent = LastDump(capture, PlaceOrderDumpPrefix, cn); + Assert.True(sent != null, "No 'PlaceOrderAsync request payload' dump was captured for this order."); + var od = sent.RootElement.GetProperty("orderDetails"); + + bool hasRi = od.TryGetProperty("requestorInformation", out var ri); + bool hasName = hasRi && ri.TryGetProperty("requestorName", out _); + string sentName = hasName ? ri.GetProperty("requestorName").GetString() : null; + bool hasPoc = od.TryGetProperty("technicalPointOfContact", out _); + string sentSigner = od.GetProperty("agreementDetails").GetProperty("signerName").GetString(); + _output.WriteLine($"WIRE: requestorInformation present={hasRi}; requestorName key present={hasName}, " + + $"value is empty string={sentName == string.Empty}, length={sentName?.Length}; " + + $"technicalPointOfContact key present={hasPoc}; " + + $"agreementDetails.signerName=\"Keyfactor Gateway\" -> {sentSigner == "Keyfactor Gateway"}"); + + // ---- What CERTInext hands back for that order (blank/non-blank only; values not printed) ---- + foreach (string id in created) + { + using var tracked = LastDump(capture, "TrackOrderAsync response payload (Order=" + id + ")", id); + if (tracked == null) + { + _output.WriteLine($"TRACKRAW {id}: no TrackOrder payload dump captured"); + continue; + } + var names = FindProps(tracked.RootElement, "requestorName"); + // Includes the plugin's built-in fallback name so a CERTInext-side substitution is distinguishable. + var configured = new[] { _fixture.Config.RequestorName, _fixture.Config.TechnicalContactName, + _fixture.Config.RequestorEmail, _fixture.Config.SignerPlace, + "Keyfactor Gateway" } + .Where(v => !string.IsNullOrWhiteSpace(v)).ToList(); + foreach (var n in names) + { + string v = n.ValueKind == JsonValueKind.String ? n.GetString() : null; + _output.WriteLine($"TRACKRAW {id}: requestorName kind={n.ValueKind}, blank={string.IsNullOrWhiteSpace(v)}, " + + $"equals a configured value or the built-in fallback={(v != null && configured.Contains(v, StringComparer.OrdinalIgnoreCase))}, " + + $"looks like an email={(v != null && v.Contains('@'))}"); + } + if (names.Count == 0) _output.WriteLine($"TRACKRAW {id}: no requestorName property in response"); + var pocKeys = TechContactLikeKeys(tracked.RootElement); + _output.WriteLine($"TRACKRAW {id}: technical-contact-like keys in response = " + + (pocKeys.Count == 0 ? "(none)" : string.Join(", ", pocKeys))); + } + + Assert.True(hasName, "requestorInformation.requestorName key must be present on the wire"); + Assert.Equal(string.Empty, sentName); + Assert.False(hasPoc, "technicalPointOfContact must be omitted from the wire body"); + Assert.Equal("Keyfactor Gateway", sentSigner); } private async Task CleanupAsync(CERTInextClient client, CERTInextCAPlugin plugin, IEnumerable orderNumbers) diff --git a/CERTInext.Tests/BlankRequestorWireTests.cs b/CERTInext.Tests/BlankRequestorWireTests.cs new file mode 100644 index 0000000..a7e0a7b --- /dev/null +++ b/CERTInext.Tests/BlankRequestorWireTests.cs @@ -0,0 +1,210 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Microsoft.Extensions.Logging; +using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Pins the bytes actually POSTed to GenerateOrderSSL when the connector has + /// RequestorName="" and TechnicalContactName="" (the configuration the opt-in live + /// probe BlankRequestorLiveTests uses). Drives the real + /// enroll and renewal paths over a real against WireMock, so the + /// assertions are on the captured wire body, not on an intermediate object. + /// + /// Expected wire shape: requestorInformation.requestorName == "" (present, empty string — + /// the plugin does not substitute a default for a blank-but-non-null connector value), + /// no technicalPointOfContact key, and agreementDetails.signerName falls back to + /// "Keyfactor Gateway". + /// + [Collection("CERTInextClientLogger-NoParallel")] + public class BlankRequestorWireTests : IDisposable + { + private readonly WireMockServer _server; + + public BlankRequestorWireTests() + { + _server = WireMockServer.Start(); + + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GenerateOrderSuccessJson(MockCertificateData.OrderNumber1))); + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.TrackOrderIssuedJson(MockCertificateData.OrderNumber1))); + _server.Given(Request.Create().WithPath("/GetCertificate").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCertificateSuccessJson())); + } + + public void Dispose() => _server.Stop(); + + private sealed class CapturingLogger : ILogger + { + public ConcurrentQueue Messages { get; } = new(); + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) => Messages.Enqueue(formatter(state, exception)); + } + + private CERTInextClient BuildBlankRequestorClient(bool logSensitiveRequestData = false) => new CERTInextClient(new CERTInextConfig + { + LogSensitiveRequestData = logSensitiveRequestData, + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "test-key", + AccountNumber = "12345", + RequestorName = string.Empty, + TechnicalContactName = string.Empty, + RequestorEmail = "requestor@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerPlace = "Austin", + SignerIp = "203.0.113.10", + PageSize = 100 + }); + + private static EnrollmentProductInfo MakeProductInfo(Dictionary extras = null) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProfileId"] = "842" + }; + if (extras != null) + foreach (var kv in extras) + parameters[kv.Key] = kv.Value; + return new EnrollmentProductInfo { ProductID = "842", ProductParameters = parameters }; + } + + private JsonElement CapturedRoot() + { + var posts = _server.LogEntries + .Where(e => e.RequestMessage.Path == "/GenerateOrderSSL") + .ToList(); + posts.Should().HaveCount(1, "exactly one GenerateOrderSSL POST should have been emitted"); + string body = posts[0].RequestMessage.Body; + body.Should().NotBeNullOrEmpty(); + return JsonDocument.Parse(body!).RootElement; + } + + private static void AssertBlankRequestorShape(JsonElement root) + { + var od = root.GetProperty("orderDetails"); + + od.TryGetProperty("requestorInformation", out var ri).Should().BeTrue(); + ri.TryGetProperty("requestorName", out var name).Should().BeTrue( + "requestorName is serialized even when blank"); + name.ValueKind.Should().Be(JsonValueKind.String); + name.GetString().Should().Be(string.Empty, + "a blank connector RequestorName reaches the wire as an empty string, not a substituted default"); + + od.TryGetProperty("technicalPointOfContact", out _).Should().BeFalse( + "no name resolves, so the technicalPointOfContact block is omitted entirely"); + + od.GetProperty("agreementDetails").GetProperty("signerName").GetString() + .Should().Be("Keyfactor Gateway", "blank requestor/signer name falls back to the built-in default"); + } + + [Fact] + public async Task Enroll_New_BlankRequestorAndTechContact_WireBodyMatchesExpectedShape() + { + var plugin = new CERTInextCAPlugin(BuildBlankRequestorClient(), new CERTInextConfig { PickupRetries = 0 }); + + await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=test.example.com", + new Dictionary { ["dns"] = new[] { "test.example.com" } }, + MakeProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + AssertBlankRequestorShape(CapturedRoot()); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalApi_BlankRequestorAndTechContact_WireBodyMatchesExpectedShape() + { + var reader = new Mock(); + reader.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())) + .ReturnsAsync(MockCertificateData.CertId1); + reader.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1)) + .Returns(DateTime.UtcNow.AddDays(30)); + + var plugin = new CERTInextCAPlugin(BuildBlankRequestorClient(), reader.Object); + + await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=test.example.com", + new Dictionary { ["dns"] = new[] { "test.example.com" } }, + MakeProductInfo(new Dictionary + { + ["PriorCertSN"] = "AABB", + ["RenewalWindowDays"] = "90" + }), + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + // Guard: the renewal API path (not the new-enroll fallback) must have produced the body. + reader.Verify(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1), Times.Once); + AssertBlankRequestorShape(CapturedRoot()); + } + + /// + /// Justifies the live test capturing the Trace PlaceOrderAsync request payload dump + /// (LogSensitiveRequestData=true) instead of the socket: apart from the redacted + /// meta.authKey, the dump's orderDetails is identical to the body WireMock + /// received. + /// + [Fact] + public async Task TraceDump_OrderDetails_EqualsWireBody_WhenSensitiveLoggingOn() + { + string marker = $"wire-{Guid.NewGuid():N}.example.com"; + var plugin = new CERTInextCAPlugin(BuildBlankRequestorClient(logSensitiveRequestData: true), + new CERTInextConfig { PickupRetries = 0 }); + + var logger = new CapturingLogger(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + { + await plugin.Enroll( + MockCertificateData.FakeCsrPem, $"CN={marker}", + new Dictionary { ["dns"] = new[] { marker } }, + MakeProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + } + + const string prefix = "PlaceOrderAsync request payload: "; + string dump = logger.Messages.Single(m => m.StartsWith(prefix, StringComparison.Ordinal) && m.Contains(marker)); + string dumpJson = dump.Substring(prefix.Length); + + dumpJson.Should().NotContain("test-key").And.Contain("***REDACTED***"); + var logged = JsonNode.Parse(dumpJson)!["orderDetails"]; + var wire = JsonNode.Parse(CapturedRoot().GetRawText())!["orderDetails"]; + JsonNode.DeepEquals(logged, wire).Should().BeTrue("the Trace dump must mirror the POSTed orderDetails"); + AssertBlankRequestorShape(JsonDocument.Parse(dumpJson).RootElement); + } + } +} From 69276ac5472d3588d2bfa054178d447170659c57 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 2 Oct 2026 16:43:18 +0000 Subject: [PATCH 45/71] docs: auto-generate README and documentation [skip ci] --- README.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f672ee1..9fb4f3d 100644 --- a/README.md +++ b/README.md @@ -118,12 +118,12 @@ CERTInext operates three separate environments. Use the sandbox environment for * **ApiUrl** - REQUIRED: CERTInext API base URL. Sandbox (US): https://sandbox-us-api.certinext.io/emSignHub-API/ — Production (US): https://us-api.certinext.io/emSignHub-API/ — Production (Global/India): https://api.certinext.io/emSignHub-API/ * **AccountNumber** - REQUIRED: Your CERTInext account number (numeric string). Available in the CERTInext portal. - * **GroupNumber** - OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests AND in the `delegationInformation.groupNumber` field of every SSL order so the order is routed to the correct account group. Some accounts will queue orders for additional review when this field is omitted. Available in the CERTInext portal under Delegation → Groups. - * **OrganizationNumber** - STRONGLY RECOMMENDED for OV/EV and faster DV issuance: numeric CERTInext organization number for a pre-vetted organization (e.g. your company's pre-vetted entry). When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and the configured `organizationNumber`, telling CERTInext to skip the manual organization-vetting queue. Without this value, orders are placed without any organizationDetails block and CERTInext may park them in `Pending System RA` for extended manual review (observed: tens of hours). Available in the CERTInext portal under Organizations → Pre-vetted Organizations. - * **TechnicalContactName** - OPTIONAL: Name sent in the `technicalPointOfContact.tpcName` field of every SSL order. Defaults to the configured RequestorName when blank. Some product configurations require a TPoC to be present; omitting it can cause CERTInext to park orders awaiting manual completion of the field. - * **TechnicalContactEmail** - OPTIONAL: Email sent in the `technicalPointOfContact.tpcEmail` field of every SSL order. Defaults to the configured RequestorEmail when blank. - * **TechnicalContactIsdCode** - OPTIONAL: International dialing code for the TPoC phone number. Defaults to the configured RequestorIsdCode when blank. - * **TechnicalContactMobileNumber** - OPTIONAL: Mobile number for the TPoC (digits only). Defaults to the configured RequestorMobileNumber when blank. + * **GroupNumber** - OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests AND in the `orderDetails.groupNumber` field of every SSL order (new and renewal) so the order is routed to the configured account group. When blank, CERTInext uses the account's default group. Available in the CERTInext portal under Delegation → Groups. + * **OrganizationNumber** - OPTIONAL, strongly recommended for OV/EV: numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this `organizationNumber`, so CERTInext can reuse that organization's pre-verified domains without fresh DCV. Leave blank to omit `organizationDetails`. Available in the CERTInext portal under Organizations → Pre-vetted Organizations. + * **TechnicalContactName** - OPTIONAL: Technical point of contact name, sent as `technicalPointOfContact.pocFirstName` / `pocLastName` on every SSL order (new and renewal). The name is split on the first whitespace: the first word is the first name and the rest is the last name; a single-word name is sent in both. Defaults to the configured RequestorName when blank. + * **TechnicalContactEmail** - OPTIONAL: Email sent in the `technicalPointOfContact.pocEmail` field of every SSL order. Defaults to the configured RequestorEmail when blank. + * **TechnicalContactIsdCode** - OPTIONAL: International dialing code for the technical contact phone number, sent as `technicalPointOfContact.pocIsdCode`. Defaults to the configured RequestorIsdCode when blank. + * **TechnicalContactMobileNumber** - OPTIONAL: Mobile number for the technical contact (digits only), sent as `technicalPointOfContact.pocMobileNumber`. Defaults to the configured RequestorMobileNumber when blank. * **AuthMode** - REQUIRED: Authentication mode. 'AccessKey' (default) — uses authKey = SHA256(accessKey + ts + txn) in every request body. 'OAuth' — uses an OAuth2 bearer token (requires OAuthTokenUrl, OAuthClientId, OAuthClientSecret). * **ApiKey** - REQUIRED when AuthMode is 'AccessKey': the REST API Access Key generated in the CERTInext portal under Integrations → APIs. This value is used to compute authKey = SHA256(accessKey + ts + txn); it is never transmitted directly. * **OAuthTokenUrl** - OAuth token endpoint URL. Required when AuthMode is 'OAuth'. @@ -141,10 +141,11 @@ CERTInext operates three separate environments. Use the sandbox environment for * **SubscriptionValidityYears** - OPTIONAL: Default validity in years for SSL orders. "1", "2", or "3". Override per template via the ValidityYears product parameter. Default: "1". * **SubscriptionAutoRenew** - OPTIONAL: Whether CERTInext should auto-renew certificates issued through this connector. "0" = disabled (recommended — renewal is driven by Keyfactor Command), "1" = enabled. Default: "0". * **SubscriptionRenewCriteriaDays** - OPTIONAL: Days before expiry at which CERTInext auto-renews (only honored when SubscriptionAutoRenew = "1"). Typical values: "30" or "60". Default: "30". - * **AutoSecureWww** - OPTIONAL: If "1", CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. "0" = use only the CN/SANs supplied with the CSR. Default: "0". + * **AutoSecureWww** - OPTIONAL: Sent as `orderDetails.autoSecureWWW` on every SSL order (new and renewal). If "1", CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN, which must also pass domain validation. "0" = use only the CN/SANs supplied with the CSR. Default: "0". * **IgnoreExpired** - If true, expired certificates will be skipped during synchronization. Default: false. * **PageSize** - Number of orders to fetch per page during synchronization. Default: 100, max: 500. * **Enabled** - Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true. + * **LogSensitiveRequestData** - OPTIONAL diagnostic escape hatch. When true, enabling it writes requestor personal data (name, email, phone, and other organization contact details) and full CA request/response payloads to the gateway logs. Meant for temporary use while verifying a new deployment — confirming exactly what was sent to the CA and that the order succeeded — and should be turned back off once verification is complete. When false (default), personal-data fields are redacted (email is masked but keeps its domain, e.g. 'j***@example.com') and the enrollment log line omits the requester name entirely. Email SAN values (rfc822Name) in log lines are masked the same way; DNS, IP and URI SANs are always logged in full. Credentials (access keys, authKey digests, OAuth secrets, tokens) are always redacted regardless of this setting. Default: false. * **DcvEnabled** - OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false. * **DcvTxtRecordTemplate** - OPTIONAL: Format string for the DNS TXT record hostname used during DCV. {0} is replaced with the domain name being validated. Default: _emsign-validation.{0} * **DcvPropagationDelaySeconds** - OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: 30. @@ -258,20 +259,21 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `RequestorMobileNumber` | Optional | Requestor mobile number (digits only, no country code). Included in the `requestorInformation` block. | N/A | `5551234567` | | `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. | Use the physical city where the signer is located. | `Austin` | | `SignerIp` | Required | Public IP address of the host accepting the subscriber agreement. Required by CERTInext for all orders. | Use the outbound IP of the AnyCA Gateway host, or the IP of the workstation from which the agreement was accepted. | `203.0.113.10` | -| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `delegationInformation.groupNumber` on every SSL order. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | -| `OrganizationNumber` | Optional, strongly recommended for OV/EV and faster DV | Numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this number, telling CERTInext to skip its manual organization-vetting queue. Without it, orders may sit in `Pending System RA` for extended manual review (observed: tens of hours). | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | -| `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` on every SSL order. Each defaults to the corresponding `Requestor*` field when blank. Some product configurations require a technical point of contact to be present; omitting it can cause CERTInext to park orders awaiting manual completion of the field. | N/A | *(defaults to Requestor fields)* | +| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `orderDetails.groupNumber` on every SSL order (new and renewal), so orders are routed to this group. When blank, CERTInext uses the account's default group. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | +| `OrganizationNumber` | Optional, strongly recommended for OV/EV | Numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this `organizationNumber`, so CERTInext can reuse that organization's pre-verified domains without fresh DCV. Leave blank to omit `organizationDetails`. | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | +| `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` (`pocFirstName`, `pocLastName`, `pocEmail`, `pocIsdCode`, `pocMobileNumber`) on every SSL order, new and renewal. `TechnicalContactName` is split on the first whitespace: the first word becomes `pocFirstName` and the rest `pocLastName`; a single-word name is sent in both. Each field defaults to the corresponding `Requestor*` field when blank. | N/A | *(defaults to Requestor fields)* | | `AccountingModel` | Optional | CERTInext billing model sent in `orderDetails.accountingModel`. `2` = credit-based (most accounts). `1` = cash model. Default: `2`. | N/A | `2` | | `EmailNotifications` | Optional | Whether CERTInext sends lifecycle-event emails to the requestor. `1` = enabled, `0` = silent (recommended for gateway-driven orders). Default: `0`. | N/A | `0` | | `SubscriptionValidityYears` | Optional | Connector-level default validity in years for SSL orders (`1`, `2`, or `3`). Overridden per template by the `ValidityYears` enrollment parameter. Default: `1`. | N/A | `1` | | `SubscriptionAutoRenew` | Optional | Whether CERTInext should auto-renew certificates issued through this connector. `0` = disabled (recommended — renewal is driven by Keyfactor Command), `1` = enabled. Default: `0`. | N/A | `0` | | `SubscriptionRenewCriteriaDays` | Optional | Days before expiry at which CERTInext auto-renews. Only honored when `SubscriptionAutoRenew` is `1`. Default: `30`. | N/A | `30` | -| `AutoSecureWww` | Optional | If `1`, CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. Default: `0`. | N/A | `0` | +| `AutoSecureWww` | Optional | Sent as `orderDetails.autoSecureWWW` on every SSL order, new and renewal. If `1`, CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN, which must also pass domain validation. `0` = only the CN/SANs from the CSR. Default: `0`. Before 1.0.1 this value never reached CERTInext, so its own default (add `www.`) applied. | N/A | `0` | | `SubmitNonDnsSans` | Optional | If `true` (default), SANs that aren't DNS names (IP address, email, URI) are submitted to CERTInext instead of silently dropped. CERTInext can't validate them, so such an order won't issue until they're removed. Set to `false` to restore the pre-1.0.1 behavior of submitting DNS names only. Default: `true`. | N/A | `true` | -| `DefaultProductCode` | Optional, but effectively required if you use renewals | Numeric product code used for **renewals only** — CERTInext's `TrackOrder` doesn't return the prior order's product code, so the renewal path sends this value verbatim, ignoring the template's `ProductCode`/`ProfileId`. If left blank, renewals go out with an empty product code. Has **no effect on new enrollments** — the `ProductCode`/`ProfileId` template resolution never falls back to it. See [issue tracking this](https://github.com/Keyfactor/certinext-caplugin/issues/26). | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | +| `DefaultProductCode` | Optional, but effectively required if templates don't set a product code | Numeric product code used for both new enrollments and renewals when the template has no product code (`ProductCode`/`ProfileId`). The template's code always takes precedence. If both are blank, the order is sent with an empty product code. CERTInext's `TrackOrder` doesn't return the prior order's product code, so renewals rely on the template's code or this value. | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | +| `LogSensitiveRequestData` | Optional | **Diagnostic escape hatch — off by default.** When `true`, this writes requestor personal data (name, email, phone, and other organization contact details) and full CA request/response payloads to the gateway logs. It's meant for temporary use while verifying a new deployment (confirming exactly what was sent to the CA and that the order succeeded) — turn it back off once verification is complete. When `false` (default), personal-data fields are redacted (email is masked but keeps its domain, e.g. `j***@example.com`) and the enrollment log line omits the requester name entirely. Email SAN values (rfc822Name) in log lines are masked the same way; DNS, IP and URI SANs are always logged in full. Credentials (access keys, `authKey` digests, OAuth secrets, tokens) are always redacted regardless of this setting. Default: `false`. | N/A | `false` | | `PickupRetries` | Optional | Number of times `Enroll` polls CERTInext for the certificate after a successful order submission, before returning pending and leaving pickup to the next sync. Set to `0` to disable the wait. OV/EV orders validate asynchronously (minutes to hours) and typically exhaust this wait regardless of the value. Default: `5`. | N/A | `5` | | `PickupDelay` | Optional | Seconds between certificate-pickup retries. `PickupRetries × PickupDelay` (plus a short initial delay) bounds how long an enrollment call occupies a Command worker thread — capped at a 180s ceiling regardless of how the two are set (aim for well under ~90s in practice, so the call doesn't run long enough to trip Command's own timeout). Default: `10` (a ~55s ceiling with default `PickupRetries`). | N/A | `10` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | From d16c67b65927b5da343bc17af7466521e9e5dbf2 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:23:24 -0700 Subject: [PATCH 46/71] fix(enroll): warn when SignerIp is not an IP address, still send it unchanged --- CERTInext/Client/CERTInextClient.cs | 19 +++++++++++++++++++ CHANGELOG.md | 1 + 2 files changed, 20 insertions(+) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 9b66dd3..8f6f279 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1719,6 +1719,7 @@ private AgreementDetails BuildAgreementDetails( // because SignerIp is blank), but a missing value emits a Warning so an // auditor sees the misrepresentation as an actionable signal in the gateway log. string signerIp = FirstNonBlank(templateSignerIp, _config.SignerIp); + WarnIfSignerIpNotAnAddress(signerIp, source: string.IsNullOrWhiteSpace(templateSignerIp) ? "connector" : "template"); if (signerIp == null) { Logger.LogWarning( @@ -1736,6 +1737,24 @@ private AgreementDetails BuildAgreementDetails( }; } + /// + /// Warn-only check: the resolved SignerIp is sent as agreementDetails.signerIP (part of + /// the subscriber-agreement audit record), so a value that is not an IP literal (for example + /// a host name) is flagged. The value is still sent unchanged; enrollment never fails here. + /// + private static void WarnIfSignerIpNotAnAddress(string signerIp, string source) + { + if (string.IsNullOrWhiteSpace(signerIp) || IPAddress.TryParse(signerIp, out _)) + return; + + string shown = LogSanitizer.Strip(signerIp.Length > 64 ? signerIp.Substring(0, 64) + "..." : signerIp); + Logger.LogWarning( + "The SignerIp value from the {Source} ('{SignerIp}') is not a valid IPv4/IPv6 address but is " + + "being sent unchanged as agreementDetails.signerIP in the subscriber agreement audit record. " + + "Set SignerIp to the gateway host's actual IP address.", + source, shown); + } + private static string FirstNonBlank(string first, string second) => !string.IsNullOrWhiteSpace(first) ? first : !string.IsNullOrWhiteSpace(second) ? second diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd2d6a..fb2a22b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - **Renewals now use the certificate template's product code.** Renewals previously always used the connector's `DefaultProductCode`, which could send an empty product code if that setting was never configured. Renewals now use the template's code, falling back to `DefaultProductCode` only when the template doesn't have one. - **New enrollments now use the connector defaults when the template or connector value is blank.** A blank template product code now falls back to `DefaultProductCode`, and a blank `RequestorName`/`SignerPlace` now sends "Keyfactor Gateway"/"Gateway" as the agreement signer instead of an empty value. - **The `SignerName`, `SignerPlace`, and `SignerIp` template parameters now take effect.** They were accepted but ignored; they now override the connector values for the subscriber agreement on both new orders and renewals. +- **A `SignerIp` that isn't an IP address now logs a Warning** naming whether it came from the template or the connector; the value is still sent unchanged and enrollment is never blocked. - **`GroupNumber`, `AutoSecureWww`, and the technical contact now reach CERTInext**; they were previously sent in fields CERTInext doesn't read. - **Renewals now send the full order details** (group, `AutoSecureWww`, technical contact, organization, remarks), the same as a new enrollment. - **Unexpected CERTInext error responses are now diagnosable from the logs.** Non-2xx responses with an unrecognised body now include the HTTP status in the error and log the redacted body (`authKey` always redacted, personal data per `LogSensitiveRequestData`). From f1360b1c02c22e6b314b5760e1121d0f158683c3 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:23:24 -0700 Subject: [PATCH 47/71] test(enroll): SignerIp warning for invalid values on enroll and renewal, none for valid IPv4/IPv6 --- CERTInext.Tests/SignerIpWarningTests.cs | 216 ++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 CERTInext.Tests/SignerIpWarningTests.cs diff --git a/CERTInext.Tests/SignerIpWarningTests.cs b/CERTInext.Tests/SignerIpWarningTests.cs new file mode 100644 index 0000000..eb34edd --- /dev/null +++ b/CERTInext.Tests/SignerIpWarningTests.cs @@ -0,0 +1,216 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Microsoft.Extensions.Logging; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// A resolved SignerIp that is not an IP literal (for example a host name) is sent unchanged as + /// agreementDetails.signerIP but logs a Warning naming the source (template or connector). + /// Enroll and renewal share BuildSslOrderDetails -> BuildAgreementDetails, so both + /// paths are covered. All values are synthetic (RFC 5737 / RFC 3849 documentation addresses). + /// + [Collection("CERTInextClientLogger-NoParallel")] + public class SignerIpWarningTests : IDisposable + { + private readonly WireMockServer _server; + + public SignerIpWarningTests() + { + _server = WireMockServer.Start(); + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GenerateOrderSuccessJson(MockCertificateData.OrderNumber1))); + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.TrackOrderIssuedJson(MockCertificateData.OrderNumber1))); + _server.Given(Request.Create().WithPath("/GetCertificate").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCertificateSuccessJson())); + } + + public void Dispose() => _server.Stop(); + + private sealed class CapturingLogger : ILogger + { + public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) => Entries.Enqueue((logLevel, formatter(state, exception))); + + public string[] SignerIpWarnings => Entries + .Where(e => e.Level == LogLevel.Warning && e.Message.Contains("SignerIp value")) + .Select(e => e.Message).ToArray(); + } + + private CERTInextClient BuildClient(string connectorSignerIp) => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "test-key", + AccountNumber = "12345", + RequestorName = "Default Requestor", + RequestorEmail = "default@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerPlace = "Austin", + SignerIp = connectorSignerIp, + PageSize = 100 + }); + + private string CapturedSignerIp() + { + var posts = _server.LogEntries.Where(e => e.RequestMessage.Path == "/GenerateOrderSSL").ToList(); + posts.Should().HaveCount(1); + return JsonDocument.Parse(posts[0].RequestMessage.Body!).RootElement + .GetProperty("orderDetails").GetProperty("agreementDetails").GetProperty("signerIP").GetString(); + } + + private static EnrollCertificateRequest EnrollReq(string templateSignerIp) => new EnrollCertificateRequest + { + ProfileId = "842", + Csr = MockCertificateData.FakeCsrPem, + Subject = "CN=test.example.com", + Comment = "Unit test", + SignerIp = templateSignerIp + }; + + private static RenewCertificateRequest RenewReq(string templateSignerIp) => new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "842", + Comment = "Unit test", + SignerIp = templateSignerIp + }; + + [Fact] + public async Task Enroll_InvalidTemplateSignerIp_WarnsNamingTemplate_AndSendsValueUnchanged() + { + var logger = new CapturingLogger(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + await BuildClient("203.0.113.10").EnrollCertificateAsync(EnrollReq("gateway-host")); + + var warnings = logger.SignerIpWarnings; + warnings.Should().ContainSingle(); + warnings[0].Should().Contain("template").And.Contain("gateway-host"); + CapturedSignerIp().Should().Be("gateway-host"); + } + + [Fact] + public async Task Enroll_InvalidConnectorSignerIp_WarnsNamingConnector_AndSendsValueUnchanged() + { + var logger = new CapturingLogger(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + await BuildClient("gateway-host").EnrollCertificateAsync(EnrollReq(null)); + + var warnings = logger.SignerIpWarnings; + warnings.Should().ContainSingle(); + warnings[0].Should().Contain("connector").And.Contain("gateway-host"); + CapturedSignerIp().Should().Be("gateway-host"); + } + + [Fact] + public async Task Renewal_InvalidTemplateSignerIp_WarnsNamingTemplate_AndSendsValueUnchanged() + { + var logger = new CapturingLogger(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + await BuildClient("203.0.113.10").RenewCertificateAsync(MockCertificateData.OrderNumber1, RenewReq("gateway-host")); + + var warnings = logger.SignerIpWarnings; + warnings.Should().ContainSingle(); + warnings[0].Should().Contain("template").And.Contain("gateway-host"); + CapturedSignerIp().Should().Be("gateway-host"); + } + + [Fact] + public async Task Enroll_InvalidSignerIpWithControlChars_LogLineHasNoRawCrLf() + { + var logger = new CapturingLogger(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + await BuildClient("203.0.113.10").EnrollCertificateAsync(EnrollReq("bad\r\nhost")); + + var warnings = logger.SignerIpWarnings; + warnings.Should().ContainSingle(); + warnings[0].Should().NotContain("\r").And.NotContain("\n").And.Contain("bad\\r\\nhost"); + CapturedSignerIp().Should().Be("bad\r\nhost"); + } + + [Theory] + [InlineData("203.0.113.10")] + [InlineData("2001:db8::1")] + public async Task Enroll_ValidSignerIp_LogsNoSignerIpWarning(string ip) + { + var logger = new CapturingLogger(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + await BuildClient("198.51.100.7").EnrollCertificateAsync(EnrollReq(ip)); + + logger.SignerIpWarnings.Should().BeEmpty(); + CapturedSignerIp().Should().Be(ip); + } + + [Theory] + [InlineData("203.0.113.10")] + [InlineData("2001:db8::1")] + public async Task Enroll_ValidConnectorSignerIp_LogsNoSignerIpWarning(string ip) + { + var logger = new CapturingLogger(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + await BuildClient(ip).EnrollCertificateAsync(EnrollReq(null)); + + logger.SignerIpWarnings.Should().BeEmpty(); + CapturedSignerIp().Should().Be(ip); + } + + [Theory] + [InlineData("203.0.113.10")] + [InlineData("2001:db8::1")] + public async Task Renewal_ValidSignerIp_LogsNoSignerIpWarning(string ip) + { + var logger = new CapturingLogger(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + await BuildClient("198.51.100.7").RenewCertificateAsync(MockCertificateData.OrderNumber1, RenewReq(ip)); + + logger.SignerIpWarnings.Should().BeEmpty(); + CapturedSignerIp().Should().Be(ip); + } + + [Fact] + public async Task Enroll_BlankSignerIpEverywhere_FallsBackWithoutInvalidIpWarning() + { + var logger = new CapturingLogger(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + await BuildClient("").EnrollCertificateAsync(EnrollReq(null)); + + logger.SignerIpWarnings.Should().BeEmpty("the existing missing-SignerIp warning is a different message"); + CapturedSignerIp().Should().Be("127.0.0.1"); + } + } +} From e0caa5c934ff5befc08ecd8583367085775e5279 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:23:31 -0700 Subject: [PATCH 48/71] fix(logging): make redaction regexes escape-aware so escaped quotes do not leak value tails --- CERTInext/Client/CERTInextClient.cs | 14 +++++++++----- CHANGELOG.md | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 8f6f279..64351eb 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1981,13 +1981,16 @@ internal static string RedactCredentials(string body) // but the field name is a common one and the cost of redacting it is zero). body = System.Text.RegularExpressions.Regex.Replace( body, - @"(?i)""(authKey|client_secret|apiKey|accessKey|password)""\s*:\s*""[^""]*""", + @"(?i)""(authKey|client_secret|apiKey|accessKey|password)""\s*:\s*""[^""\\]*(?:\\.[^""\\]*)*""", @"""$1"":""***REDACTED***"""); - // Form-urlencoded: client_secret=... or authKey=... (before any & or end) + // The JSON value pattern above is escape-aware: a value such as "ab\"cd" is matched whole. + // Form-urlencoded: client_secret=... or authKey=... (before any & or end). + // A backslash-escaped char (e.g. \") is consumed as part of the value so a form value + // embedded in a JSON-escaped string cannot leak the text after the escape. body = System.Text.RegularExpressions.Regex.Replace( body, - @"(?i)\b(authKey|client_secret|apiKey|accessKey|password)=([^&\s""]+)", + @"(?i)\b(authKey|client_secret|apiKey|accessKey|password)=(?:\\.|[^&\s""])+", "$1=***REDACTED***"); // Authorization header lines if a header dump ever ends up in body shape. @@ -2072,13 +2075,14 @@ internal static string RedactPersonalData(string body) /// Replaces the value of every occurrence of a JSON string field named /// (case-insensitive, exact key match) with applied to the /// original value. Leaves already-empty values untouched. Whitespace around the colon and - /// around the key's own quotes is tolerated. + /// around the key's own quotes is tolerated. The value match is escape-aware (\" and + /// \\ do not end the string), so nothing after an escaped quote is left unredacted. /// private static string RedactJsonField(string body, string keyName, Func transform) { return System.Text.RegularExpressions.Regex.Replace( body, - $@"(?i)(""{System.Text.RegularExpressions.Regex.Escape(keyName)}""\s*:\s*"")([^""]*)("")", + $@"(?i)(""{System.Text.RegularExpressions.Regex.Escape(keyName)}""\s*:\s*"")([^""\\]*(?:\\.[^""\\]*)*)("")", m => string.IsNullOrEmpty(m.Groups[2].Value) ? m.Value : m.Groups[1].Value + transform(m.Groups[2].Value) + m.Groups[3].Value); diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2a22b..3a54ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - **Renewals now send the full order details** (group, `AutoSecureWww`, technical contact, organization, remarks), the same as a new enrollment. - **Unexpected CERTInext error responses are now diagnosable from the logs.** Non-2xx responses with an unrecognised body now include the HTTP status in the error and log the redacted body (`authKey` always redacted, personal data per `LogSensitiveRequestData`). - **Gateway logs no longer contain the `authKey` or requestor personal data by default**; set `LogSensitiveRequestData` to log PII temporarily (credentials stay redacted). +- **Log redaction no longer leaks the rest of a value after an escaped quote** (e.g. `"authKey":"ab\"cd"`, `"requestorName":"Jane \"JD\" Doe"`). - **Connector and template validation no longer leaks an HTTP client per check.** ## Chores From 50c8e19ffaaa7eee803ad2462924e76b031498f7 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:23:31 -0700 Subject: [PATCH 49/71] test(logging): cover escaped quotes and backslashes in PII and credential redaction --- CERTInext.Tests/RedactCredentialsTests.cs | 46 +++++++++++++++++++ CERTInext.Tests/RedactPersonalDataTests.cs | 53 ++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/CERTInext.Tests/RedactCredentialsTests.cs b/CERTInext.Tests/RedactCredentialsTests.cs index fad3e46..e689656 100644 --- a/CERTInext.Tests/RedactCredentialsTests.cs +++ b/CERTInext.Tests/RedactCredentialsTests.cs @@ -54,6 +54,52 @@ public void RedactCredentials_ScrubsFormUrlEncodedCredentialFields(string input, CERTInextClient.RedactCredentials(input).Should().Be(expected); } + // JSON string values can contain escaped quotes and backslashes. A scrubber that stops at the + // first '"' it sees leaks everything after the escape into the log. Values below are synthetic. + [Theory] + [InlineData( + "{\"authKey\":\"ab\\\"cd\"}", + "{\"authKey\":\"***REDACTED***\"}")] + [InlineData( + "{\"meta\":{\"authKey\":\"ab\\\"cd\\\"ef\",\"ts\":\"2026\"}}", + "{\"meta\":{\"authKey\":\"***REDACTED***\",\"ts\":\"2026\"}}")] + [InlineData( + "{\"password\":\"p\\\\q\",\"other\":\"keep\"}", + "{\"password\":\"***REDACTED***\",\"other\":\"keep\"}")] + [InlineData( + "{\"client_secret\":\"ends-with-backslash\\\\\",\"other\":\"keep\"}", + "{\"client_secret\":\"***REDACTED***\",\"other\":\"keep\"}")] + [InlineData( + "{\"apiKey\":\"a\\\"b\",\"accessKey\":\"c\\\\d\\\"e\"}", + "{\"apiKey\":\"***REDACTED***\",\"accessKey\":\"***REDACTED***\"}")] + public void RedactCredentials_ScrubsJsonValuesContainingEscapes(string input, string expected) + { + CERTInextClient.RedactCredentials(input).Should().Be(expected); + } + + [Fact] + public void RedactCredentials_EscapedQuoteInJsonValue_DoesNotLeakTail() + { + string actual = CERTInextClient.RedactCredentials("{\"authKey\":\"head\\\"TAILSECRET\"}"); + actual.Should().NotContain("TAILSECRET").And.NotContain("head"); + } + + [Fact] + public void RedactCredentials_EmptyJsonValue_IsStillRedactedAsBefore() + { + // Pre-existing behavior (unlike PII fields, credential fields are not skipped when empty). + CERTInextClient.RedactCredentials("{\"authKey\":\"\"}") + .Should().Be("{\"authKey\":\"***REDACTED***\"}"); + } + + [Theory] + [InlineData("authKey=ab\\\"TAILSECRET", "authKey=***REDACTED***")] + [InlineData("x=1&password=a\\\\b\\\"TAILSECRET&y=2", "x=1&password=***REDACTED***&y=2")] + public void RedactCredentials_FormValueWithEscapedQuote_DoesNotLeakTail(string input, string expected) + { + CERTInextClient.RedactCredentials(input).Should().Be(expected); + } + [Fact] public void RedactCredentials_ScrubsAuthorizationHeaderLines() { diff --git a/CERTInext.Tests/RedactPersonalDataTests.cs b/CERTInext.Tests/RedactPersonalDataTests.cs index efb81e6..e682311 100644 --- a/CERTInext.Tests/RedactPersonalDataTests.cs +++ b/CERTInext.Tests/RedactPersonalDataTests.cs @@ -526,6 +526,59 @@ public void RedactPersonalData_MalformedOrTruncatedBody_DoesNotThrow(string inpu act2.Should().NotThrow(); } + // JSON string values can contain escaped quotes and backslashes. A scrubber that stops at the + // first '"' it sees leaks everything after the escape into the log. Values below are synthetic. + [Theory] + [InlineData( + "{\"requestorName\":\"Jane \\\"JD\\\" Doe\"}", + "{\"requestorName\":\"***REDACTED***\"}")] + [InlineData( + "{\"requestorName\":\"Jane \\\"JD\\\" Doe\",\"requestorDesignation\":\"Eng\"}", + "{\"requestorName\":\"***REDACTED***\",\"requestorDesignation\":\"***REDACTED***\"}")] + [InlineData( + "{\"signerPlace\":\"C:\\\\Users\\\\jdoe\",\"other\":\"keep\"}", + "{\"signerPlace\":\"***REDACTED***\",\"other\":\"keep\"}")] + [InlineData( + "{\"pocLastName\":\"Doe\\\\\",\"other\":\"keep\"}", + "{\"pocLastName\":\"***REDACTED***\",\"other\":\"keep\"}")] + [InlineData( + "{\"pocFirstName\":\"\\\"\",\"other\":\"keep\"}", + "{\"pocFirstName\":\"***REDACTED***\",\"other\":\"keep\"}")] + public void RedactPersonalData_RedactsOtherFieldValuesContainingEscapes(string input, string expected) + { + CERTInextClient.RedactPersonalData(input).Should().Be(expected); + } + + [Fact] + public void RedactPersonalData_EmailValueContainingEscapedQuote_IsMaskedWithoutLeakingTail() + { + string actual = CERTInextClient.RedactPersonalData( + "{\"requestorEmail\":\"jane\\\"TAILSECRET\\\"@example.com\",\"other\":\"keep\"}"); + actual.Should().NotContain("TAILSECRET"); + actual.Should().Contain("\"other\":\"keep\""); + } + + [Fact] + public void RedactPersonalData_EmptyValues_AreLeftUntouchedByEscapeAwareMatching() + { + string input = "{\"requestorName\":\"\",\"requestorEmail\":\"\"}"; + CERTInextClient.RedactPersonalData(input).Should().Be(input); + } + + [Fact] + public void ApplyLoggingRedaction_EscapedQuotesInCredentialAndPii_NeitherLeaks() + { + string input = "{\"authKey\":\"ab\\\"AUTHTAIL\",\"requestorName\":\"Jane \\\"JD\\\" PIITAIL\"}"; + + CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: false) + .Should().Be("{\"authKey\":\"***REDACTED***\",\"requestorName\":\"***REDACTED***\"}"); + + // Credentials are scrubbed even when PII logging is opted in; the PII is left as sent. + string credsOnly = CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: true); + credsOnly.Should().NotContain("AUTHTAIL"); + credsOnly.Should().Contain("PIITAIL"); + } + [Fact] public void RedactPersonalData_TruncatedBody_MasksElementsSeenBeforeTheFault() { From 013731b9ef024c3e601cc8112c3a8107e0b17c8e Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:23:40 -0700 Subject: [PATCH 50/71] test(integration): scrub CA-echoed emails from GroupAndWww live-test output via shared TestOutputScrub Extract Scrub from BlankRequestorLiveTests into a shared TestOutputScrub (email mask and 500-char cap, plus Describe for exception chains) and use it for every exception and CA-text line in both live test files. Adds unit tests for the helper. --- .../BlankRequestorLiveTests.cs | 28 +++----- .../GroupAndWwwLiveTests.cs | 17 +++-- CERTInext.IntegrationTests/TestOutputScrub.cs | 56 +++++++++++++++ .../TestOutputScrubTests.cs | 68 +++++++++++++++++++ 4 files changed, 142 insertions(+), 27 deletions(-) create mode 100644 CERTInext.IntegrationTests/TestOutputScrub.cs create mode 100644 CERTInext.IntegrationTests/TestOutputScrubTests.cs diff --git a/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs b/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs index 9fc3ec1..493fa82 100644 --- a/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs +++ b/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs @@ -181,14 +181,6 @@ private static string GenerateCsrPem(string commonName) + "\n-----END CERTIFICATE REQUEST-----"; } - /// Masks anything that looks like an email address, then truncates to 500 chars. - private static string Scrub(string s) - { - if (s == null) return null; - s = Regex.Replace(s, @"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", ""); - return s.Length > 500 ? s.Substring(0, 500) : s; - } - [SkippableFact] public async Task DvOrder_WithBlankRequestorNameAndTechnicalContactName_RecordsCertinextResponse() { @@ -229,19 +221,19 @@ public async Task DvOrder_WithBlankRequestorNameAndTechnicalContactName_RecordsC created.Add(result.CARequestID); _output.WriteLine($"RESULT: ACCEPTED order={result.CARequestID} status={result.Status} " + - $"statusMessage={Scrub(result.StatusMessage)}"); + $"statusMessage={TestOutputScrub.Scrub(result.StatusMessage)}"); var track = await client.TrackOrderAsync(result.CARequestID); string storedName = track.OrderDetails?.RequestorInformation?.RequestorName; - _output.WriteLine($"TRACK: orderStatusId={track.OrderDetails?.OrderStatusId} ({track.OrderDetails?.OrderStatus}), " + - $"certificateStatusId={track.OrderDetails?.CertificateStatusId} ({track.OrderDetails?.CertificateStatus}), " + + _output.WriteLine($"TRACK: orderStatusId={track.OrderDetails?.OrderStatusId} ({TestOutputScrub.Scrub(track.OrderDetails?.OrderStatus)}), " + + $"certificateStatusId={track.OrderDetails?.CertificateStatusId} ({TestOutputScrub.Scrub(track.OrderDetails?.CertificateStatus)}), " + $"stored requestorName blank={string.IsNullOrWhiteSpace(storedName)}"); } catch (Exception ex) { - _output.WriteLine($"RESULT: REJECTED/FAILED {ex.GetType().Name}: {Scrub(ex.Message)}"); + _output.WriteLine($"RESULT: REJECTED/FAILED {ex.GetType().Name}: {TestOutputScrub.Scrub(ex.Message)}"); if (ex.InnerException != null) - _output.WriteLine($" inner {ex.InnerException.GetType().Name}: {Scrub(ex.InnerException.Message)}"); + _output.WriteLine($" inner {ex.InnerException.GetType().Name}: {TestOutputScrub.Scrub(ex.InnerException.Message)}"); } } finally @@ -313,7 +305,7 @@ private async Task CleanupAsync(CERTInextClient client, CERTInextCAPlugin plugin { var before = await client.TrackOrderAsync(id); int.TryParse(before.OrderDetails?.CertificateStatusId, out int st); - _output.WriteLine($"cleanup {id}: before certificateStatusId={st} ({before.OrderDetails?.CertificateStatus})"); + _output.WriteLine($"cleanup {id}: before certificateStatusId={st} ({TestOutputScrub.Scrub(before.OrderDetails?.CertificateStatus)})"); if (st == Constants.CertificateStatusId.CertificateRevoked) continue; @@ -336,7 +328,7 @@ private async Task CleanupAsync(CERTInextClient client, CERTInextCAPlugin plugin } catch (Exception ex) { - _output.WriteLine($"cleanup {id}: REVOKE/CANCEL FAILED -> {ex.GetType().Name}: {Scrub(ex.Message)}"); + _output.WriteLine($"cleanup {id}: REVOKE/CANCEL FAILED -> {ex.GetType().Name}: {TestOutputScrub.Scrub(ex.Message)}"); } } @@ -346,12 +338,12 @@ private async Task CleanupAsync(CERTInextClient client, CERTInextCAPlugin plugin try { var after = await client.TrackOrderAsync(id); - _output.WriteLine($"verify {id}: orderStatusId={after.OrderDetails?.OrderStatusId} ({after.OrderDetails?.OrderStatus}), " + - $"certificateStatusId={after.OrderDetails?.CertificateStatusId} ({after.OrderDetails?.CertificateStatus})"); + _output.WriteLine($"verify {id}: orderStatusId={after.OrderDetails?.OrderStatusId} ({TestOutputScrub.Scrub(after.OrderDetails?.OrderStatus)}), " + + $"certificateStatusId={after.OrderDetails?.CertificateStatusId} ({TestOutputScrub.Scrub(after.OrderDetails?.CertificateStatus)})"); } catch (Exception ex) { - _output.WriteLine($"verify {id}: TrackOrder failed -> {ex.GetType().Name}: {Scrub(ex.Message)}"); + _output.WriteLine($"verify {id}: TrackOrder failed -> {ex.GetType().Name}: {TestOutputScrub.Scrub(ex.Message)}"); } } } diff --git a/CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs b/CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs index 78cef4b..0003f03 100644 --- a/CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs +++ b/CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs @@ -181,8 +181,8 @@ private void AssertGroupAndNoWww(string label, string orderNumber, OrderReportEn bool groupMatches = string.Equals(entry!.GroupNumber, _fixture.Config.GroupNumber, StringComparison.Ordinal); _output.WriteLine($" [{label}] {orderNumber}: ListOrders groupNumber matches configured group = {groupMatches} " + $"(report groupNumber blank = {string.IsNullOrWhiteSpace(entry.GroupNumber)})"); - _output.WriteLine($" [{label}] {orderNumber}: TrackOrder domains = [{string.Join(", ", domains)}]; " + - $"report domainName = {entry.DomainName}"); + _output.WriteLine($" [{label}] {orderNumber}: TrackOrder domains = [{TestOutputScrub.Scrub(string.Join(", ", domains))}]; " + + $"report domainName = {TestOutputScrub.Scrub(entry.DomainName)}"); groupMatches.Should().BeTrue($"{label} order must land in the configured CERTInext group"); domains.Should().Contain(d => string.Equals(d, cn, StringComparison.OrdinalIgnoreCase), @@ -203,7 +203,7 @@ private async Task CleanupAsync(CERTInextCAPlugin plugin, IEnumerable or { var before = await _fixture.Client.TrackOrderAsync(id); int.TryParse(before.OrderDetails?.CertificateStatusId, out int st); - _output.WriteLine($" cleanup {id}: before certificateStatusId={st} ({before.OrderDetails?.CertificateStatus})"); + _output.WriteLine($" cleanup {id}: before certificateStatusId={st} ({TestOutputScrub.Scrub(before.OrderDetails?.CertificateStatus)})"); if (st == Constants.CertificateStatusId.CertificateRevoked) continue; @@ -227,7 +227,7 @@ private async Task CleanupAsync(CERTInextCAPlugin plugin, IEnumerable or } catch (Exception ex) { - _output.WriteLine($" cleanup {id}: REVOKE/CANCEL FAILED -> {ex.GetType().Name}: {Truncate(ex.Message)}"); + _output.WriteLine($" cleanup {id}: REVOKE/CANCEL FAILED -> {TestOutputScrub.Describe(ex)}"); } } @@ -237,17 +237,16 @@ private async Task CleanupAsync(CERTInextCAPlugin plugin, IEnumerable or try { var after = await _fixture.Client.TrackOrderAsync(id); - _output.WriteLine($" verify {id}: orderStatusId={after.OrderDetails?.OrderStatusId} ({after.OrderDetails?.OrderStatus}), " + - $"certificateStatusId={after.OrderDetails?.CertificateStatusId} ({after.OrderDetails?.CertificateStatus})"); + _output.WriteLine($" verify {id}: orderStatusId={after.OrderDetails?.OrderStatusId} ({TestOutputScrub.Scrub(after.OrderDetails?.OrderStatus)}), " + + $"certificateStatusId={after.OrderDetails?.CertificateStatusId} ({TestOutputScrub.Scrub(after.OrderDetails?.CertificateStatus)})"); } catch (Exception ex) { - _output.WriteLine($" verify {id}: TrackOrder failed -> {ex.GetType().Name}: {Truncate(ex.Message)}"); + _output.WriteLine($" verify {id}: TrackOrder failed -> {TestOutputScrub.Describe(ex)}"); } } } - private static string Truncate(string s) => s != null && s.Length > 500 ? s.Substring(0, 500) : s; // --------------------------------------------------------------------------- // Test A: new enrollment (compiles on both DcvSupport variants) @@ -395,7 +394,7 @@ await FindInOrderReportAsync(first.CARequestID), } catch (Exception ex) { - _output.WriteLine($"RENEWAL REFUSED/FAILED: {ex.GetType().Name}: {Truncate(ex.Message)}"); + _output.WriteLine($"RENEWAL REFUSED/FAILED: {TestOutputScrub.Describe(ex)}"); throw; } diff --git a/CERTInext.IntegrationTests/TestOutputScrub.cs b/CERTInext.IntegrationTests/TestOutputScrub.cs new file mode 100644 index 0000000..fea50b1 --- /dev/null +++ b/CERTInext.IntegrationTests/TestOutputScrub.cs @@ -0,0 +1,56 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Text.RegularExpressions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Scrubs CA-originated text (exception messages, status messages, status strings) before it is + /// written to ITestOutputHelper, so a CA error that echoes the requestor or POC email from + /// ~/.env_certinext cannot land in test or CI output. + /// + internal static class TestOutputScrub + { + private const int MaxLength = 500; + + private static readonly Regex EmailPattern = + new Regex(@"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", RegexOptions.Compiled); + + /// Masks anything that looks like an email address, then truncates to 500 chars. + public static string Scrub(string s) + { + if (s == null) return null; + s = EmailPattern.Replace(s, ""); + return s.Length > MaxLength ? s.Substring(0, MaxLength) : s; + } + + /// + /// "TypeName: scrubbed message" for , followed by the same for each inner + /// exception (separated by " <- "). + /// + public static string Describe(Exception ex) + { + if (ex == null) return null; + var sb = new System.Text.StringBuilder(); + for (var cur = ex; cur != null; cur = cur.InnerException) + { + if (sb.Length > 0) sb.Append(" <- "); + sb.Append(cur.GetType().Name).Append(": ").Append(Scrub(cur.Message)); + } + return sb.ToString(); + } + } +} diff --git a/CERTInext.IntegrationTests/TestOutputScrubTests.cs b/CERTInext.IntegrationTests/TestOutputScrubTests.cs new file mode 100644 index 0000000..340bfa3 --- /dev/null +++ b/CERTInext.IntegrationTests/TestOutputScrubTests.cs @@ -0,0 +1,68 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using FluentAssertions; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Pure unit tests (no live API) for , which keeps CA-echoed + /// email addresses out of ITestOutputHelper output. + /// + public class TestOutputScrubTests + { + [Fact] + public void Scrub_NullInput_ReturnsNull() + { + TestOutputScrub.Scrub(null).Should().BeNull(); + } + + [Theory] + [InlineData("Requestor jane.doe+test@example.com is invalid", "Requestor is invalid")] + [InlineData("a@b.io and c_d@e-f.org", " and ")] + [InlineData("no address here", "no address here")] + public void Scrub_MasksEmailAddresses(string input, string expected) + { + TestOutputScrub.Scrub(input).Should().Be(expected); + } + + [Fact] + public void Scrub_TruncatesTo500Characters() + { + TestOutputScrub.Scrub(new string('x', 800)).Should().HaveLength(500); + } + + [Fact] + public void Describe_ScrubsMessageAndInnerExceptionMessages() + { + var ex = new InvalidOperationException( + "CA rejected contact poc@example.com", + new ArgumentException("inner echoes requestor@example.org")); + + string text = TestOutputScrub.Describe(ex); + + text.Should().NotContain("@example"); + text.Should().Contain("InvalidOperationException: CA rejected contact "); + text.Should().Contain("ArgumentException: inner echoes "); + } + + [Fact] + public void Describe_NullException_ReturnsNull() + { + TestOutputScrub.Describe(null).Should().BeNull(); + } + } +} From 8c6499ce4affdd064cd82a325664fa6a8d77bf53 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:24:31 -0700 Subject: [PATCH 51/71] fix(enroll): warn when SignerName/SignerPlace fall back to placeholder values The subscriber agreement is a legal record; log a Warning naming the template parameter or connector field to set, matching the existing SignerIp warning. Values sent are unchanged. --- CERTInext/Client/CERTInextClient.cs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 64351eb..ec7f3fb 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1728,11 +1728,31 @@ private AgreementDetails BuildAgreementDetails( "gateway host's actual public-routable IP so the audit record is accurate."); signerIp = "127.0.0.1"; } + // Same SOC1 rationale as SignerIp: placeholder SignerName/SignerPlace values land in + // the legal agreement record, so a fallback is surfaced as a Warning (values unchanged). + string signerName = FirstNonBlank(templateSignerName, _config.RequestorName); + if (signerName == null) + { + Logger.LogWarning( + "Neither the template SignerName parameter nor the connector RequestorName config is set — " + + "falling back to \"Keyfactor Gateway\" for the subscriber agreement. Set SignerName on the " + + "template or RequestorName on the connector to the actual signer so the audit record is accurate."); + signerName = "Keyfactor Gateway"; + } + string signerPlace = FirstNonBlank(templateSignerPlace, _config.SignerPlace); + if (signerPlace == null) + { + Logger.LogWarning( + "Neither the template SignerPlace parameter nor the connector SignerPlace config is set — " + + "falling back to \"Gateway\" for the subscriber agreement. Set SignerPlace on the " + + "template or connector to the signer's actual location so the audit record is accurate."); + signerPlace = "Gateway"; + } return new AgreementDetails { AcceptAgreement = "1", - SignerName = FirstNonBlank(templateSignerName, _config.RequestorName) ?? "Keyfactor Gateway", - SignerPlace = FirstNonBlank(templateSignerPlace, _config.SignerPlace) ?? "Gateway", + SignerName = signerName, + SignerPlace = signerPlace, SignerIp = signerIp }; } From 18af44afd9baa6f8b131d9ac9148c579aea5cf84 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:24:32 -0700 Subject: [PATCH 52/71] test(enroll): pin SignerName/SignerPlace fallback warnings and unchanged wire values Also adds the 1.0.1 CHANGELOG entry. --- CERTInext.Tests/SignerFallbackWarningTests.cs | 186 ++++++++++++++++++ CHANGELOG.md | 1 + 2 files changed, 187 insertions(+) create mode 100644 CERTInext.Tests/SignerFallbackWarningTests.cs diff --git a/CERTInext.Tests/SignerFallbackWarningTests.cs b/CERTInext.Tests/SignerFallbackWarningTests.cs new file mode 100644 index 0000000..45a77da --- /dev/null +++ b/CERTInext.Tests/SignerFallbackWarningTests.cs @@ -0,0 +1,186 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Microsoft.Extensions.Logging; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// SOC1 accuracy-of-processing: when SignerName / SignerPlace fall back to the built-in + /// placeholders ("Keyfactor Gateway" / "Gateway") the client must log a Warning naming what to + /// configure, without changing the values sent in agreementDetails. + /// + [Collection("CERTInextClientLogger-NoParallel")] + public class SignerFallbackWarningTests : IDisposable + { + private readonly WireMockServer _server; + + public SignerFallbackWarningTests() + { + _server = WireMockServer.Start(); + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GenerateOrderSuccessJson(MockCertificateData.OrderNumber1))); + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.TrackOrderIssuedJson(MockCertificateData.OrderNumber1))); + _server.Given(Request.Create().WithPath("/GetCertificate").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCertificateSuccessJson())); + } + + public void Dispose() => _server.Stop(); + + private sealed class CapturingLogger : ILogger + { + public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + + // The client logger is a process-wide static, so enrolls from unrelated test classes that + // run in parallel can log into it while it is overridden. Only record entries logged from + // this test's async flow (AsyncLocal flows down to callees, not across parallel tests). + private readonly AsyncLocal _active = new(); + public void Activate() => _active.Value = true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + if (_active.Value) Entries.Enqueue((logLevel, formatter(state, exception))); + } + + public List Warnings(string contains) => Entries + .Where(e => e.Level == LogLevel.Warning && e.Message.Contains(contains, StringComparison.Ordinal)) + .Select(e => e.Message).ToList(); + } + + private CERTInextClient BuildClient(string requestorName, string signerPlace) => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "test-key", + AccountNumber = "12345", + RequestorName = requestorName, + RequestorEmail = "requestor@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerPlace = signerPlace, + SignerIp = "203.0.113.10", + PageSize = 100 + }); + + private async Task<(CapturingLogger Logger, JsonElement Agreement)> EnrollAsync( + CERTInextClient client, Dictionary templateParams = null) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ProfileId"] = "842" }; + if (templateParams != null) + foreach (var kv in templateParams) parameters[kv.Key] = kv.Value; + + var plugin = new CERTInextCAPlugin(client, new CERTInextConfig { PickupRetries = 0 }); + var logger = new CapturingLogger(); + logger.Activate(); + using (CERTInextClient.OverrideLoggerForTests(logger)) + { + await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=test.example.com", + new Dictionary { ["dns"] = new[] { "test.example.com" } }, + new EnrollmentProductInfo { ProductID = "842", ProductParameters = parameters }, + RequestFormat.PKCS10, EnrollmentType.New); + } + + var post = _server.LogEntries.Single(e => e.RequestMessage.Path == "/GenerateOrderSSL"); + var agreement = JsonDocument.Parse(post.RequestMessage.Body!).RootElement + .GetProperty("orderDetails").GetProperty("agreementDetails").Clone(); + return (logger, agreement); + } + + [Fact] + public async Task SignerName_Unset_WarnsAndSendsPlaceholder() + { + var (logger, agreement) = await EnrollAsync(BuildClient(requestorName: "", signerPlace: "Austin")); + + var warnings = logger.Warnings("SignerName"); + warnings.Should().ContainSingle(); + warnings[0].Should().Contain("RequestorName").And.Contain("template"); + logger.Warnings("SignerPlace").Should().BeEmpty(); + agreement.GetProperty("signerName").GetString().Should().Be("Keyfactor Gateway"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Austin"); + } + + [Fact] + public async Task SignerPlace_Unset_WarnsAndSendsPlaceholder() + { + var (logger, agreement) = await EnrollAsync(BuildClient(requestorName: "Jane Doe", signerPlace: "")); + + var warnings = logger.Warnings("SignerPlace"); + warnings.Should().ContainSingle(); + warnings[0].Should().Contain("template").And.Contain("connector"); + logger.Warnings("SignerName").Should().BeEmpty(); + agreement.GetProperty("signerName").GetString().Should().Be("Jane Doe"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Gateway"); + } + + [Fact] + public async Task SignerFields_SetOnConnector_NoWarning_ValuesUnchanged() + { + var (logger, agreement) = await EnrollAsync(BuildClient(requestorName: "Jane Doe", signerPlace: "Austin")); + + logger.Warnings("SignerName").Should().BeEmpty(); + logger.Warnings("SignerPlace").Should().BeEmpty(); + agreement.GetProperty("signerName").GetString().Should().Be("Jane Doe"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Austin"); + } + + [Fact] + public async Task SignerFields_SetOnTemplate_NoWarning_TemplateWins() + { + var (logger, agreement) = await EnrollAsync( + BuildClient(requestorName: "", signerPlace: ""), + new Dictionary { ["SignerName"] = "Template Signer", ["SignerPlace"] = "Denver" }); + + logger.Warnings("SignerName").Should().BeEmpty(); + logger.Warnings("SignerPlace").Should().BeEmpty(); + agreement.GetProperty("signerName").GetString().Should().Be("Template Signer"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Denver"); + } + + [Fact] + public async Task SignerFields_BothUnset_WarnsForEach_AndSendsBothPlaceholders() + { + var (logger, agreement) = await EnrollAsync(BuildClient(requestorName: null, signerPlace: null)); + + logger.Warnings("SignerName").Should().ContainSingle(); + logger.Warnings("SignerPlace").Should().ContainSingle(); + agreement.GetProperty("signerName").GetString().Should().Be("Keyfactor Gateway"); + agreement.GetProperty("signerPlace").GetString().Should().Be("Gateway"); + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a54ca2..35f3dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **New enrollments now use the connector defaults when the template or connector value is blank.** A blank template product code now falls back to `DefaultProductCode`, and a blank `RequestorName`/`SignerPlace` now sends "Keyfactor Gateway"/"Gateway" as the agreement signer instead of an empty value. - **The `SignerName`, `SignerPlace`, and `SignerIp` template parameters now take effect.** They were accepted but ignored; they now override the connector values for the subscriber agreement on both new orders and renewals. - **A `SignerIp` that isn't an IP address now logs a Warning** naming whether it came from the template or the connector; the value is still sent unchanged and enrollment is never blocked. +- **A Warning is now logged when `SignerName` or `SignerPlace` fall back to the "Keyfactor Gateway"/"Gateway" placeholders**, naming the template parameter or connector field to set; the values sent are unchanged. - **`GroupNumber`, `AutoSecureWww`, and the technical contact now reach CERTInext**; they were previously sent in fields CERTInext doesn't read. - **Renewals now send the full order details** (group, `AutoSecureWww`, technical contact, organization, remarks), the same as a new enrollment. - **Unexpected CERTInext error responses are now diagnosable from the logs.** Non-2xx responses with an unrecognised body now include the HTTP status in the error and log the redacted body (`authKey` always redacted, personal data per `LogSensitiveRequestData`). From 966288224cfdb178eff4a827d96d7c37e23c7cf2 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:24:43 -0700 Subject: [PATCH 53/71] fix(logging): mask emails in CA-supplied error text unless LogSensitiveRequestData is on meta.errorMessage / legacy message were logged and thrown verbatim while the adjacent body was redacted, so an email echoed by CERTInext leaked into logs and into the exception message Command stores. Add LogSanitizer.SanitizeCaText (email tokens masked via MaskEmail, CR/LF/tab stripped, flag on = unchanged) and apply it in LogApiFailure, ExtractErrorMessage (new fail-closed logSensitiveRequestData parameter) and every thrown message built from meta.ErrorMessage. IsRateLimitSurface and the duplicate-txn check still see the raw text. --- CERTInext/Client/CERTInextClient.cs | 52 ++++++++++++++++++++--------- CERTInext/Models/LogSanitizer.cs | 32 ++++++++++++++++++ CHANGELOG.md | 1 + 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index ec7f3fb..36d43c6 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -194,7 +194,7 @@ public async Task PingAsync(CancellationToken ct = default) result.Meta.ErrorCode, result.Meta.ErrorMessage, level: LogLevel.Error); throw new Exception( - $"CERTInext credential validation failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " + + $"CERTInext credential validation failed: {MaskCaText(result.Meta.ErrorMessage) ?? result.Meta.ErrorCode}. " + "See gateway logs for details."); } @@ -314,7 +314,7 @@ public async Task PlaceOrderAsync( Logger.LogWarning( "PlaceOrder hit rate-limit-shaped error \"{ErrorMessage}\" (attempt {Attempt}/{Max}). " + "Backing off {WaitSeconds:F1}s before retrying. See Troubleshooting in README for context.", - result.Meta.ErrorMessage, attempt, RateLimitMaxAttempts, waitSeconds); + MaskCaText(result.Meta.ErrorMessage), attempt, RateLimitMaxAttempts, waitSeconds); try { await Task.Delay(TimeSpan.FromSeconds(waitSeconds), ct); @@ -352,7 +352,7 @@ public async Task PlaceOrderAsync( } throw new Exception( - $"CERTInext order failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " + + $"CERTInext order failed: {MaskCaText(result.Meta.ErrorMessage) ?? result.Meta.ErrorCode}. " + "See gateway logs for details."); } @@ -476,10 +476,10 @@ public async Task TrackOrderAsync(string orderNumber, Cancel if (result.Meta.ErrorCode != null && (result.Meta.ErrorCode.StartsWith("EMS-9") || result.Meta.ErrorMessage?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)) { - throw new KeyNotFoundException($"Order '{orderNumber}' was not found in CERTInext. Error: {result.Meta.ErrorMessage}"); + throw new KeyNotFoundException($"Order '{orderNumber}' was not found in CERTInext. Error: {MaskCaText(result.Meta.ErrorMessage)}"); } throw new Exception( - $"CERTInext TrackOrder failed for order '{orderNumber}': {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}."); + $"CERTInext TrackOrder failed for order '{orderNumber}': {MaskCaText(result.Meta.ErrorMessage) ?? result.Meta.ErrorCode}."); } Logger.MethodExit(LogLevel.Trace); @@ -528,7 +528,7 @@ public async Task DownloadCertificateAsync(string orderN LogApiFailure($"{Constants.Api.GetCertificatePath} {orderNumber}", resp, result.Meta.ErrorCode, result.Meta.ErrorMessage); throw new Exception( - $"CERTInext GetCertificate failed for order '{orderNumber}': {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}."); + $"CERTInext GetCertificate failed for order '{orderNumber}': {MaskCaText(result.Meta.ErrorMessage) ?? result.Meta.ErrorCode}."); } Logger.MethodExit(LogLevel.Trace); @@ -571,7 +571,8 @@ public async Task RevokeOrderAsync(RevokeOrderRequest request, CancellationToken } string errMsg = ExtractErrorMessage(resp.Content, - $"revoke order {request.RevocationDetails?.OrderNumber}"); + $"revoke order {request.RevocationDetails?.OrderNumber}", + logSensitiveRequestData: _config.LogSensitiveRequestData); Logger.LogError( "RevokeOrder API call failed. OrderNumber={OrderNumber}, HttpStatus={Status}, Error={Error}", request.RevocationDetails?.OrderNumber, (int)resp.StatusCode, errMsg); @@ -591,7 +592,7 @@ public async Task RevokeOrderAsync(RevokeOrderRequest request, CancellationToken resp, revResp.Meta.ErrorCode, revResp.Meta.ErrorMessage); throw new Exception( $"CERTInext RevokeOrder returned failure for order " + - $"'{request.RevocationDetails?.OrderNumber}': {revResp.Meta.ErrorMessage ?? revResp.Meta.ErrorCode}."); + $"'{request.RevocationDetails?.OrderNumber}': {MaskCaText(revResp.Meta.ErrorMessage) ?? revResp.Meta.ErrorCode}."); } } catch (JsonException) @@ -1107,7 +1108,7 @@ public async Task GetDcvAsync( $"{Constants.Api.GetDcvPath} {orderNumber}/{domainName}", resp, result.Meta.ErrorCode, result.Meta.ErrorMessage); throw new Exception( - $"CERTInext GetDcv failed for order '{orderNumber}' domain '{domainName}': {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}."); + $"CERTInext GetDcv failed for order '{orderNumber}' domain '{domainName}': {MaskCaText(result.Meta.ErrorMessage) ?? result.Meta.ErrorCode}."); } // SOX CC7.3: log token presence (never value) so each DCV step is independently @@ -1180,7 +1181,7 @@ public async Task VerifyDcvAsync( $"{Constants.Api.VerifyDcvPath} {orderNumber}/{domainName}", resp, verifyResp.Meta.ErrorCode, verifyResp.Meta.ErrorMessage); throw new Exception( - $"CERTInext VerifyDcv returned failure for order '{orderNumber}' domain '{domainName}': {verifyResp.Meta.ErrorMessage ?? verifyResp.Meta.ErrorCode}."); + $"CERTInext VerifyDcv returned failure for order '{orderNumber}' domain '{domainName}': {MaskCaText(verifyResp.Meta.ErrorMessage) ?? verifyResp.Meta.ErrorCode}."); } } catch (JsonException) { /* non-JSON 200 body is acceptable */ } @@ -1859,7 +1860,8 @@ private T DeserializeOrThrow(RestResponse resp, string operation) where T : c // non-2xx body here is usually not from the V1 application at all (e.g. ApiUrl missing // the /emSignHub-API/ segment). Log the redacted body and put the HTTP status in the // message so "See gateway logs for details" has something to point at. - string errMsg = ExtractErrorMessage(resp.Content, operation, (int)resp.StatusCode); + string errMsg = ExtractErrorMessage( + resp.Content, operation, (int)resp.StatusCode, _config.LogSensitiveRequestData); LogApiFailure(operation, resp, errorMessage: errMsg, level: LogLevel.Error); throw new Exception(errMsg); } @@ -2282,6 +2284,8 @@ internal static string ApplyLoggingRedaction(string body, bool logSensitiveReque /// the SIEM-alert level convention. /// // Instance (not static) so it can read _config.LogSensitiveRequestData — see issue 0040. + // The errorMessage argument is CA-supplied text and is masked here (idempotently), so callers + // may pass it raw or already masked. private void LogApiFailure( string operationContext, RestResponse resp, @@ -2297,11 +2301,25 @@ private void LogApiFailure( operationContext, (int?)resp?.StatusCode ?? 0, errorCode ?? "(none)", - errorMessage ?? "(none)", + MaskCaText(errorMessage) ?? "(none)", Truncate(sanitizedBody, LoggedResponseBodyCapBytes)); } - internal static string ExtractErrorMessage(string content, string operation, int? httpStatus = null) + /// + /// Masks email-shaped tokens in, and strips CR/LF from, CA-supplied error text unless + /// LogSensitiveRequestData is on. Use for every CA message that reaches a log line or + /// an exception message; feed the raw text instead. + /// + private string MaskCaText(string text) => LogSanitizer.SanitizeCaText(text, _config.LogSensitiveRequestData); + + /// + /// Builds the exception/log message for a non-success CERTInext body. CA-supplied text + /// (meta.errorMessage, meta.errorCode, legacy message) goes through + /// ; + /// defaults to false so a caller that omits it fails closed. + /// + internal static string ExtractErrorMessage( + string content, string operation, int? httpStatus = null, bool logSensitiveRequestData = false) { string status = httpStatus.HasValue ? $" (HTTP {httpStatus.Value})" : string.Empty; @@ -2325,15 +2343,17 @@ internal static string ExtractErrorMessage(string content, string operation, int { string errMsg = null; string errCode = null; - if (meta.TryGetProperty("errorMessage", out var em)) errMsg = em.GetString(); - if (meta.TryGetProperty("errorCode", out var ec)) errCode = ec.GetString(); + if (meta.TryGetProperty("errorMessage", out var em)) + errMsg = LogSanitizer.SanitizeCaText(em.GetString(), logSensitiveRequestData); + if (meta.TryGetProperty("errorCode", out var ec)) + errCode = LogSanitizer.SanitizeCaText(ec.GetString(), logSensitiveRequestData); if (!string.IsNullOrWhiteSpace(errMsg) || !string.IsNullOrWhiteSpace(errCode)) return $"CERTInext error during '{operation}'{status}: {errMsg ?? errCode} [{errCode}]"; } // Fall back to legacy ApiErrorResponse shape if (doc.RootElement.TryGetProperty("message", out var legacyMsg)) - return $"CERTInext error during '{operation}'{status}: {legacyMsg.GetString()}"; + return $"CERTInext error during '{operation}'{status}: {LogSanitizer.SanitizeCaText(legacyMsg.GetString(), logSensitiveRequestData)}"; } catch { diff --git a/CERTInext/Models/LogSanitizer.cs b/CERTInext/Models/LogSanitizer.cs index 9ebaf07..56aebff 100644 --- a/CERTInext/Models/LogSanitizer.cs +++ b/CERTInext/Models/LogSanitizer.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; namespace Keyfactor.Extensions.CAPlugin.CERTInext.Models { @@ -61,6 +62,37 @@ internal static string MaskEmail(string value) return value.Substring(0, 1) + "***@" + domain; } + // Email-shaped token inside free text. The lookbehind restricts match starts to token + // boundaries so scanning stays linear on long runs of local-part characters (error bodies + // can be up to 64 KB). Already-masked output ("j***@example.com") does not re-match, so + // masking is idempotent. A trailing '.' is left out of the domain ("a@b.com." -> "a@b.com"). + private static readonly Regex EmailInText = new Regex( + @"(? + /// Prepares CA-supplied error text (meta.errorMessage, legacy message) for a + /// log line or an exception message. With on the + /// text is returned unchanged. Off, email-shaped tokens are masked via + /// (CERTInext may echo a submitted request value in its error text) and CR/LF/tab are + /// neutralized via ; everything else (codes, field names, reasons) is kept + /// word for word. Names and phone numbers in free text are not detected. Null passes through. + /// Rate-limit detection must be given the raw text, not this output. + /// + internal static string SanitizeCaText(string text, bool logSensitiveRequestData) + { + if (logSensitiveRequestData || string.IsNullOrEmpty(text)) return text; + try + { + return Strip(EmailInText.Replace(text, m => MaskEmail(m.Value))); + } + catch (RegexMatchTimeoutException) + { + return "(CA error text withheld: could not be safely masked)"; + } + } + // SAN type spellings (case-insensitive) whose values are email addresses: the gateway's // "rfc822name" plus the variants CERTInextCAPlugin.MapSanType normalizes to "email". private static readonly HashSet EmailSanTypes = diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f3dfb..987b590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - **Unexpected CERTInext error responses are now diagnosable from the logs.** Non-2xx responses with an unrecognised body now include the HTTP status in the error and log the redacted body (`authKey` always redacted, personal data per `LogSensitiveRequestData`). - **Gateway logs no longer contain the `authKey` or requestor personal data by default**; set `LogSensitiveRequestData` to log PII temporarily (credentials stay redacted). - **Log redaction no longer leaks the rest of a value after an escaped quote** (e.g. `"authKey":"ab\"cd"`, `"requestorName":"Jane \"JD\" Doe"`). +- **CERTInext error text in logs and error messages now has email addresses masked** unless `LogSensitiveRequestData` is set. - **Connector and template validation no longer leaks an HTTP client per check.** ## Chores From 6364e4b9246f324e27728d04176562ae4769dc7d Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:24:43 -0700 Subject: [PATCH 54/71] test(logging): CA error text email masking in log lines and exception messages --- CERTInext.Tests/CaErrorTextMaskingTests.cs | 288 +++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 CERTInext.Tests/CaErrorTextMaskingTests.cs diff --git a/CERTInext.Tests/CaErrorTextMaskingTests.cs b/CERTInext.Tests/CaErrorTextMaskingTests.cs new file mode 100644 index 0000000..bdc0d8e --- /dev/null +++ b/CERTInext.Tests/CaErrorTextMaskingTests.cs @@ -0,0 +1,288 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Microsoft.Extensions.Logging; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// CA-supplied error text (meta.errorMessage / legacy message) may echo a request + /// value such as an email. With LogSensitiveRequestData off, email-shaped tokens are masked + /// (via MaskEmail) and CR/LF stripped in both the log line and the exception message that + /// Command stores; the rest of the text is preserved. With the flag on the text is verbatim. + /// All data is synthetic. + /// + [Collection("CERTInextClientLogger-NoParallel")] + public class CaErrorTextMaskingTests : IDisposable + { + private const string Email = "jane.doe@example.com"; + private const string MaskedEmail = "j***@example.com"; + + private readonly WireMockServer _server = WireMockServer.Start(); + + public void Dispose() => _server.Stop(); + + // --------------------------------------------------------------------------- + // LogSanitizer.SanitizeCaText + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("Invalid requestorEmail jane.doe@example.com for order", "Invalid requestorEmail j***@example.com for order")] + [InlineData("Email 'jane.doe@example.com'.", "Email 'j***@example.com'.")] + [InlineData("Sent to jane.doe@example.com.", "Sent to j***@example.com.")] + [InlineData("a@b.example.org and c+tag@sub.example.co.uk differ", "a***@b.example.org and c***@sub.example.co.uk differ")] + [InlineData("EMS-956 Invalid Request for this API.", "EMS-956 Invalid Request for this API.")] + [InlineData("Inactive Account User.", "Inactive Account User.")] + [InlineData("no at-sign, handle@ only, @example.com alone", "no at-sign, handle@ only, @example.com alone")] + public void SanitizeCaText_FlagOff_MasksOnlyEmailTokens(string input, string expected) + { + LogSanitizer.SanitizeCaText(input, false).Should().Be(expected); + } + + [Fact] + public void SanitizeCaText_FlagOff_StripsCrLfAndTab() + { + LogSanitizer.SanitizeCaText("first\r\nsecond\tthird", false) + .Should().Be("first\\r\\nsecond\\tthird"); + } + + [Fact] + public void SanitizeCaText_FlagOn_IsVerbatim() + { + string text = "Bad " + Email + "\r\nline two"; + LogSanitizer.SanitizeCaText(text, true).Should().Be(text); + } + + [Fact] + public void SanitizeCaText_NullAndEmpty_PassThrough() + { + LogSanitizer.SanitizeCaText(null, false).Should().BeNull(); + LogSanitizer.SanitizeCaText(string.Empty, false).Should().BeEmpty(); + } + + [Fact] + public void SanitizeCaText_IsIdempotent() + { + string once = LogSanitizer.SanitizeCaText("Bad " + Email, false); + LogSanitizer.SanitizeCaText(once, false).Should().Be(once); + } + + [Fact] + public void SanitizeCaText_LargeInputWithoutAtSign_CompletesQuickly() + { + string big = new string('a', 64 * 1024); + var sw = System.Diagnostics.Stopwatch.StartNew(); + LogSanitizer.SanitizeCaText(big, false).Should().Be(big); + sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1)); + } + + // --------------------------------------------------------------------------- + // ExtractErrorMessage + // --------------------------------------------------------------------------- + + [Fact] + public void ExtractErrorMessage_MetaBody_FlagOff_MasksEmailKeepsCodeAndStatus() + { + string body = "{\"meta\":{\"status\":\"0\",\"errorCode\":\"EMS-100\",\"errorMessage\":\"Invalid requestorEmail " + Email + " (field requestorEmail)\"}}"; + + CERTInextClient.ExtractErrorMessage(body, "op", 400) + .Should().Be($"CERTInext error during 'op' (HTTP 400): Invalid requestorEmail {MaskedEmail} (field requestorEmail) [EMS-100]"); + } + + [Fact] + public void ExtractErrorMessage_MetaBody_FlagOn_IsVerbatim() + { + string body = "{\"meta\":{\"status\":\"0\",\"errorCode\":\"EMS-100\",\"errorMessage\":\"Invalid requestorEmail " + Email + "\"}}"; + + CERTInextClient.ExtractErrorMessage(body, "op", 400, logSensitiveRequestData: true) + .Should().Be($"CERTInext error during 'op' (HTTP 400): Invalid requestorEmail {Email} [EMS-100]"); + } + + [Fact] + public void ExtractErrorMessage_MetaBody_FlagOff_StripsCrLf() + { + // JSON \r\n escapes decode to real CR/LF characters. + string body = "{\"meta\":{\"errorCode\":\"EMS-100\",\"errorMessage\":\"line one\\r\\nforged line\"}}"; + + string msg = CERTInextClient.ExtractErrorMessage(body, "op"); + + msg.Should().NotContain("\r").And.NotContain("\n"); + msg.Should().Contain("line one\\r\\nforged line"); + } + + [Fact] + public void ExtractErrorMessage_LegacyBody_FlagOffMasksFlagOnVerbatim() + { + string body = "{\"message\":\"Unknown user " + Email + "\"}"; + + CERTInextClient.ExtractErrorMessage(body, "op", 503) + .Should().Be($"CERTInext error during 'op' (HTTP 503): Unknown user {MaskedEmail}"); + CERTInextClient.ExtractErrorMessage(body, "op", 503, logSensitiveRequestData: true) + .Should().Be($"CERTInext error during 'op' (HTTP 503): Unknown user {Email}"); + } + + [Fact] + public void ExtractErrorMessage_OmittedFlag_FailsClosed() + { + string body = "{\"message\":\"Unknown user " + Email + "\"}"; + + CERTInextClient.ExtractErrorMessage(body, "op").Should().NotContain(Email); + } + + // --------------------------------------------------------------------------- + // End to end through the client: log line and exception message + // --------------------------------------------------------------------------- + + private sealed class CapturingLogger : ILogger + { + public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => Entries.Enqueue((logLevel, formatter(state, exception))); + } + + private CERTInextClient BuildClient(bool logSensitiveRequestData) => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "synthetic-access-key", + AccountNumber = "9988776655", + LogSensitiveRequestData = logSensitiveRequestData + }); + + private async Task<(Exception Error, List Lines)> RunAsync( + bool logSensitiveRequestData, string path, int status, string body, string marker, + Func call) + { + _server.Reset(); + _server.Given(Request.Create().WithPath("/" + path).UsingPost()) + .RespondWith(Response.Create().WithStatusCode(status) + .WithHeader("Content-Type", "application/json").WithBody(body)); + + var client = BuildClient(logSensitiveRequestData); + var logger = new CapturingLogger(); + Exception error = null; + using (CERTInextClient.OverrideLoggerForTests(logger)) + { + try { await call(client); } + catch (Exception ex) { error = ex; } + } + + // The client logger is process-wide; scope to lines carrying this call's marker. + var lines = logger.Entries.Select(e => e.Message) + .Where(m => m != null && m.Contains(marker)).ToList(); + return (error, lines); + } + + private static string MetaFailureBody(string marker, string errorCode = "EMS-100") => + "{\"meta\":{\"status\":\"0\",\"errorCode\":\"" + errorCode + "\",\"errorMessage\":\"" + marker + + " Invalid requestorEmail " + Email + " for field requestorEmail\\r\\nforged\"}}"; + + // The ErrorMessage= field of the "CERTInext API non-success" line (the ResponseBody= field + // that follows is redacted separately by ApplyLoggingRedaction). + private static string ErrorMessageField(IEnumerable lines) + { + string failure = lines.Single(l => l.StartsWith("CERTInext API non-success")); + int start = failure.IndexOf("ErrorMessage=", StringComparison.Ordinal); + int end = failure.IndexOf(", ResponseBody=", StringComparison.Ordinal); + return failure.Substring(start, end - start); + } + + [Fact] + public async Task TrackOrder_MetaFailure_FlagOff_MasksEmailInLogAndException() + { + string marker = "m-" + Guid.NewGuid().ToString("N"); + var (error, lines) = await RunAsync(false, "TrackOrder", 200, MetaFailureBody(marker), marker, + c => c.TrackOrderAsync("ORD-1")); + + error.Should().NotBeNull(); + error.Message.Should().Contain("Invalid requestorEmail " + MaskedEmail + " for field requestorEmail") + .And.NotContain(Email).And.NotContain("\r").And.NotContain("\n"); + + string errField = ErrorMessageField(lines); + errField.Should().Contain("Invalid requestorEmail " + MaskedEmail + " for field requestorEmail") + .And.NotContain(Email).And.NotContain("\r").And.NotContain("\n").And.Contain("\\r\\nforged"); + lines.Single(l => l.StartsWith("CERTInext API non-success")).Should().Contain("ErrorCode=EMS-100"); + } + + [Fact] + public async Task TrackOrder_MetaFailure_FlagOn_IsVerbatimInLogAndException() + { + string marker = "m-" + Guid.NewGuid().ToString("N"); + var (error, lines) = await RunAsync(true, "TrackOrder", 200, MetaFailureBody(marker), marker, + c => c.TrackOrderAsync("ORD-1")); + + error.Message.Should().Contain("Invalid requestorEmail " + Email + " for field requestorEmail"); + ErrorMessageField(lines).Should().Contain("Invalid requestorEmail " + Email + " for field requestorEmail"); + } + + [Fact] + public async Task TrackOrder_NotFound_FlagOff_MasksEmailInKeyNotFoundMessage() + { + string marker = "m-" + Guid.NewGuid().ToString("N"); + var (error, _) = await RunAsync(false, "TrackOrder", 200, MetaFailureBody(marker, "EMS-913"), marker, + c => c.TrackOrderAsync("ORD-1")); + + error.Should().BeOfType(); + error.Message.Should().Contain(MaskedEmail).And.NotContain(Email); + } + + [Fact] + public async Task GetProductDetails_Non2xxMetaBody_FlagOff_MasksEmailInLogAndException() + { + string marker = "m-" + Guid.NewGuid().ToString("N"); + var (error, lines) = await RunAsync(false, "GetProductDetails", 400, MetaFailureBody(marker), marker, + c => c.GetProductDetailsAsync()); + + error.Message.Should().Contain("(HTTP 400)").And.Contain("[EMS-100]") + .And.Contain("Invalid requestorEmail " + MaskedEmail + " for field requestorEmail") + .And.NotContain(Email); + ErrorMessageField(lines).Should().Contain(MaskedEmail).And.NotContain(Email); + } + + [Fact] + public async Task GetProductDetails_Non2xxMetaBody_FlagOn_IsVerbatimInLogAndException() + { + string marker = "m-" + Guid.NewGuid().ToString("N"); + var (error, lines) = await RunAsync(true, "GetProductDetails", 400, MetaFailureBody(marker), marker, + c => c.GetProductDetailsAsync()); + + error.Message.Should().Contain("Invalid requestorEmail " + Email + " for field requestorEmail"); + ErrorMessageField(lines).Should().Contain("Invalid requestorEmail " + Email + " for field requestorEmail"); + } + + [Fact] + public void IsRateLimitSurface_StillMatchesRawTextContainingEmail() + { + CERTInextClient.IsRateLimitSurface("Inactive Account User. contact " + Email).Should().BeTrue(); + // And the masked form keeps the phrase, so masking never hides a rate-limit diagnosis. + LogSanitizer.SanitizeCaText("Inactive Account User. contact " + Email, false) + .Should().Contain("Inactive Account User."); + } + } +} From 6baa5d3488046b3e1d21293c3e6099cd6dec1b77 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:34:30 -0700 Subject: [PATCH 55/71] fix(enroll): do not fail enroll or renewal when TrackOrder or DCV fails after the order is placed PlaceOrder had already created (and billed) the CERTInext order when the follow-up TrackOrder threw, so Enroll failed without Command learning the order number and an operator retry could place a duplicate paid order. TrackOrder failures after placement (client enroll and renewal) are now logged as a Warning naming the order and return a pending result carrying the order number, like the certificate download beside it. A real caller cancellation still propagates; a spurious timeout cancellation does not. The in-call DCV / issuance wait in EnrollNewAsync had the same exposure (TrackOrder/VerifyDcv errors, or the DcvTimeoutMinutes token) and is absorbed the same way; the sync-DCV retry path completes the order. Three existing DCV tests that asserted the exception propagated out of Enroll now assert the pending result instead. Issue 0077. --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 25 +++++---- CERTInext/CERTInextCAPlugin.cs | 27 ++++++++++ CERTInext/Client/CERTInextClient.cs | 56 +++++++++++++++++--- CHANGELOG.md | 1 + 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index b45f0ac..f3cf1cf 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -596,9 +596,11 @@ public async Task Dcv_CleanupAlwaysCalled_EvenWhenVerifyDcvThrows() var validator = new FakeDomainValidator(); var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); - Func act = () => Enroll(plugin); - - await act.Should().ThrowAsync().WithMessage("*DNS record not found*"); + // The order is already placed, so the VerifyDcv failure no longer fails Enroll (issue + // 0077): it is logged and the pending result carrying the order number is returned. + var result = await Enroll(plugin); + result.CARequestID.Should().Be(MockCertificateData.DcvOrderId); + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); // Cleanup must run even when VerifyDcv throws string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain); @@ -1086,11 +1088,14 @@ public async Task Dcv_CancellationDuringGetDcv_PropagatesRatherThanBeingSkippedA var validator = new FakeDomainValidator(); var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); - Func act = () => Enroll(plugin); + // The cancellation still reaches the outer catch inside PerformDcvIfNeededAsync (not the + // per-domain "GetDcv failed" skip) — pinned by the no-VerifyDcv assertion below — but + // since the order is already placed, EnrollNewAsync now absorbs it and returns the + // pending result carrying the order number instead of failing Enroll (issue 0077). + var result = await Enroll(plugin); - // Must propagate as a cancellation, not be swallowed and reported as "GetDcv failed" in - // the skipped-domains summary while Enroll completes normally. - await act.Should().ThrowAsync(); + result.CARequestID.Should().Be(MockCertificateData.DcvOrderId); + mock.Verify(c => c.VerifyDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } /// @@ -1177,8 +1182,10 @@ public async Task Dcv_CleanupAfterCancellation_UsesAFreshBoundedToken_NotTheAmbi var validator = new FakeDomainValidator(); var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); - Func act = () => Enroll(plugin); - await act.Should().ThrowAsync(); + // The mid-loop cancellation no longer fails Enroll (issue 0077: the order is already + // placed); the cleanup behavior asserted below is unchanged. + var result = await Enroll(plugin); + result.CARequestID.Should().Be(order); validator.StagedRecords.Should().ContainSingle( "'good' must have staged before 'bad' threw, for this test to exercise cleanup at all"); diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 9d6ba51..35e5e18 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -129,6 +129,18 @@ internal CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certD _config = new CERTInextConfig(); } + /// + /// Internal test-injection constructor — client, certificate-data reader and config, for + /// renewal tests that must also control config such as PickupRetries. + /// + internal CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDataReader, CERTInextConfig config) + { + _client = client; + _clientWasInjected = true; + _certificateDataReader = certDataReader; + _config = config ?? new CERTInextConfig(); + } + /// /// Internal test-injection constructor — pass a mock /// and a specific for tests that need to override @@ -1237,6 +1249,21 @@ private async Task EnrollNewAsync( } } } + catch (Exception dcvEx) + { + // The order is already placed (and paid for) at CERTInext. A failure of the + // in-call DCV / issuance wait — a TrackOrder or VerifyDcv error, or the + // DcvTimeoutMinutes token firing (Enroll has no caller token, so that + // cancellation is always the internal timeout, never the caller) — must not + // fail the enrollment: Command would never learn the order number and an + // operator retry would place a duplicate paid order. Return the pending + // result instead; the sync-DCV retry path completes the order. Issue 0077. + _logger.LogWarning(dcvEx, + "In-call DCV/issuance wait failed after order {OrderNumber} was placed. Returning the " + + "pending result carrying the order number; the next synchronization will retry DCV " + + "and pick up the certificate.", + orderNumber); + } finally { _dcvInFlight.TryRemove(orderNumber, out _); diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 36d43c6..7e4912a 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -761,9 +761,11 @@ public async Task EnrollCertificateAsync( // If the CSR was provided in the legacy request, submit it now if not already included in the order // (The real API accepts CSR inline in GenerateOrderSSL; the legacy flow may not have set it) - // Poll TrackOrder to get the current status - var trackResp = await TrackOrderAsync(orderNumber, ct); - string certStatusId = trackResp.OrderDetails?.CertificateStatusId ?? "1"; + // Poll TrackOrder to get the current status. The order already exists at CERTInext + // (and is paid for), so a failure here must not fail the enrollment — see + // TryTrackOrderAfterPlacementAsync. + var trackResp = await TryTrackOrderAfterPlacementAsync(orderNumber, "enrollment", ct); + string certStatusId = trackResp?.OrderDetails?.CertificateStatusId ?? "1"; // Try to download the certificate if the order is fulfilled string pemCert = null; @@ -792,7 +794,7 @@ public async Task EnrollCertificateAsync( Certificate = pemCert, SerialNumber = serialNumber, ProfileId = request.ProfileId, - Message = trackResp.OrderDetails?.CertificateStatus + Message = trackResp?.OrderDetails?.CertificateStatus }; Logger.LogInformation( @@ -883,8 +885,10 @@ public async Task RenewCertificateAsync( if (string.IsNullOrWhiteSpace(newOrderNumber)) throw new Exception("CERTInext renewal order placement succeeded but returned no orderNumber."); - var trackResp = await TrackOrderAsync(newOrderNumber, ct); - string certStatusId = trackResp.OrderDetails?.CertificateStatusId ?? "1"; + // Same post-placement rule as enrollment: the renewal order exists, so a TrackOrder + // failure degrades to a pending result instead of failing the renewal. + var trackResp = await TryTrackOrderAfterPlacementAsync(newOrderNumber, "renewal", ct); + string certStatusId = trackResp?.OrderDetails?.CertificateStatusId ?? "1"; string pemCert = null; string serialNumber = null; @@ -908,7 +912,7 @@ public async Task RenewCertificateAsync( Status = MapCertStatusIdToLegacyString(certStatusId), Certificate = pemCert, SerialNumber = serialNumber, - Message = trackResp.OrderDetails?.CertificateStatus + Message = trackResp?.OrderDetails?.CertificateStatus }; Logger.LogInformation( @@ -918,6 +922,44 @@ public async Task RenewCertificateAsync( return legacyResp; } + /// + /// Looks up the status of an order that GenerateOrderSSL has just placed. The order + /// already exists at CERTInext (and is paid for), so a failure here — a transient 5xx, a + /// timeout, or an EMS-9xx "not found" during propagation lag surfacing as + /// — must not fail the enrollment/renewal: that would hide + /// the order number from Command and let an operator retry place a duplicate paid order. + /// On failure this logs a Warning naming the order and returns null; the callers then + /// treat the status as unknown (pending), so sync and pickup finish the order later. + /// A genuine cancellation ( cancelled) still propagates; an + /// with a live token (an HTTP-timeout + /// ) is treated like any other failure. + /// + private async Task TryTrackOrderAfterPlacementAsync( + string orderNumber, string operation, CancellationToken ct) + { + try + { + return await TrackOrderAsync(orderNumber, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + Logger.LogWarning( + "CERTInext {Operation} was cancelled after order {OrderNumber} was placed. The order exists at " + + "CERTInext and will be imported by the next synchronization.", + operation, orderNumber); + throw; + } + catch (Exception ex) + { + Logger.LogWarning(ex, + "TrackOrder failed after order {OrderNumber} was placed ({Operation}); the order exists at " + + "CERTInext. Returning a pending result carrying the order number; the certificate will be " + + "retrieved during next synchronization.", + orderNumber, operation); + return null; + } + } + /// public async Task GetCertificateAsync( string certificateId, diff --git a/CHANGELOG.md b/CHANGELOG.md index 987b590..f19d8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - **Gateway logs no longer contain the `authKey` or requestor personal data by default**; set `LogSensitiveRequestData` to log PII temporarily (credentials stay redacted). - **Log redaction no longer leaks the rest of a value after an escaped quote** (e.g. `"authKey":"ab\"cd"`, `"requestorName":"Jane \"JD\" Doe"`). - **CERTInext error text in logs and error messages now has email addresses masked** unless `LogSensitiveRequestData` is set. +- **Enrollment and renewal no longer fail after the order is placed.** A failed status check or DCV step now returns pending with the order number, so sync finishes the order and a retry can't place a duplicate. - **Connector and template validation no longer leaks an HTTP client per check.** ## Chores From d951a1de5f6e618c7793258ba1bd46e8c5a38300 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:34:30 -0700 Subject: [PATCH 56/71] test(enroll): TrackOrder and DCV failure after order placement returns pending with the order number --- CERTInext.Tests/CERTInext.Tests.csproj | 1 + .../PostPlacementDcvFailureTests.cs | 178 ++++++++++ .../PostPlacementTrackOrderFailureTests.cs | 316 ++++++++++++++++++ 3 files changed, 495 insertions(+) create mode 100644 CERTInext.Tests/PostPlacementDcvFailureTests.cs create mode 100644 CERTInext.Tests/PostPlacementTrackOrderFailureTests.cs diff --git a/CERTInext.Tests/CERTInext.Tests.csproj b/CERTInext.Tests/CERTInext.Tests.csproj index 84ce7a6..a17ee62 100644 --- a/CERTInext.Tests/CERTInext.Tests.csproj +++ b/CERTInext.Tests/CERTInext.Tests.csproj @@ -21,6 +21,7 @@ exist, so exclude these files unless SUPPORTS_DCV is defined. See issue 0003. --> + diff --git a/CERTInext.Tests/PostPlacementDcvFailureTests.cs b/CERTInext.Tests/PostPlacementDcvFailureTests.cs new file mode 100644 index 0000000..5a85f5f --- /dev/null +++ b/CERTInext.Tests/PostPlacementDcvFailureTests.cs @@ -0,0 +1,178 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0077 (DCV build): the in-call DCV / issuance wait in EnrollNewAsync runs after + /// the order has been placed. A failure in it (TrackOrder or VerifyDcv error, or the internal + /// DcvTimeoutMinutes cancellation) used to escape Enroll, failing the enrollment while a paid + /// order existed at CERTInext. It must now log a Warning naming the order and return the pending + /// result carrying the order number so the sync-DCV retry path finishes the order. + /// Only compiled on the -p:DcvSupport=true build (see CERTInext.Tests.csproj). + /// + [Collection("LogHandlerFactory-NoParallel")] + public class PostPlacementDcvFailureTests + { + private const string Order = MockCertificateData.DcvOrderId; + private const string Domain = MockCertificateData.DcvDomain; + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries); + public void Dispose() { } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue<(LogLevel, string)> _entries; + public CapturingLogger(ConcurrentQueue<(LogLevel, string)> entries) => _entries = entries; + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) => _entries.Enqueue((logLevel, formatter(state, exception))); + } + } + + private static CERTInextConfig DcvConfig() => new CERTInextConfig + { + DcvEnabled = true, + DcvPropagationDelaySeconds = 1, + DcvTimeoutMinutes = 1, + DcvWaitForChallengeSeconds = 0, + DcvWaitForIssuanceSeconds = 0, + PickupRetries = 0 + }; + + private static Mock PlacedOrderMock() + { + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse(Order)); + return mock; + } + + private static async Task<(EnrollmentResult Result, IReadOnlyList<(LogLevel Level, string Message)> Logs)> EnrollAsync( + Mock mock, FakeDomainValidator validator) + { + var provider = new CapturingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace)); + try + { + LogHandler.Factory = factory; + // Constructed after the swap so the plugin's per-instance logger resolves through it. + var plugin = new CERTInextCAPlugin(mock.Object, new FakeDomainValidatorFactory(validator), DcvConfig()); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, $"CN={Domain}", + new Dictionary { ["dns"] = new[] { Domain } }, + new EnrollmentProductInfo + { + ProductID = MockCertificateData.ProfileIdTls, + ProductParameters = new Dictionary { ["ProfileId"] = MockCertificateData.ProfileIdTls } + }, + RequestFormat.PKCS10, EnrollmentType.New); + return (result, provider.Entries.ToList()); + } + finally + { + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + factory.Dispose(); + } + } + + private static void AssertPendingWithOrder(EnrollmentResult result, IReadOnlyList<(LogLevel Level, string Message)> logs) + { + result.CARequestID.Should().Be(Order); + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.Certificate.Should().BeNull(); + logs.Should().Contain(l => l.Level == LogLevel.Warning && l.Message.Contains(Order) && l.Message.Contains("after order"), + "the swallowed DCV failure must be logged as a Warning naming the order"); + } + + [Fact] + public async Task Enroll_DcvTrackOrderThrows_ReturnsPendingWithOrderNumber() + { + var mock = PlacedOrderMock(); + mock.Setup(c => c.TrackOrderAsync(Order, It.IsAny())) + .ThrowsAsync(new Exception("CERTInext error during 'track order' (HTTP 500)")); + + var (result, logs) = await EnrollAsync(mock, new FakeDomainValidator()); + + AssertPendingWithOrder(result, logs); + } + + [Fact] + public async Task Enroll_DcvTrackOrderNotFound_ReturnsPendingWithOrderNumber() + { + var mock = PlacedOrderMock(); + mock.Setup(c => c.TrackOrderAsync(Order, It.IsAny())) + .ThrowsAsync(new KeyNotFoundException($"Order '{Order}' was not found in CERTInext.")); + + var (result, logs) = await EnrollAsync(mock, new FakeDomainValidator()); + + AssertPendingWithOrder(result, logs); + } + + [Fact] + public async Task Enroll_DcvVerifyThrows_ReturnsPendingWithOrderNumber_AndCleansUpStagedRecord() + { + var mock = PlacedOrderMock(); + mock.Setup(c => c.TrackOrderAsync(Order, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse(Order, Domain)); + mock.Setup(c => c.GetDcvAsync(Order, Domain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse()); + mock.Setup(c => c.VerifyDcvAsync(Order, Domain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ThrowsAsync(new Exception("VerifyDcv HTTP 503")); + var validator = new FakeDomainValidator(); + + var (result, logs) = await EnrollAsync(mock, validator); + + AssertPendingWithOrder(result, logs); + validator.StagedRecords.Should().ContainSingle(); + validator.CleanedUpKeys.Should().ContainSingle("the staged TXT record is still removed on failure"); + } + + [Fact] + public async Task Enroll_DcvTimeoutCancellation_ReturnsPendingWithOrderNumber() + { + // Enroll has no caller token: an OperationCanceledException out of the DCV block is the + // internal DcvTimeoutMinutes token (or a spurious HTTP-timeout TaskCanceledException) and + // must not fail an enrollment whose order already exists. + var mock = PlacedOrderMock(); + mock.Setup(c => c.TrackOrderAsync(Order, It.IsAny())) + .ThrowsAsync(new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout")); + + var (result, logs) = await EnrollAsync(mock, new FakeDomainValidator()); + + AssertPendingWithOrder(result, logs); + } + } +} diff --git a/CERTInext.Tests/PostPlacementTrackOrderFailureTests.cs b/CERTInext.Tests/PostPlacementTrackOrderFailureTests.cs new file mode 100644 index 0000000..6cfbcf1 --- /dev/null +++ b/CERTInext.Tests/PostPlacementTrackOrderFailureTests.cs @@ -0,0 +1,316 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; +using Moq; +using WireMock; +using WireMock.Matchers; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using WireMock.Types; +using WireMock.Util; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0077: once GenerateOrderSSL has returned an order number, a failure of the + /// follow-up TrackOrder (transient 5xx, a timeout, or an EMS-9xx "not found" during + /// propagation lag that surfaces as ) must not fail Enroll or + /// Renewal. A paid order already exists at CERTInext; failing here would hide its order number + /// from Command and invite an operator retry that places a duplicate paid order. The client + /// logs a Warning naming the order and returns a pending result carrying the order number, so + /// sync and pickup finish the order later. + /// + /// Driven against WireMock with the real . All data is synthetic. + /// + [Collection("CERTInextClientLogger-NoParallel")] + public class PostPlacementTrackOrderFailureTests : IDisposable + { + private const string PriorOrder = MockCertificateData.OrderNumber1; + private const string NewOrder = MockCertificateData.OrderNumber2; + + private readonly WireMockServer _server; + + public PostPlacementTrackOrderFailureTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() => _server.Stop(); + + private sealed class CapturingLogger : ILogger + { + public ConcurrentQueue<(LogLevel Level, string Message, Exception Exception)> Entries { get; } = new(); + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) => Entries.Enqueue((logLevel, formatter(state, exception), exception)); + } + + private CERTInextConfig Config() => new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "test-key", + AccountNumber = "12345", + RequestorName = "Default Requestor", + RequestorEmail = "default@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerPlace = "Austin", + SignerIp = "203.0.113.10", + PageSize = 100, + // Plugin-level tests: keep the synchronous pickup poll out of the way. + PickupRetries = 0 + }; + + private CERTInextClient BuildClient() => new CERTInextClient(Config()); + + private static EnrollCertificateRequest EnrollReq() => new EnrollCertificateRequest + { + ProfileId = "842", + Csr = MockCertificateData.FakeCsrPem, + Subject = "CN=test.example.com", + Comment = "Unit test" + }; + + private static RenewCertificateRequest RenewReq() => new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + Subject = "CN=test.example.com", + ProfileId = "842", + Comment = "Unit test" + }; + + private void StubPlacement(string orderNumber) => + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GenerateOrderSuccessJson(orderNumber))); + + private void StubTrackOrderOk(string orderNumber, string body) => + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost() + .WithBody(new WildcardMatcher($"*{orderNumber}*"))) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json").WithBody(body)); + + /// + /// Makes TrackOrder for fail the way the issue describes. + /// http500 = transient server error, ems100 = CA-reported failure, + /// http404 / ems913 = "not found" (propagation lag) -> KeyNotFoundException. + /// + private void StubTrackOrderFailure(string orderNumber, string scenario) + { + var response = Response.Create().WithHeader("Content-Type", "application/json"); + switch (scenario) + { + case "http500": + response = response.WithStatusCode(500).WithBody(MockCertificateData.ServerErrorJson()); + break; + case "http404": + response = response.WithStatusCode(404).WithBody(MockCertificateData.ApiFailureJson("EMS-913", "Order not found")); + break; + case "ems913": + response = response.WithStatusCode(200).WithBody(MockCertificateData.ApiFailureJson("EMS-913", "Order not found")); + break; + case "ems100": + response = response.WithStatusCode(200).WithBody(MockCertificateData.ApiFailureJson("EMS-100", "An error occurred")); + break; + default: + throw new ArgumentOutOfRangeException(nameof(scenario)); + } + + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost() + .WithBody(new WildcardMatcher($"*{orderNumber}*"))) + .RespondWith(response); + } + + private int CallsTo(string path) => _server.LogEntries.Count(e => e.RequestMessage.Path == path); + + private static void AssertWarningNamesOrder(CapturingLogger logger, string orderNumber, bool expectKeyNotFound) + { + var warnings = logger.Entries + .Where(e => e.Level == LogLevel.Warning && e.Message.Contains(orderNumber) && e.Exception != null) + .ToList(); + warnings.Should().NotBeEmpty("the swallowed TrackOrder failure must be logged as a Warning naming the order"); + if (expectKeyNotFound) + warnings.Should().Contain(w => w.Exception is KeyNotFoundException); + else + warnings.Should().Contain(w => !(w.Exception is KeyNotFoundException)); + } + + // --------------------------------------------------------------------------- + // Client: enroll + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("http500", false)] + [InlineData("ems100", false)] + [InlineData("http404", true)] + [InlineData("ems913", true)] + public async Task Enroll_TrackOrderFailsAfterPlacement_ReturnsPendingWithOrderNumber(string scenario, bool expectKeyNotFound) + { + StubPlacement(NewOrder); + StubTrackOrderFailure(NewOrder, scenario); + + var logger = new CapturingLogger(); + EnrollCertificateResponse resp; + using (CERTInextClient.OverrideLoggerForTests(logger)) + resp = await BuildClient().EnrollCertificateAsync(EnrollReq()); + + resp.Id.Should().Be(NewOrder); + resp.Certificate.Should().BeNull(); + StatusMapper.ToRequestDisposition(resp.Status).Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + CallsTo("/GenerateOrderSSL").Should().Be(1, "the order must not be placed twice"); + CallsTo("/GetCertificate").Should().Be(0, "no download is attempted when the status is unknown"); + AssertWarningNamesOrder(logger, NewOrder, expectKeyNotFound); + } + + // --------------------------------------------------------------------------- + // Client: renewal + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("http500", false)] + [InlineData("ems100", false)] + [InlineData("http404", true)] + [InlineData("ems913", true)] + public async Task Renewal_TrackOrderFailsAfterPlacement_ReturnsPendingWithOrderNumber(string scenario, bool expectKeyNotFound) + { + // The prior-order TrackOrder (pre-placement) succeeds; only the new order's fails. + StubTrackOrderOk(PriorOrder, MockCertificateData.TrackOrderIssuedJson(PriorOrder)); + StubPlacement(NewOrder); + StubTrackOrderFailure(NewOrder, scenario); + + var logger = new CapturingLogger(); + EnrollCertificateResponse resp; + using (CERTInextClient.OverrideLoggerForTests(logger)) + resp = await BuildClient().RenewCertificateAsync(PriorOrder, RenewReq()); + + resp.Id.Should().Be(NewOrder); + resp.Certificate.Should().BeNull(); + StatusMapper.ToRequestDisposition(resp.Status).Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + CallsTo("/GenerateOrderSSL").Should().Be(1, "the renewal order must not be placed twice"); + CallsTo("/GetCertificate").Should().Be(0); + AssertWarningNamesOrder(logger, NewOrder, expectKeyNotFound); + } + + // --------------------------------------------------------------------------- + // Plugin: result carries CARequestID and nothing downstream throws on the missing status + // --------------------------------------------------------------------------- + + private static EnrollmentProductInfo ProductInfo(string priorSerial = null) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ProfileId"] = "842" }; + if (priorSerial != null) + { + parameters["PriorCertSN"] = priorSerial; + parameters["RenewalWindowDays"] = "90"; + } + return new EnrollmentProductInfo { ProductID = "842", ProductParameters = parameters }; + } + + [Theory] + [InlineData("http500")] + [InlineData("ems913")] + public async Task Plugin_Enroll_New_TrackOrderFailsAfterPlacement_ReturnsPendingWithCARequestID(string scenario) + { + StubPlacement(NewOrder); + StubTrackOrderFailure(NewOrder, scenario); + var plugin = new CERTInextCAPlugin(BuildClient(), Config()); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=test.example.com", + new Dictionary { ["dns"] = new[] { "test.example.com" } }, + ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + result.CARequestID.Should().Be(NewOrder); + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.Certificate.Should().BeNull(); + CallsTo("/GenerateOrderSSL").Should().Be(1); + } + + [Theory] + [InlineData("http500")] + [InlineData("ems913")] + public async Task Plugin_Enroll_RenewalApi_TrackOrderFailsAfterPlacement_ReturnsPendingWithCARequestID(string scenario) + { + StubTrackOrderOk(PriorOrder, MockCertificateData.TrackOrderIssuedJson(PriorOrder)); + StubPlacement(NewOrder); + StubTrackOrderFailure(NewOrder, scenario); + + var reader = new Mock(); + reader.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())).ReturnsAsync(PriorOrder); + reader.Setup(r => r.GetExpirationDateByRequestId(PriorOrder)).Returns(DateTime.UtcNow.AddDays(30)); + var plugin = new CERTInextCAPlugin(BuildClient(), reader.Object, Config()); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=test.example.com", + new Dictionary { ["dns"] = new[] { "test.example.com" } }, + ProductInfo("AABB"), RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + // Guard: the renewal API path (not the new-enroll fallback) produced the order. + reader.Verify(r => r.GetExpirationDateByRequestId(PriorOrder), Times.Once); + result.CARequestID.Should().Be(NewOrder); + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.Certificate.Should().BeNull(); + CallsTo("/GenerateOrderSSL").Should().Be(1); + } + + // --------------------------------------------------------------------------- + // A real cancellation may still propagate + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_RealCancellationAfterPlacement_StillPropagates() + { + using var cts = new CancellationTokenSource(); + // Cancel the caller's token as the placement response is produced, so the follow-up + // TrackOrder runs with a genuinely cancelled token. + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithCallback(_ => + { + cts.Cancel(); + return new ResponseMessage + { + StatusCode = 200, + BodyData = new BodyData + { + DetectedBodyType = BodyType.String, + BodyAsString = MockCertificateData.GenerateOrderSuccessJson(NewOrder) + } + }; + })); + StubTrackOrderOk(NewOrder, MockCertificateData.TrackOrderPendingJson(NewOrder)); + + Func act = () => BuildClient().EnrollCertificateAsync(EnrollReq(), cts.Token); + + await act.Should().ThrowAsync(); + } + } +} From 3799fb70bdfcf1f71b436e9cfd08768ecd20d2a7 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:06:07 -0700 Subject: [PATCH 57/71] docs: fix misplaced DcvPropagationDelaySeconds XML doc; clarify CSR SAN fallback in CHANGELOG --- CERTInext/CERTInextCAPluginConfig.cs | 8 ++++---- CHANGELOG.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index d090413..2b89317 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -778,10 +778,6 @@ public class CERTInextConfig [JsonPropertyName("DcvTxtRecordTemplate")] public string DcvTxtRecordTemplate { get; set; } = Constants.Dcv.DefaultTxtRecordTemplate; - /// - /// Seconds to wait after publishing the DNS TXT record before calling VerifyDcv. - /// Default: 30. - /// /// /// Number of GetCertificate poll attempts inside Enroll() after an order is /// submitted, before falling back to a pending result (picked up by the next sync). @@ -799,6 +795,10 @@ public class CERTInextConfig [JsonPropertyName("PickupDelay")] public int PickupDelayInSeconds { get; set; } = Constants.Pickup.DefaultDelaySeconds; + /// + /// Seconds to wait after publishing the DNS TXT record before calling VerifyDcv. + /// Default: 30. + /// [JsonPropertyName("DcvPropagationDelaySeconds")] public int DcvPropagationDelaySeconds { get; set; } = 30; diff --git a/CHANGELOG.md b/CHANGELOG.md index f19d8d8..28da53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - **Faster enrollment for quickly-issued certificates.** Enrollment now waits briefly and returns the certificate in the same request when it issues fast, instead of always waiting for the next sync. Configurable via `PickupRetries` (default 5, `0` disables) and `PickupDelay` (default 10s). Orders that don't issue in time (e.g. OV/EV) return pending and are picked up by the next sync, as before. ## Bug Fixes -- **UCC certificates no longer come back with only the common name.** The gateway sends SANs under the key `dnsname`, which the plugin didn't recognize, so orders went out with an empty domain list. SANs are now read from every key the gateway sends, plus from the CSR itself. +- **UCC certificates no longer come back with only the common name.** The gateway sends SANs under the key `dnsname`, which the plugin didn't recognize, so orders went out with an empty domain list. SANs are now read from every key the gateway sends, and from the CSR when the gateway sends no SAN data. - **Renewals no longer lose their SANs.** Renewals were submitted with no additional domains and the wrong primary domain; both now come from the certificate being renewed. - **Enrollment no longer fails on an order CERTInext auto-approves before it finishes issuing.** The plugin used to report these as issued with no certificate attached, which the gateway rejected. It now returns pending and picks up the certificate once CERTInext finishes issuing it. - **Renewals now use the certificate template's product code.** Renewals previously always used the connector's `DefaultProductCode`, which could send an empty product code if that setting was never configured. Renewals now use the template's code, falling back to `DefaultProductCode` only when the template doesn't have one. From 55d6f7bbb5ab8a1b28dc9d8491d1585d4c037cb2 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:10:02 -0700 Subject: [PATCH 58/71] docs(manifest): add SubmitNonDnsSans, PickupRetries and PickupDelay to ca_plugin_config These connector settings are exposed by GetCAConnectorAnnotations but were missing from integration-manifest.json, so the doctool-generated README would not document them. --- integration-manifest.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/integration-manifest.json b/integration-manifest.json index 64cd4e8..f29ca39 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -133,6 +133,10 @@ "name": "IgnoreExpired", "description": "If true, expired certificates will be skipped during synchronization. Default: false." }, + { + "name": "SubmitNonDnsSans", + "description": "If true (default), SANs that are not DNS names (IP address, email, URI) are submitted to CERTInext in additionalDomains along with the DNS names. CERTInext registers them verbatim as order domains and they cannot pass domain validation, so such an order will not issue until they are removed — but nothing the subscriber requested is dropped silently. Set to false to submit DNS names only, which restores the pre-1.0.1 behaviour: the order issues, but the certificate will not contain the non-DNS names. Default: true." + }, { "name": "PageSize", "description": "Number of orders to fetch per page during synchronization. Default: 100, max: 500." @@ -145,6 +149,14 @@ "name": "LogSensitiveRequestData", "description": "OPTIONAL diagnostic escape hatch. When true, enabling it writes requestor personal data (name, email, phone, and other organization contact details) and full CA request/response payloads to the gateway logs. Meant for temporary use while verifying a new deployment — confirming exactly what was sent to the CA and that the order succeeded — and should be turned back off once verification is complete. When false (default), personal-data fields are redacted (email is masked but keeps its domain, e.g. 'j***@example.com') and the enrollment log line omits the requester name entirely. Email SAN values (rfc822Name) in log lines are masked the same way; DNS, IP and URI SANs are always logged in full. Credentials (access keys, authKey digests, OAuth secrets, tokens) are always redacted regardless of this setting. Default: false." }, + { + "name": "PickupRetries", + "description": "OPTIONAL: Number of times Enroll() will poll CERTInext to download the certificate after a successful order submission. If the certificate has not issued within this window it is picked up during the next synchronization instead. Set to 0 to disable the wait. Default: 5. NOTE: CERTInext issues OV/EV certificates asynchronously (organization verification, minutes to hours), so those typically exhaust the wait and are returned pending regardless of this value." + }, + { + "name": "PickupDelay", + "description": "OPTIONAL: Number of seconds between certificate-pickup retries. PickupRetries times this delay (plus a short initial delay) is the maximum time an enrollment call occupies a Command worker thread. If the duration is too long the request may time out, so target a total well under ~90s. As a safety backstop the plugin additionally caps the effective total at 180s regardless of how PickupRetries/PickupDelay are set, reducing the retry count to fit. Default: 10 (with default retries this yields a ~55s ceiling)." + }, { "name": "DcvEnabled", "description": "OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false." From 01f1733956e1115b1c92b2051bc192719c56164a Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:11:09 -0700 Subject: [PATCH 59/71] docs(manifest): correct AutoApprove description and ApiUrl example URLs AutoApprove currently has no effect; the manifest claimed it auto-approves pending certificates. The ApiUrl annotation's production example URLs omitted the required /emSignHub-API/ path segment. --- CERTInext/CERTInextCAPluginConfig.cs | 4 ++-- integration-manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 2b89317..2302b0f 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -29,8 +29,8 @@ public static Dictionary GetCAConnectorAnnotations() { Comments = "REQUIRED: CERTInext API base URL. " + "Sandbox (US): https://sandbox-us-api.certinext.io/emSignHub-API/ — " + - "Production (US): https://us-api.certinext.io/ — " + - "Production (Global/India): https://api.certinext.io/", + "Production (US): https://us-api.certinext.io/emSignHub-API/ — " + + "Production (Global/India): https://api.certinext.io/emSignHub-API/", Hidden = false, DefaultValue = string.Empty, Type = "String" diff --git a/integration-manifest.json b/integration-manifest.json index f29ca39..d6284ae 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -209,7 +209,7 @@ }, { "name": "AutoApprove", - "description": "OPTIONAL: If true, the gateway will attempt automatic approval of certificates that are returned in a pending-approval state. Default: false." + "description": "Currently has no effect — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting." }, { "name": "RequesterName", From 08132d42f84cc9470fb7a8a80b639b41af8260ce Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:11:08 -0700 Subject: [PATCH 60/71] fix(logging): mask mailto addresses and userinfo in URI SAN log values unless LogSensitiveRequestData is set --- CERTInext/Models/LogSanitizer.cs | 45 ++++++++++++++++++++++++++++---- CHANGELOG.md | 1 + 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/CERTInext/Models/LogSanitizer.cs b/CERTInext/Models/LogSanitizer.cs index 56aebff..02bc910 100644 --- a/CERTInext/Models/LogSanitizer.cs +++ b/CERTInext/Models/LogSanitizer.cs @@ -98,22 +98,56 @@ internal static string SanitizeCaText(string text, bool logSensitiveRequestData) private static readonly HashSet EmailSanTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { "email", "rfc822", "rfc822name" }; - // SAN types logged verbatim even with LogSensitiveRequestData off: host names, IP - // literals and URIs are audit fields, not personal data (issue 0040 follow-up). + // SAN types logged verbatim even with LogSensitiveRequestData off: host names and IP + // literals are audit fields, not personal data (issue 0040 follow-up). private static readonly HashSet VerbatimSanTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { "dns", "dnsname", "dnsnames", - "ip", "ipaddress", "ipaddresses", - "uri", "uniformresourceidentifier" + "ip", "ipaddress", "ipaddresses" }; + // URI SAN type spellings. Logged verbatim unless the value carries an '@' (see MaskUri). + private static readonly HashSet UriSanTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "uri", "uniformresourceidentifier" }; + + /// + /// Masks the personal part of a URI SAN for logging. A URI without @ is returned + /// unchanged. For a hierarchical URI (scheme://) the userinfo before the last + /// @ of the authority is replaced with *** (https://user:pw@host/ becomes + /// https://***@host/); an @ after the authority (path, query, fragment) is left + /// alone. For an opaque URI (mailto:, sip:, ...) the part after the scheme is + /// masked with (mailto:jane@example.com becomes + /// mailto:j***@example.com). Anything unparseable falls back to . + /// + internal static string MaskUri(string value) + { + if (string.IsNullOrEmpty(value) || value.IndexOf('@') < 0) return value; + + int schemeEnd = value.IndexOf(':'); + if (schemeEnd <= 0) return MaskEmail(value); + + int authStart = schemeEnd + 1; + if (string.CompareOrdinal(value, authStart, "//", 0, 2) == 0) + { + authStart += 2; + int authEnd = value.IndexOfAny(new[] { '/', '?', '#' }, authStart); + if (authEnd < 0) authEnd = value.Length; + string authority = value.Substring(authStart, authEnd - authStart); + int at = authority.LastIndexOf('@'); + return at < 0 ? value : value.Substring(0, authStart) + "***" + value.Substring(authStart + at); + } + + return value.Substring(0, authStart) + MaskEmail(value.Substring(authStart)); + } + /// /// Returns a single SAN value as it should appear in a log line (issue 0040 follow-up). /// With on, the value is returned as-is. Off, /// an email-type SAN (rfc822name and its spelling variants) is masked with /// , and so is any value containing @ whose type is unknown - /// or null (untyped host lists). DNS, IP and URI values are always returned as-is. + /// or null (untyped host lists). URI values go through , so userinfo + /// and mailto: addresses are masked. DNS and IP values are always returned as-is. /// Does not ; callers strip the formatted line. /// internal static string FormatSanValue(string sanType, string value, bool logSensitiveRequestData) @@ -121,6 +155,7 @@ internal static string FormatSanValue(string sanType, string value, bool logSens if (logSensitiveRequestData || string.IsNullOrEmpty(value)) return value; if (sanType != null && EmailSanTypes.Contains(sanType)) return MaskEmail(value); if (sanType != null && VerbatimSanTypes.Contains(sanType)) return value; + if (sanType != null && UriSanTypes.Contains(sanType)) return MaskUri(value); return value.IndexOf('@') >= 0 ? MaskEmail(value) : value; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 28da53c..61ab5bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - **Gateway logs no longer contain the `authKey` or requestor personal data by default**; set `LogSensitiveRequestData` to log PII temporarily (credentials stay redacted). - **Log redaction no longer leaks the rest of a value after an escaped quote** (e.g. `"authKey":"ab\"cd"`, `"requestorName":"Jane \"JD\" Doe"`). - **CERTInext error text in logs and error messages now has email addresses masked** unless `LogSensitiveRequestData` is set. +- **URI SANs in enrollment logs no longer leak personal data by default.** `mailto:` addresses are masked and `user:pw@` userinfo is replaced with `***` unless `LogSensitiveRequestData` is set. - **Enrollment and renewal no longer fail after the order is placed.** A failed status check or DCV step now returns pending with the order number, so sync finishes the order and a retry can't place a duplicate. - **Connector and template validation no longer leaks an HTTP client per check.** From 89a48fe2574cacc6abdb606d78d5ab94769cbfea Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:11:08 -0700 Subject: [PATCH 61/71] test(logging): URI SAN masking for mailto and userinfo, verbatim for plain URIs and when flag is on --- CERTInext.Tests/SanLogMaskingTests.cs | 51 ++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/CERTInext.Tests/SanLogMaskingTests.cs b/CERTInext.Tests/SanLogMaskingTests.cs index 2bc2104..1f56d79 100644 --- a/CERTInext.Tests/SanLogMaskingTests.cs +++ b/CERTInext.Tests/SanLogMaskingTests.cs @@ -59,13 +59,62 @@ public void FormatSanValue_FlagOn_IsVerbatim(string type) [InlineData("ipaddress", "192.0.2.10")] [InlineData("ip", "2001:db8::1")] [InlineData("uri", "https://example.com/path")] - [InlineData("uniformresourceidentifier", "https://user@example.com/")] + [InlineData("uri", "https://host.example.com:8443/a/b?q=1#frag")] + [InlineData("uniformresourceidentifier", "urn:example:thing")] + [InlineData("uri", "https://example.com/path/@handle")] + [InlineData("uri", "https://example.com/?contact=a@b.example")] public void FormatSanValue_DnsIpUri_VerbatimEitherWay(string type, string value) { LogSanitizer.FormatSanValue(type, value, false).Should().Be(value); LogSanitizer.FormatSanValue(type, value, true).Should().Be(value); } + [Theory] + [InlineData("uri", "mailto:jane.doe@example.com", "mailto:j***@example.com")] + [InlineData("URI", "MAILTO:jane.doe@example.com", "MAILTO:j***@example.com")] + [InlineData("uniformresourceidentifier", "sip:jane.doe@example.com", "sip:j***@example.com")] + [InlineData("uri", "https://user:pw@host.example.com/", "https://***@host.example.com/")] + [InlineData("uri", "https://user@host.example.com/", "https://***@host.example.com/")] + [InlineData("uri", "ldaps://cn=a:p@ss@host.example.com:636/dc=x?q", "ldaps://***@host.example.com:636/dc=x?q")] + [InlineData("uri", "https://user:pw@host.example.com", "https://***@host.example.com")] + public void FormatSanValue_FlagOff_UriWithAt_IsMasked(string type, string value, string expected) + { + string masked = LogSanitizer.FormatSanValue(type, value, false); + masked.Should().Be(expected); + masked.Should().NotContain("jane.doe").And.NotContain("pw@").And.NotContain("user"); + } + + [Theory] + [InlineData("uri", "mailto:jane.doe@example.com")] + [InlineData("uri", "https://user:pw@host.example.com/")] + [InlineData("uniformresourceidentifier", "https://user@host.example.com/")] + public void FormatSanValue_FlagOn_UriIsVerbatim(string type, string value) + => LogSanitizer.FormatSanValue(type, value, true).Should().Be(value); + + [Fact] + public void FormatSans_Dictionary_FlagOff_MasksUriUserinfoAndMailto() + { + var san = new Dictionary + { + ["uri"] = new[] { "mailto:jane.doe@example.com", "https://user:pw@host.example.com/", "https://example.com" } + }; + + string off = LogSanitizer.FormatSans(san, false); + off.Should().Be("uri:mailto:j***@example.com; uri:https://***@host.example.com/; uri:https://example.com"); + off.Should().NotContain("jane.doe").And.NotContain("pw"); + LogSanitizer.FormatSans(san, true).Should().Contain("uri:mailto:jane.doe@example.com") + .And.Contain("uri:https://user:pw@host.example.com/"); + } + + [Fact] + public void FormatUntypedSans_FlagOff_UriWithAt_LeaksNoLocalPartOrUserinfo() + { + string line = LogSanitizer.FormatUntypedSans( + new[] { "mailto:jane.doe@example.com", "https://user:pw@host.example.com/", "https://example.com/x" }, false); + line.Should().NotContain("jane.doe").And.NotContain("user").And.NotContain("pw@") + .And.Contain("https://example.com/x"); + } + [Theory] [InlineData("upn")] [InlineData(null)] From 51cfbb0b6bb8d8666a1f16db4bff86d72b5b87ba Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:12:34 -0700 Subject: [PATCH 62/71] perf(logging): guard Trace payload redaction behind IsEnabled in the client PlaceOrder request and TrackOrder response payload dumps (and LogApiFailure) computed ApplyLoggingRedaction eagerly even with the level disabled; full syncs call TrackOrder thousands of times. Output when enabled is unchanged. --- CERTInext/Client/CERTInextClient.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 7e4912a..a21c376 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -254,8 +254,10 @@ public async Task PlaceOrderAsync( string jsonBody = JsonSerializer.Serialize(request, GetJsonOptions()); // Issue 0040: the body carries the replayable meta.authKey digest (always redacted) // and requestor/contact PII (redacted unless LogSensitiveRequestData is on). - Logger.LogTrace("PlaceOrderAsync request payload: {Payload}", - ApplyLoggingRedaction(jsonBody, _config.LogSensitiveRequestData)); + // Guarded: redaction is a regex + JSON-reader pass that must not run when Trace is off. + if (Logger.IsEnabled(LogLevel.Trace)) + Logger.LogTrace("PlaceOrderAsync request payload: {Payload}", + ApplyLoggingRedaction(jsonBody, _config.LogSensitiveRequestData)); req.AddJsonBody(jsonBody); var sw = System.Diagnostics.Stopwatch.StartNew(); @@ -465,8 +467,10 @@ public async Task TrackOrderAsync(string orderNumber, Cancel } var result = DeserializeOrThrow(resp, $"track order {orderNumber}"); - Logger.LogTrace("TrackOrderAsync response payload (Order={OrderNumber}): {Payload}", - orderNumber, ApplyLoggingRedaction(resp.Content, _config.LogSensitiveRequestData)); + // Guarded: full syncs call TrackOrder thousands of times; skip redaction when Trace is off. + if (Logger.IsEnabled(LogLevel.Trace)) + Logger.LogTrace("TrackOrderAsync response payload (Order={OrderNumber}): {Payload}", + orderNumber, ApplyLoggingRedaction(resp.Content, _config.LogSensitiveRequestData)); // A meta status of "0" with errorCode EMS-913 or similar means the order was not found if (result.Meta != null && !result.Meta.IsSuccess) @@ -2335,6 +2339,8 @@ private void LogApiFailure( string errorMessage = null, LogLevel level = LogLevel.Warning) { + // Skip the redaction passes entirely when this level is disabled. + if (!Logger.IsEnabled(level)) return; string sanitizedBody = ApplyLoggingRedaction(resp?.Content, _config.LogSensitiveRequestData) ?? "(empty)"; Logger.Log( level, From 1d69befb5c6357e00428342161d2d927983bbd02 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:12:34 -0700 Subject: [PATCH 63/71] test(logging): payload redaction skipped when Trace disabled, unchanged when enabled --- CERTInext.Tests/TracePayloadGuardTests.cs | 197 ++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 CERTInext.Tests/TracePayloadGuardTests.cs diff --git a/CERTInext.Tests/TracePayloadGuardTests.cs b/CERTInext.Tests/TracePayloadGuardTests.cs new file mode 100644 index 0000000..e81f374 --- /dev/null +++ b/CERTInext.Tests/TracePayloadGuardTests.cs @@ -0,0 +1,197 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Microsoft.Extensions.Logging; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Finding #6 (perf): the PlaceOrder request dump and TrackOrder response dump are Trace logs + /// whose argument is ApplyLoggingRedaction(...) (dozens of regex passes plus a JSON parse). + /// C# evaluates that argument eagerly, and the LogTrace extension does not itself consult + /// IsEnabled before calling ILogger.Log, so unguarded the cost was paid, and the + /// payload handed to the logger, on every call even with Trace off. These tests use a logger + /// that reports Trace disabled yet still records any Log call it receives: the payload + /// line must never reach it when Trace is off, and must be byte-identical to + /// ApplyLoggingRedaction output when Trace is on. All data is synthetic. + /// + [Collection("CERTInextClientLogger-NoParallel")] + public class TracePayloadGuardTests : IDisposable + { + private const string RequestorName = "Jane Doe"; + private const string RequestorEmail = "jane.doe@example.com"; + + private readonly WireMockServer _server; + + public TracePayloadGuardTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() => _server.Stop(); + + private sealed class LevelGatedLogger : ILogger + { + private readonly LogLevel _min; + public LevelGatedLogger(LogLevel min) => _min = min; + public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => logLevel >= _min; + // Deliberately records without re-checking IsEnabled, as the LogTrace extension would + // reach it: any payload line seen here means the call site did not guard. + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => Entries.Enqueue((logLevel, formatter(state, exception))); + } + + private CERTInextClient BuildClient(bool logSensitiveRequestData) => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "synthetic-access-key", + AccountNumber = "9988776655", + LogSensitiveRequestData = logSensitiveRequestData + }); + + private static GenerateOrderSslRequest BuildOrder(string primaryDomain) => new GenerateOrderSslRequest + { + OrderDetails = new SslOrderDetails + { + ProductCode = "842", + RequestorInformation = new RequestorInformation + { + RequestorName = RequestorName, + RequestorMobileNumber = "5551234567", + RequestorEmail = RequestorEmail, + RequestorDesignation = "IT Administrator" + }, + CertificateInformation = new CertificateInformation + { + DomainName = primaryDomain, + AdditionalDomains = new List { "www." + primaryDomain } + }, + AgreementDetails = new AgreementDetails { SignerName = "John Signer", SignerPlace = "Austin", SignerIp = "203.0.113.10" } + } + }; + + private void StubGenerateOrder() => + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GenerateOrderSuccessJson("ORD-GUARD-1"))); + + private string StubTrackOrder(string orderNumber) + { + string body = + "{\"meta\":{\"status\":\"1\"},\"orderDetails\":{\"orderNumber\":\"" + orderNumber + "\"," + + "\"orderStatusId\":\"1\",\"certificateStatusId\":\"1\"," + + "\"requestorInformation\":{\"requestorName\":\"" + RequestorName + "\",\"requestorEmail\":\"" + RequestorEmail + "\"}}}"; + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json").WithBody(body)); + return body; + } + + private static async Task> CaptureAsync( + LogLevel minLevel, string marker, Func act) + { + var logger = new LevelGatedLogger(minLevel); + using (CERTInextClient.OverrideLoggerForTests(logger)) + await act(); + // The client logger is process-wide; scope to lines carrying this call's marker. + return logger.Entries.Where(e => e.Message != null && e.Message.Contains(marker)).ToList(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TrackOrder_TraceDisabled_PayloadLogNotEmitted(bool logSensitiveRequestData) + { + string order = "ORD-" + Guid.NewGuid().ToString("N"); + StubTrackOrder(order); + using var client = BuildClient(logSensitiveRequestData); + + var lines = await CaptureAsync(LogLevel.Debug, order, () => client.TrackOrderAsync(order)); + + lines.Should().NotContain(l => l.Message.StartsWith("TrackOrderAsync response payload"), + "redaction and the payload log must be skipped when Trace is disabled"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TrackOrder_TraceEnabled_PayloadMatchesApplyLoggingRedaction(bool logSensitiveRequestData) + { + string order = "ORD-" + Guid.NewGuid().ToString("N"); + string body = StubTrackOrder(order); + using var client = BuildClient(logSensitiveRequestData); + + var lines = await CaptureAsync(LogLevel.Trace, order, () => client.TrackOrderAsync(order)); + + var dump = lines.Single(l => l.Message.StartsWith("TrackOrderAsync response payload")); + dump.Level.Should().Be(LogLevel.Trace); + dump.Message.Should().Be( + $"TrackOrderAsync response payload (Order={order}): " + + CERTInextClient.ApplyLoggingRedaction(body, logSensitiveRequestData)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PlaceOrder_TraceDisabled_PayloadLogNotEmitted(bool logSensitiveRequestData) + { + string domain = "po-" + Guid.NewGuid().ToString("N") + ".example.com"; + StubGenerateOrder(); + using var client = BuildClient(logSensitiveRequestData); + + var lines = await CaptureAsync(LogLevel.Debug, domain, () => client.PlaceOrderAsync(BuildOrder(domain))); + + lines.Should().Contain(l => l.Message.StartsWith("Submitting order to CERTInext"), + "precondition: the client logged this call, so the absence below is meaningful"); + lines.Should().NotContain(l => l.Message.StartsWith("PlaceOrderAsync request payload"), + "redaction and the payload log must be skipped when Trace is disabled"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PlaceOrder_TraceEnabled_PayloadMatchesApplyLoggingRedaction(bool logSensitiveRequestData) + { + string domain = "po-" + Guid.NewGuid().ToString("N") + ".example.com"; + StubGenerateOrder(); + using var client = BuildClient(logSensitiveRequestData); + + var lines = await CaptureAsync(LogLevel.Trace, domain, () => client.PlaceOrderAsync(BuildOrder(domain))); + + var dump = lines.Single(l => l.Message.StartsWith("PlaceOrderAsync request payload")); + dump.Level.Should().Be(LogLevel.Trace); + string sentBody = _server.LogEntries.Last(e => e.RequestMessage.Path == "/GenerateOrderSSL").RequestMessage.Body; + dump.Message.Should().Be( + "PlaceOrderAsync request payload: " + + CERTInextClient.ApplyLoggingRedaction(sentBody, logSensitiveRequestData)); + } + } +} From 32caf31ca060c1b7912a696b90572a37ef727626 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:13:47 -0700 Subject: [PATCH 64/71] fix(dcv): skip post-DCV issuance wait when any domain was skipped PerformDcvIfNeededAsync now returns a DcvOutcome. When some pending domains are skipped (no DNS provider, stage failure, non-FQDN entry), the staged domains are still verified and cleaned up, but Enroll no longer holds the worker in WaitForIssuanceAfterDcvAsync for an order that cannot issue; sync DCV retries later. Sync/refresh callers keep their existing semantics. --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 12 +++-- CERTInext/CERTInextCAPlugin.cs | 56 ++++++++++++++------ CHANGELOG.md | 1 + 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index f3cf1cf..03c8098 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -1007,7 +1007,9 @@ public async Task Dcv_NonFqdnPendingDomain_IsSkipped_AndValidDomainStillStaged() mock.Verify(c => c.GetDcvAsync(order, bad, It.IsAny(), It.IsAny()), Times.Never, "a non-FQDN domain must never be sent to GetDcv"); - result.Status.Should().Be((int)EndEntityStatus.GENERATED); + // A domain was skipped, so the order cannot issue: the post-DCV issuance wait must not run. + mock.Verify(c => c.GetCertificateAsync(order, It.IsAny()), Times.Never); + result.Status.Should().NotBe((int)EndEntityStatus.GENERATED); } /// @@ -1061,7 +1063,9 @@ public async Task Dcv_DomainWithTrailingNewline_IsRejectedAsInvalid_AndValidDoma mock.Verify(c => c.GetDcvAsync(order, bad, It.IsAny(), It.IsAny()), Times.Never, "a domain with a trailing newline must never be sent to GetDcv"); - result.Status.Should().Be((int)EndEntityStatus.GENERATED); + // A domain was skipped, so the order cannot issue: the post-DCV issuance wait must not run. + mock.Verify(c => c.GetCertificateAsync(order, It.IsAny()), Times.Never); + result.Status.Should().NotBe((int)EndEntityStatus.GENERATED); } /// @@ -1140,7 +1144,9 @@ public async Task Dcv_DomainWithNoResolvableValidator_IsSkipped_AndValidDomainSt validator.StagedRecords.Should().ContainSingle( "only the domain with a resolvable provider should be staged, and it must still be staged") .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); - result.Status.Should().Be((int)EndEntityStatus.GENERATED); + // A domain was skipped, so the order cannot issue: the post-DCV issuance wait must not run. + mock.Verify(c => c.GetCertificateAsync(order, It.IsAny()), Times.Never); + result.Status.Should().NotBe((int)EndEntityStatus.GENERATED); } /// diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 35e5e18..6c5c36a 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1227,8 +1227,13 @@ private async Task EnrollNewAsync( { try { - bool dcvDone = await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token); - if (dcvDone) + var dcvOutcome = await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token); + // Only wait for issuance when every pending domain was validated. If any + // domain was skipped (e.g. an IP/email SAN with no DNS provider) the order + // cannot issue, so waiting would just hold the worker for + // DcvWaitForIssuanceSeconds; return pending and let the sync DCV retry path + // pick the order up. + if (dcvOutcome == DcvOutcome.Completed) { // Poll GetCertificate until CERTInext finishes generating the cert OR the // issuance budget expires. CERTInext issuance is async — DCV may verify @@ -1520,9 +1525,12 @@ private async Task TryRunDcvDuringSyncAsync(string orderNumber, Cancellati "OrderNumber={OrderNumber}, DcvTimeoutMinutes={Timeout}", orderNumber, timeoutMinutes); - return await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token, + // Any outcome other than NotRun means DCV executed, so sync/refresh callers re-fetch + // the order (a partial run may still have advanced per-domain state). + var outcome = await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token, waitForChallengeSecondsOverride: 0, propagationDelaySecondsOverride: fastSync ? Constants.Dcv.SyncPropagationDelaySeconds : (int?)null); + return outcome != DcvOutcome.NotRun; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -1549,8 +1557,11 @@ private async Task TryRunDcvDuringSyncAsync(string orderNumber, Cancellati /// /// Runs DNS DCV for any domains on that are still pending - /// validation. Returns true when DCV steps were executed, false when - /// skipped (order already issued, no pending domains, or factory not available). + /// validation. Returns when every pending domain was + /// staged and verified, when some were + /// verified but others were skipped (the order cannot issue yet), and + /// when skipped (order already issued, no pending domains, + /// or factory not available). /// /// Rule: if the order is already issued we never attempt DCV — it would be a no-op /// at best and could confuse the CA at worst. @@ -1563,7 +1574,18 @@ private async Task TryRunDcvDuringSyncAsync(string orderNumber, Cancellati /// budget (user-visible latency benefits from a one-shot end-to-end finish). /// #if SUPPORTS_DCV - private async Task PerformDcvIfNeededAsync( + /// Result of . + private enum DcvOutcome + { + /// No DCV work was done (nothing pending, deferred, or no domain could be staged). + NotRun, + /// All pending domains were staged and verified. + Completed, + /// Staged domains were verified and cleaned up, but at least one domain was skipped. + CompletedWithSkippedDomains + } + + private async Task PerformDcvIfNeededAsync( string orderNumber, CancellationToken ct, int? waitForChallengeSecondsOverride = null, @@ -1603,7 +1625,7 @@ private async Task PerformDcvIfNeededAsync( _logger.LogDebug( "DCV skipped — order {OrderNumber} is already in terminal state (certificateStatusId={Status}).", orderNumber, certStatusId); - return false; + return DcvOutcome.NotRun; } } @@ -1618,7 +1640,7 @@ private async Task PerformDcvIfNeededAsync( "DCV skipped — order {OrderNumber} is cancelled/rejected " + "(orderStatusId={OrderStatus}).", orderNumber, track.OrderDetails.OrderStatusId); - return false; + return DcvOutcome.NotRun; } domainVerification = track.OrderDetails?.DomainVerification; @@ -1632,7 +1654,7 @@ private async Task PerformDcvIfNeededAsync( "DCV challenge not exposed by CERTInext within {Budget}s for order {OrderNumber} " + "(attempted {Attempts} TrackOrder polls). Deferring to next sync cycle.", waitBudgetSeconds, orderNumber, pollAttempts); - return false; + return DcvOutcome.NotRun; } try @@ -1641,7 +1663,7 @@ private async Task PerformDcvIfNeededAsync( } catch (OperationCanceledException) { - return false; + return DcvOutcome.NotRun; } } @@ -1667,7 +1689,7 @@ private async Task PerformDcvIfNeededAsync( "(aggregateStatus={Aggregate}, perDomainAllValidated={PerDomain}). " + "Skipping DNS-TXT staging; caller may run the issuance poll.", orderNumber, aggregateValidated, everyDomainValidated); - return true; + return DcvOutcome.Completed; } // Include domains that are pending DCV and either have no method set yet, @@ -1747,7 +1769,7 @@ private async Task PerformDcvIfNeededAsync( pendingDomains = validPendingDomains; if (pendingDomains.Count == 0) - return false; + return DcvOutcome.NotRun; _logger.LogInformation( "DCV required for order {OrderNumber}. Pending DNS TXT domains: [{Domains}]", @@ -1995,7 +2017,7 @@ async Task CleanupOneStagedValidationAsync( if (deferToNextSyncCycle) { await CleanupPartialStagingAsync(); - return false; + return DcvOutcome.NotRun; } if (skippedDomains.Count > 0) @@ -2008,7 +2030,7 @@ async Task CleanupOneStagedValidationAsync( } if (stagedValidations.Count == 0) - return false; + return DcvOutcome.NotRun; try { @@ -2045,7 +2067,11 @@ await Task.WhenAll(stagedValidations.Select(entry => CleanupOneStagedValidationAsync(entry, ""))); } - return true; + // A skipped domain (non-FQDN entry filtered above, or one that could not be staged) + // means the order cannot issue yet: tell the caller not to wait for issuance. + return (skippedDomains.Count > 0 || invalidDomains.Count > 0) + ? DcvOutcome.CompletedWithSkippedDomains + : DcvOutcome.Completed; } #endif diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ab5bb..faedaf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - **CERTInext error text in logs and error messages now has email addresses masked** unless `LogSensitiveRequestData` is set. - **URI SANs in enrollment logs no longer leak personal data by default.** `mailto:` addresses are masked and `user:pw@` userinfo is replaced with `***` unless `LogSensitiveRequestData` is set. - **Enrollment and renewal no longer fail after the order is placed.** A failed status check or DCV step now returns pending with the order number, so sync finishes the order and a retry can't place a duplicate. +- **Enrollment no longer waits for issuance on a DV order that cannot issue** (e.g. an IP or email SAN with no DNS provider). Valid domains are still validated; it returns pending and sync finishes the order. - **Connector and template validation no longer leaks an HTTP client per check.** ## Chores From 69b5cdb0bf914305809e678df3cb35e41f2c60ce Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:13:47 -0700 Subject: [PATCH 65/71] test(dcv): partial-skip skips issuance wait; all-staged still waits --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 107 +++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index 03c8098..f81c238 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -1344,5 +1344,112 @@ public async Task Dcv_StageFailureOnSecondDomain_DoesNotAbortTheGoodDomain() validator.CleanedUpKeys.Should().NotContain(badHostname, "the bad domain was never staged, so there is nothing to clean up for it"); } + + // --------------------------------------------------------------------------- + // Partial skip vs. all-staged: the post-DCV issuance wait + // --------------------------------------------------------------------------- + + /// + /// Regression: when some pending domains are skipped (here an IP-literal SAN with no DNS + /// provider) the order cannot issue, so Enroll must not hold the worker in the post-DCV + /// issuance wait. The staged domain must still be verified and its TXT record cleaned up; + /// sync DCV completes the order later. + /// + [Fact] + public async Task Dcv_PartialSkip_VerifiesAndCleansUpStagedDomains_ButSkipsIssuanceWait() + { + const string order = MockCertificateData.DcvOrderId; + const string good = MockCertificateData.DcvDomain; + const string ip = "192.0.2.10"; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, ip)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good)); + mock.Setup(c => c.GetDcvAsync(order, It.IsAny(), Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken)); + mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + // Configured so that a (wrong) issuance wait would succeed and be observable. + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator, resolvableDomain: good), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + string goodHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good); + validator.StagedRecords.Should().ContainSingle().Which.key.Should().Be(goodHostname); + mock.Verify(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(goodHostname); + + mock.Verify(c => c.GetCertificateAsync(order, It.IsAny()), Times.Never, + "the order cannot issue while a domain is skipped, so the post-DCV issuance wait must not run"); + result.CARequestID.Should().Be(order); + result.Status.Should().NotBe((int)EndEntityStatus.GENERATED); + } + + /// + /// Counterpart: every pending domain staged and verified -> the issuance wait still runs and + /// the issued certificate is returned from Enroll. + /// + [Fact] + public async Task Dcv_AllDomainsStaged_StillRunsIssuanceWait() + { + const string order = MockCertificateData.DcvOrderId; + const string a = "a.example.com"; + const string b = "b.example.com"; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + var verifiedDetail = DcvDetail(Constants.Dcv.StatusValidated); + var verifiedRaw = new Dictionary { [a] = verifiedDetail, [b] = verifiedDetail }; + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, a, b)) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated, + RawDomainEntries = verifiedRaw + } + } + }); + foreach (string d in new[] { a, b }) + { + mock.Setup(c => c.GetDcvAsync(order, d, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse($"token-{d}")); + mock.Setup(c => c.VerifyDcvAsync(order, d, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + } + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + validator.StagedRecords.Should().HaveCount(2); + validator.CleanedUpKeys.Should().HaveCount(2); + mock.Verify(c => c.GetCertificateAsync(order, It.IsAny()), Times.Once, + "every domain was staged, so the post-DCV issuance wait must still run"); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + } } } From 87ab07025584448a9b5cd20017ff30fcb6a5a46f Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:13:56 -0700 Subject: [PATCH 66/71] test: serialize all global-logger-state tests in one non-parallel collection Replace the CERTInextClientLogger-NoParallel and LogHandlerFactory-NoParallel collections with a single LoggingStateCollection (DisableParallelization) so no test can run alongside one that swaps CERTInextClient's static logger or LogHandler.Factory. Drop the AsyncLocal filter workaround in SignerFallbackWarningTests, now redundant. --- CERTInext.Tests/BlankRequestorWireTests.cs | 2 +- .../CERTInextCAPluginAuditLoggingTests.cs | 8 ++-- CERTInext.Tests/CaErrorTextMaskingTests.cs | 2 +- .../ClientPayloadLogRedactionTests.cs | 2 +- CERTInext.Tests/LoggingStateCollection.cs | 37 +++++++++++++++++++ .../PostPlacementDcvFailureTests.cs | 2 +- .../PostPlacementTrackOrderFailureTests.cs | 2 +- CERTInext.Tests/SanLogMaskingTests.cs | 2 +- CERTInext.Tests/SignerFallbackWarningTests.cs | 14 +------ CERTInext.Tests/SignerIpWarningTests.cs | 2 +- .../V1NonSuccessLogRedactionTests.cs | 2 +- 11 files changed, 51 insertions(+), 24 deletions(-) create mode 100644 CERTInext.Tests/LoggingStateCollection.cs diff --git a/CERTInext.Tests/BlankRequestorWireTests.cs b/CERTInext.Tests/BlankRequestorWireTests.cs index a7e0a7b..98dc412 100644 --- a/CERTInext.Tests/BlankRequestorWireTests.cs +++ b/CERTInext.Tests/BlankRequestorWireTests.cs @@ -43,7 +43,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// no technicalPointOfContact key, and agreementDetails.signerName falls back to /// "Keyfactor Gateway". /// - [Collection("CERTInextClientLogger-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class BlankRequestorWireTests : IDisposable { private readonly WireMockServer _server; diff --git a/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs index d85fdab..0083bd7 100644 --- a/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs @@ -37,12 +37,12 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// Client.CERTInextClient.Logger, which is a static readonly field resolved once /// per process — not swappable after the fact). Swapping /// before constructing a fresh plugin instance is therefore a genuine, narrow capture seam for - /// this one log line. All tests in this class run in the "LogHandlerFactory-NoParallel" - /// collection (sequential within the class by xUnit default; the named collection also blocks - /// any other class opting into it from interleaving) and restore the original factory in a + /// this one log line. All tests in this class run in the shared + /// (non-parallel: no other test, in or out of the + /// collection, runs concurrently with them) and restore the original factory in a /// finally block so the global static mutation can't outlive a single test. /// - [Collection("LogHandlerFactory-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class CERTInextCAPluginAuditLoggingTests { private sealed class CapturingLoggerProvider : ILoggerProvider diff --git a/CERTInext.Tests/CaErrorTextMaskingTests.cs b/CERTInext.Tests/CaErrorTextMaskingTests.cs index bdc0d8e..6d3eac9 100644 --- a/CERTInext.Tests/CaErrorTextMaskingTests.cs +++ b/CERTInext.Tests/CaErrorTextMaskingTests.cs @@ -35,7 +35,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// Command stores; the rest of the text is preserved. With the flag on the text is verbatim. /// All data is synthetic. /// - [Collection("CERTInextClientLogger-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class CaErrorTextMaskingTests : IDisposable { private const string Email = "jane.doe@example.com"; diff --git a/CERTInext.Tests/ClientPayloadLogRedactionTests.cs b/CERTInext.Tests/ClientPayloadLogRedactionTests.cs index c9c529e..1db5891 100644 --- a/CERTInext.Tests/ClientPayloadLogRedactionTests.cs +++ b/CERTInext.Tests/ClientPayloadLogRedactionTests.cs @@ -41,7 +41,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// the capture while it is installed; every assertion is scoped to lines carrying this call's /// unique marker. All data is synthetic. /// - [Collection("CERTInextClientLogger-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class ClientPayloadLogRedactionTests : IDisposable { private const string RequestorName = "Jane Doe"; diff --git a/CERTInext.Tests/LoggingStateCollection.cs b/CERTInext.Tests/LoggingStateCollection.cs new file mode 100644 index 0000000..c441caa --- /dev/null +++ b/CERTInext.Tests/LoggingStateCollection.cs @@ -0,0 +1,37 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// The single xUnit collection for every test that mutates or asserts on process-global logger + /// state: the static CERTInextClient.OverrideLoggerForTests logger and + /// LogHandler.Factory (finding #15). + /// + /// DisableParallelization = true makes xUnit run this collection by itself, after the + /// parallel collections have finished, so no other test (in this collection or outside it) can + /// log into a swapped logger while a capture is active. Without it, captured-log assertions + /// (must-not-contain, exact counts) could pick up entries from unrelated tests and flake. + /// + /// Any new test class that swaps either logger, or asserts on captured log lines, MUST carry + /// [Collection(LoggingStateCollection.Name)]. + /// + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class LoggingStateCollection + { + public const string Name = "GlobalLoggerState-NoParallel"; + } +} diff --git a/CERTInext.Tests/PostPlacementDcvFailureTests.cs b/CERTInext.Tests/PostPlacementDcvFailureTests.cs index 5a85f5f..69ce6e9 100644 --- a/CERTInext.Tests/PostPlacementDcvFailureTests.cs +++ b/CERTInext.Tests/PostPlacementDcvFailureTests.cs @@ -38,7 +38,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// result carrying the order number so the sync-DCV retry path finishes the order. /// Only compiled on the -p:DcvSupport=true build (see CERTInext.Tests.csproj). /// - [Collection("LogHandlerFactory-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class PostPlacementDcvFailureTests { private const string Order = MockCertificateData.DcvOrderId; diff --git a/CERTInext.Tests/PostPlacementTrackOrderFailureTests.cs b/CERTInext.Tests/PostPlacementTrackOrderFailureTests.cs index 6cfbcf1..692a033 100644 --- a/CERTInext.Tests/PostPlacementTrackOrderFailureTests.cs +++ b/CERTInext.Tests/PostPlacementTrackOrderFailureTests.cs @@ -48,7 +48,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// /// Driven against WireMock with the real . All data is synthetic. /// - [Collection("CERTInextClientLogger-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class PostPlacementTrackOrderFailureTests : IDisposable { private const string PriorOrder = MockCertificateData.OrderNumber1; diff --git a/CERTInext.Tests/SanLogMaskingTests.cs b/CERTInext.Tests/SanLogMaskingTests.cs index 1f56d79..fc2c7ca 100644 --- a/CERTInext.Tests/SanLogMaskingTests.cs +++ b/CERTInext.Tests/SanLogMaskingTests.cs @@ -204,7 +204,7 @@ public void FormatUntypedSans_NullOrEmpty_ReturnsNone() /// ; only lines carrying this call's unique /// subject marker are considered. /// - [Collection("LogHandlerFactory-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class SanLogMaskingPluginTests { private const string EmailSan = "alice@example.com"; diff --git a/CERTInext.Tests/SignerFallbackWarningTests.cs b/CERTInext.Tests/SignerFallbackWarningTests.cs index 45a77da..f7e6958 100644 --- a/CERTInext.Tests/SignerFallbackWarningTests.cs +++ b/CERTInext.Tests/SignerFallbackWarningTests.cs @@ -17,7 +17,6 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Keyfactor.AnyGateway.Extensions; @@ -35,7 +34,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// placeholders ("Keyfactor Gateway" / "Gateway") the client must log a Warning naming what to /// configure, without changing the values sent in agreementDetails. /// - [Collection("CERTInextClientLogger-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class SignerFallbackWarningTests : IDisposable { private readonly WireMockServer _server; @@ -65,17 +64,9 @@ private sealed class CapturingLogger : ILogger public IDisposable BeginScope(TState state) => null; public bool IsEnabled(LogLevel logLevel) => true; - // The client logger is a process-wide static, so enrolls from unrelated test classes that - // run in parallel can log into it while it is overridden. Only record entries logged from - // this test's async flow (AsyncLocal flows down to callees, not across parallel tests). - private readonly AsyncLocal _active = new(); - public void Activate() => _active.Value = true; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) - { - if (_active.Value) Entries.Enqueue((logLevel, formatter(state, exception))); - } + => Entries.Enqueue((logLevel, formatter(state, exception))); public List Warnings(string contains) => Entries .Where(e => e.Level == LogLevel.Warning && e.Message.Contains(contains, StringComparison.Ordinal)) @@ -106,7 +97,6 @@ public List Warnings(string contains) => Entries var plugin = new CERTInextCAPlugin(client, new CERTInextConfig { PickupRetries = 0 }); var logger = new CapturingLogger(); - logger.Activate(); using (CERTInextClient.OverrideLoggerForTests(logger)) { await plugin.Enroll( diff --git a/CERTInext.Tests/SignerIpWarningTests.cs b/CERTInext.Tests/SignerIpWarningTests.cs index eb34edd..c0639f4 100644 --- a/CERTInext.Tests/SignerIpWarningTests.cs +++ b/CERTInext.Tests/SignerIpWarningTests.cs @@ -34,7 +34,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// Enroll and renewal share BuildSslOrderDetails -> BuildAgreementDetails, so both /// paths are covered. All values are synthetic (RFC 5737 / RFC 3849 documentation addresses). /// - [Collection("CERTInextClientLogger-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class SignerIpWarningTests : IDisposable { private readonly WireMockServer _server; diff --git a/CERTInext.Tests/V1NonSuccessLogRedactionTests.cs b/CERTInext.Tests/V1NonSuccessLogRedactionTests.cs index baadfb8..bcb34a6 100644 --- a/CERTInext.Tests/V1NonSuccessLogRedactionTests.cs +++ b/CERTInext.Tests/V1NonSuccessLogRedactionTests.cs @@ -34,7 +34,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// DeserializeOrThrow call site through WireMock and captures what the client logged via /// CERTInextClient.OverrideLoggerForTests. All data is synthetic. /// - [Collection("CERTInextClientLogger-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class V1NonSuccessLogRedactionTests : IDisposable { private const string AuthKey = "SYNTHETIC-AUTHKEY-0073"; From 46cd9306bc7347c58b82596da8757fc625c88fbf Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:15:49 -0700 Subject: [PATCH 67/71] test: move TracePayloadGuardTests into the shared logging-state collection --- CERTInext.Tests/TracePayloadGuardTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CERTInext.Tests/TracePayloadGuardTests.cs b/CERTInext.Tests/TracePayloadGuardTests.cs index e81f374..35f51bf 100644 --- a/CERTInext.Tests/TracePayloadGuardTests.cs +++ b/CERTInext.Tests/TracePayloadGuardTests.cs @@ -38,7 +38,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// line must never reach it when Trace is off, and must be byte-identical to /// ApplyLoggingRedaction output when Trace is on. All data is synthetic. /// - [Collection("CERTInextClientLogger-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class TracePayloadGuardTests : IDisposable { private const string RequestorName = "Jane Doe"; From 89d6db6874f7bddbf02a52041fc3a211e9f18d5a Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:16:11 -0700 Subject: [PATCH 68/71] fix(renew): skip the pickup wait for a renewal pending DNS-01 validation when DCV is enabled --- CERTInext/CERTInextCAPlugin.cs | 94 ++++++++++++++++++++++++++++++---- CHANGELOG.md | 1 + 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 6c5c36a..18897e0 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1422,8 +1422,18 @@ private async Task RenewOrReissueAsync( priorCaRequestId, renewResult.CARequestID, renewResult.Status); // Synchronous certificate pickup (Sectigo-parity), same as the new-enrollment path. - // The renew path never runs an in-call DCV issuance wait, so pickup always applies. - renewResult = await PickUpEnrolledCertificateAsync(renewResult, renewResp.Id, dcvIssuanceWaitRan: false); + // The renew path never runs an in-call DCV, so on a DCV-enabled deployment a renewal + // that is still waiting on DNS-01 validation cannot issue inside the pickup window: + // polling it only holds a gateway worker for a guaranteed miss. Skip pickup for that + // case and return the pending result; the sync-driven DCV retry completes the order. + // Every other renewal (validation reused, no DCV pending, DCV disabled) keeps pickup. + bool awaitsDnsDcv = false; +#if SUPPORTS_DCV + if (renewResult.Status == (int)EndEntityStatus.EXTERNALVALIDATION) + awaitsDnsDcv = await RenewalAwaitsDnsDcvAsync(renewResp.Id); +#endif + if (!awaitsDnsDcv) + renewResult = await PickUpEnrolledCertificateAsync(renewResult, renewResp.Id, dcvIssuanceWaitRan: false); return renewResult; } @@ -1440,6 +1450,76 @@ private async Task RenewOrReissueAsync( // DCV helpers // --------------------------------------------------------------------------- +#if SUPPORTS_DCV + /// + /// True when a domain entry is pending validation (dcvStatus="0") and is either not yet + /// assigned a method or assigned to DNS TXT — the only domains this plugin's DCV can complete. + /// Domains assigned to HTTP or e-mail validation are excluded. + /// + private static bool IsPendingDnsDcvEntry(DomainVerificationDetail detail) + { + if (!string.Equals(detail?.DcvStatus, Constants.Dcv.StatusPending, StringComparison.Ordinal)) + return false; + string method = detail?.DcvMethod ?? string.Empty; + return string.IsNullOrEmpty(method) + || string.Equals(method, Constants.Dcv.MethodDnsTxt, StringComparison.Ordinal) + || string.Equals(method, Constants.Dcv.MethodDnsTxtLabel, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Whether a just-placed renewal order is waiting on DNS-01 validation that this call will + /// never perform (the renew path does not run in-call DCV), so the synchronous pickup poll + /// cannot succeed. True only when DCV is enabled with a validator factory and a non-terminal + /// order has at least one pending DNS-01 domain. One TrackOrder call; any failure (including + /// cancellation — Enroll has no caller token) logs a Warning and returns false so the caller + /// falls back to the existing pickup behavior. Never throws: the order already exists at + /// CERTInext, so this check must not fail the renewal (issue 0077). + /// + private async Task RenewalAwaitsDnsDcvAsync(string orderNumber) + { + if (_domainValidatorFactory == null || !_config.DcvEnabled || string.IsNullOrWhiteSpace(orderNumber)) + return false; + + try + { + var track = await _client.TrackOrderAsync(orderNumber); + var details = track?.OrderDetails; + if (details == null) + return false; + + // Terminal orders (issued/revoked, cancelled/rejected) can keep a stale pending + // domain entry; pickup resolves those immediately, so never skip it for them. + if (int.TryParse(details.CertificateStatusId, out int certStatusId)) + { + int disposition = StatusMapper.CertificateStatusIdToRequestDisposition(certStatusId); + if (disposition == (int)EndEntityStatus.GENERATED || disposition == (int)EndEntityStatus.REVOKED) + return false; + } + if (details.OrderStatusId is "4" or "5") + return false; + + int pending = details.DomainVerification?.GetDomainEntries() + .Count(kvp => IsPendingDnsDcvEntry(kvp.Value)) ?? 0; + if (pending == 0) + return false; + + _logger.LogInformation( + "Renewal order {OrderNumber} has {Count} domain(s) pending DNS-01 validation, which the renew " + + "path does not perform in-call; synchronous pickup skipped. Returning the pending result; " + + "the next synchronization completes DCV and imports the certificate.", + orderNumber, pending); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not check DCV state for renewal order {OrderNumber}; falling back to the normal " + + "synchronous pickup.", orderNumber); + return false; + } + } +#endif + /// /// True when a GetDcv failure is the CERTInext-side "DCV slot is exposed in /// TrackOrder but the endpoint won't accept calls yet" condition. Observed as the @@ -1696,15 +1776,7 @@ private async Task PerformDcvIfNeededAsync( // or are already assigned to DNS TXT (numeric "1" from API or label from TrackOrder). // Domains assigned to HTTP or email DCV are excluded — we must not override them. var pendingDomains = domainVerification.GetDomainEntries() - .Where(kvp => - { - if (!string.Equals(kvp.Value?.DcvStatus, Constants.Dcv.StatusPending, StringComparison.Ordinal)) - return false; - string method = kvp.Value?.DcvMethod ?? string.Empty; - return string.IsNullOrEmpty(method) - || string.Equals(method, Constants.Dcv.MethodDnsTxt, StringComparison.Ordinal) - || string.Equals(method, Constants.Dcv.MethodDnsTxtLabel, StringComparison.OrdinalIgnoreCase); - }) + .Where(kvp => IsPendingDnsDcvEntry(kvp.Value)) .ToList(); // SOX CC6.1: validate domain names before passing them to the DNS provider plugin diff --git a/CHANGELOG.md b/CHANGELOG.md index faedaf1..f91271c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - **URI SANs in enrollment logs no longer leak personal data by default.** `mailto:` addresses are masked and `user:pw@` userinfo is replaced with `***` unless `LogSensitiveRequestData` is set. - **Enrollment and renewal no longer fail after the order is placed.** A failed status check or DCV step now returns pending with the order number, so sync finishes the order and a retry can't place a duplicate. - **Enrollment no longer waits for issuance on a DV order that cannot issue** (e.g. an IP or email SAN with no DNS provider). Valid domains are still validated; it returns pending and sync finishes the order. +- **With DCV enabled, a renewal waiting on DNS-01 validation no longer holds a gateway worker for the pickup wait.** It returns pending at once and the next sync completes it; other renewals still wait for fast issuance. - **Connector and template validation no longer leaks an HTTP client per check.** ## Chores From 8dc1ebe1e30ca20e2a5069b1e6496c8db298b856 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:16:11 -0700 Subject: [PATCH 69/71] test(renew): renewal pickup is skipped only when DNS-01 DCV is pending; check failures never throw --- CERTInext.Tests/RenewalPickupDcvTests.cs | 289 +++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 CERTInext.Tests/RenewalPickupDcvTests.cs diff --git a/CERTInext.Tests/RenewalPickupDcvTests.cs b/CERTInext.Tests/RenewalPickupDcvTests.cs new file mode 100644 index 0000000..02e8414 --- /dev/null +++ b/CERTInext.Tests/RenewalPickupDcvTests.cs @@ -0,0 +1,289 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Finding 5: on a DCV-enabled deployment a renewal that is waiting on DNS-01 validation cannot + /// reach GENERATED inside the synchronous pickup window — the renew path never runs DCV in the + /// call, only sync-driven DCV does. Pickup (~55s by default) must therefore be skipped for such + /// a renewal (pending result carrying the new order number), while every renewal that can still + /// issue promptly keeps the pickup benefit. A failure of the DCV-state check must never fail the + /// renewal (the order is already placed — issue 0077). + /// + [Collection("LogHandlerFactory-NoParallel")] + public class RenewalPickupDcvTests + { + private const string PriorOrder = "ORD-PRIOR-001"; + private const string NewOrder = "ORD-RENEW-002"; + private const string Domain = "renew.example.com"; + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries); + public void Dispose() { } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue<(LogLevel, string)> _entries; + public CapturingLogger(ConcurrentQueue<(LogLevel, string)> entries) => _entries = entries; + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) => _entries.Enqueue((logLevel, formatter(state, exception))); + } + } + + // One pickup poll, 1s apart: the smallest real pickup window (InitialDelaySeconds is a + // fixed 5s), so the "pickup still runs" cases cost ~6s each. + private static CERTInextConfig Config(bool dcvEnabled = false) => new CERTInextConfig + { + PickupRetries = 1, + PickupDelayInSeconds = 1, + DcvEnabled = dcvEnabled, + DcvPropagationDelaySeconds = 1, + DcvTimeoutMinutes = 1, + DcvWaitForChallengeSeconds = 0, + DcvWaitForIssuanceSeconds = 0 + }; + + private static Mock Reader() + { + var reader = new Mock(); + reader.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())).ReturnsAsync(PriorOrder); + reader.Setup(r => r.GetExpirationDateByRequestId(PriorOrder)).Returns(DateTime.UtcNow.AddDays(30)); + return reader; + } + + private static Mock RenewedOrderMock() + { + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.RenewCertificateAsync(PriorOrder, It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse(NewOrder)); + return mock; + } + + private static void SetupPickupIssues(Mock mock) => + mock.Setup(c => c.GetCertificateAsync(NewOrder, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(NewOrder)); + + private static EnrollmentProductInfo ProductInfo() => new EnrollmentProductInfo + { + ProductID = MockCertificateData.ProfileIdTls, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProfileId"] = MockCertificateData.ProfileIdTls, + ["PriorCertSN"] = "AABB", + ["RenewalWindowDays"] = "90" + } + }; + + private static async Task<(EnrollmentResult Result, IReadOnlyList<(LogLevel Level, string Message)> Logs)> RenewAsync( + Mock mock, Mock reader, CERTInextConfig config, bool withDcvFactory) + { + var provider = new CapturingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace)); + try + { + LogHandler.Factory = factory; + // Constructed after the swap so the plugin's per-instance logger resolves through it. + CERTInextCAPlugin plugin; +#if SUPPORTS_DCV + if (withDcvFactory) + { + plugin = new CERTInextCAPlugin(mock.Object, new FakeDomainValidatorFactory(new FakeDomainValidator()), config); + // The DCV test constructor takes no certificate-data reader; inject it directly. + typeof(CERTInextCAPlugin) + .GetField("_certificateDataReader", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(plugin, reader.Object); + } + else +#endif + { + plugin = new CERTInextCAPlugin(mock.Object, reader.Object, config); + } + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, $"CN={Domain}", + new Dictionary { ["dns"] = new[] { Domain } }, + ProductInfo(), RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + // Guard: the renewal API path (not the new-enroll fallback) produced the order. + reader.Verify(r => r.GetExpirationDateByRequestId(PriorOrder), Times.Once); + return (result, provider.Entries.ToList()); + } + finally + { + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + factory.Dispose(); + } + } + + [Fact] + public async Task Renewal_DcvDisabled_PickupStillRuns_AndNeverChecksDcvState() + { + var mock = RenewedOrderMock(); + SetupPickupIssues(mock); + + var (result, _) = await RenewAsync(mock, Reader(), Config(dcvEnabled: false), withDcvFactory: false); + + result.CARequestID.Should().Be(NewOrder); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().NotBeNullOrEmpty(); + mock.Verify(c => c.GetCertificateAsync(NewOrder, It.IsAny()), Times.Once); + mock.Verify(c => c.TrackOrderAsync(It.IsAny(), It.IsAny()), Times.Never); + } + +#if SUPPORTS_DCV + [Fact] + public async Task Renewal_PendingDnsDcvDomain_SkipsPickup_ReturnsPendingWithNewOrderNumber() + { + var mock = RenewedOrderMock(); + mock.Setup(c => c.TrackOrderAsync(NewOrder, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse(NewOrder, Domain)); + + var (result, logs) = await RenewAsync(mock, Reader(), Config(dcvEnabled: true), withDcvFactory: true); + + result.CARequestID.Should().Be(NewOrder); + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.Certificate.Should().BeNull(); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never, + "an order waiting on DNS-01 cannot issue inside the pickup window"); + logs.Should().Contain(l => l.Level == LogLevel.Information + && l.Message.Contains(NewOrder) && l.Message.Contains("pickup skipped"), + "the skip must be explained at Information"); + } + + [Fact] + public async Task Renewal_DcvEnabled_NoPendingDcv_PickupStillRuns() + { + // Domain validation reused from the prior order: every domain already validated. + var mock = RenewedOrderMock(); + mock.Setup(c => c.TrackOrderAsync(NewOrder, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(NewOrder, Domain)); + SetupPickupIssues(mock); + + var (result, _) = await RenewAsync(mock, Reader(), Config(dcvEnabled: true), withDcvFactory: true); + + result.CARequestID.Should().Be(NewOrder); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().NotBeNullOrEmpty(); + mock.Verify(c => c.GetCertificateAsync(NewOrder, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Renewal_DcvEnabled_NoDomainVerificationBlock_PickupStillRuns() + { + // CERTInext has not exposed domainVerification (e.g. OV/EV or not yet materialized): + // nothing is known to be pending DNS-01, so keep the pickup benefit. + var track = MockCertificateData.DcvPendingTrackResponse(NewOrder, Domain); + track.OrderDetails.DomainVerification = null; + var mock = RenewedOrderMock(); + mock.Setup(c => c.TrackOrderAsync(NewOrder, It.IsAny())).ReturnsAsync(track); + SetupPickupIssues(mock); + + var (result, _) = await RenewAsync(mock, Reader(), Config(dcvEnabled: true), withDcvFactory: true); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + mock.Verify(c => c.GetCertificateAsync(NewOrder, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Renewal_DcvEnabled_PendingDomainOnNonDnsMethod_PickupStillRuns() + { + // Only DNS-01 is something the sync-DCV path can complete; a domain assigned to HTTP or + // e-mail validation is out of this check's scope. + var track = MockCertificateData.DcvPendingTrackResponse(NewOrder, Domain); + track.OrderDetails.DomainVerification.RawDomainEntries[Domain] = JsonSerializer.SerializeToElement( + new DomainVerificationDetail + { + DcvMethod = Constants.Dcv.MethodHttpFile, + DcvStatus = Constants.Dcv.StatusPending, + Status = "1" + }); + var mock = RenewedOrderMock(); + mock.Setup(c => c.TrackOrderAsync(NewOrder, It.IsAny())).ReturnsAsync(track); + SetupPickupIssues(mock); + + var (result, _) = await RenewAsync(mock, Reader(), Config(dcvEnabled: true), withDcvFactory: true); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + mock.Verify(c => c.GetCertificateAsync(NewOrder, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Renewal_DcvEnabled_TerminalOrderWithStalePendingDomain_PickupStillRuns() + { + // A cancelled/rejected order can keep a stale dcvStatus="0"; pickup surfaces FAILED + // quickly, so it must not be skipped (mirrors the terminal guard in PerformDcvIfNeededAsync). + var track = MockCertificateData.DcvPendingTrackResponse(NewOrder, Domain); + track.OrderDetails.OrderStatusId = "5"; + var mock = RenewedOrderMock(); + mock.Setup(c => c.TrackOrderAsync(NewOrder, It.IsAny())).ReturnsAsync(track); + mock.Setup(c => c.GetCertificateAsync(NewOrder, It.IsAny())) + .ReturnsAsync(new LegacyGetCertificateResponse { Id = NewOrder, Status = "rejected" }); + + var (result, _) = await RenewAsync(mock, Reader(), Config(dcvEnabled: true), withDcvFactory: true); + + result.CARequestID.Should().Be(NewOrder); + mock.Verify(c => c.GetCertificateAsync(NewOrder, It.IsAny()), Times.Once); + } + + [Theory] + [InlineData("exception")] + [InlineData("notfound")] + [InlineData("canceled")] + public async Task Renewal_DcvStateCheckThrows_DoesNotThrow_FallsBackToPickup(string kind) + { + Exception ex = kind switch + { + "notfound" => new KeyNotFoundException($"Order '{NewOrder}' was not found in CERTInext."), + "canceled" => new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout"), + _ => new Exception("CERTInext error during 'track order' (HTTP 500)") + }; + var mock = RenewedOrderMock(); + mock.Setup(c => c.TrackOrderAsync(NewOrder, It.IsAny())).ThrowsAsync(ex); + SetupPickupIssues(mock); + + var (result, logs) = await RenewAsync(mock, Reader(), Config(dcvEnabled: true), withDcvFactory: true); + + // Falls back to today's behavior: pickup runs and the order is returned, never a throw. + result.CARequestID.Should().Be(NewOrder); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + mock.Verify(c => c.GetCertificateAsync(NewOrder, It.IsAny()), Times.Once); + logs.Should().Contain(l => l.Level == LogLevel.Warning && l.Message.Contains(NewOrder), + "the swallowed DCV-state check failure must be logged as a Warning naming the order"); + } +#endif + } +} From 392e5aed6db00dfdf102d41d909b0cb46ed3f293 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:16:56 -0700 Subject: [PATCH 70/71] test: move RenewalPickupDcvTests into the shared logging-state collection --- CERTInext.Tests/RenewalPickupDcvTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CERTInext.Tests/RenewalPickupDcvTests.cs b/CERTInext.Tests/RenewalPickupDcvTests.cs index 02e8414..faf0545 100644 --- a/CERTInext.Tests/RenewalPickupDcvTests.cs +++ b/CERTInext.Tests/RenewalPickupDcvTests.cs @@ -40,7 +40,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// issue promptly keeps the pickup benefit. A failure of the DCV-state check must never fail the /// renewal (the order is already placed — issue 0077). /// - [Collection("LogHandlerFactory-NoParallel")] + [Collection(LoggingStateCollection.Name)] public class RenewalPickupDcvTests { private const string PriorOrder = "ORD-PRIOR-001"; From 85298257f11041b78bebb4824579b1f3ff400cab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 5 Oct 2026 00:44:21 +0000 Subject: [PATCH 71/71] docs: auto-generate README and documentation [skip ci] --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9fb4f3d..3a851a9 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,12 @@ CERTInext operates three separate environments. Use the sandbox environment for * **SubscriptionRenewCriteriaDays** - OPTIONAL: Days before expiry at which CERTInext auto-renews (only honored when SubscriptionAutoRenew = "1"). Typical values: "30" or "60". Default: "30". * **AutoSecureWww** - OPTIONAL: Sent as `orderDetails.autoSecureWWW` on every SSL order (new and renewal). If "1", CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN, which must also pass domain validation. "0" = use only the CN/SANs supplied with the CSR. Default: "0". * **IgnoreExpired** - If true, expired certificates will be skipped during synchronization. Default: false. + * **SubmitNonDnsSans** - If true (default), SANs that are not DNS names (IP address, email, URI) are submitted to CERTInext in additionalDomains along with the DNS names. CERTInext registers them verbatim as order domains and they cannot pass domain validation, so such an order will not issue until they are removed — but nothing the subscriber requested is dropped silently. Set to false to submit DNS names only, which restores the pre-1.0.1 behaviour: the order issues, but the certificate will not contain the non-DNS names. Default: true. * **PageSize** - Number of orders to fetch per page during synchronization. Default: 100, max: 500. * **Enabled** - Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true. * **LogSensitiveRequestData** - OPTIONAL diagnostic escape hatch. When true, enabling it writes requestor personal data (name, email, phone, and other organization contact details) and full CA request/response payloads to the gateway logs. Meant for temporary use while verifying a new deployment — confirming exactly what was sent to the CA and that the order succeeded — and should be turned back off once verification is complete. When false (default), personal-data fields are redacted (email is masked but keeps its domain, e.g. 'j***@example.com') and the enrollment log line omits the requester name entirely. Email SAN values (rfc822Name) in log lines are masked the same way; DNS, IP and URI SANs are always logged in full. Credentials (access keys, authKey digests, OAuth secrets, tokens) are always redacted regardless of this setting. Default: false. + * **PickupRetries** - OPTIONAL: Number of times Enroll() will poll CERTInext to download the certificate after a successful order submission. If the certificate has not issued within this window it is picked up during the next synchronization instead. Set to 0 to disable the wait. Default: 5. NOTE: CERTInext issues OV/EV certificates asynchronously (organization verification, minutes to hours), so those typically exhaust the wait and are returned pending regardless of this value. + * **PickupDelay** - OPTIONAL: Number of seconds between certificate-pickup retries. PickupRetries times this delay (plus a short initial delay) is the maximum time an enrollment call occupies a Command worker thread. If the duration is too long the request may time out, so target a total well under ~90s. As a safety backstop the plugin additionally caps the effective total at 180s regardless of how PickupRetries/PickupDelay are set, reducing the retry count to fit. Default: 10 (with default retries this yields a ~55s ceiling). * **DcvEnabled** - OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false. * **DcvTxtRecordTemplate** - OPTIONAL: Format string for the DNS TXT record hostname used during DCV. {0} is replaced with the domain name being validated. Default: _emsign-validation.{0} * **DcvPropagationDelaySeconds** - OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: 30. @@ -183,7 +186,7 @@ In the Keyfactor Command Management Portal, navigate to **Certificate Templates* * **ProfileId** - DEPRECATED: Use ProductCode instead. Kept for backward compatibility — mapped to ProductCode if ProductCode is not set. * **ValidityYears** - OPTIONAL: Subscription validity in years: 1, 2, or 3. Default: 1. Note: CERTInext validates per 390-day certificate within the subscription; the 'validity' field in the order is the subscription term, not certificate lifetime. * **ValidityDays** - DEPRECATED: Use ValidityYears instead. If set, value is divided by 365 and rounded up to get the subscription year count. - * **AutoApprove** - OPTIONAL: If true, the gateway will attempt automatic approval of certificates that are returned in a pending-approval state. Default: false. + * **AutoApprove** - Currently has no effect — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. * **RequesterName** - OPTIONAL: Default requester name to include in the enrollment request. Used when no requester name can be derived from the subject. * **RequesterEmail** - OPTIONAL: Default requester email address. Used when no email can be derived from the subject. * **RenewalWindowDays** - OPTIONAL: Number of days before certificate expiration within which a renewal is triggered. Certificates expiring further than this window are reissued instead. Certificates that have already expired also fall back to reissue. Default: 90.