diff --git a/.gitignore b/.gitignore index 609bcd8..68b2f86 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,10 @@ terraform/terraform.tfvars # Analysis / scratch — never commit analysis/ - +issues/ +.claude/ +.codex/ +CLAUDE.md +AGENTS.md +/logs*/ +*.log diff --git a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj index bd3ec73..6ecc7fa 100644 --- a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj +++ b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj @@ -6,9 +6,9 @@ 12.0 false true - - false + + true $(DefineConstants);SUPPORTS_DCV @@ -16,14 +16,17 @@ - + + + + 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..5f2d57e 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 @@ -88,6 +102,7 @@ private static async Task> RunSyncAsync(CERTInextCA var syncTask = Task.Run(async () => { await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: System.Threading.CancellationToken.None); + // Synchronize calls CompleteAdding() in its finally block; guard against double-call. if (!buffer.IsAddingCompleted) buffer.CompleteAdding(); }); @@ -655,7 +670,6 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks() List synced = null; System.Diagnostics.Stopwatch syncPhaseSw = System.Diagnostics.Stopwatch.StartNew(); int passesUsed = 0; - int finalNotIssued = -1; for (int pass = 1; pass <= maxSyncPasses; pass++) { @@ -664,13 +678,27 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks() synced = await RunSyncAsync(plugin); passSw.Stop(); + // Classify enrolled orders by their current status so that FAILED orders + // are not silently counted as still-pending, which would burn the full + // pass budget before producing a misleading "expected 0" assertion. int generated = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.GENERATED); - int pending = enrolledIds.Count - generated; - finalNotIssued = pending; + int failed = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED); + int pending = enrolledIds.Count - generated - failed; _output.WriteLine( $"--- Sync pass #{pass}: returned {synced.Count} records, {generated}/{enrolledIds.Count} GENERATED, " + - $"{pending} still pending, elapsed={passSw.Elapsed:mm\\:ss} ---"); + $"{failed} FAILED, {pending} still pending, elapsed={passSw.Elapsed:mm\\:ss} ---"); + + if (failed > 0) + { + var failedIds = synced + .Where(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED) + .Select(r => r.CARequestID) + .Take(5); + Assert.Fail( + $"Pass #{pass}: {failed} order(s) reached FAILED status and will never issue: " + + string.Join(", ", failedIds)); + } if (pending == 0) break; @@ -696,10 +724,14 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks() $"{string.Join(", ", missing.Take(5))}{(missing.Count > 5 ? ", ..." : "")}"); // Final assertion — every enrolled order must be GENERATED after the polling window. - var lookup = synced.ToDictionary(r => r.CARequestID, r => r); + // Filter null CARequestIDs before building the lookup (guards against any CA response + // that omits the ID, which would otherwise throw ArgumentNullException in ToDictionary). + var lookup = synced + .Where(r => r.CARequestID != null) + .ToDictionary(r => r.CARequestID, r => r); var notIssued = enrolledIds + .Where(id => lookup.TryGetValue(id, out var rec) && rec.Status != (int)EndEntityStatus.GENERATED) .Select(id => lookup[id]) - .Where(r => r.Status != (int)EndEntityStatus.GENERATED) .ToList(); if (notIssued.Count > 0) @@ -711,7 +743,7 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks() notIssued.Should().BeEmpty( $"every enrolled DV order should auto-issue on the new sandbox after {maxSyncPasses} sync passes; " + - $"{notIssued.Count} did not (last pass: {finalNotIssued} pending)."); + $"{notIssued.Count} did not."); _output.WriteLine($"--- SUCCESS: {count}/{count} DV orders enrolled, synced, and issued in {passesUsed} sync pass(es). " + $"Enroll={sw.Elapsed:mm\\:ss} SyncPhase={syncPhaseSw.Elapsed:mm\\:ss} Total={(sw.Elapsed + syncPhaseSw.Elapsed):mm\\:ss} ---"); diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md index 441f573..9f77771 100644 --- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md +++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md @@ -1,155 +1,187 @@ -# CERTInext Integration Tests +# CERTInext Integration Tests — Setup and Running -This project contains xUnit integration tests that exercise the CERTInext plugin against -the live CERTInext REST API. All tests skip automatically when credentials are absent, -so the project is safe to include in CI pipelines that do not have API access. +This project contains xUnit integration tests that exercise the CERTInext plugin against the +live CERTInext REST API (V1 and V2). Every test skips automatically when credentials are absent, +so the project is safe to include in CI pipelines that have no API access. Tests that place +orders, publish DNS records, or cancel orders are additionally gated behind opt-in environment +flags. For the list of tests and what each checks, see [TESTING.md](TESTING.md). --- ## Prerequisites -- .NET 8 or .NET 10 SDK -- Access to a CERTInext account (sandbox or production) -- An API Access Key generated in the CERTInext portal under **Integrations → APIs** +- .NET 10 SDK (the test project targets `net8.0`) +- Access to a CERTInext account (a sandbox account is recommended) +- For V1 tests: an API Access Key from the CERTInext portal under **Integrations → APIs** +- For V2 tests: an OAuth-mode credential (client ID and secret) from the same page +- For DNS-01 DCV tests: a Cloudflare API token and zone ID for a domain you control --- ## Credential Setup -Create the file `~/.env_certinext` with the following content: +### V1: `~/.env_certinext` ```sh -# CERTInext API credentials -CERTINEXT_API_URL=https://api.certinext.io/emSignHub-API/ +# CERTInext V1 API credentials +CERTINEXT_API_URL=https://sandbox-us-api.certinext.io/emSignHub-API/ CERTINEXT_ACCESS_KEY=your-access-key-here CERTINEXT_ACCOUNT_NUMBER=your-account-number CERTINEXT_GROUP_NUMBER=your-group-number CERTINEXT_ORG_NUMBER=your-org-number -CERTINEXT_PRODUCT_CODE=838 +CERTINEXT_PRODUCT_CODE=842 CERTINEXT_REQUESTOR_EMAIL=you@example.com CERTINEXT_REQUESTOR_NAME=Your Name ``` -### Field reference - | Variable | Required | Description | |----------|----------|-------------| -| `CERTINEXT_API_URL` | Yes | Base URL of the CERTInext API, e.g. `https://api.certinext.io/emSignHub-API/` | -| `CERTINEXT_ACCESS_KEY` | Yes | REST API Access Key from the CERTInext portal (Integrations → APIs) | +| `CERTINEXT_API_URL` | Yes | V1 base URL, including the `/emSignHub-API` path segment | +| `CERTINEXT_ACCESS_KEY` | Yes | REST API Access Key from the CERTInext portal | | `CERTINEXT_ACCOUNT_NUMBER` | Yes | Your CERTInext account number (numeric string) | -| `CERTINEXT_GROUP_NUMBER` | No | Group number for order filtering | -| `CERTINEXT_ORG_NUMBER` | No | Organization number for order placement | -| `CERTINEXT_PRODUCT_CODE` | No | Default product code (e.g. `838` for DV SSL) | -| `CERTINEXT_REQUESTOR_EMAIL` | No | Email submitted with test orders | -| `CERTINEXT_REQUESTOR_NAME` | No | Name submitted with test orders | - -### API URL reference - -| Environment | URL | -|-------------|-----| +| `CERTINEXT_GROUP_NUMBER` | No | Group number for order placement and `GetProductDetails`; some accounts need it for the product list to be non-empty | +| `CERTINEXT_ORG_NUMBER` | No | Pre-vetted organization number for OV/EV order placement | +| `CERTINEXT_PRODUCT_CODE` | For order-placing tests | Numeric product code for your account. **Product codes are per account** — find yours with `make get-product-details-group` or `make probe-products` | +| `CERTINEXT_REQUESTOR_EMAIL`, `CERTINEXT_REQUESTOR_NAME` | For order-placing tests | Requestor submitted with test orders; the email must be registered in the account | +| `CERTINEXT_CF_API_TOKEN`, `CERTINEXT_CF_ZONE_ID` | For DNS-01 DCV tests | Cloudflare token with DNS edit permission on the zone, and the zone ID | +| `CERTINEXT_DCV_DOMAIN` | For DNS-01 DCV tests | A domain inside that zone that test orders use | +| `CERTINEXT_ORDER_ID` | For `SmokeTests` order lookups | An existing order number | + +| Environment | V1 `CERTINEXT_API_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/` | -### Credential file format - -The file is parsed line by line: -- Lines starting with `#` are treated as comments and ignored. -- Blank lines are ignored. -- 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). - ---- - -## Running the Tests +### V2: `~/.env_certinext_v2` -### Using dotnet CLI +The V2 tests read this file themselves. ```sh -dotnet test CERTInext.IntegrationTests/ --verbosity normal +# CERTInext V2 API credentials +CERTINEXT_API_URL=https://sandbox-us-api.certinext.io # V2 base URL: no /emSignHub-API suffix +CERTINEXT_CLIENT_ID=your-oauth-client-id +CERTINEXT_CLIENT_SECRET=your-oauth-client-secret +CERTINEXT_USE_V2_API=1 # any non-empty value enables the V2 tests +CERTINEXT_PRODUCT_CODE=842 # optional; V2 catalog code, default 842 ``` -### Using the Makefile +The V2 file reuses key names from the V1 file (`CERTINEXT_API_URL`, `CERTINEXT_PRODUCT_CODE`, the +Cloudflare keys, `CERTINEXT_DCV_DOMAIN`) with V2 values, so source only `~/.env_certinext` into +your shell and never `~/.env_certinext_v2`. The V2 test classes never write those shared keys +into the process environment. -```sh -make integration-test -``` - -### From the solution root (all tests including unit tests) +### File format -```sh -dotnet test certinext-caplugin.sln --verbosity normal -``` +- Lines starting with `#` and blank lines are ignored. +- Each line is `KEY=VALUE`. One pair of matching surrounding single or double quotes is stripped from the value. +- Real environment variables override values from the V1 file, which makes CI injection easy. +- The V1 fixture fails fast, with an actionable message, if the resolved `CERTINEXT_API_URL` lacks + `/emSignHub-API`, which indicates a V2 URL leaked into the V1 side. +- When running from a shell that needs the opt-in flags below, load the V1 file with + `set -a; . ~/.env_certinext; set +a`. --- -## Skip Behaviour - -Each test calls `IntegrationSkip.IfNotConfigured(fixture)` at the top of the test method. -When `~/.env_certinext` is absent or either `CERTINEXT_API_URL` or `CERTINEXT_ACCESS_KEY` -is empty, every test is reported as **Skipped** rather than Failed. - -This makes the test project safe to include in CI pipelines where live credentials are -not available — the tests show up in the results as skipped rather than causing a -pipeline failure. - ---- - -## Test Classes +## Running the Tests -### `ConnectivityTests` +```sh +# all integration tests (live tests skip when credentials are absent) +dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release --verbosity normal -| Test | What it checks | -|------|---------------| -| `Ping_ReturnsSuccess` | Calls `ValidateCredentials` endpoint; asserts no exception is thrown | +# or via the Makefile +make integration-test -### `ProductTests` +# a single class +dotnet test CERTInext.IntegrationTests/ --filter "FullyQualifiedName~LifecycleTests" -v normal -| Test | What it checks | -|------|---------------| -| `GetProductDetails_ReturnsProducts` | Calls `GetProductDetails`; asserts the call succeeds; when products are returned, asserts product code `838` is present | +# from the solution root, including the unit tests +dotnet test certinext-caplugin.sln --verbosity normal +``` -> Note: some CERTInext accounts return an empty list from `GetProductDetails` even though -> orders using those product codes are visible in `GetOrderReport`. An empty list is -> treated as acceptable in this test — only the absence of an exception is mandatory. +The DCV test classes compile into the default build; no build flag is needed. -### `OrderReportTests` +--- -| Test | What it checks | -|------|---------------| -| `GetOrderReport_ReturnsOrders` | Fetches page 1; asserts at least one order is returned | -| `GetOrderReport_AllOrders_HaveRequiredFields` | For each order on page 1: `requestNumber`, `productCode`, and `orderDate` are non-empty | +## Skip Behaviour -### `PluginSmokeTests` +- **V1 tests** call `IntegrationSkip.IfNotConfigured(fixture)` first. When `~/.env_certinext` is + absent, or `CERTINEXT_API_URL` or `CERTINEXT_ACCESS_KEY` is empty, the test is reported as + **Skipped**, not failed. +- **V2 tests** skip unless `CERTINEXT_USE_V2_API`, `CERTINEXT_API_URL`, `CERTINEXT_CLIENT_ID`, and + `CERTINEXT_CLIENT_SECRET` are all set (in `~/.env_certinext_v2` or the environment). +- **DCV tests** additionally skip unless the Cloudflare token and zone ID are set. +- Some tests skip when the account lacks the state they need, such as no existing orders or an + order that has not yet reached an issued state. + +### Opt-in flags + +Tests that place real orders, publish DNS records, or cancel orders only run when you export the +matching flag in your shell. These flags are deliberately **not** read from `~/.env_certinext` or +`~/.env_certinext_v2`, so a flag left in a file can't arm them on every run. + +| Flag | Arms | +|------|------| +| `CERTINEXT_ALGO_MATRIX=1` | `AlgorithmMatrixTests.Enroll_AcceptsKeyAlgorithm` — one sandbox order per key algorithm | +| `CERTINEXT_ALGO_MATRIX_DCV=1` | `DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm` (also needs Cloudflare) | +| `CERTINEXT_RUN_BULK_TEST=1` | `DcvLifecycleTests.BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks` (also needs Cloudflare); tune with `CERTINEXT_BULK_TEST_COUNT` and `CERTINEXT_BULK_TEST_PARALLEL` | +| `CERTINEXT_COMPLETE_PENDING=1` | `DcvLifecycleTests.CompleteAllPendingDvOrders` (also needs Cloudflare) — repeated full syncs until no DV order is pending | +| `CERTINEXT_V2_ALGO_MATRIX=1` | `V2DcvLifecycleTests.EnrollWithDcvOn_V2_IssuesPerKeyAlgorithm` (also needs Cloudflare) | +| `CERTINEXT_V2_RUN_BULK_TEST=1` | `V2DcvLifecycleTests.BulkV2Enrollment_AllOrdersIssue_AndPaginationWorks` (also needs Cloudflare); tune with `CERTINEXT_V2_BULK_TEST_COUNT` and `CERTINEXT_V2_BULK_TEST_PARALLEL` | +| `CERTINEXT_V2_LIFECYCLE_DV_UCC=1`, `_OV=1`, `_OV_UCC=1`, `_EV=1`, `_WILDCARD_DV=1`, `_RENEW_REISSUE=1` | The matching `V2FullLifecycleTests` method; the OV and OV UCC tests also need `CERTINEXT_ORG_NUMBER`, and the EV test needs its own pre-vetted `CERTINEXT_EV_ORG_NUMBER` | +| `CERTINEXT_V2_LIFECYCLE_FRESH_DCV=1` | `V2FreshDomainDcvLifecycleTests` (also needs Cloudflare and `CERTINEXT_V2_FRESH_DCV_PARENT`, the parent domain that fresh subdomains are created under) | +| `CERTINEXT_PRIVATE_PKI_LIVE=1` | `PrivatePkiV2LiveTests.PrivatePki_V2_EnrollIntranetSsl_ThenRevoke_Live`; override the product code and CN with `CERTINEXT_PRIVATE_PKI_PRODUCT_CODE` and `CERTINEXT_PRIVATE_PKI_CN` | +| `CERTINEXT_V2_OPS_TESTS=1` | `V2OrderWindowSweepTests` — see below | + +Further variables select existing orders for specific tests: `CERTINEXT_PENDING_ORDER_ID` +(`DcvLifecycleTests.GetSingleRecord_DrivesDcvForPendingOrder`), `CERTINEXT_V2_PENDING_ORDER_ID`, +`CERTINEXT_V2_ORDER_ID`, `CERTINEXT_V2_ISSUED_ORDER_ID`, and `CERTINEXT_REVOKE_ORDER_ID` (V2 +lifecycle and revoke tests), and `CERTINEXT_V2_FULL_SYNC_TEST` (any value enables the V2 full-history +sync test, which can be slow on a shared account). + +### Operations and diagnostic tests + +Two opt-in tests are maintenance tools rather than checks: + +- **`V2OrderWindowSweepTests`** lists every V2 order in a date window and can cancel specific + orders. It needs `CERTINEXT_V2_OPS_TESTS=1`, `CERTINEXT_V2_SWEEP_FROM` and `CERTINEXT_V2_SWEEP_TO` + (UTC ISO-8601 timestamps), and is read-only unless `CERTINEXT_V2_SWEEP_CANCEL_IDS` lists order + IDs, in which case it cancels exactly those orders if they are found in the window and not already + in a terminal state. +- **`PendingDvDiagnosticsTests`** dumps the DCV state of pending V1 orders. It needs + `CERTINEXT_DIAG_ORDER_IDS="orderId|domain,orderId|domain"` and makes read-only calls. + +### Completing pending DV orders + +To drive every pending DV order on a sandbox account to issuance (needs a DNS provider, so the +Cloudflare variables): -End-to-end tests exercising `CERTInextCAPlugin` via the `IAnyCAPlugin` interface with -a live `CERTInextClient` injected through the `(ICERTInextClient, CERTInextConfig)` -test constructor. +```sh +set -a; . ~/.env_certinext; set +a +export CERTINEXT_COMPLETE_PENDING=1 +dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \ + --filter "FullyQualifiedName~CompleteAllPendingDvOrders" \ + --logger "console;verbosity=detailed" > /tmp/dvrun.log 2>&1 +``` -| Test | What it checks | -|------|---------------| -| `Ping_ThroughPlugin_Succeeds` | Calls `IAnyCAPlugin.Ping()`; asserts no exception | -| `GetProductIds_ReturnsAtLeastOneProduct` | Calls `IAnyCAPlugin.GetProductIds()`; asserts a non-null list is returned without throwing | -| `Synchronize_ReturnsAtLeastOneRecord` | Runs a full sync; asserts at least one `AnyCAPluginCertificate` record is produced | +xUnit buffers test output until the test ends, so read the per-pass report at the end of the log. +Run only one full-history synchronization at a time against a shared account; full syncs are +slow. --- ## Authentication -The CERTInext API uses HMAC-SHA256 authentication computed for every request: +V1 uses the AccessKey digest the plugin computes for every request: ``` authKey = SHA256(accessKey + ts + txn) (lowercase hex) ``` -Where: -- `accessKey` is the raw API Access Key from `CERTINEXT_ACCESS_KEY` -- `ts` is the current timestamp in ISO 8601 format -- `txn` is a random numeric transaction ID - -The `CERTInextClient` handles this computation automatically. The raw access key is -never transmitted over the wire — only the derived `authKey` hash is sent. +`accessKey` is `CERTINEXT_ACCESS_KEY`, `ts` is the current ISO 8601 timestamp, and `txn` is a random +numeric transaction ID. The client computes this itself, and the raw access key is never transmitted. +V2 tests authenticate with OAuth2 `client_credentials` using `CERTINEXT_CLIENT_ID` and +`CERTINEXT_CLIENT_SECRET`. --- @@ -157,7 +189,14 @@ never transmitted over the wire — only the derived `authKey` hash is sent. | Symptom | Likely cause | Fix | |---------|-------------|-----| -| All tests skipped | Missing or empty `~/.env_certinext` | Create the file with required variables | -| `Ping` fails with 401 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key in the CERTInext portal | -| `Ping` fails with timeout | Wrong `CERTINEXT_API_URL` | Verify the URL matches your account region | -| `GetOrderReport` returns 0 orders | Account has no orders | Place a test order first (see `make generate-order` in the project Makefile) | +| All tests skipped | Missing or empty `~/.env_certinext` (V1) or `~/.env_certinext_v2` (V2) | Create the file with the required variables | +| V1 fixture throws about `/emSignHub-API` | A V2 `CERTINEXT_API_URL` leaked into the shell | Run `unset CERTINEXT_API_URL`, or open a fresh shell, and source only `~/.env_certinext` | +| `Ping` fails with 401/403 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key under Integrations → APIs | +| `Ping` fails with a timeout or 404 | Wrong `CERTINEXT_API_URL` | Check the URL against your account's region (V1 needs the `/emSignHub-API` path) | +| V2 token request fails with 401 | Wrong client ID or secret | Check `CERTINEXT_CLIENT_ID` and `CERTINEXT_CLIENT_SECRET` | +| V2 token request fails with 403 | Key not generated in OAuth mode | Create a new OAuth-mode key in the portal | +| `Enroll` fails with "Invalid Product Code" (EMS-1162) | `CERTINEXT_PRODUCT_CODE` isn't provisioned for the account | Run `make probe-products` and use a code the account accepts | +| `GetProductDetails` returns an empty list | `CERTINEXT_GROUP_NUMBER` not set | Add your group number; some accounts need it | +| `Enroll` fails with "Invalid Organization Number" (EMS-1073) | OV/EV order with an unregistered organization | Use a DV product for automated tests, or register and approve the organization | +| Revoke step skips with "not GENERATED" | A sandbox DV order needs domain validation before it is issued | Expected without DCV; run the DCV tests with Cloudflare configured, or approve the order in the portal | +| `OrderReportTests` skip | The account has no orders | Run `LifecycleTests` first to place one | diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs index 8e4f637..abebc1a 100644 --- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs +++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs @@ -20,6 +20,85 @@ 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 either env file. These gate destructive or mutating tests + /// so a developer cannot accidentally arm them by leaving flags in ~/.env_certinext + /// OR ~/.env_certinext_v2. Exposed internal (rather than private) so + /// can exclude the same names from its own + /// promotion of ~/.env_certinext_v2 — without that, a flag left in the V2 file would + /// be read as unset by the first test class constructed in a run, then promoted into + /// process env, silently arming every later test in the same run. + /// + internal static readonly System.Collections.Generic.HashSet _optInOnlyFlags = + new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase) + { + "CERTINEXT_COMPLETE_PENDING", + "CERTINEXT_RUN_BULK_TEST", + "CERTINEXT_V2_RUN_BULK_TEST", + "CERTINEXT_PRIVATE_PKI_LIVE", + "CERTINEXT_V2_OPS_TESTS", + // V2 full-lifecycle readiness suite (DV UCC / OV / OV UCC / EV / wildcard DV / + // renew+reissue / fresh-domain DCV) — each places real sandbox orders and must + // be armed individually in the real shell, never left in either env file. + "CERTINEXT_V2_LIFECYCLE_DV_UCC", + "CERTINEXT_V2_LIFECYCLE_OV", + "CERTINEXT_V2_LIFECYCLE_OV_UCC", + "CERTINEXT_V2_LIFECYCLE_EV", + "CERTINEXT_V2_LIFECYCLE_WILDCARD_DV", + "CERTINEXT_V2_LIFECYCLE_RENEW_REISSUE", + "CERTINEXT_V2_LIFECYCLE_FRESH_DCV", + }; + + // --------------------------------------------------------------------------- + // V1 env keys + // --------------------------------------------------------------------------- + + internal const string ApiUrlKey = "CERTINEXT_API_URL"; + internal const string AccessKeyKey = "CERTINEXT_ACCESS_KEY"; + internal const string AccountNumberKey = "CERTINEXT_ACCOUNT_NUMBER"; + internal const string GroupNumberKey = "CERTINEXT_GROUP_NUMBER"; + internal const string OrgNumberKey = "CERTINEXT_ORG_NUMBER"; + internal const string ProductCodeKey = "CERTINEXT_PRODUCT_CODE"; + internal const string RequestorEmailKey = "CERTINEXT_REQUESTOR_EMAIL"; + internal const string RequestorNameKey = "CERTINEXT_REQUESTOR_NAME"; + internal const string CloudflareApiTokenKey = "CERTINEXT_CF_API_TOKEN"; + internal const string CloudflareZoneIdKey = "CERTINEXT_CF_ZONE_ID"; + + /// + /// Path segment every V1 (emSignHub-API) base URL carries. A resolved + /// without it is almost always the V2 base URL from + /// ~/.env_certinext_v2. + /// + internal const string V1ApiPathSegment = "/emSignHub-API"; + + /// + /// Every env key the V1 side of the harness reads: the keys this fixture resolves, plus + /// CERTINEXT_DCV_DOMAIN, which V1 DcvLifecycleTests reads straight from + /// process env. must never write these into + /// process env, because real env vars take precedence over ~/.env_certinext here + /// and the V2 file defines the same names with V2 values. + /// + internal static readonly IReadOnlySet V1EnvKeys = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + ApiUrlKey, + AccessKeyKey, + AccountNumberKey, + GroupNumberKey, + OrgNumberKey, + ProductCodeKey, + RequestorEmailKey, + RequestorNameKey, + CloudflareApiTokenKey, + CloudflareZoneIdKey, + "CERTINEXT_DCV_DOMAIN", + }; + // --------------------------------------------------------------------------- // Credential properties // --------------------------------------------------------------------------- @@ -83,29 +162,41 @@ 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); + ApiUrl = GetEnvValue(env, ApiUrlKey); + AccessKey = GetEnvValue(env, AccessKeyKey); + AccountNumber = GetEnvValue(env, AccountNumberKey); + GroupNumber = GetEnvValue(env, GroupNumberKey); + OrgNumber = GetEnvValue(env, OrgNumberKey); + ProductCode = GetEnvValue(env, ProductCodeKey); + RequestorEmail = GetEnvValue(env, RequestorEmailKey); + RequestorName = GetEnvValue(env, RequestorNameKey); - ApiUrl = GetEnvValue(env, "CERTINEXT_API_URL"); - AccessKey = GetEnvValue(env, "CERTINEXT_ACCESS_KEY"); - AccountNumber = GetEnvValue(env, "CERTINEXT_ACCOUNT_NUMBER"); - GroupNumber = GetEnvValue(env, "CERTINEXT_GROUP_NUMBER"); - OrgNumber = GetEnvValue(env, "CERTINEXT_ORG_NUMBER"); - ProductCode = GetEnvValue(env, "CERTINEXT_PRODUCT_CODE"); - RequestorEmail = GetEnvValue(env, "CERTINEXT_REQUESTOR_EMAIL"); - RequestorName = GetEnvValue(env, "CERTINEXT_REQUESTOR_NAME"); - - CloudflareApiToken = GetEnvValue(env, "CERTINEXT_CF_API_TOKEN"); - CloudflareZoneId = GetEnvValue(env, "CERTINEXT_CF_ZONE_ID"); + CloudflareApiToken = GetEnvValue(env, CloudflareApiTokenKey); + CloudflareZoneId = GetEnvValue(env, CloudflareZoneIdKey); IsCloudflareConfigured = !string.IsNullOrWhiteSpace(CloudflareApiToken) && !string.IsNullOrWhiteSpace(CloudflareZoneId); IsConfigured = !string.IsNullOrWhiteSpace(ApiUrl) && !string.IsNullOrWhiteSpace(AccessKey); + // Fail fast (before promoting anything into process env and before any + // client/network call) when a V2 base URL has leaked into the V1 fixture. Only + // checked when the fixture would otherwise be configured, so an unconfigured run + // still skips cleanly. + if (IsConfigured) + EnsureV1ApiUrl(ApiUrl, + fromProcessEnvironment: System.Environment.GetEnvironmentVariable(ApiUrlKey) != null); + + // Promote env-file values into the process environment so that any code + // calling System.Environment.GetEnvironmentVariable() picks them up. + // Opt-in destructive-test flags are deliberately excluded: they must be + // set explicitly in the shell so a developer who leaves them in the file + // does not accidentally arm bulk/mutating tests on every bare `dotnet test`. + foreach (var kv in env) + if (System.Environment.GetEnvironmentVariable(kv.Key) == null + && !_optInOnlyFlags.Contains(kv.Key)) + System.Environment.SetEnvironmentVariable(kv.Key, kv.Value); + if (IsConfigured) { Config = new CERTInextConfig @@ -141,8 +232,11 @@ public void Dispose() { } /// /// Reads a KEY=VALUE file, stripping blank lines and lines starting with '#'. /// Real environment variables overlay the file so CI overrides always win. + /// defaults to the real process environment; unit + /// tests pass their own so they never have to mutate shared process state. /// - private static Dictionary LoadEnvFile(string path) + internal static Dictionary LoadEnvFile( + string path, System.Collections.IDictionary processEnvironment = null) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -165,7 +259,8 @@ private static Dictionary LoadEnvFile(string path) } // Real environment variables take precedence over the file - foreach (System.Collections.DictionaryEntry de in System.Environment.GetEnvironmentVariables()) + foreach (System.Collections.DictionaryEntry de in + processEnvironment ?? System.Environment.GetEnvironmentVariables()) { string k = de.Key?.ToString(); string v = de.Value?.ToString(); @@ -198,6 +293,36 @@ internal static string ParseEnvValue(string rawValue) return val; } + /// + /// Throws when lacks + /// the V1 , i.e. a V2 base URL has leaked into the V1 + /// fixture. Left unchecked, every V1 call 404s and surfaces as a misleading + /// "unrecognised error body". The message names the key and where it came from, and + /// shows only scheme/host/path — never credentials, userinfo, or query strings. + /// Exposed internal for direct unit-testing. + /// + internal static void EnsureV1ApiUrl(string apiUrl, bool fromProcessEnvironment) + { + if (string.IsNullOrWhiteSpace(apiUrl) || + apiUrl.IndexOf(V1ApiPathSegment, StringComparison.OrdinalIgnoreCase) >= 0) + return; + + string shown = Uri.TryCreate(apiUrl.Trim(), UriKind.Absolute, out Uri uri) + ? $"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}" + : "(not an absolute URL)"; + string source = fromProcessEnvironment + ? "the process environment (real env vars override ~/.env_certinext)" + : "~/.env_certinext"; + + throw new InvalidOperationException( + $"IntegrationTestFixture: {ApiUrlKey} resolved to '{shown}' (from {source}), which lacks " + + $"the V1 path segment '{V1ApiPathSegment}'. This looks like a CERTInext V2 base URL leaking " + + "into the V1 fixture; V1 calls against it fail with 'unrecognised error body'. " + + "Source only ~/.env_certinext into the shell (set -a; . ~/.env_certinext; set +a), never " + + "~/.env_certinext_v2 — the V2 tests read that file from disk themselves. In an already-" + + $"polluted shell, run 'unset {ApiUrlKey}' or open a fresh shell."); + } + private static string GetEnvValue(Dictionary env, string key) { return env.TryGetValue(key, out string val) ? val : string.Empty; diff --git a/CERTInext.IntegrationTests/KfclabCsrEmitterTests.cs b/CERTInext.IntegrationTests/KfclabCsrEmitterTests.cs new file mode 100644 index 0000000..159cb8f --- /dev/null +++ b/CERTInext.IntegrationTests/KfclabCsrEmitterTests.cs @@ -0,0 +1,82 @@ +// 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 +// +// Utility: emit BouncyCastle-generated PKCS#10 CSRs (CN + DNS SANs) to disk for manual +// Command-driven lab enrollments (e.g. Command Reissue via /Enrollment/CSR, UCC multi-SAN +// checks). Makes no CA calls. Opt-in: set CERTINEXT_EMIT_CSR_DIR (output directory) and +// CERTINEXT_EMIT_CSR_SPEC, a ';'-separated list of "=[,...]". +// The CN is always included as the first DNS SAN. +// +// Example: +// CERTINEXT_EMIT_CSR_DIR=/tmp/csrs \ +// CERTINEXT_EMIT_CSR_SPEC="reissue=a.example.com;ucc=b.example.com,c.example.com" \ +// dotnet test --filter FullyQualifiedName~KfclabCsrEmitterTests + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +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 KfclabCsrEmitterTests + { + private readonly ITestOutputHelper _out; + + public KfclabCsrEmitterTests(ITestOutputHelper output) + { + _out = output; + } + + private static string GenerateCsrPem(string cn, IReadOnlyList dnsSans) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var kp = keyGen.GenerateKeyPair(); + + var names = new GeneralNames(dnsSans.Select(s => new GeneralName(GeneralName.DnsName, s)).ToArray()); + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, false, names); + var attrs = new DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, new DerSet(extGen.Generate()))); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attrs, kp.Private); + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----\n"; + } + + [SkippableFact] + public void EmitCsrs() + { + string dir = Environment.GetEnvironmentVariable("CERTINEXT_EMIT_CSR_DIR"); + string spec = Environment.GetEnvironmentVariable("CERTINEXT_EMIT_CSR_SPEC"); + Skip.If(string.IsNullOrWhiteSpace(dir) || string.IsNullOrWhiteSpace(spec), + "Set CERTINEXT_EMIT_CSR_DIR and CERTINEXT_EMIT_CSR_SPEC to emit CSRs."); + + Directory.CreateDirectory(dir); + foreach (string entry in spec.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + string[] kv = entry.Split('=', 2); + Assert.True(kv.Length == 2, $"Bad CSR spec entry '{entry}' (expected =[,...])."); + string[] hosts = kv[1].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + Assert.NotEmpty(hosts); + + string path = Path.Combine(dir, kv[0] + ".csr"); + File.WriteAllText(path, GenerateCsrPem(hosts[0], hosts)); + _out.WriteLine($"{path}: CN={hosts[0]} SANs={string.Join(",", hosts)}"); + } + } + } +} diff --git a/CERTInext.IntegrationTests/PendingDvDiagnosticsTests.cs b/CERTInext.IntegrationTests/PendingDvDiagnosticsTests.cs new file mode 100644 index 0000000..49fa064 --- /dev/null +++ b/CERTInext.IntegrationTests/PendingDvDiagnosticsTests.cs @@ -0,0 +1,123 @@ +// 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 +// +// Read-only diagnostic for pending-DV orders that won't advance through sync-DCV. +// For each "orderId|domain" pair in CERTINEXT_DIAG_ORDER_IDS (comma-separated), it +// dumps the TrackOrder DCV state and probes GetDcv to determine whether CERTInext +// has actually exposed a DCV challenge for the order — the question that decides +// whether the plugin's deferred-DCV retry can ever complete it. +// +// Run: +// export CERTINEXT_DIAG_ORDER_IDS="9937569678|bulk-0b3cbd54.scrup.org,6373633518|bulk-49818a84.scrup.org" +// dotnet test --filter FullyQualifiedName~PendingDvDiagnostics + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class PendingDvDiagnosticsTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _out; + + public PendingDvDiagnosticsTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _out = output; + } + + [SkippableFact] + public async Task PendingDvDiagnostics_DumpDcvState() + { + IntegrationSkip.IfNotConfigured(_fixture); + + string raw = Environment.GetEnvironmentVariable("CERTINEXT_DIAG_ORDER_IDS"); + Skip.If(string.IsNullOrWhiteSpace(raw), + "Set CERTINEXT_DIAG_ORDER_IDS=\"orderId|domain,orderId|domain,...\" to run the diagnostic."); + + var pairs = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(p => + { + var bits = p.Split('|', 2); + return (Id: bits[0].Trim(), Domain: bits.Length > 1 ? bits[1].Trim() : null); + }) + .ToList(); + + var ct = CancellationToken.None; + int challengeReady = 0, challengeNotReady = 0, alreadyValidated = 0, errored = 0; + + foreach (var (id, domain) in pairs) + { + _out.WriteLine($"==================== Order {id} ({domain ?? "?"}) ===================="); + ICERTInextClient client = _fixture.Client; + + try + { + var track = await client.TrackOrderAsync(id, ct); + var od = track.OrderDetails; + _out.WriteLine($" OrderStatus: {od?.OrderStatus} (id={od?.OrderStatusId})"); + _out.WriteLine($" CertStatus: {od?.CertificateStatus} (id={od?.CertificateStatusId})"); + + var dv = od?.DomainVerification; + if (dv == null) + { + _out.WriteLine(" DomainVerification: (CERTInext has NOT exposed a DCV challenge slot)"); + } + else + { + _out.WriteLine($" DomainVerification.status: '{dv.Status}' (0=Pending,1=Validated,2=Rejected)"); + var entries = dv.GetDomainEntries(); + if (entries.Count == 0) + _out.WriteLine(" per-domain entries: "); + foreach (var kv in entries) + _out.WriteLine( + $" [{kv.Key}] dcvMethod='{kv.Value.DcvMethod}' dcvStatus='{kv.Value.DcvStatus}' " + + $"status='{kv.Value.Status}' caaStatus='{kv.Value.CaaStatus}' verifiedDate='{kv.Value.VerifiedDate}'"); + + if (dv.Status == Constants.Dcv.StatusValidated || + entries.Values.All(e => e.DcvStatus == Constants.Dcv.StatusValidated)) + alreadyValidated++; + } + + // Probe GetDcv — the decisive test: does CERTInext hand back a challenge token? + if (!string.IsNullOrWhiteSpace(domain)) + { + try + { + var dcv = await client.GetDcvAsync(id, domain, Constants.Dcv.MethodDnsTxt, ct); + bool tokenPresent = !string.IsNullOrWhiteSpace(dcv.DcvDetails?.Token); + _out.WriteLine($" GetDcv: tokenPresent={tokenPresent}"); + if (tokenPresent) challengeReady++; + } + catch (Exception gex) + { + _out.WriteLine($" GetDcv: FAILED -> {gex.Message}"); + if (gex.Message.Contains("956", StringComparison.OrdinalIgnoreCase) || + gex.Message.Contains("not ready", StringComparison.OrdinalIgnoreCase)) + challengeNotReady++; + else + errored++; + } + } + } + catch (Exception ex) + { + _out.WriteLine($" TrackOrder FAILED: {ex.Message}"); + errored++; + } + } + + _out.WriteLine(""); + _out.WriteLine($"=== SUMMARY over {pairs.Count} orders: " + + $"challengeReady={challengeReady}, challengeNotReady={challengeNotReady}, " + + $"alreadyValidated={alreadyValidated}, errored={errored} ==="); + } + } +} diff --git a/CERTInext.IntegrationTests/PrivatePkiV2LiveTests.cs b/CERTInext.IntegrationTests/PrivatePkiV2LiveTests.cs new file mode 100644 index 0000000..2204457 --- /dev/null +++ b/CERTInext.IntegrationTests/PrivatePkiV2LiveTests.cs @@ -0,0 +1,481 @@ +// 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.Net; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +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 Org.BouncyCastle.X509; +using Org.BouncyCastle.X509.Extension; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Opt-in live verification of the V2 private-pki enrollment path end to end through + /// the real plugin surface: plugin.Enroll with + /// ProductFamily=private-pki / ProductVariant=intranet-ssl against the CERTInext + /// sandbox, then plugin.Revoke (CRL reason 4, superseded) in cleanup. + /// + /// PLACES EXACTLY ONE REAL ORDER. Gated behind CERTINEXT_PRIVATE_PKI_LIVE=1, which must be + /// exported in the shell (it is read from the process environment before the V2 env file is + /// promoted). Skips with no network calls when the flag or the V2 OAuth2 credentials are absent. + /// No retries anywhere: a thrown or FAILED enroll is reported, never re-attempted. + /// + /// Env: + /// CERTINEXT_PRIVATE_PKI_LIVE=1 required opt-in + /// CERTINEXT_PRIVATE_PKI_PRODUCT_CODE default 149 (Sandbox emSign Intranet SSL 1 Year) + /// CERTINEXT_PRIVATE_PKI_CN default pki0033-<UTC MMddHHmm>.intranet.lab + /// V2 creds (CERTINEXT_API_URL / CERTINEXT_CLIENT_ID / CERTINEXT_CLIENT_SECRET) are loaded + /// from ~/.env_certinext_v2 by , same as . + /// + [Collection(PrivatePkiV2LiveCollection.Name)] + public class PrivatePkiV2LiveTests : IClassFixture + { + private const string OptInFlag = "CERTINEXT_PRIVATE_PKI_LIVE"; + private const string IpSan = "10.0.0.50"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + private readonly bool _optedIn; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _productCode; + private readonly string _cnOverride; + private readonly bool _v2CredsPresent; + + public PrivatePkiV2LiveTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + // Read the opt-in flag from the real process environment BEFORE promoting the V2 env + // file, so leaving the flag in ~/.env_certinext_v2 cannot arm this order-placing test. + _optedIn = Environment.GetEnvironmentVariable(OptInFlag)?.Trim() == "1"; + + var env = V2EnvHelper.LoadAndPromote(); + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _productCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRIVATE_PKI_PRODUCT_CODE", "149"); + _cnOverride = V2EnvHelper.GetEnv(env, "CERTINEXT_PRIVATE_PKI_CN"); + + _v2CredsPresent = !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + /// + /// V2 config for the private-pki order: mirrors V2LifecycleTests.BuildV2Config (V2 + /// mode, no V1-only fields, requestor placeholders) with DCV disabled. Private PKI has no DCV + /// anyway; disabling it keeps the no-DCV and DCV builds on the same path. + /// + private CERTInextConfig BuildV2Config() => new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + PageSize = 100, + + DcvEnabled = false + }; + + /// + /// BouncyCastle-only RSA-2048 PKCS#10 CSR with a SAN extension request. Same construction as + /// KfclabCsrEmitterTests.GenerateCsrPem (which is private and DNS-only), extended to + /// carry IP SANs. + /// + private static string GenerateCsrPem(string cn, IReadOnlyList dnsSans, IReadOnlyList ipSans) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var kp = keyGen.GenerateKeyPair(); + + var generalNames = dnsSans.Select(d => new GeneralName(GeneralName.DnsName, d)) + .Concat(ipSans.Select(ip => new GeneralName(GeneralName.IPAddress, ip))) + .ToArray(); + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, false, new GeneralNames(generalNames)); + var attrs = new DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, new DerSet(extGen.Generate()))); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attrs, kp.Private); + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----\n"; + } + + /// Parses the first (leaf) PEM block of a possibly chained PEM string. + private static X509Certificate ParseLeaf(string pem) + { + var m = Regex.Match(pem ?? string.Empty, + @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", RegexOptions.Singleline); + if (!m.Success) return null; + string b64 = m.Groups[1].Value.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + return new X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + } + + /// SAN entries as ("dns"|"ip"|"other:<tag>", value), read with BouncyCastle. + private static List<(string Type, string Value)> ReadSans(X509Certificate cert) + { + var result = new List<(string, string)>(); + var ext = cert.GetExtensionValue(X509Extensions.SubjectAlternativeName); + if (ext == null) return result; + + var names = GeneralNames.GetInstance(X509ExtensionUtilities.FromExtensionValue(ext)); + foreach (var gn in names.GetNames()) + { + switch (gn.TagNo) + { + case GeneralName.DnsName: + result.Add(("dns", DerIA5String.GetInstance(gn.Name).GetString())); + break; + case GeneralName.IPAddress: + // IPAddress parses raw octets only (no crypto) — not a BCL-crypto dependency. + result.Add(("ip", new IPAddress(Asn1OctetString.GetInstance(gn.Name).GetOctets()).ToString())); + break; + default: + result.Add(($"other:{gn.TagNo}", gn.Name.ToString())); + break; + } + } + return result; + } + + [SkippableFact] + public async Task PrivatePki_V2_EnrollIntranetSsl_ThenRevoke_Live() + { + Skip.If(!_optedIn, + $"{OptInFlag} is not set to 1 — this test places ONE real private-pki order; skipping (no network calls)."); + Skip.If(!_v2CredsPresent, + "V2 OAuth2 credentials (CERTINEXT_API_URL / CERTINEXT_CLIENT_ID / CERTINEXT_CLIENT_SECRET) not configured — skipping (no network calls)."); + + string cn = string.IsNullOrWhiteSpace(_cnOverride) + ? $"pki0033-{DateTime.UtcNow:MMddHHmm}.intranet.lab" + : _cnOverride.Trim(); + + var config = BuildV2Config(); + var realClient = new CERTInextClient(config); + // Pass-through proxy around the real client: records the order id the moment + // PlaceOrderV2Async returns, so cleanup still knows the order if a later step inside + // Enroll (CSR submit, track, download) throws before an EnrollmentResult exists. + var recorder = OrderIdRecordingClientProxy.Wrap(realClient, out ICERTInextClient proxiedClient); + var plugin = new CERTInextCAPlugin(proxiedClient, config); + + var productInfo = new EnrollmentProductInfo + { + ProductID = _productCode, + ProductParameters = new Dictionary + { + [Constants.EnrollmentParam.ProductFamily] = "private-pki", + [Constants.EnrollmentParam.ProductVariant] = Constants.ApiV2.PrivatePkiVariantIntranetSsl, + [Constants.EnrollmentParam.ProductCode] = _productCode, + } + }; + // Gateway SAN dictionary exactly as Command sends it ("dnsname" / "ipaddress" keys). + var san = new Dictionary + { + ["dnsname"] = new[] { cn }, + ["ipaddress"] = new[] { IpSan }, + }; + + _output.WriteLine("=== private-pki live enrollment (ONE order, no retries) ==="); + _output.WriteLine($"Family=private-pki, Variant={Constants.ApiV2.PrivatePkiVariantIntranetSsl}, ProductCode={_productCode}"); + _output.WriteLine($"CN={cn}, SANs: dnsname=[{cn}], ipaddress=[{IpSan}]"); + + string orderId = null; + // Set only once Enroll returned GENERATED with a certificate body; cleanup revokes an + // issued order and cancels anything else. + bool issued = false; + try + { + EnrollmentResult result = null; + Exception enrollEx = null; + try + { + result = await plugin.Enroll( + csr: GenerateCsrPem(cn, new[] { cn }, new[] { IpSan }), + subject: $"CN={cn}", + san: san, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) + { + enrollEx = ex; + } + + orderId = !string.IsNullOrWhiteSpace(result?.CARequestID) ? result.CARequestID : recorder.PlacedOrderId; + + // Order id first — before anything below that can fail. + _output.WriteLine($"CARequestID (order id): {orderId ?? ""}"); + _output.WriteLine($" (recorded from PlaceOrderV2Async: {recorder.PlacedOrderId ?? ""}, " + + $"initial CA status: {recorder.PlacedOrderStatus ?? ""})"); + + if (enrollEx != null) + { + _output.WriteLine($"Enroll THREW {enrollEx.GetType().Name}: {enrollEx.Message}"); + _output.WriteLine("Not retrying. NOTE: a client-side timeout does not prove no order exists — if the " + + "order id above is , check the CERTInext portal for a private-pki order " + + $"with hostname '{cn}'."); + ExceptionDispatchInfo.Capture(enrollEx).Throw(); + } + + if (result == null) + { + _output.WriteLine("Enroll returned a null EnrollmentResult. Not retrying."); + Assert.Fail("private-pki Enroll returned a null EnrollmentResult."); + return; // unreachable + } + + _output.WriteLine($"Enroll status: {result.Status} ({(EndEntityStatus)result.Status})"); + _output.WriteLine($"Enroll message: {result.StatusMessage}"); + + if (result.Status == (int)EndEntityStatus.FAILED) + { + _output.WriteLine("Enroll returned FAILED. Not retrying; no further order will be placed."); + Assert.Fail($"private-pki Enroll returned FAILED: {result.StatusMessage}"); + } + + if (result.Status != (int)EndEntityStatus.GENERATED || string.IsNullOrWhiteSpace(result.Certificate)) + { + _output.WriteLine($"Order {orderId} is still pending (not issued within the plugin's pickup poll). " + + "Not polling further; cleanup below will cancel it once and otherwise print " + + "manual-cleanup instructions."); + Assert.Fail($"INCONCLUSIVE: private-pki order '{orderId}' did not issue within the pickup poll " + + $"(status {result.Status}); end-to-end issuance not verified."); + } + + issued = true; + var leaf = ParseLeaf(result.Certificate); + leaf.Should().NotBeNull("the GENERATED result must carry a parseable leaf certificate PEM"); + + var sans = ReadSans(leaf); + _output.WriteLine($"Issued subject: {leaf.SubjectDN}"); + _output.WriteLine($"Issued issuer: {leaf.IssuerDN}"); + _output.WriteLine($"Issued serial: {leaf.SerialNumber.ToString(16).ToUpperInvariant()}"); + _output.WriteLine($"Issued SANs: [{string.Join(", ", sans.Select(s => $"{s.Type}:{s.Value}"))}]"); + _output.WriteLine($"Validity: {leaf.NotBefore:o} .. {leaf.NotAfter:o}"); + + sans.Should().Contain(s => s.Type == "ip" && s.Value == IpSan, + "the IP SAN submitted via additionalHosts must appear on the issued certificate"); + sans.Should().Contain(s => s.Type == "dns" && string.Equals(s.Value, cn, StringComparison.OrdinalIgnoreCase), + "the CN (sent as hostname) must appear as a DNS SAN on the issued certificate"); + } + finally + { + await CleanupAsync(plugin, realClient, orderId, cn, issued); + realClient.Dispose(); + } + } + + /// + /// Best-effort cleanup: exactly one cleanup action, never retried, never throwing (a cleanup + /// failure must not mask the test's own result). An issued order is revoked via + /// plugin.Revoke (CRL reason 4, superseded); any other order is cancelled via + /// on the private-pki family + /// (plugin.Revoke refuses non-issued orders). Manual-cleanup instructions are printed + /// only when that action fails. If Enroll's own Submit CSR failure path already cancelled the + /// order, this cancel reports the CA's 422 "already terminal" answer. Finishes with one + /// read-only GET on the private-pki order. + /// + private async Task CleanupAsync(CERTInextCAPlugin plugin, CERTInextClient client, string orderId, string cn, bool issued) + { + _output.WriteLine("--- Cleanup ---"); + if (string.IsNullOrWhiteSpace(orderId)) + { + _output.WriteLine("No order id captured — nothing to revoke or cancel. If Enroll threw after sending the " + + $"create request, check the CERTInext portal for a private-pki order with hostname '{cn}'."); + return; + } + + bool cleanedUp = false; + if (issued) + { + try + { + int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */); + _output.WriteLine($"Revoke(order={orderId}, reason=4 superseded) returned {revokeResult} ({(EndEntityStatus)revokeResult})."); + cleanedUp = true; + } + catch (Exception ex) + { + _output.WriteLine($"Revoke FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}"); + } + } + else + { + try + { + var outcome = await client.CancelOrderV2Async( + Constants.ApiV2.FamilyPrivatePki, orderId, "Keyfactor plugin live test cleanup."); + _output.WriteLine($"CancelOrderV2Async(private-pki, order={orderId}) returned {outcome}" + + (outcome == V2CancelOrderOutcome.AlreadyTerminal + ? " (HTTP 422: already in a terminal state; nothing cancelled)." + : " (HTTP 2xx: order cancelled).")); + cleanedUp = outcome == V2CancelOrderOutcome.Cancelled; + } + catch (Exception ex) + { + _output.WriteLine($"Cancel FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}"); + } + } + + if (!cleanedUp) + { + _output.WriteLine("Not retrying. Unless the track below shows the order cancelled or revoked, MANUAL " + + "CLEANUP REQUIRED: in the CERTInext portal, cancel (if pending) or revoke (if issued) " + + $"order id {orderId}, product family private-pki " + + $"({Constants.ApiV2.PrivatePkiCertificatesPath}/{orderId})."); + } + + try + { + var (status, _, body) = await client.ProbeV2GetAsync($"{Constants.ApiV2.PrivatePkiCertificatesPath}/{orderId}"); + _output.WriteLine($"Post-cleanup track (read-only GET, private-pki): HTTP {status}, " + + $"status={Field(body, "status")}, certificateState={Field(body, "certificateState")}, " + + $"orderState={Field(body, "orderState")}, revocation.status={Field(body, "revocation", "status")}, " + + $"revocation.reason={Field(body, "revocation", "reason")}"); + } + catch (Exception ex) + { + _output.WriteLine($"Post-cleanup track failed (read-only; not retried): {ex.GetType().Name}: {ex.Message}"); + } + } + + /// Reads a (nested) string field from a JSON body; "<none>" when absent/unparseable. + private static string Field(string json, params string[] path) + { + if (string.IsNullOrWhiteSpace(json)) return ""; + try + { + using var doc = JsonDocument.Parse(json); + var el = doc.RootElement; + foreach (var p in path) + { + if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(p, out el)) + return ""; + } + return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString(); + } + catch (JsonException) + { + return ""; + } + } + + /// + /// Offline sanity check (no network): the recording proxy can be generated for + /// . A generation failure would otherwise only surface inside + /// the live test, after the opt-in. + /// + [Fact] + public void PrivatePki_V2_RecordingProxy_BuildsOffline() + { + using var client = new CERTInextClient(new CERTInextConfig { UseV2Api = true, ApiUrl = "https://invalid.example" }); + var recorder = OrderIdRecordingClientProxy.Wrap(client, out ICERTInextClient proxied); + proxied.Should().NotBeNull(); + recorder.PlacedOrderId.Should().BeNull(); + } + } + + /// + /// Pass-through over a real that + /// records the order id returned by any PlaceOrderV2Async overload. Changes no behavior: + /// every call is forwarded unchanged and its result/exception returned as-is. + /// + public class OrderIdRecordingClientProxy : DispatchProxy + { + private ICERTInextClient _inner; + + public string PlacedOrderId { get; private set; } + public string PlacedOrderStatus { get; private set; } + + public static OrderIdRecordingClientProxy Wrap(ICERTInextClient inner, out ICERTInextClient proxied) + { + proxied = Create(); + var recorder = (OrderIdRecordingClientProxy)(object)proxied; + recorder._inner = inner; + return recorder; + } + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + object result; + try + { + result = targetMethod.Invoke(_inner, args); + } + catch (TargetInvocationException tie) when (tie.InnerException != null) + { + ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + throw; // unreachable + } + + if (targetMethod.Name == nameof(ICERTInextClient.PlaceOrderV2Async) + && result is Task placeTask) + return RecordAsync(placeTask); + + return result; + } + + private async Task RecordAsync(Task placeTask) + { + var resp = await placeTask; + PlacedOrderId = resp?.OrderId; + PlacedOrderStatus = resp?.Status; + return resp; + } + } + + /// + /// Runs alone: its constructor promotes ~/.env_certinext_v2 + /// into process env (). + /// + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class PrivatePkiV2LiveCollection + { + public const string Name = "PrivatePkiV2Live-NoParallel"; + } +} diff --git a/CERTInext.IntegrationTests/ProductTests.cs b/CERTInext.IntegrationTests/ProductTests.cs index 99f45f3..6ea9b88 100644 --- a/CERTInext.IntegrationTests/ProductTests.cs +++ b/CERTInext.IntegrationTests/ProductTests.cs @@ -2,11 +2,13 @@ // 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.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CERTInext.API; using Xunit; @@ -73,5 +75,40 @@ await act.Should().NotThrowAsync( "in the account's product list when GetProductDetails returns results"); } } + + /// + /// Drives + /// + /// (not just the client method) through the plugin, the same path AnyGatewayREST's + /// ConfigurationValidator exercises when a template is saved. V1 mode (the + /// default, UseV2Api unset) must be unaffected by V2-specific validation paths. + /// + [SkippableFact] + public async Task ValidateProductInfo_V1_AcceptsConfiguredProductCode() + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.If(string.IsNullOrWhiteSpace(_fixture.ProductCode), + "CERTINEXT_PRODUCT_CODE not set — cannot assert against a real product code."); + + var plugin = new Keyfactor.Extensions.CAPlugin.CERTInext.CERTInextCAPlugin(); + var connectionInfo = new Dictionary + { + ["ApiUrl"] = _fixture.ApiUrl, + ["AuthMode"] = "AccessKey", + ["ApiKey"] = _fixture.AccessKey, + ["AccountNumber"] = _fixture.AccountNumber, + ["GroupNumber"] = _fixture.GroupNumber + }; + var productInfo = new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = _fixture.ProductCode } + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connectionInfo); + + await act.Should().NotThrowAsync( + $"configured product code \"{_fixture.ProductCode}\" should validate in V1 mode"); + } } } diff --git a/CERTInext.IntegrationTests/RecordingDomainValidator.cs b/CERTInext.IntegrationTests/RecordingDomainValidator.cs new file mode 100644 index 0000000..4ecaeac --- /dev/null +++ b/CERTInext.IntegrationTests/RecordingDomainValidator.cs @@ -0,0 +1,94 @@ +// 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.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// spy that wraps a real (Cloudflare or stub) validator + /// and records every StageValidation/CleanupValidation call, including the + /// FQDN and staged value, so DCV-on tests can assert whether the plugin actually staged + /// a TXT record rather than just asserting that Enroll did not throw. + /// + internal sealed class RecordingDomainValidator : IDomainValidator + { + private readonly IDomainValidator _inner; + private readonly ConcurrentQueue<(string Fqdn, string Value)> _staged = new(); + private readonly ConcurrentQueue _cleanedUp = new(); + + public RecordingDomainValidator(IDomainValidator inner) + { + _inner = inner; + } + + public IReadOnlyList<(string Fqdn, string Value)> StagedCalls => _staged.ToList(); + public IReadOnlyList CleanedUpFqdns => _cleanedUp.ToList(); + + public void Initialize(IDomainValidatorConfigProvider configProvider) => _inner.Initialize(configProvider); + + public async Task StageValidation(string key, string value, CancellationToken cancellationToken) + { + _staged.Enqueue((key, value)); + return await _inner.StageValidation(key, value, cancellationToken); + } + + public async Task CleanupValidation(string key, CancellationToken cancellationToken) + { + _cleanedUp.Enqueue(key); + return await _inner.CleanupValidation(key, cancellationToken); + } + + public Task ValidateConfiguration(Dictionary configuration) => _inner.ValidateConfiguration(configuration); + public Dictionary GetDomainValidatorAnnotations() => _inner.GetDomainValidatorAnnotations(); + public string GetValidationType() => _inner.GetValidationType(); + } + + /// + /// that wraps another factory and hands out + /// spies so tests can inspect what the plugin + /// actually did with the DNS provider, keyed by (domain, validationType). Does not own + /// disposal of the wrapped factory — callers that build a disposable inner factory + /// (e.g. CloudflareDomainValidatorFactory) remain responsible for disposing it. + /// + internal sealed class RecordingDomainValidatorFactory : IDomainValidatorFactory + { + private readonly IDomainValidatorFactory _inner; + private readonly ConcurrentDictionary _wrapped = new(); + + public RecordingDomainValidatorFactory(IDomainValidatorFactory inner) + { + _inner = inner; + } + + public IDomainValidator ResolveDomainValidator(string domain, string validationType) + { + string cacheKey = $"{domain}|{validationType}"; + return _wrapped.GetOrAdd(cacheKey, _ => new RecordingDomainValidator(_inner.ResolveDomainValidator(domain, validationType))); + } + + /// All StageValidation calls recorded across every domain resolved so far. + public IReadOnlyList<(string Fqdn, string Value)> StagedCalls => + _wrapped.Values.SelectMany(v => v.StagedCalls).ToList(); + + /// All CleanupValidation calls recorded across every domain resolved so far. + public IReadOnlyList CleanedUpFqdns => + _wrapped.Values.SelectMany(v => v.CleanedUpFqdns).ToList(); + } +} diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/TESTING.md index b961130..5db4303 100644 --- a/CERTInext.IntegrationTests/TESTING.md +++ b/CERTInext.IntegrationTests/TESTING.md @@ -1,194 +1,120 @@ -# CERTInext Integration Tests +# CERTInext Integration Tests — Test Catalog -This project contains xUnit integration tests that exercise the CERTInext plugin against -the live CERTInext REST API. All tests skip automatically when credentials are absent, -so the project is safe to include in CI pipelines that do not have API access. +This page lists the tests in `CERTInext.IntegrationTests`, what each one checks, and what to expect +for a given account state. For credentials, opt-in flags, and run commands, see +[INTEGRATION_TESTING.md](INTEGRATION_TESTING.md). + +Every live test skips (is reported as Skipped, not Failed) when its credentials or opt-in flag are +absent. Tests marked **opt-in** place real orders, publish DNS records, or cancel orders, and run +only when their flag is exported in the shell. --- ## Product Codes Are Per-Account -**CERTInext product codes are provisioned per account by eMudhra.** The codes available -to your account are established when the account is created and may differ from any -documentation examples or from codes used by other accounts. - -Key findings verified against sandbox account `9374221333` in April 2026: +CERTInext product codes are provisioned per account by eMudhra. The codes your account can order +are set when the account is created and can differ from documentation examples and from other +accounts. Notes that apply when choosing `CERTINEXT_PRODUCT_CODE`: -- `GetProductDetails` returns an empty list when called without `groupNumber` in the - `productDetails` block on some sandbox accounts. The plugin now passes `groupNumber` - automatically when `GroupNumber` is set in the connector config. -- The SSL/TLS product codes on this sandbox account are `842–851` (not `838–847` as on - the prior dev account). DV SSL is `842` on this account. -- Product code `100` (Private PKI / emSign Intranet SSL) is not provisioned on this - account — `GenerateOrderSSL` returns `EMS-1162: Invalid Product Code`. -- Product code `149` (Sandbox emSign Intranet SSL) appears in `GetProductDetails` for - this account but also returns `EMS-1162` when ordering — it is not usable for orders. -- EV SSL (codes `850`, `851`) requires an `organizationNumber` that is registered and - approved in CERTInext; using an unregistered org returns `EMS-1073: Invalid Organization Number`. -- The `GenerateOrderSSL` API requires `additionalInformation.remarks` in the request body. - Omitting it returns `EMS-918: Additional Information cannot be empty`. +- `GetProductDetails` returns an empty list on some sandbox accounts unless the request carries + `groupNumber`. The plugin sends it automatically when `GroupNumber` is configured. +- Private PKI codes (for example `100`, or `149` for the sandbox "emSign Intranet SSL") need a + separate entitlement. On an account without it, placing an order returns `EMS-1162: Invalid + Product Code` even when the code appears in the catalog. +- EV SSL needs a registered and approved `organizationNumber`; an unregistered one returns + `EMS-1073: Invalid Organization Number`. +- The V1 `GenerateOrderSSL` call requires `additionalInformation.remarks`; the plugin always sends it. -To discover the valid product codes for a new account, use: +To discover the codes your account accepts: ```sh make probe-products ``` -This places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports -which ones return a `requestNumber` (valid) vs. an error (invalid or not provisioned). - ---- - -## Prerequisites - -- .NET 8 or .NET 10 SDK -- Access to a CERTInext sandbox or production account -- An API Access Key generated in the CERTInext portal under **Integrations → APIs** - ---- - -## Credential Setup - -Create the file `~/.env_certinext` with the following content: - -```sh -# CERTInext API credentials -CERTINEXT_API_URL=https://sandbox-us-api.certinext.io/emSignHub-API -CERTINEXT_ACCESS_KEY=your-access-key-here -CERTINEXT_ACCOUNT_NUMBER=your-account-number -CERTINEXT_GROUP_NUMBER=your-group-number -CERTINEXT_ORG_NUMBER=your-org-number -CERTINEXT_PRODUCT_CODE=842 -CERTINEXT_REQUESTOR_EMAIL=you@example.com -CERTINEXT_REQUESTOR_NAME=Your Name -CERTINEXT_REQUESTOR_MOBILE=0000000000 -``` - -### Field reference - -| Variable | Required | Description | -|----------|----------|-------------| -| `CERTINEXT_API_URL` | Yes | Base URL of the CERTInext API (no trailing slash) | -| `CERTINEXT_ACCESS_KEY` | Yes | REST API Access Key from the CERTInext portal (Integrations → APIs) | -| `CERTINEXT_ACCOUNT_NUMBER` | Yes | Your CERTInext account number (numeric string) | -| `CERTINEXT_GROUP_NUMBER` | No | Group number for order placement, filtering, and `GetProductDetails`. Required on some sandbox accounts for `GetProductDetails` to return a non-empty list. | -| `CERTINEXT_ORG_NUMBER` | No | Organization number for OV/EV order placement | -| `CERTINEXT_PRODUCT_CODE` | Yes | Numeric product code for the target account. **This is per-account** — obtain the correct code for your account by calling `GetProductDetails` (or `make probe-products`). Default shown is for sandbox account `9374221333`. | -| `CERTINEXT_REQUESTOR_EMAIL` | Yes | Email submitted with test orders — must be registered in the account | -| `CERTINEXT_REQUESTOR_NAME` | Yes | Name submitted with test orders | -| `CERTINEXT_REQUESTOR_MOBILE` | No | Mobile number submitted with test orders | - -### API URL reference - -| Environment | 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` | - -### Credential file format - -The file is parsed line by line: -- Lines starting with `#` are treated as comments and ignored. -- Blank lines are ignored. -- 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). - ---- - -## Running the Tests - -### Build only - -```sh -dotnet build CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj --configuration Release -``` - -### Run all integration tests - -```sh -dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj --configuration Release -v normal -``` - -### Run a single test class - -```sh -dotnet test CERTInext.IntegrationTests/ --filter "FullyQualifiedName~LifecycleTests" -v normal -``` - -### From the solution root (all tests including unit tests) - -```sh -dotnet test certinext-caplugin.sln --verbosity normal -``` - ---- - -## Skip Behaviour - -Each test calls `IntegrationSkip.IfNotConfigured(fixture)` at the top of the test method. -When `~/.env_certinext` is absent or either `CERTINEXT_API_URL` or `CERTINEXT_ACCESS_KEY` -is empty, every test is reported as **Skipped** rather than Failed. - -Some tests additionally skip when the account has no orders yet (e.g. on a fresh sandbox -account). These tests display a skip reason explaining that the account state does not -satisfy the test's pre-condition. +This places `saveAndHold=1` draft orders for the known SSL/TLS product codes and reports which return +a `requestNumber` (valid) and which return an error (invalid or not provisioned). --- ## Test Classes -### `ConnectivityTests` - -Verifies basic API reachability and credential validity. - -| Test | What it checks | -|------|---------------| -| `Ping_ReturnsSuccess` | Calls `ValidateCredentials`; asserts no exception is thrown | - -### `ProductTests` - -Verifies product discovery. - -| Test | What it checks | -|------|---------------| -| `GetProductDetails_ReturnsProducts` | Calls `GetProductDetails`; asserts the call succeeds without throwing; when products are returned, asserts the expected product code from `CERTINEXT_PRODUCT_CODE` is among them | - -Note: some CERTInext accounts return an empty list from `GetProductDetails` even though -orders using those product codes are visible in `GetOrderReport`. An empty list is -treated as acceptable — only the absence of an exception is mandatory. - -### `OrderReportTests` - -Exercises the `ListOrdersAsync` path used by `Synchronize`. Tests skip gracefully -when the account has no orders rather than failing. - -| Test | What it checks | -|------|---------------| -| `GetOrderReport_ReturnsOrders` | Fetches page 1; skips when account has no orders; otherwise asserts the list is non-empty | -| `GetOrderReport_AllOrders_HaveRequiredFields` | For each order on page 1: `requestNumber`, `productCode`, and `orderDate` are non-empty; skips when account has no orders | - -### `PluginSmokeTests` - -End-to-end tests exercising `CERTInextCAPlugin` via the `IAnyCAPlugin` interface with -a live `CERTInextClient` injected through the `(ICERTInextClient, CERTInextConfig)` -test constructor. - -| Test | What it checks | -|------|---------------| -| `Ping_ThroughPlugin_Succeeds` | Calls `IAnyCAPlugin.Ping()`; asserts no exception | -| `GetProductIds_ReturnsAtLeastOneProduct` | Calls `IAnyCAPlugin.GetProductIds()`; asserts a non-null list is returned without throwing | -| `Synchronize_ReturnsAtLeastOneRecord` | Runs a full sync; skips when account has no records; otherwise asserts at least one `AnyCAPluginCertificate` is produced | - -### `LifecycleTests` - -Full end-to-end lifecycle tests that create real orders against the configured CERTInext -account. These tests do not require any pre-existing account state. - -| Test | What it checks | -|------|---------------| -| `Enroll_Synchronize_Revoke_FullLifecycle` | (1) Generates a fresh RSA-2048 CSR; (2) calls `Enroll` and asserts a non-empty `CARequestID` is returned; (3) runs a full sync and asserts the new order appears by `CARequestID`; (4) attempts revocation — skips gracefully if the order is not yet in an issued/approved state | +### V1 — read-only and basic + +| Class | Test | What it checks | +|---|---|---| +| `ConnectivityTests` | `Ping_ReturnsSuccess` | `ValidateCredentials` succeeds | +| `ProductTests` | `GetProductDetails_ReturnsProducts` | `GetProductDetails` succeeds; when products come back, the configured product code is among them. An empty list is accepted, because some accounts return one | +| | `ValidateProductInfo_V1_AcceptsConfiguredProductCode` | `CERTInextCAPlugin.ValidateProductInfo` in V1 mode accepts `CERTINEXT_PRODUCT_CODE`; skips if it is unset | +| `OrderReportTests` | `GetOrderReport_ReturnsOrders` | Page 1 of `GetOrderReport` is non-empty; skips when the account has no orders | +| | `GetOrderReport_AllOrders_HaveRequiredFields` | Every order on page 1 has `requestNumber`, `productCode`, and `orderDate`; skips when the account has no orders | +| `PluginSmokeTests` | `Ping_ThroughPlugin_Succeeds` | `IAnyCAPlugin.Ping()` through a live client | +| | `GetProductIds_ReturnsAtLeastOneProduct` | `IAnyCAPlugin.GetProductIds()` returns a non-null list | +| | `Synchronize_ReturnsAtLeastOneRecord` | A full sync produces at least one record; skips when the account has none | +| `SmokeTests` | `Ping_Succeeds`, `GetProductDetails_ReturnsProducts`, `ListOrders_ReturnsFirstPage` | Client-level checks of the same endpoints | +| | `TrackOrder_ReturnsDetails`, `GetSingleRecord_ReturnsRecord` | Look up the order in `CERTINEXT_ORDER_ID`; skip when it is unset | +| | `GetSingleRecord_ForAllOrders_AllSucceed`, `Synchronize_DumpsAllRecords` | Read every order and write the results to the test output | + +### V1 — order lifecycle + +| Class | Test | What it checks | +|---|---|---| +| `LifecycleTests` | `Enroll_Synchronize_Revoke_FullLifecycle` | Generates an RSA-2048 CSR, enrolls it and asserts a `CARequestID`, runs a full sync and finds the new order, then attempts revocation. Skips gracefully if the order isn't issued yet | +| `AlgorithmMatrixTests` | `Csr_RoundTripsKeyAlgorithm` | Offline: each key algorithm in the matrix generates a CSR whose signature verifies and whose public key parses back to the same type and size | +| | `Enroll_AcceptsKeyAlgorithm` | **Opt-in** (`CERTINEXT_ALGO_MATRIX`). Submits one order per key algorithm and records whether CERTInext accepts it. CERTInext accepts RSA 2048/3072/4096 and ECC P-256/P-384, and rejects larger RSA, ECC P-521, and Ed25519/Ed448 | + +### V1 — DNS-01 DCV (need Cloudflare credentials unless noted) + +| Class | Test | What it checks | +|---|---|---| +| `DcvLifecycleTests` | `DcvEnroll_CompletesWithoutThrowing` | An enrollment with DCV enabled completes | +| | `EnrollWithoutDcv_DoesNotInvokeDnsProvider` | With DCV disabled, the DNS provider is never called | +| | `EnrollWithDcvOff_OrderAppearsInSync_PluginDidNotInvokeDcv` | An order placed with DCV off still appears in a full sync, and the plugin didn't run DCV | +| | `EnrollWithDcvOn_OrderIssuedEndToEnd_AndAppearsInSync` | Enroll with DCV on, issue the certificate, and find it in a sync | +| | `EnrollWithDcvOn_IssuesPerKeyAlgorithm` | **Opt-in** (`CERTINEXT_ALGO_MATRIX_DCV`). DCV issuance for each key algorithm | +| | `GetSingleRecord_DrivesDcvForPendingOrder` | Needs `CERTINEXT_PENDING_ORDER_ID`. A single-record refresh drives a pending order through DCV | +| | `BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks` | **Opt-in** (`CERTINEXT_RUN_BULK_TEST`). Many concurrent DV enrollments all issue, and sync pagination returns them | +| | `CompleteAllPendingDvOrders` | **Opt-in** (`CERTINEXT_COMPLETE_PENDING`). Repeated full syncs until no DV order remains pending | +| | `FullSync_AllIssuedCerts_CarryParseableCertificateBody` | Every issued record from a full sync carries a parseable certificate | +| `PendingDvDiagnosticsTests` | `PendingDvDiagnostics_DumpDcvState` | Diagnostic. Needs `CERTINEXT_DIAG_ORDER_IDS`. Read-only dump of each listed order's DCV state | + +### V2 — API and lifecycle + +The V2 tests skip unless the V2 credentials are present (see [INTEGRATION_TESTING.md](INTEGRATION_TESTING.md#skip-behaviour)). + +| Class | Test | What it checks | +|---|---|---| +| `V2ApiTests` | `Connectivity_V2_Ping` | `GET /auth/me` succeeds with an OAuth token | +| | `Lifecycle_V2_EnrollTrackRevoke` | Enroll, track, and revoke through the V2 API | +| | `Sync_UsesV2_WithZeroV1Credentials` | Synchronize works with no V1 credentials configured | +| | `GetProductDetails_V2_ReturnsProducts` | The V2 catalog returns products | +| | `ValidateProductInfo_V2_AcceptsConfiguredProductCode`, `ValidateProductInfo_V2_RejectsUnknownProductCode` | Template validation against the V2 catalog | +| | `GetSingleRecord_V2_ReturnsOrderDetails`, `Revoke_V2_IssuedOrder`, `ChainPem_V2_IsAssembled` | Single-record lookup, revocation, and chain assembly for an issued order (`CERTINEXT_V2_ISSUED_ORDER_ID`, or a fresh order) | +| | `DcvFlow_V2_PublishesAndVerifies` | Needs Cloudflare. The V2 DCV flow publishes the TXT record and CERTInext verifies it | +| `V2LifecycleTests` | `Enroll_V2_ReturnsCARequestID`, `Enroll_Synchronize_Revoke_V2_FullLifecycle` | V2 enrollment returns an ID; a full enroll, sync, revoke cycle | +| | `Revoke_V2_ExplicitOrder_Superseded`, `Revoke_V2_IssuedOrder_ReturnsRevoked` | Revocation with an explicit reason, and of an issued order (`CERTINEXT_REVOKE_ORDER_ID`) | +| | `GetSingleRecord_V2_Plugin_ReturnsDetails`, `GetSingleRecord_V2_IssuedOrder_HasParseableCertBody`, `GetSingleRecord_V2_AllSyncedOrders_DoNotThrow` | Single-record lookup through the plugin | +| | `Sync_V2_UsesV2ReportsOrders_ReturnsRecords`, `Sync_V2_WithZeroV1Credentials_Succeeds`, `Sync_V2_SmallPageSize_PaginatesAcrossMultiplePages` | V2 synchronization, with no V1 credentials and across several small pages | +| | `Sync_V2_FullSync_PaginatesEntireHistory` | Set `CERTINEXT_V2_FULL_SYNC_TEST` to run it; can be slow on a shared account | +| `V2DcvLifecycleTests` | `DcvEnroll_V2_CompletesWithoutThrowing`, `EnrollWithoutDcv_V2_DoesNotInvokeDnsProvider`, `GetSingleRecord_V2_DrivesDcvForPendingOrder`, `EnrollWithDcvOn_V2_OrderIssuedEndToEnd_AndAppearsInSync` | The V2 counterparts of the V1 DCV tests (`CERTINEXT_V2_PENDING_ORDER_ID` for the single-record one) | +| | `EnrollWithDcvOn_V2_IssuesPerKeyAlgorithm` | **Opt-in** (`CERTINEXT_V2_ALGO_MATRIX`) | +| | `BulkV2Enrollment_AllOrdersIssue_AndPaginationWorks` | **Opt-in** (`CERTINEXT_V2_RUN_BULK_TEST`) | +| `V2FreshDomainDcvLifecycleTests` | `EnrollWithDcvOn_V2_FreshUnverifiedSubdomain_StagesAndCleansUpTxt`, `EnrollWithDcvOn_V2_WildcardFreshSubdomain_RecordsTxtHostnameAndCleansUp` | **Opt-in** (`CERTINEXT_V2_LIFECYCLE_FRESH_DCV`). DCV against a never-validated subdomain, and against a wildcard on one: the TXT record is staged at the expected hostname and removed afterward | +| `V2FullLifecycleTests` | `Enroll_V2_DvUcc_WithMultipleSans_FullLifecycle`, `Enroll_V2_Ov_FullLifecycle`, `Enroll_V2_OvUcc_WithMultipleSans_FullLifecycle`, `Enroll_V2_Ev_FullLifecycle`, `Enroll_V2_WildcardDv_WildcardOnly_FullLifecycle`, `Enroll_V2_WildcardDv_WildcardPlusApexSan_RecordsActualBehavior`, `EnrollRenewReissue_V2_IssuedDvOrder_RecordsActualBehavior` | **Opt-in** (one `CERTINEXT_V2_LIFECYCLE_*` flag per product, see [INTEGRATION_TESTING.md](INTEGRATION_TESTING.md#opt-in-flags)). Each enrolls, waits, synchronizes, and cleans up its order | +| `PrivatePkiV2LiveTests` | `PrivatePki_V2_EnrollIntranetSsl_ThenRevoke_Live` | **Opt-in** (`CERTINEXT_PRIVATE_PKI_LIVE`). Enrolls a Private PKI Intranet SSL order with DNS and IP SANs, checks the issued SANs, and revokes it. Needs a Private PKI entitlement | +| | `PrivatePki_V2_RecordingProxy_BuildsOffline` | Offline sanity check of the test's recording proxy | +| `V2OrderWindowSweepTests` | `Sweep_ListRecentOrders_ByWindow_DryRun_ThenCancelExplicitIds` | **Opt-in** (`CERTINEXT_V2_OPS_TESTS`). Operations tool: lists V2 orders in a date window and optionally cancels listed IDs | + +### Offline tests and utilities + +| Class | What it covers | +|---|---| +| `IntegrationTestFixtureTests` | Parsing of `KEY=VALUE` env-file values: quote handling and null input | +| `V1FixtureApiUrlGuardTests` | The V1 fixture rejects a V2 `CERTINEXT_API_URL`; the V2 file loader never promotes V1 keys or opt-in flags into the process environment | +| `KfclabCsrEmitterTests` | Utility, not an API test. With `CERTINEXT_EMIT_CSR_DIR` and `CERTINEXT_EMIT_CSR_SPEC` set, writes CSR files for use by external tooling. Makes no CA calls | + +Shared helpers (not tests): `IntegrationTestFixture`, `IntegrationSkip`, `KeyAlgorithms`, +`V2EnvHelper`, `V2DomainStatusHelper`, `V2RawHttpHelpers`, and the DNS validators +`CloudflareDomainValidator`, `RecordingDomainValidator`, and `StubDomainValidator`. --- @@ -199,104 +125,29 @@ account. These tests do not require any pre-existing account state. | Test class | Expected result | |-----------|----------------| | `ConnectivityTests` | Pass — credentials only | -| `ProductTests` | Pass — product list may be empty if `CERTINEXT_GROUP_NUMBER` is not set and the account requires it; test tolerates an empty list | +| `ProductTests` | Pass — the product list may be empty if `CERTINEXT_GROUP_NUMBER` is unset and the account needs it; the test tolerates an empty list | | `OrderReportTests` | Skip — "account has no orders yet" | | `PluginSmokeTests.Synchronize_ReturnsAtLeastOneRecord` | Skip — "account has no certificate records yet" | -| `LifecycleTests.Enroll_Synchronize_Revoke_FullLifecycle` | Skip with "Invalid Product Code" if `CERTINEXT_PRODUCT_CODE` is not provisioned for this account; otherwise the enroll and sync steps pass, and the revoke step skips because the DV SSL sandbox order requires domain control verification and RA approval before it reaches an issued/revocable state | +| `LifecycleTests.Enroll_Synchronize_Revoke_FullLifecycle` | Skip with "Invalid Product Code" if `CERTINEXT_PRODUCT_CODE` isn't provisioned for the account; otherwise enroll and sync pass, and revoke skips because a sandbox DV order needs domain validation before it is issued | -### Account with history (orders previously placed) +### Account with history | Test class | Expected result | |-----------|----------------| -| `ConnectivityTests` | Pass | -| `ProductTests` | Pass | -| `OrderReportTests` | Pass | -| `PluginSmokeTests` | Pass | -| `LifecycleTests` | Pass (all three steps) | - ---- - -## Removed Tests - -The following test files were present in earlier versions but have been removed because -they relied on pre-existing account state that is not portable across accounts or -sandbox environments: - -- **`DraftOrderTests.cs`** — contained five tests that asserted specific `requestNumber` - values (e.g. `4572531551`, `9149755266`) hardcoded from a different developer account. - On any other account these request numbers do not exist so all five tests failed. - -- **`TrackOrderTests.cs`** — contained one test that located a known draft order by - `requestNumber` and asserted its `orderNumber` was null (draft/on-hold semantic). - Same problem: the hardcoded `requestNumber` does not exist on other accounts. - -The intent of those tests (verifying draft-order and track-order semantics) is now -covered indirectly by `LifecycleTests`, which creates its own order and verifies the -resulting state without relying on account-specific identifiers. - ---- - -## Authentication - -The CERTInext API uses HMAC-SHA256 authentication computed for every request: - -``` -authKey = SHA256(accessKey + ts + txn) (lowercase hex) -``` - -Where: -- `accessKey` is the raw API Access Key from `CERTINEXT_ACCESS_KEY` -- `ts` is the current timestamp in ISO 8601 format -- `txn` is a random numeric transaction ID - -The `CERTInextClient` handles this computation automatically. The raw access key is -never transmitted over the wire — only the derived `authKey` hash is sent. - ---- - -## Fresh Account Setup for Integration Tests - -When setting up a brand-new CERTInext sandbox account to run integration tests: - -1. **Discover valid product codes** — run `make probe-products` from the repo root. This places - `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports which ones your - account accepts. Use the first DV SSL code that returns a `requestNumber` as your - `CERTINEXT_PRODUCT_CODE`. - -2. **Set `CERTINEXT_GROUP_NUMBER`** — if `make probe-products` or `GetProductDetails` returns no - products, find your group number in the CERTInext portal under **Delegation → Groups** and add - it to `~/.env_certinext`. The `GetProductDetails` API requires it on some accounts. - -3. **Run connectivity tests first** — `make integration-test` or - `dotnet test CERTInext.IntegrationTests/ -v normal`. The `ConnectivityTests` class verifies - credentials. The `LifecycleTests` class places real orders — it can be run even before any - orders exist. - -4. **Expect the revoke step to skip** — DV SSL orders on the sandbox require domain control - verification (DCV) and RA approval before they are issued. The `LifecycleTests` enroll step - will succeed and sync will find the order, but revoke will skip because the order is in a - pending state. This is the expected behavior for a public DV SSL order in sandbox. To test - revocation, either use a private PKI product that auto-approves, or log in to the CERTInext - portal and manually approve the pending order after `LifecycleTests` runs. - -5. **Account-specific product codes** — update `CERTINEXT_PRODUCT_CODE` in `~/.env_certinext` - with the code discovered in step 1. Do not use `100` (private PKI, not provisioned on - standard accounts) or codes from documentation examples — they may not be provisioned for your - account. - ---- - -## Troubleshooting - -| Symptom | Likely cause | Fix | -|---------|-------------|-----| -| All tests skipped | Missing or empty `~/.env_certinext` | Create the file with `CERTINEXT_API_URL` and `CERTINEXT_ACCESS_KEY` | -| `Ping` fails with 401/403 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key in the CERTInext portal under Integrations → APIs | -| `Ping` fails with timeout or 404 | Wrong `CERTINEXT_API_URL` | Verify the URL matches your account region (see API URL table above) | -| `Enroll` fails with "Invalid Product Code" (EMS-1162) | Wrong `CERTINEXT_PRODUCT_CODE` | Run `make probe-products` to discover the codes provisioned for your account | -| `GetProductDetails` returns empty list | `CERTINEXT_GROUP_NUMBER` not set | Add your group number to `~/.env_certinext`; some accounts require it for `GetProductDetails` to return results | -| `Enroll` fails with "Additional Information cannot be empty" (EMS-918) | Old plugin version missing `additionalInformation.remarks` | Rebuild and redeploy the plugin — the `remarks` field is now populated automatically | -| `Enroll` fails with "Invalid Organization Number" (EMS-1073) | OV/EV product code selected with an unregistered org | Use a DV SSL product code for automated tests, or register and approve your org in CERTInext first | -| Revoke step skips with "not GENERATED" | Sandbox DV SSL order requires domain validation and RA approval | Expected behavior for public DV SSL in sandbox — log in to the CERTInext portal and approve the pending order, then re-run; or use a private PKI product that auto-approves | -| `OrderReportTests` all skip | Fresh account with no orders | Run `LifecycleTests` first to place at least one order | -| `ProductTests` asserts configured product code is not found | `CERTINEXT_PRODUCT_CODE` set to a code not provisioned for the account | Run `make probe-products` and update `CERTINEXT_PRODUCT_CODE` with a valid code | +| `ConnectivityTests`, `ProductTests`, `OrderReportTests`, `PluginSmokeTests` | Pass | +| `LifecycleTests` | Pass for enroll and sync; revoke runs only if the new order is issued | + +The DCV tests complete a DV order end to end only when Cloudflare credentials are configured and the +domain in `CERTINEXT_DCV_DOMAIN` is in that zone. Without them, the revoke step of `LifecycleTests` +skips, because DV orders on the sandbox can't be issued without domain validation. + +### Fresh account setup + +1. **Discover valid product codes** with `make probe-products`. Use the first DV SSL code that + returns a `requestNumber` as `CERTINEXT_PRODUCT_CODE`. +2. **Set `CERTINEXT_GROUP_NUMBER`** if `make probe-products` or `GetProductDetails` returns no + products. Find it in the portal under **Delegation → Groups**. +3. **Run `ConnectivityTests` first**, then `LifecycleTests`, which places a real order and can run + before any orders exist. +4. **Expect the revoke step to skip** without DCV. To exercise revocation, configure Cloudflare so a + DV order can issue, or use a product that issues without domain validation. diff --git a/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs b/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs new file mode 100644 index 0000000..541826d --- /dev/null +++ b/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs @@ -0,0 +1,179 @@ +// 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; +using System.IO; +using FluentAssertions; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Pure offline regression tests (no live-API dependency, no process-env mutation, so they + /// are safe to run in parallel with every other class): + /// + /// the V1 fixture's guard + /// rejects a V2 base URL with an actionable message that never echoes secrets; + /// never promotes a key the V1 side reads, + /// nor any of the fixture's opt-in-only flags. + /// + /// + public class V1FixtureApiUrlGuardTests + { + private const string V1Url = "https://sandbox-us-api.certinext.io/emSignHub-API"; + private const string V2Url = "https://sandbox-us-api.certinext.io"; + + // ------------------------------------------------------------------------- + // (D) fail-fast guard + // ------------------------------------------------------------------------- + + [Theory] + [InlineData(V1Url)] + [InlineData(V1Url + "/")] + [InlineData("https://api.certinext.io/emsignhub-api/")] // case-insensitive + [InlineData("")] // unconfigured: nothing to check + [InlineData(null)] + public void EnsureV1ApiUrl_V1OrEmptyUrl_DoesNotThrow(string apiUrl) + { + Action act = () => IntegrationTestFixture.EnsureV1ApiUrl(apiUrl, fromProcessEnvironment: false); + act.Should().NotThrow(); + } + + [Theory] + [InlineData(V2Url, true)] + [InlineData(V2Url + "/", false)] + [InlineData("https://sandbox-us-api.certinext.io/v2", true)] + public void EnsureV1ApiUrl_V2BaseUrl_ThrowsActionableMessage(string apiUrl, bool fromProcessEnv) + { + Action act = () => IntegrationTestFixture.EnsureV1ApiUrl(apiUrl, fromProcessEnv); + + var ex = act.Should().Throw().Which; + ex.Message.Should().Contain("CERTINEXT_API_URL") + .And.Contain("/emSignHub-API") + .And.Contain("V2 base URL") + .And.Contain("set -a; . ~/.env_certinext; set +a") + .And.Contain("~/.env_certinext_v2"); + ex.Message.Should().Contain(fromProcessEnv ? "process environment" : "(from ~/.env_certinext)"); + } + + [Fact] + public void EnsureV1ApiUrl_UrlWithUserInfoAndQuery_NeverEchoesThem() + { + Action act = () => IntegrationTestFixture.EnsureV1ApiUrl( + "https://someuser:not-a-real-secret@sandbox-us-api.certinext.io/?token=not-a-real-token", + fromProcessEnvironment: true); + + var ex = act.Should().Throw().Which; + ex.Message.Should().Contain("sandbox-us-api.certinext.io"); + ex.Message.Should().NotContain("someuser") + .And.NotContain("not-a-real-secret") + .And.NotContain("not-a-real-token"); + } + + /// + /// End-to-end offline composition of the shell-overlay path: a correct V1 file, a + /// V2 CERTINEXT_API_URL in the (simulated) process environment. Real env vars keep + /// precedence (documented behaviour), and the guard then rejects the leaked value — the + /// same two steps the fixture constructor runs before it builds any client. + /// + [Fact] + public void LoadEnvFile_ProcessEnvV2UrlOverridesV1File_GuardRejectsIt() + { + string path = Path.Combine(Path.GetTempPath(), $"certinext-v1url-{Guid.NewGuid():N}.env"); + try + { + File.WriteAllLines(path, new[] + { + $"CERTINEXT_API_URL={V1Url}", + "CERTINEXT_ACCESS_KEY=dummy-access-key", + }); + var processEnv = new Hashtable { ["CERTINEXT_API_URL"] = V2Url }; + + var env = IntegrationTestFixture.LoadEnvFile(path, processEnv); + + env["CERTINEXT_API_URL"].Should().Be(V2Url, "real env vars still override the file"); + Action act = () => IntegrationTestFixture.EnsureV1ApiUrl(env["CERTINEXT_API_URL"], true); + act.Should().Throw() + .Which.Message.Should().NotContain("dummy-access-key"); + } + finally + { + File.Delete(path); + } + } + + // ------------------------------------------------------------------------- + // (B) V2EnvHelper no longer promotes V1-shared keys + // ------------------------------------------------------------------------- + + [Fact] + public void PromotableKeys_ExcludesEveryV1Key_KeepsV2OnlyKeys() + { + // Mirrors the key set ~/.env_certinext_v2 defines today (names only). + string[] v2FileKeys = + { + "CERTINEXT_ACCOUNT_NUMBER", "CERTINEXT_API_URL", "CERTINEXT_CF_API_TOKEN", + "CERTINEXT_CF_ZONE_ID", "CERTINEXT_CLIENT_ID", "CERTINEXT_CLIENT_SECRET", + "CERTINEXT_DCV_DOMAIN", "CERTINEXT_GROUP_NUMBER", "CERTINEXT_ORG_NUMBER", + "CERTINEXT_PRODUCT_CODE", "CERTINEXT_REQUESTOR_EMAIL", "CERTINEXT_REQUESTOR_MOBILE", + "CERTINEXT_REQUESTOR_NAME", "CERTINEXT_SIGNER_IP", "CERTINEXT_USE_V2_API", + "certinext_api_url", // case-insensitive match + }; + + var promoted = V2EnvHelper.PromotableKeys(v2FileKeys); + + promoted.Should().BeEquivalentTo( + "CERTINEXT_CLIENT_ID", "CERTINEXT_CLIENT_SECRET", "CERTINEXT_REQUESTOR_MOBILE", + "CERTINEXT_SIGNER_IP", "CERTINEXT_USE_V2_API"); + } + + /// + /// If a developer ever left one of the fixture's opt-in-only flags (e.g. + /// CERTINEXT_V2_OPS_TESTS, CERTINEXT_PRIVATE_PKI_LIVE) in ~/.env_certinext_v2, it must + /// NOT come back out of — otherwise the first + /// test class constructed in a run reads the flag as unset, then promotes it into real + /// process env, silently arming every later-constructed test class in the same run even + /// though nothing was ever exported in the shell. Covers every flag in + /// , so a future addition to that set + /// is covered automatically. + /// + [Fact] + public void PromotableKeys_ExcludesEveryOptInOnlyFlag() + { + var promoted = V2EnvHelper.PromotableKeys(IntegrationTestFixture._optInOnlyFlags); + + promoted.Should().BeEmpty( + "every opt-in-only flag must be excluded from V2-file promotion, or a value left " + + "in ~/.env_certinext_v2 could silently arm a later test in the same run"); + } + + [Theory] + [InlineData("CERTINEXT_API_URL")] + [InlineData("CERTINEXT_ACCESS_KEY")] + [InlineData("CERTINEXT_ACCOUNT_NUMBER")] + [InlineData("CERTINEXT_GROUP_NUMBER")] + [InlineData("CERTINEXT_ORG_NUMBER")] + [InlineData("CERTINEXT_PRODUCT_CODE")] + [InlineData("CERTINEXT_REQUESTOR_EMAIL")] + [InlineData("CERTINEXT_REQUESTOR_NAME")] + [InlineData("CERTINEXT_CF_API_TOKEN")] + [InlineData("CERTINEXT_CF_ZONE_ID")] + [InlineData("CERTINEXT_DCV_DOMAIN")] // read from process env by V1 DcvLifecycleTests + public void V1EnvKeys_CoversEveryKeyTheV1SideReads(string key) + { + IntegrationTestFixture.V1EnvKeys.Should().Contain(key); + } + } +} diff --git a/CERTInext.IntegrationTests/V2ApiTests.cs b/CERTInext.IntegrationTests/V2ApiTests.cs new file mode 100644 index 0000000..99a18e4 --- /dev/null +++ b/CERTInext.IntegrationTests/V2ApiTests.cs @@ -0,0 +1,724 @@ +// 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.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Integration test stubs for the V2 REST API code path. + /// + /// All tests are gated behind the CERTINEXT_USE_V2_API=1 environment variable + /// and skip gracefully when it is not set, so they are safe to run in CI environments + /// that do not have V2 credentials configured. + /// + /// To run against a live V2 environment: + /// + /// set -a; . ~/.env_certinext; set +a + /// export CERTINEXT_USE_V2_API=1 + /// dotnet test CERTInext.IntegrationTests/ --filter "FullyQualifiedName~V2ApiTests" + /// + /// Note: the shell must source ONLY ~/.env_certinext (never ~/.env_certinext_v2); + /// this class loads ~/.env_certinext_v2 itself from disk at test-construction time. + /// + /// Required variables in ~/.env_certinext_v2 (or real env vars): + /// + /// CERTINEXT_API_URL — V2 base URL (e.g. https://sandbox-us-api.certinext.io) + /// CERTINEXT_CLIENT_ID — OAuth2 client ID + /// CERTINEXT_CLIENT_SECRET — OAuth2 client secret + /// CERTINEXT_PRODUCT_CODE — product code for lifecycle test (e.g. 842) + /// CERTINEXT_DCV_DOMAIN — domain for lifecycle test (e.g. dcv-test.example.com) + /// + /// V1 variables (CERTINEXT_API_URL, CERTINEXT_ACCESS_KEY, etc.) are NOT required + /// for V2-mode tests — Synchronize uses V2 /reports/orders when UseV2Api is true, + /// and V1 credentials are optional in that mode. + /// + public class V2ApiTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2ProductCode; + private readonly string _v2Domain; + private readonly bool _v2Enabled; + private readonly string _cfApiToken; + private readonly string _cfZoneId; + private readonly bool _dcvEnabled; + private readonly string _issuedOrderId; + + public V2ApiTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + // Load ~/.env_certinext_v2 via the shared helper (one env loader, not a private + // copy per test class). V2 file values take priority over + // process env because IntegrationTestFixture may have already promoted the V1 + // CERTINEXT_API_URL (with /emSignHub-API suffix) into process env, and the V2 base + // URL is different. + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2ProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + _cfApiToken = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_API_TOKEN"); + _cfZoneId = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_ZONE_ID"); + _issuedOrderId = V2EnvHelper.GetEnv(env, "CERTINEXT_V2_ISSUED_ORDER_ID"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + + _dcvEnabled = _v2Enabled + && !string.IsNullOrWhiteSpace(_cfApiToken) + && !string.IsNullOrWhiteSpace(_cfZoneId); + } + + // --------------------------------------------------------------------------- + // V2 Connectivity + // --------------------------------------------------------------------------- + + /// + /// Calls GET /api/certinext/v2/auth/me and verifies a non-empty accountNumber + /// is returned. Skips when CERTINEXT_USE_V2_API is not set. + /// + [SkippableFact] + public async Task Connectivity_V2_Ping() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + var me = await client.GetAuthMeV2Async(); + + me.Should().NotBeNull(); + me.AccountNumber.Should().NotBeNullOrEmpty("auth/me must return accountNumber for a valid OAuth2 client"); + me.AuthType.Should().Be("oauth2"); + } + + // --------------------------------------------------------------------------- + // V2 Lifecycle: place order → track → revoke + // --------------------------------------------------------------------------- + + /// + /// Places a V2 SSL order, asserts that the CARequestID starts with "ord_", + /// then revokes the order. + /// Skips when CERTINEXT_USE_V2_API is not set. + /// + [SkippableFact] + public async Task Lifecycle_V2_EnrollTrackRevoke() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + + // Place order + var orderReq = BuildStandardOrderRequest(); + var createResp = await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq); + + createResp.Should().NotBeNull(); + createResp.OrderId.Should().NotBeNullOrEmpty( + "V2 place-order must return a non-empty orderId (sandbox may return numeric IDs rather than 'ord_' prefix)"); + + // Track the order + var (_, trackResp) = await ResolveOrderFamilyAsync(client, createResp.OrderId); + trackResp.OrderId.Should().Be(createResp.OrderId); + trackResp.Status.Should().NotBeNullOrEmpty( + "V2 TrackOrder must return a status for the placed order"); + // Best-effort structural check: this sandbox's TrackOrder response can omit + // "_links" entirely, so this logs rather than hard-fails — the shape actually + // guarded against here is OrderId/Status. + if (trackResp.Links?.Self?.Href is string href && !string.IsNullOrWhiteSpace(href)) + _output.WriteLine($"TrackOrder links.self.href: {href}"); + else + _output.WriteLine("TrackOrder response did not include a links.self.href (sandbox may omit _links)."); + + // Note: revoke requires the order to reach 'issued' state first. + // The sandbox processes orders asynchronously, so we only assert enroll + track here. + // A full revoke smoke test requires waiting for issuance (run separately with DCV configured). + } + + // --------------------------------------------------------------------------- + // Synchronize uses V2 /reports/orders when UseV2Api=true + // --------------------------------------------------------------------------- + + /// + /// Verifies that Synchronize calls V2 /reports/orders (not V1 GetOrderReport) + /// when UseV2Api=true, and succeeds with ZERO V1 credentials configured at all. + /// A single serves both modes, so the V1-only + /// fields (ApiKey/AccountNumber/AuthMode) below are simply never set. + /// + [SkippableFact] + public async Task Sync_UsesV2_WithZeroV1Credentials() + { + Skip.If(!_v2Enabled, "V2 opt-in (CERTINEXT_USE_V2_API) or V2 credentials not configured — skipping."); + + var config = new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = "Keyfactor Test", + RequestorEmail = "test@example.com", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + PageSize = 10, + // A small lookback keeps this test's live API call volume bounded — every + // issued row in the window needs a live certificate download (the report + // carries no body), and ResolveAndDownloadCertificateV2Async re-resolves the + // product family via a sequential TrackOrder probe when it isn't already known. + // The DEFAULT 72h lookback margin (Constants.ApiV2.DefaultSyncLookbackHours) is + // always added on top of lastSync regardless of how recent lastSync is, so on a + // busy shared sandbox account even a "last hour" delta sync still touches + // several days of orders unless this is overridden. This is a real, currently + // unbounded cost on the live path, not just a test-tuning artifact. + V2SyncLookbackHours = 1 + // Deliberately NOT set: ApiKey, AccountNumber, AuthMode, OAuthTokenUrl — all + // V1-only fields. Proving Synchronize succeeds without them is the point of + // this test. + }; + + using var client = new CERTInextClient(config); + var plugin = new CERTInextCAPlugin(client, config); + + var buffer = new BlockingCollection(1000); + // 300s: this shared sandbox can return 100+ orders even within a narrow ~1-2h + // window (heavy ongoing test activity), and each issued row costs a live download + // plus (when family isn't already known) a family-probe TrackOrder call — a + // genuine current performance characteristic of the live path, not a test artifact. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(300)); + + await plugin.Synchronize(buffer, DateTime.UtcNow.AddHours(-1), false, cts.Token); + if (!buffer.IsAddingCompleted) + buffer.CompleteAdding(); + + var records = new List(); + foreach (var record in buffer.GetConsumingEnumerable()) + records.Add(record); + + // The delta sync window is narrow (see V2SyncLookbackHours above), so this only + // proves correctness (zero V1 creds, records returned, shape is sane) — not sync + // performance at scale, which is a separate, real concern. + records.Should().NotBeEmpty( + "Synchronize must return records via V2 /reports/orders when UseV2Api=true, with zero V1 " + + "credentials configured — an empty result here proves nothing about which code path ran"); + records.Should().OnlyContain(r => !string.IsNullOrWhiteSpace(r.CARequestID)); + + _output.WriteLine( + $"Sync_UsesV2_WithZeroV1Credentials: {records.Count} record(s) returned via V2 /reports/orders, " + + "with no ApiKey/AccountNumber/AuthMode configured."); + } + + // --------------------------------------------------------------------------- + // V2 Product catalogue + // --------------------------------------------------------------------------- + + /// + /// Calls GET /api/certinext/v2/catalog/products and asserts a non-empty list + /// is returned. Skips when CERTINEXT_USE_V2_API is not set. + /// + [SkippableFact] + public async Task GetProductDetails_V2_ReturnsProducts() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().NotBeNull("V2 catalog/products must return a non-null list"); + products.Should().NotBeEmpty("V2 catalog/products must return at least one product"); + + // The live catalog/products response is a nested category envelope, the same + // shape V1's GetProductDetails returns. ParseProductDetailsV2Response flattens + // it, so every parsed product must carry a non-empty ProductCode. + products.Should().OnlyContain(p => !string.IsNullOrWhiteSpace(p.ProductCode), + "ParseProductDetailsV2Response must flatten the nested category envelope into ProductCode-bearing rows"); + _output.WriteLine($"{products.Count}/{products.Count} catalog products carry a non-empty ProductCode."); + } + + /// + /// Drives (not just the client + /// method) end-to-end in V2 mode against the configured product code. Read-only. + /// + [SkippableFact] + public async Task ValidateProductInfo_V2_AcceptsConfiguredProductCode() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var plugin = new CERTInextCAPlugin(); + var connectionInfo = BuildV2ConnectionInfo(); + var productInfo = new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = _v2ProductCode } + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connectionInfo); + + await act.Should().NotThrowAsync( + $"ProductCode '{_v2ProductCode}' should be present in the live V2 catalog"); + } + + /// + /// Same as but with a + /// product code that should never exist, asserting the same "not found" failure mode + /// V1 has always had. Read-only — no order is placed. + /// + [SkippableFact] + public async Task ValidateProductInfo_V2_RejectsUnknownProductCode() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var plugin = new CERTInextCAPlugin(); + var connectionInfo = BuildV2ConnectionInfo(); + var productInfo = new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = "999999" } + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connectionInfo); + + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + /// + /// ignores the constructor-injected + /// client/config and builds its own from connectionInfo, so integration tests + /// must pass a real dictionary — UseV2Api is a bool, not a string + /// (CERTInextCAPluginConfig.cs, CERTInextCAPlugin.cs's is bool check). + /// + private Dictionary BuildV2ConnectionInfo() + { + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = _v2ApiUrl, + ["OAuthClientId"] = _v2ClientId, + ["OAuthClientSecret"] = _v2ClientSecret + }; + + string groupNumber = _fixture.IsConfigured ? _fixture.GroupNumber : null; + if (!string.IsNullOrWhiteSpace(groupNumber)) + info["GroupNumber"] = groupNumber; + + return info; + } + + // --------------------------------------------------------------------------- + // GetSingleRecord via V2 (ResolveAndTrackOrderV2Async) + // --------------------------------------------------------------------------- + + /// + /// Places a fresh DV SSL order then calls ResolveAndTrackOrderV2Async on the + /// returned orderId. Asserts that the order can be found and has a non-empty + /// status. The order will typically be pending-csr or pending-dcv; that is fine. + /// Skips when CERTINEXT_USE_V2_API is not set. + /// + [SkippableFact] + public async Task GetSingleRecord_V2_ReturnsOrderDetails() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + + var orderReq = BuildStandardOrderRequest(); + var createResp = await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq); + + createResp.Should().NotBeNull(); + string orderId = createResp.OrderId; + orderId.Should().NotBeNullOrEmpty("PlaceOrderV2Async must return a non-empty orderId"); + + var status = await client.ResolveAndTrackOrderV2Async(orderId); + + status.Should().NotBeNull("ResolveAndTrackOrderV2Async must return a non-null status"); + status.OrderId.Should().Be(orderId, "tracked order ID must match the placed order"); + status.Status.Should().NotBeNullOrEmpty("TrackOrder must return a non-empty status string"); + } + + // --------------------------------------------------------------------------- + // Revoke a known-issued V2 order + // --------------------------------------------------------------------------- + + /// + /// Revokes a previously issued V2 order. Prefers CERTINEXT_V2_ISSUED_ORDER_ID; + /// otherwise self-enrolls a fresh order via + /// and polls (bounded) for issuance, so the test does not depend on another test's + /// run order to have a usable order ID. + /// + [SkippableFact] + public async Task Revoke_V2_IssuedOrder() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + var (orderId, family) = await EnsureIssuedOrderIdAsync(client); + + // Revoke — sandbox may report 'issued' via track but reject revocation + // with 422 ("Certificate Request still being processed") while the order + // is still being processed internally. + var revokeReq = new V2RevokeRequest + { + Reason = "superseded", + Note = "V2 integration test cleanup" + }; + + try + { + await client.RevokeOrderV2Async(family, orderId, revokeReq); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed")) + { + // Retry once after a short delay before giving up — any other exception + // (or a second failure) must fail the test rather than be swallowed here. + _output.WriteLine($"Revoke rejected as still-processing; retrying once after 15s: {ex.Message}"); + await Task.Delay(TimeSpan.FromSeconds(15)); + try + { + await client.RevokeOrderV2Async(family, orderId, revokeReq); + } + catch (InvalidOperationException ex2) when (ex2.Message.Contains("still being processed")) + { + Skip.If(true, + $"Order {orderId} tracked as 'issued' but CA rejected revocation twice (sandbox timing): {ex2.Message}"); + return; // unreachable; satisfies compiler + } + } + + // Re-track — must be revoked + var trackAfter = await client.ResolveAndTrackOrderV2Async(orderId); + trackAfter.Status.Should().Be( + Constants.ApiV2.StatusRevoked, + $"order {orderId} must be 'revoked' after revocation"); + } + + // --------------------------------------------------------------------------- + // DCV flow (publishes real Cloudflare TXT record) — requires SUPPORTS_DCV build + // --------------------------------------------------------------------------- + +#if SUPPORTS_DCV + /// + /// Places a DV SSL order, publishes the DCV TXT token via real Cloudflare DNS, + /// calls VerifyDcvV2Async, and polls until the order leaves pending-dcv. + /// Requires CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID in addition to + /// CERTINEXT_USE_V2_API. Skips if either is absent. + /// + /// CERTInext's domain DCV is account-scoped and reusable (BR 3.2.2.5): once + /// CERTINEXT_DCV_DOMAIN is verified once, it stays verified for the + /// validTill reuse window, and GetDcv/VerifyDcv return EMS-1080 + /// ("Domain is already verified") instead of issuing a fresh challenge. That is + /// treated here as the reuse-path outcome, not a failure: the publish/verify + /// steps are skipped and the order is polled directly for leaving pending-dcv. + /// + [SkippableFact] + public async Task DcvFlow_V2_PublishesAndVerifies() + { + Skip.If(!_dcvEnabled, + "DCV test requires CERTINEXT_USE_V2_API + CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID — skipping."); + + using var client = BuildV2Client(); + var dns = new CloudflareDomainValidator(_cfApiToken, _cfZoneId); + string txtKey = null; + string orderId = null; + + var (domainVerifiedBeforeEnroll, rawStatus) = await V2DomainStatusHelper.GetDcvStatusAsync(client, _v2Domain); + _output.WriteLine($"Pre-enroll domain status for '{_v2Domain}': dcvStatus={rawStatus ?? ""}"); + + try + { + // 1. Place a DV SSL order — it lands in pending-dcv + var orderReq = BuildStandardOrderRequest(); + var createResp = await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq); + orderId = createResp.OrderId; + orderId.Should().NotBeNullOrEmpty(); + + // 2. Get DCV challenge + V2DcvChallengeResponse dcvResp; + try + { + dcvResp = await client.GetDcvV2Async(orderId, Constants.ApiV2.FamilySsl); + } + catch (Exception ex) when (ex.Message.Contains("EMS-1080")) + { + // Reuse path: the domain is already verified account-wide, so there is no + // fresh challenge to publish. Prove the order still reaches a non-pending-dcv + // state without ever staging a TXT record. + _output.WriteLine($"GetDcv returned EMS-1080 (domain already verified) — reuse path: {ex.Message}"); + _output.WriteLine($"(pre-enroll domain probe {(domainVerifiedBeforeEnroll ? "agreed: VERIFIED" : "did NOT show VERIFIED — status may have changed between the probe and this order")}.)"); + + V2OrderStatusResponse reuseStatus = null; + var reuseDeadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < reuseDeadline) + { + reuseStatus = await client.ResolveAndTrackOrderV2Async(orderId); + _output.WriteLine($"Poll (reuse path): orderId={orderId} status={reuseStatus.Status}"); + if (reuseStatus.Status != Constants.ApiV2.StatusPendingDcv) + break; + await Task.Delay(TimeSpan.FromSeconds(5)); + } + + reuseStatus.Should().NotBeNull(); + reuseStatus!.Status.Should().NotBe( + Constants.ApiV2.StatusPendingDcv, + $"order {orderId} must leave pending-dcv on a reused/already-verified domain (EMS-1080) " + + "without a fresh TXT challenge."); + return; + } + dcvResp.Should().NotBeNull(); + dcvResp.Token.Should().NotBeNullOrEmpty( + "GetDcvV2Async must return a TXT token in Token"); + + // The live response has no domainName field — the domain is already known + // locally from the order-placement request. + string domainName = _v2Domain; + + // 3. Publish TXT record + txtKey = $"_emudhra-challenge.{domainName}"; + _output.WriteLine($"Publishing TXT {txtKey} = {dcvResp.Token}"); + var staged = await dns.StageValidation(txtKey, dcvResp.Token, CancellationToken.None); + staged.Success.Should().BeTrue($"Cloudflare TXT record creation must succeed: {staged.ErrorMessage}"); + + // Brief propagation pause + await Task.Delay(TimeSpan.FromSeconds(5)); + + // 4. Ask CERTInext to verify + var verifyResp = await client.VerifyDcvV2Async(orderId, _v2Domain, Constants.ApiV2.FamilySsl); + verifyResp.Should().NotBeNull(); + verifyResp.OverallStatus.Should().Be("VERIFIED", + "VerifyDcvV2Async must return OverallStatus=VERIFIED after DNS record is published"); + + // 5. Poll until order leaves pending-dcv (up to 60s) + V2OrderStatusResponse finalStatus = null; + var deadline = DateTime.UtcNow.AddSeconds(60); + while (DateTime.UtcNow < deadline) + { + finalStatus = await client.ResolveAndTrackOrderV2Async(orderId); + _output.WriteLine($"Poll: orderId={orderId} status={finalStatus.Status}"); + if (finalStatus.Status != Constants.ApiV2.StatusPendingDcv) + break; + await Task.Delay(TimeSpan.FromSeconds(5)); + } + + finalStatus.Should().NotBeNull(); + finalStatus!.Status.Should().NotBe( + Constants.ApiV2.StatusPendingDcv, + "order must leave pending-dcv after successful DCV verification"); + } + finally + { + if (txtKey != null) + { + _output.WriteLine($"Cleaning up TXT record: {txtKey}"); + await dns.CleanupValidation(txtKey, CancellationToken.None); + } + } + } +#endif + + // --------------------------------------------------------------------------- + // Chain PEM assembly + // --------------------------------------------------------------------------- + + /// + /// Downloads the certificate for a known-issued V2 order and logs whether + /// ChainPem is populated. The test passes in either case — it is a + /// best-effort diagnostic to confirm chain assembly works in production. + /// Prefers CERTINEXT_V2_ISSUED_ORDER_ID; otherwise self-enrolls a fresh order + /// via . + /// + [SkippableFact] + public async Task ChainPem_V2_IsAssembled() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + var (orderId, family) = await EnsureIssuedOrderIdAsync(client); + + V2CertificateDownloadResponse downloadResp; + try + { + downloadResp = await client.DownloadCertificateV2Async(family, orderId); + } + catch (Exception ex) when (ex.Message.Contains("422") || ex.Message.Contains("Invalid request status")) + { + Skip.If(true, + $"Order {orderId} tracked as 'issued' but CA rejected download (sandbox timing): {ex.Message}"); + return; // unreachable; satisfies compiler + } + + downloadResp.Should().NotBeNull("DownloadCertificateV2Async must return a non-null response"); + downloadResp.CertificatePem.Should().NotBeNull( + "CertificatePem must be present for an issued order"); + downloadResp.CertificatePem.Should().StartWith( + "-----BEGIN CERTIFICATE-----", + "leaf certificate must be PEM-encoded"); + + bool chainPresent = downloadResp.ChainPem != null && downloadResp.ChainPem.Count > 0; + _output.WriteLine(chainPresent + ? $"ChainPem: {downloadResp.ChainPem!.Count} intermediate(s) returned." + : "ChainPem: null or empty — sandbox may not return chain."); + + if (chainPresent) + { + foreach (string chainCert in downloadResp.ChainPem!) + { + chainCert.Should().StartWith( + "-----BEGIN CERTIFICATE-----", + "each chain entry must be a PEM-encoded certificate"); + } + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private V2CreateSslOrderRequest BuildStandardOrderRequest() => + new V2CreateSslOrderRequest + { + ProductVariant = "dv", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = _fixture.Config?.RequestorName ?? "Keyfactor Test", + Email = _fixture.Config?.RequestorEmail ?? "test@example.com", + Phone = "0000000000", + Designation = "IT Administrator" + }, + Certificate = new V2CertificateParams + { + Domain = _v2Domain, + AutoSecureWww = false + }, + Subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = false, + RenewBeforeDays = 30 + }, + Agreement = new V2AgreementParams + { + SignerName = _fixture.Config?.RequestorName ?? "Keyfactor Test", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + Accepted = true + }, + Remarks = "Keyfactor V2 integration test — safe to revoke immediately." + }; + + private CERTInextClient BuildV2Client() + { + return new CERTInextClient(new CERTInextConfig + { + // A single ApiUrl serves V2 — no V1-only fields are set here. + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + PageSize = 100 + }); + } + + /// + /// Returns an issued V2 order (and the family it lives in) to exercise. Prefers + /// CERTINEXT_V2_ISSUED_ORDER_ID if set; otherwise places a fresh order on + /// and polls (bounded) until it reaches issued, so + /// tests using this helper are self-contained and don't depend on env state or + /// another test's run order. Skip.Ifs when + /// no env ID is set and the freshly-placed order never reaches issued within + /// the poll budget — sandboxes may require DCV to auto-issue. + /// + private async Task<(string orderId, string family)> EnsureIssuedOrderIdAsync(CERTInextClient client) + { + if (!string.IsNullOrWhiteSpace(_issuedOrderId)) + { + var (family, status) = await ResolveOrderFamilyAsync(client, _issuedOrderId); + Skip.If(status.Status != Constants.ApiV2.StatusIssued, + $"Order '{_issuedOrderId}' is in '{status.Status}' state, not 'issued' — skipping."); + return (_issuedOrderId, family); + } + + var orderReq = BuildStandardOrderRequest(); + var createResp = await client.PlaceOrderV2Async(Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq); + createResp.Should().NotBeNull(); + string orderId = createResp.OrderId; + orderId.Should().NotBeNullOrEmpty("PlaceOrderV2Async must return a non-empty orderId"); + _output.WriteLine( + $"EnsureIssuedOrderIdAsync: no CERTINEXT_V2_ISSUED_ORDER_ID set — placed fresh order {orderId}."); + + V2OrderStatusResponse trackResp = null; + var deadline = DateTime.UtcNow.AddSeconds(90); + while (DateTime.UtcNow < deadline) + { + trackResp = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, orderId); + _output.WriteLine($"EnsureIssuedOrderIdAsync poll: orderId={orderId} status={trackResp.Status}"); + if (trackResp.Status == Constants.ApiV2.StatusIssued) + break; + await Task.Delay(TimeSpan.FromSeconds(15)); + } + + Skip.If(trackResp?.Status != Constants.ApiV2.StatusIssued, + $"Freshly-placed order '{orderId}' did not reach 'issued' within the poll budget " + + $"(status={trackResp?.Status}) — sandbox may require DCV to auto-issue. Set " + + "CERTINEXT_V2_ISSUED_ORDER_ID to a known-issued order to bypass placement."); + + return (orderId, Constants.ApiV2.FamilySsl); + } + + private static async Task<(string family, V2OrderStatusResponse status)> ResolveOrderFamilyAsync( + CERTInextClient client, string orderId) + { + foreach (var family in new[] { Constants.ApiV2.FamilySsl, Constants.ApiV2.FamilyPrivatePki, Constants.ApiV2.FamilySignature }) + { + try + { + var s = await client.TrackOrderV2Async(family, orderId); + return (family, s); + } + catch (KeyNotFoundException) + { + // try next + } + } + throw new KeyNotFoundException($"Order '{orderId}' not found in any V2 product family."); + } + + } +} diff --git a/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs b/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs new file mode 100644 index 0000000..3339ca1 --- /dev/null +++ b/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs @@ -0,0 +1,636 @@ +// 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. + +#if SUPPORTS_DCV +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +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 FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Plugin-level DCV integration tests for the V2 (OAuth2) API path (gaps 5-8, 14-15). + /// Mirrors 's structure for V1: uses a real + /// when Cloudflare credentials are + /// configured, otherwise a . + /// + /// Requires the SUPPORTS_DCV build (-p:DcvSupport=true) because it uses + /// the v3.3-only constructor. Excluded from the + /// no-DCV build via the test project's <Compile Remove> item group. + /// + public class V2DcvLifecycleTests : IClassFixture, IDisposable + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly List _toDispose = new List(); + + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2ProductCode; + private readonly string _v2Domain; + private readonly bool _v2Enabled; + private readonly string _cfApiToken; + private readonly string _cfZoneId; + private readonly bool _dcvEnabled; + + public V2DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2ProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + _cfApiToken = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_API_TOKEN"); + _cfZoneId = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_ZONE_ID"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + + _dcvEnabled = _v2Enabled + && !string.IsNullOrWhiteSpace(_cfApiToken) + && !string.IsNullOrWhiteSpace(_cfZoneId); + } + + public void Dispose() + { + foreach (var d in _toDispose) + d.Dispose(); + _toDispose.Clear(); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static string GenerateCsrPem(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task> RunSyncAsync( + CERTInextCAPlugin plugin, DateTime? lastSync = null, bool fullSync = true) + { + var buffer = new BlockingCollection(boundedCapacity: 10_000); + var collected = new List(); + + var syncTask = Task.Run(async () => + { + await plugin.Synchronize(buffer, lastSync: lastSync, fullSync: fullSync, cancelToken: CancellationToken.None); + if (!buffer.IsAddingCompleted) + buffer.CompleteAdding(); + }); + + foreach (var record in buffer.GetConsumingEnumerable()) + collected.Add(record); + + await syncTask; + return collected; + } + + private IDomainValidatorFactory BuildV2DnsFactory() + { + if (_dcvEnabled) + { + var factory = new CloudflareDomainValidatorFactory(_cfApiToken, _cfZoneId); + _toDispose.Add(factory); + return factory; + } + return new StubDomainValidatorFactory(); + } + + /// + /// Builds a wired for the V2 API. A single + /// serves both modes, and V2 auth reuses + /// /. + /// Deliberately omits every V1-only field (ApiKey/AccountNumber/AuthMode) — V1 + /// credentials are optional when UseV2Api is true, including for Synchronize + /// (which uses V2 /reports/orders). + /// + private CERTInextConfig BuildV2Config( + bool dcvEnabled = true, int propagationDelaySeconds = 5, int? pageSize = null, int? syncLookbackHours = null) + { + return new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + // Default 72h (Constants.ApiV2.DefaultSyncLookbackHours) is always added on top + // of lastSync — on a busy shared sandbox that makes an un-narrowed delta sync + // slow, since every issued row costs a live certificate download. Narrow via + // syncLookbackHours in tests that don't need the full margin. + V2SyncLookbackHours = syncLookbackHours ?? Constants.ApiV2.DefaultSyncLookbackHours, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + + PageSize = pageSize ?? 100, + + DcvEnabled = dcvEnabled, + DcvPropagationDelaySeconds = propagationDelaySeconds, + DcvTimeoutMinutes = 3 + }; + } + + /// + /// Builds a plugin wired for the V2 API with a real DNS factory injected via the + /// v3.3-only three-arg test constructor, so EnrollV2Async / + /// GetSingleRecordV2Async can drive DCV inline. + /// + private CERTInextCAPlugin BuildV2DcvPlugin( + bool dcvEnabled = true, int propagationDelaySeconds = 5, int? pageSize = null, int? syncLookbackHours = null) + { + var config = BuildV2Config(dcvEnabled, propagationDelaySeconds, pageSize, syncLookbackHours); + var client = new CERTInextClient(config); + return new CERTInextCAPlugin(client, BuildV2DnsFactory(), config); + } + + private EnrollmentProductInfo BuildV2ProductInfo() => + new EnrollmentProductInfo + { + ProductID = _v2ProductCode, + ProductParameters = new Dictionary + { + [Constants.EnrollmentParam.ProductCode] = _v2ProductCode, + [Constants.EnrollmentParam.ProfileId] = _v2ProductCode, + } + }; + + /// + /// Parses an issued certificate PEM and asserts its public key matches the requested + /// algorithm/size. Copy of the equivalent helper in . + /// + private static void AssertIssuedCertMatchesAlgorithm(string certPem, KeyAlgorithmSpec spec, string tag) + { + var b64 = certPem + .Replace("-----BEGIN CERTIFICATE-----", string.Empty) + .Replace("-----END CERTIFICATE-----", string.Empty) + .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + + var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + cert.Should().NotBeNull($"{tag}: issued cert PEM must parse"); + + var pub = cert.GetPublicKey(); + switch (spec.Kind) + { + case KeyKind.Rsa: + pub.Should().BeOfType(); + ((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength, + $"{tag}: issued RSA cert must have a {spec.Strength}-bit modulus"); + break; + case KeyKind.Ecdsa: + pub.Should().BeOfType(); + ((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength, + $"{tag}: issued EC cert must use a {spec.Strength}-bit curve"); + break; + case KeyKind.Ed25519: + pub.Should().BeOfType(); + break; + case KeyKind.Ed448: + pub.Should().BeOfType(); + break; + } + } + + // --------------------------------------------------------------------------- + // Enroll with DCV on, V2 path, does not throw + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task DcvEnroll_V2_CompletesWithoutThrowing() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var config = BuildV2Config(dcvEnabled: true); + using var probeClient = new CERTInextClient(config); + var (domainVerified, rawStatus) = await V2DomainStatusHelper.GetDcvStatusAsync(probeClient, _v2Domain); + _output.WriteLine($"Pre-enroll domain status for '{_v2Domain}': dcvStatus={rawStatus ?? ""}"); + + var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory()); + var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config); + + var result = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull("Enroll must return a result even when DCV verification does not complete inline"); + _output.WriteLine($"CARequestID: {result.CARequestID}"); + _output.WriteLine($"Status: {result.Status}"); + _output.WriteLine($"Message: {result.StatusMessage}"); + + var staged = recordingFactory.StagedCalls; + var cleaned = recordingFactory.CleanedUpFqdns; + _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}"); + + if (domainVerified) + { + // Reuse path: the domain is already verified account-wide, so no fresh TXT + // record should ever be staged for it. + staged.Should().BeEmpty( + $"domain '{_v2Domain}' was already VERIFIED before enrollment (reuse path) — no TXT record " + + "should be staged. The plugin must treat the CA's EMS-1080 'already verified' response as " + + "satisfied rather than as a failure requiring a deferred retry."); + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(result.Status, + $"a reused, already-verified domain must let the order proceed to pending or issued; " + + $"got {result.Status}. Message: {result.StatusMessage}"); + } + else + { + // Publish path: a fresh challenge must actually get staged and cleaned up. + staged.Should().NotBeEmpty( + $"domain '{_v2Domain}' was not yet VERIFIED (dcvStatus={rawStatus ?? ""}) — Enroll " + + "must stage a TXT record to exercise the publish path."); + cleaned.Should().NotBeEmpty( + "a staged DCV TXT record must be cleaned up after the publish-path attempt."); + } + } + + // --------------------------------------------------------------------------- + // Enroll with DCV off, V2 path, does not invoke the DNS provider + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task EnrollWithoutDcv_V2_DoesNotInvokeDnsProvider() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var config = BuildV2Config(dcvEnabled: false); + var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory()); + var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config); + + var result = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + result.CARequestID.Should().NotBeNullOrWhiteSpace( + "the CA must accept the order even with DCV off — DCV-off must not block enrollment"); + + recordingFactory.StagedCalls.Should().BeEmpty( + "with DcvEnabled=false the plugin must never stage a DCV TXT record — this test's name promised " + + "that, but nothing previously checked it"); + recordingFactory.CleanedUpFqdns.Should().BeEmpty( + "with DcvEnabled=false the plugin must never attempt DCV cleanup either"); + } + + // --------------------------------------------------------------------------- + // GetSingleRecord drives DCV for an existing pending V2 order + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task GetSingleRecord_V2_DrivesDcvForPendingOrder() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string orderId = Environment.GetEnvironmentVariable("CERTINEXT_V2_PENDING_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "Set CERTINEXT_V2_PENDING_ORDER_ID to a real pending-dcv V2 order to run this test."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin can publish a real TXT record."); + + var plugin = BuildV2DcvPlugin(dcvEnabled: true); + var record = await plugin.GetSingleRecord(orderId); + + record.Should().NotBeNull(); + _output.WriteLine($"CARequestID: {record.CARequestID}"); + _output.WriteLine($"Status: {record.Status}"); + + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION } + .Should().Contain(record.Status, + "deferred-DCV retry should leave the V2 order in a valid pending or issued state"); + } + + // --------------------------------------------------------------------------- + // End-to-end DCV-on enrollment, issued cert appears in sync + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task EnrollWithDcvOn_V2_OrderIssuedEndToEnd_AndAppearsInSync() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV-on test must publish real TXT records."); + + var config = BuildV2Config(dcvEnabled: true); + using var probeClient = new CERTInextClient(config); + var (domainVerified, rawStatus) = await V2DomainStatusHelper.GetDcvStatusAsync(probeClient, _v2Domain); + _output.WriteLine($"Pre-enroll domain status for '{_v2Domain}': dcvStatus={rawStatus ?? ""}"); + + var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory()); + var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config); + + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + _output.WriteLine($"Enroll CARequestID={enrollResult.CARequestID}, Status={enrollResult.Status}"); + + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(enrollResult.Status, + $"DCV-on V2 Enroll must return pending or issued; got {enrollResult.Status}"); + + var staged = recordingFactory.StagedCalls; + var cleaned = recordingFactory.CleanedUpFqdns; + _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}"); + + if (domainVerified) + { + // Reuse path: no fresh TXT record should be staged for an already-verified + // domain. + staged.Should().BeEmpty( + $"domain '{_v2Domain}' was already VERIFIED before enrollment (reuse path) — no TXT record " + + "should be staged."); + } + else + { + staged.Should().NotBeEmpty( + $"domain '{_v2Domain}' was not yet VERIFIED (dcvStatus={rawStatus ?? ""}) — Enroll " + + "must stage a TXT record to exercise the publish path."); + cleaned.Should().NotBeEmpty( + "a staged DCV TXT record must be cleaned up after the publish-path attempt."); + } + + // Delta sync — this sandbox account has 1000+ historical orders. + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddDays(-1), fullSync: false); + var record = synced.FirstOrDefault(r => r.CARequestID == enrollResult.CARequestID); + record.Should().NotBeNull( + $"the enrolled V2 order ({enrollResult.CARequestID}) must appear in plugin.Synchronize results"); + + _output.WriteLine($"Synced record status: {record!.Status}"); + + if (record.Status == (int)EndEntityStatus.GENERATED) + { + record.Certificate.Should().NotBeNullOrWhiteSpace( + "Synchronize must populate the cert body for an issued V2 order (mirroring V1 behavior)"); + } + } + + // --------------------------------------------------------------------------- + // Key-algorithm issuance matrix, V2 path (opt-in) + // --------------------------------------------------------------------------- + + [SkippableTheory] + [MemberData(nameof(KeyAlgorithms.AsMemberData), MemberType = typeof(KeyAlgorithms))] + public async Task EnrollWithDcvOn_V2_IssuesPerKeyAlgorithm(string tag) + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_ALGO_MATRIX") != "1", + "Opt-in: set CERTINEXT_V2_ALGO_MATRIX=1 to issue one real V2 cert per key algorithm."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV issuance must publish real TXT records."); + + var spec = KeyAlgorithms.For(tag); + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"algo-{KeyAlgorithms.Slug(tag)}-{suffix}.{_v2Domain}"; + string csr = KeyAlgorithms.GenerateCsrPem(cn, spec); + + var plugin = BuildV2DcvPlugin(dcvEnabled: true); + + EnrollmentResult enrollResult; + try + { + enrollResult = await plugin.Enroll( + csr: csr, + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) + { + string reason = KeyAlgorithms.ClassifyRejection(ex.Message); + _output.WriteLine($"[SKIP] {tag}: {reason} — {ex.Message}"); + Skip.If(true, $"CERTInext did not issue a {tag} V2 cert: {reason}. CA message: {ex.Message}"); + return; // unreachable + } + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace($"{tag}: CA must return a CARequestID when it accepts the order"); + _output.WriteLine($"[{tag}] enrolled cn={cn} id={enrollResult.CARequestID} status={enrollResult.Status}"); + + const int maxPolls = 6; + const int delaySeconds = 15; + AnyCAPluginCertificate record = null; + for (int poll = 1; poll <= maxPolls; poll++) + { + record = await plugin.GetSingleRecord(enrollResult.CARequestID); + int status = record?.Status ?? -1; + _output.WriteLine($"[{tag}] poll #{poll}: status={status} certLen={record?.Certificate?.Length ?? 0}"); + + if (status == (int)EndEntityStatus.GENERATED && !string.IsNullOrWhiteSpace(record?.Certificate)) + break; + if (status == (int)EndEntityStatus.FAILED) + { + Skip.If(true, $"CERTInext FAILED the {tag} V2 order — algorithm not issuable on this account/profile."); + return; // unreachable + } + if (poll < maxPolls) + await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); + } + + record.Should().NotBeNull($"{tag}: enrolled order {enrollResult.CARequestID} must be retrievable"); + if (record!.Status != (int)EndEntityStatus.GENERATED) + { + Skip.If(true, $"CERTInext accepted the {tag} V2 order but it did not reach GENERATED within the polling window " + + $"(Status={record.Status})."); + return; // unreachable + } + + record.Certificate.Should().NotBeNullOrWhiteSpace($"{tag}: issued V2 cert must carry a PEM body"); + AssertIssuedCertMatchesAlgorithm(record.Certificate, spec, tag); + _output.WriteLine($"--- {tag}: V2 DCV-on issuance OK — order {enrollResult.CARequestID} GENERATED. ---"); + } + + // --------------------------------------------------------------------------- + // Bulk V2 enrollment + pagination smoke test (opt-in) + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task BulkV2Enrollment_AllOrdersIssue_AndPaginationWorks() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_RUN_BULK_TEST") != "1", + "Opt-in: set CERTINEXT_V2_RUN_BULK_TEST=1 to run the V2 volume/pagination test."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — bulk test must publish real TXT records."); + + int count = int.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_BULK_TEST_COUNT"), out int c) ? c : 101; + int parallel = int.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_BULK_TEST_PARALLEL"), out int p) ? p : 5; + + // PageSize=100 ensures the 101st order forces a second page during Synchronize. + // Since this plugin is UseV2Api=true, Synchronize pages through V2 + // /reports/orders (ListOrdersV2Async) rather than V1 GetOrderReport — + // this is the live pagination proof for that path, not just the WireMock-based + // client unit tests. + // syncLookbackHours narrowed to 2h: the default 72h margin would otherwise re-download + // every issued cert in a multi-day window on EACH of the (up to 8) sync passes below, + // compounded by the retry loop. + var plugin = BuildV2DcvPlugin(dcvEnabled: true, propagationDelaySeconds: 5, pageSize: 100, syncLookbackHours: 2); + + var enrolled = new ConcurrentBag<(int idx, string cn, EnrollmentResult result)>(); + var failures = new ConcurrentBag<(int idx, string error)>(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + using (var sem = new SemaphoreSlim(parallel, parallel)) + { + var tasks = Enumerable.Range(0, count).Select(async i => + { + await sem.WaitAsync(); + try + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"v2bulk-{suffix}.{_v2Domain}"; + string csr = GenerateCsrPem(cn); + + var result = await plugin.Enroll( + csr: csr, + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrolled.Add((i, cn, result)); + _output.WriteLine($"[{i:000}] OK cn={cn} id={result.CARequestID} status={result.Status}"); + } + catch (Exception ex) + { + failures.Add((i, ex.Message)); + _output.WriteLine($"[{i:000}] FAIL {ex.GetType().Name}: {ex.Message}"); + } + finally + { + sem.Release(); + } + }); + await Task.WhenAll(tasks); + } + + sw.Stop(); + _output.WriteLine($"--- Enroll phase: enrolled={enrolled.Count}, failed={failures.Count}, elapsed={sw.Elapsed:mm\\:ss} ---"); + + failures.Should().BeEmpty($"every V2 Enroll() call must succeed; got {failures.Count} hard failures."); + enrolled.Count.Should().Be(count, $"expected {count} successful V2 Enroll() calls"); + + var enrolledIds = enrolled + .Where(e => !string.IsNullOrEmpty(e.result.CARequestID)) + .Select(e => e.result.CARequestID) + .ToHashSet(); + enrolledIds.Count.Should().Be(count, "every V2 enrollment must return a CARequestID"); + + const int maxSyncPasses = 8; + const int delayBetweenPassesSeconds = 30; + + List synced = null; + int passesUsed = 0; + + for (int pass = 1; pass <= maxSyncPasses; pass++) + { + passesUsed = pass; + synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddDays(-1), fullSync: false); + + int generated = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.GENERATED); + int failed = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED); + int pending = enrolledIds.Count - generated - failed; + + _output.WriteLine($"--- Sync pass #{pass}: {generated}/{enrolledIds.Count} GENERATED, {failed} FAILED, {pending} pending ---"); + + if (failed > 0) + { + var failedIds = synced + .Where(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED) + .Select(r => r.CARequestID) + .Take(5); + Assert.Fail($"Pass #{pass}: {failed} V2 order(s) reached FAILED status: {string.Join(", ", failedIds)}"); + } + + if (pending == 0) + break; + + if (pass < maxSyncPasses) + await Task.Delay(TimeSpan.FromSeconds(delayBetweenPassesSeconds)); + } + + var syncedIds = synced!.Select(r => r.CARequestID).ToHashSet(); + var missing = enrolledIds.Where(id => !syncedIds.Contains(id)).ToList(); + missing.Should().BeEmpty( + $"{missing.Count} enrolled V2 orders did not appear in sync results: {string.Join(", ", missing.Take(5))}"); + + var lookup = synced!.Where(r => r.CARequestID != null).ToDictionary(r => r.CARequestID, r => r); + var notIssued = enrolledIds + .Where(id => lookup.TryGetValue(id, out var rec) && rec.Status != (int)EndEntityStatus.GENERATED) + .Select(id => lookup[id]) + .ToList(); + + notIssued.Should().BeEmpty( + $"every enrolled V2 order should auto-issue after {maxSyncPasses} sync passes; {notIssued.Count} did not."); + + _output.WriteLine($"--- SUCCESS: {count}/{count} V2 orders enrolled and issued in {passesUsed} sync pass(es). ---"); + } + } +} +#endif diff --git a/CERTInext.IntegrationTests/V2DomainStatusHelper.cs b/CERTInext.IntegrationTests/V2DomainStatusHelper.cs new file mode 100644 index 0000000..9340e6b --- /dev/null +++ b/CERTInext.IntegrationTests/V2DomainStatusHelper.cs @@ -0,0 +1,58 @@ +// 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.Json; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Read-only helper for querying GET /api/certinext/v2/domains?search=&exactMatch=true + /// via the existing escape hatch, so DCV-on V2 + /// tests can tell the reuse path (domain already VERIFIED) apart from the publish path + /// before asserting what the DNS provider spy should have recorded. + /// + internal static class V2DomainStatusHelper + { + /// + /// Returns whether currently has dcvStatus=VERIFIED, plus + /// the raw dcvStatus string (null if the domain has no row at all, e.g. never + /// submitted on any order yet). + /// + public static async Task<(bool IsVerified, string RawStatus)> GetDcvStatusAsync( + CERTInextClient client, string domain) + { + string query = $"/api/certinext/v2/domains?search={Uri.EscapeDataString(domain)}&exactMatch=true"; + var (statusCode, _, content) = await client.ProbeV2GetAsync(query); + + // A failed lookup must not masquerade as "not verified" — that would steer the caller + // into asserting the publish path for the wrong reason. + if (statusCode != 200 || string.IsNullOrWhiteSpace(content)) + throw new InvalidOperationException( + $"GET /domains lookup for '{domain}' failed: HTTP {statusCode}; cannot tell reuse path from publish path."); + + using var doc = JsonDocument.Parse(content); + if (!doc.RootElement.TryGetProperty("content", out var arr) + || arr.ValueKind != JsonValueKind.Array + || arr.GetArrayLength() == 0) + return (false, null); + + var row = arr[0]; + string dcvStatus = row.TryGetProperty("dcvStatus", out var v) ? v.GetString() : null; + return (string.Equals(dcvStatus, "VERIFIED", StringComparison.OrdinalIgnoreCase), dcvStatus); + } + } +} diff --git a/CERTInext.IntegrationTests/V2FreshDomainDcvLifecycleTests.cs b/CERTInext.IntegrationTests/V2FreshDomainDcvLifecycleTests.cs new file mode 100644 index 0000000..817ed4f --- /dev/null +++ b/CERTInext.IntegrationTests/V2FreshDomainDcvLifecycleTests.cs @@ -0,0 +1,412 @@ +// 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. +// +// V2 release-candidate readiness: DCV against a FRESH, never-before-seen domain. Every DCV +// test in V2DcvLifecycleTests.cs targets CERTINEXT_DCV_DOMAIN, which this sandbox account has +// reused across dozens of prior test runs and is therefore typically already VERIFIED +// account-wide — so those tests take the reuse path (staged=0) and never actually exercise +// the TXT publish/verify/cleanup path. This file's test targets a freshly-generated subdomain +// instead, so a real TXT challenge must be staged and cleaned up (staged>0). + +#if SUPPORTS_DCV +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +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 +{ + public class V2FreshDomainDcvLifecycleTests : IClassFixture, IDisposable + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly List _toDispose = new List(); + + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2Domain; + private readonly string _freshDcvParent; + private readonly bool _v2Enabled; + private readonly string _cfApiToken; + private readonly string _cfZoneId; + private readonly bool _dcvEnabled; + + public V2FreshDomainDcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + _cfApiToken = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_API_TOKEN"); + _cfZoneId = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_ZONE_ID"); + + // A fresh subdomain of CERTINEXT_DCV_DOMAIN is NOT genuinely unverified — this + // sandbox account has already completed DCV for CERTINEXT_DCV_DOMAIN itself, and + // CERTInext (like most DCV implementations) treats that as covering every + // subdomain beneath it. A dcv-fresh- name built under CERTINEXT_DCV_DOMAIN + // therefore never actually exercises the publish path (staged stays 0) — it is + // simply inheriting the parent's prior verification. A sibling domain under a + // DIFFERENT, still-unverified parent is required instead. Defaults to + // CERTINEXT_DCV_DOMAIN with its first label stripped (e.g. + // "dcv-test.scrup.org" -> "scrup.org"), which this sandbox account has never + // itself completed DCV against. + _freshDcvParent = V2EnvHelper.GetEnv(env, "CERTINEXT_V2_FRESH_DCV_PARENT", DeriveDefaultFreshDcvParent(_v2Domain)); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + + _dcvEnabled = _v2Enabled + && !string.IsNullOrWhiteSpace(_cfApiToken) + && !string.IsNullOrWhiteSpace(_cfZoneId); + } + + /// + /// Strips the first DNS label from (e.g. + /// "dcv-test.scrup.org" -> "scrup.org") to derive a default value for + /// CERTINEXT_V2_FRESH_DCV_PARENT when it is unset — a sibling built under this + /// parent is not covered by the DCV domain's own prior verification. Falls back to the + /// input unchanged if it has no "." to strip. + /// + private static string DeriveDefaultFreshDcvParent(string domain) + { + if (string.IsNullOrWhiteSpace(domain)) return domain; + int dot = domain.IndexOf('.'); + return dot >= 0 && dot < domain.Length - 1 ? domain.Substring(dot + 1) : domain; + } + + public void Dispose() + { + foreach (var d in _toDispose) + d.Dispose(); + _toDispose.Clear(); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static string GenerateCsrPem(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private IDomainValidatorFactory BuildV2DnsFactory() + { + if (_dcvEnabled) + { + var factory = new CloudflareDomainValidatorFactory(_cfApiToken, _cfZoneId); + _toDispose.Add(factory); + return factory; + } + return new StubDomainValidatorFactory(); + } + + private CERTInextConfig BuildV2Config() + { + return new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + DefaultProductCode = Environment.GetEnvironmentVariable("CERTINEXT_PRODUCT_CODE") ?? "842", + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + V2SyncLookbackHours = 1, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + + PageSize = 100, + + DcvEnabled = true, + DcvPropagationDelaySeconds = 5, + DcvTimeoutMinutes = 3 + }; + } + + /// + /// Same revoke-if-issued / cancel-otherwise cleanup as V2FullLifecycleTests. + /// CleanupOrderAsync — single attempt only, never retries a cancel, logs rather than + /// throws so a cleanup problem never masks the test's own assertion result. Returns + /// whether cleanup completed without throwing (true = revoked or cancelled successfully, + /// or nothing to do), so a caller that wants to assert "nothing leaks" — e.g. the + /// wildcard fresh-subdomain test below — has something other than log text to check. + /// + private async System.Threading.Tasks.Task CleanupOrderAsync(CERTInextCAPlugin plugin, string orderId) + { + if (string.IsNullOrWhiteSpace(orderId)) + return true; + + try + { + var current = await plugin.GetSingleRecord(orderId); + if (current?.Status == (int)EndEntityStatus.GENERATED) + { + int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */); + _output.WriteLine($"Cleanup: revoked issued order {orderId} -> {revokeResult}."); + } + else + { + await V2RawHttpHelpers.CancelSslOrderRawAsync( + _v2ApiUrl, _v2ClientId, _v2ClientSecret, orderId, + "V2 fresh-domain DCV test cleanup — order not issued, cancelling."); + _output.WriteLine($"Cleanup: cancelled non-issued order {orderId} (status={current?.Status})."); + } + return true; + } + catch (Exception ex) + { + _output.WriteLine( + $"Cleanup FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}. " + + "Revoke/cancel it by hand in the CERTInext portal if it should not remain pending."); + return false; + } + } + + // --------------------------------------------------------------------------- + // 7. DCV against a fresh, never-before-verified subdomain + // --------------------------------------------------------------------------- + + /// + /// Enrolls a DV order for a freshly-generated subdomain of a genuinely unverified parent + /// (CERTINEXT_V2_FRESH_DCV_PARENT, NOT a subdomain of CERTINEXT_DCV_DOMAIN itself + /// — that domain's own prior DCV covers every subdomain beneath it, so a + /// dcv-fresh-<ts>.CERTINEXT_DCV_DOMAIN name never actually exercises the publish + /// path), with DcvEnabled=true and a real Cloudflare-backed + /// wrapped in . + /// Because the domain is guaranteed unseen, this is the one DCV test in the V2 suite that + /// actually exercises the publish path: every existing V2DcvLifecycleTests case targets + /// the long-reused CERTINEXT_DCV_DOMAIN, which this account has verified account-wide, so + /// those always take the reuse path and observe staged=0. Asserts staged>0 + /// and cleaned==staged. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async System.Threading.Tasks.Task EnrollWithDcvOn_V2_FreshUnverifiedSubdomain_StagesAndCleansUpTxt() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin can publish a real TXT record."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_FRESH_DCV") != "1", + "CERTINEXT_V2_LIFECYCLE_FRESH_DCV=1 not set — this places a real sandbox order and publishes a live DNS TXT record. Skipping."); + + string freshDomain = $"dcv-fresh-{DateTime.UtcNow:yyyyMMddHHmmssfff}.{_freshDcvParent}"; + + var config = BuildV2Config(); + var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory()); + var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config); + + string orderId = null; + try + { + var result = await plugin.Enroll( + csr: GenerateCsrPem(freshDomain), + subject: $"CN={freshDomain}", + san: new Dictionary { ["dns"] = new[] { freshDomain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + result.CARequestID.Should().NotBeNullOrWhiteSpace("Enroll must return a CARequestID even if DCV verification does not complete inline"); + orderId = result.CARequestID; + _output.WriteLine($"Fresh-domain order {orderId} for '{freshDomain}' (parent={_freshDcvParent}): Status={result.Status}, Message={result.StatusMessage}"); + + var staged = recordingFactory.StagedCalls; + var cleaned = recordingFactory.CleanedUpFqdns; + _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}"); + + // Expected behavior is staged>0 (see class-level remarks). The CA may instead + // treat the fresh subdomain's parent as already covering it and issue + // immediately with zero TXT records staged — in which case the TXT + // publish/verify path was never exercised and the assertions below cannot be + // meaningfully evaluated. Skip rather than fail; the finally below still runs + // cleanup regardless of this skip. + Skip.If(staged.Count == 0, + $"blocked by sandbox: CA treated {freshDomain} as pre-validated; TXT publish/verify path not exercised"); + + staged.Should().NotBeEmpty( + $"domain '{freshDomain}' is freshly generated under a genuinely unverified parent " + + "and cannot already be VERIFIED on this account — unlike every pre-existing " + + "V2DcvLifecycleTests case (which targets the long-reused CERTINEXT_DCV_DOMAIN and " + + "always takes the reuse path with staged=0), Enroll must actually stage " + + "a TXT record here."); + cleaned.Count.Should().Be(staged.Count, + "every staged DCV TXT record for a fresh domain must be cleaned up after the attempt."); + + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(result.Status, + $"DCV-on Enroll for a fresh domain must return pending or issued; got {result.Status}. Message: {result.StatusMessage}"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 8. Wildcard DV DCV against a fresh, never-before-verified subdomain + // --------------------------------------------------------------------------- + + /// + /// Wildcard-only CSR shape (CN = SAN = the wildcard) on a freshly-generated, + /// never-before-seen subdomain of a genuinely unverified parent + /// (CERTINEXT_V2_FRESH_DCV_PARENT, same freshness rationale as + /// above), + /// for . Asserts the staged TXT hostname + /// does NOT contain a literal '*' (a wildcard's "*." label is not a queryable DNS name — + /// see the base-domain hostname fix). DOES fail if the order ends FAILED. If the order + /// is still at EXTERNALVALIDATION (pending DCV) when this test's wait elapses, wildcard + /// DCV completion was never actually exercised, so the test Skips with a "blocked by + /// sandbox" message rather than claiming wildcard DCV works — cleanup (cancel) still + /// runs regardless. Also records what Track Order's + /// verifications.domain.domains[].domain echoes back for the same order, and + /// whether any domain entry reached VERIFIED within the wait. Cleanup (revoke-if-issued + /// or cancel) must succeed regardless of outcome, so this probe never leaks a live order. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async System.Threading.Tasks.Task EnrollWithDcvOn_V2_WildcardFreshSubdomain_RecordsTxtHostnameAndCleansUp() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin can publish a real TXT record."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_FRESH_DCV") != "1", + "CERTINEXT_V2_LIFECYCLE_FRESH_DCV=1 not set — this places a real sandbox order and publishes a live DNS TXT record. Skipping."); + + string freshSubdomain = $"dcv-fresh-{DateTime.UtcNow:yyyyMMddHHmmssfff}.{_freshDcvParent}"; + string wildcard = $"*.{freshSubdomain}"; + + var config = BuildV2Config(); + var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory()); + var client = new CERTInextClient(config); + var plugin = new CERTInextCAPlugin(client, recordingFactory, config); + + string orderId = null; + try + { + var result = await plugin.Enroll( + csr: GenerateCsrPem(wildcard), + subject: $"CN={wildcard}", + san: new Dictionary { ["dns"] = new[] { wildcard } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSslWildcard }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + _output.WriteLine($"Wildcard fresh-subdomain order ({wildcard}, parent={_freshDcvParent}): Status={result.Status}, Message={result.StatusMessage}"); + + if (!string.IsNullOrWhiteSpace(result.CARequestID)) + orderId = result.CARequestID; + + // Don't fail solely on CA verification timing (EXTERNALVALIDATION is fine) — + // but a FAILED order (CERTInext rejected/cancelled it) is a real problem, not a + // timing artifact. + result.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"the order must not end FAILED; Message: {result.StatusMessage}"); + + // Ending at EXTERNALVALIDATION means DCV never actually completed within this + // test's wait — the wildcard DCV path was not exercised to issuance, so this test + // cannot claim wildcard DCV works. Skip rather than pass silently; cleanup + // (cancel) still runs in the finally below regardless of this skip. + Skip.If(result.Status == (int)EndEntityStatus.EXTERNALVALIDATION, + $"blocked by sandbox: wildcard order for '{wildcard}' ended at pending-approval (EXTERNALVALIDATION); wildcard DCV completion not exercised"); + + var staged = recordingFactory.StagedCalls; + var cleaned = recordingFactory.CleanedUpFqdns; + _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}"); + foreach (var call in staged) + _output.WriteLine($"Staged TXT hostname: Fqdn='{call.Fqdn}'."); + foreach (var fqdn in cleaned) + _output.WriteLine($"Cleaned-up TXT hostname: Fqdn='{fqdn}'."); + + staged.Should().OnlyContain(call => call.Fqdn == null || !call.Fqdn.Contains('*'), + "a literal '*' DNS label is not queryable by the CA and must never be staged — " + + "see the wildcard base-domain hostname fix."); + + if (!string.IsNullOrWhiteSpace(orderId)) + { + try + { + var tracked = await client.ResolveAndTrackOrderV2Async(orderId); + var domainEntries = tracked?.Verifications?.Domain?.Domains; + if (domainEntries != null && domainEntries.Count > 0) + { + foreach (var entry in domainEntries) + _output.WriteLine( + $"Track Order verifications.domain.domains[]: domain='{entry.Domain}', dcvStatus={entry.DcvStatus ?? ""}."); + + bool anyVerified = domainEntries.Any(e => + string.Equals(e.DcvStatus, "VERIFIED", StringComparison.OrdinalIgnoreCase)); + _output.WriteLine(anyVerified + ? "At least one domain entry reached VERIFIED within this test's wait — the CA " + + "accepted a base-domain TXT record for a wildcard domain entry." + : "No domain entry reached VERIFIED within this test's wait (CA-side timing, or " + + "the base-domain TXT record is not accepted for a wildcard domain entry — still " + + "UNVERIFIED; this is not asserted as a test failure)."); + } + else + { + _output.WriteLine("Track Order returned no verifications.domain.domains[] entries for this order."); + } + } + catch (Exception ex) + { + _output.WriteLine($"Track Order (for verifications detail) FAILED: {ex.GetType().Name}: {ex.Message}."); + } + } + } + finally + { + bool cleanedUp = await CleanupOrderAsync(plugin, orderId); + cleanedUp.Should().BeTrue( + "cleanup (revoke-if-issued or cancel) must succeed so this wildcard fresh-subdomain probe " + + "never leaves a live order on the sandbox, regardless of what the TXT-hostname/Track-Order " + + "observations above turn out to show — see the 'Cleanup FAILED' output above if this fails."); + } + } + } +} +#endif diff --git a/CERTInext.IntegrationTests/V2FullLifecycleTests.cs b/CERTInext.IntegrationTests/V2FullLifecycleTests.cs new file mode 100644 index 0000000..e7b6b06 --- /dev/null +++ b/CERTInext.IntegrationTests/V2FullLifecycleTests.cs @@ -0,0 +1,957 @@ +// 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. +// +// V2 release-candidate readiness: assertion-bearing live lifecycle coverage for the product +// families/shapes the existing V2 suite (V2LifecycleTests/V2ApiTests/V2DcvLifecycleTests) +// never exercised end to end — DV UCC, OV, OV UCC, EV, wildcard DV, and renew/reissue. Each +// test is gated by its own CERTINEXT_V2_LIFECYCLE_=1 flag (never +// promoted from ~/.env_certinext_v2 — see IntegrationTestFixture._optInOnlyFlags), places real +// sandbox orders, and always cleans up (revoke if issued, cancel otherwise) via +// CleanupOrderAsync in a try/finally. Fresh-domain DCV coverage lives in +// V2FreshDomainDcvLifecycleTests.cs (requires the SUPPORTS_DCV build). + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Plugin-level V2 lifecycle tests for product shapes/flows the pre-existing V2 suite did + /// not cover with an assertion-bearing live test (readiness audit, 2026-10-01): DV UCC, OV, + /// OV UCC, EV, wildcard DV (both CSR shapes), and renew/reissue of an issued DV order. + /// + public class V2FullLifecycleTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2Domain; + private readonly bool _v2Enabled; + + /// + /// 300s target for OV/EV order-creation calls (CA latency for these product types can + /// be significant). NOT actually enforceable at this (plugin-level) layer: + /// CERTInextClient's V2 RestClient hard-codes Timeout = TimeSpan.FromSeconds(120) + /// (CERTInextClient.cs, both the V1 and V2 RestClientOptions blocks) with no + /// CERTInextConfig override to raise it. OV/EV tests below catch a client-side timeout + /// distinctly from a CA-side rejection and record it rather than assert past it — see + /// IsClientTimeout below. This is a known production gap: the client-side timeout can + /// trip before the CA itself would reject or accept the order. + /// + private static readonly TimeSpan OvEvCreateTimeoutTarget = TimeSpan.FromSeconds(300); + + public V2FullLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static string Timestamp() => DateTime.UtcNow.ToString("yyyyMMddHHmmssfff"); + + private static string GenerateCsrPem(string commonName) => GenerateCsrPem(commonName, ouTag: null); + + /// + /// , when supplied, is folded into the CSR subject as an OU — + /// e.g. ov- for the OV/OV-UCC orphan-sweep probes below. The orders report + /// () does not surface OU anywhere, so this + /// tag is NOT how an orphan is actually located (that's domain + creation-time window — + /// see ); it exists only so a human reviewing + /// the order in the CERTInext portal or a raw CSR dump can see which test run placed it. + /// + private static string GenerateCsrPem(string commonName, string ouTag) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + string subjectDn = string.IsNullOrWhiteSpace(ouTag) ? $"CN={commonName}" : $"CN={commonName},OU={ouTag}"; + var subject = new X509Name(subjectDn); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task> RunSyncAsync( + CERTInextCAPlugin plugin, DateTime? lastSync = null, bool fullSync = true) + { + var buffer = new BlockingCollection(boundedCapacity: 10_000); + var collected = new List(); + + var syncTask = Task.Run(async () => + { + await plugin.Synchronize(buffer, lastSync: lastSync, fullSync: fullSync, cancelToken: CancellationToken.None); + if (!buffer.IsAddingCompleted) + buffer.CompleteAdding(); + }); + + foreach (var record in buffer.GetConsumingEnumerable()) + collected.Add(record); + + await syncTask; + return collected; + } + + private CERTInextConfig BuildV2Config( + int? syncLookbackHours = null, string organizationNumber = null) + { + return new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + DefaultProductCode = Environment.GetEnvironmentVariable("CERTINEXT_PRODUCT_CODE") ?? "842", + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + + PageSize = 100, + + V2SyncLookbackHours = syncLookbackHours ?? 1, + + OrganizationNumber = organizationNumber ?? string.Empty, + + DcvEnabled = false + }; + } + + private static CERTInextCAPlugin BuildV2Plugin(CERTInextConfig config) + { + var client = new CERTInextClient(config); + return new CERTInextCAPlugin(client, config); + } + + /// + /// Distinguishes a client-side HTTP timeout (RestSharp/TaskCanceledException — the + /// plugin's hard-coded 120s V2 RestClient timeout expiring before the CA responds) from a + /// genuine CA-side rejection. See 's doc comment. + /// + /// Also matches the shape the CA can return for the same condition: CERTInextClient's + /// ThrowOnV2Failure does not always surface a + /// for a RestSharp-level transport timeout — it can instead produce a plain + /// reading "CERTInext V2 API error during '...'. HTTP 0. + /// CERTInext V2 returned no body for '...'." (StatusCode 0 = no HTTP response was ever + /// received). Both substrings ("HTTP 0" and "returned no body") must be present so this + /// never also matches a genuine HTTP-0-with-a-body CA-side condition. + /// + private static bool IsClientTimeout(Exception ex) => + ex is TaskCanceledException + || ex is OperationCanceledException + || (ex.Message?.IndexOf("timed out", StringComparison.OrdinalIgnoreCase) >= 0) + || (ex.Message != null + && ex.Message.IndexOf("HTTP 0", StringComparison.OrdinalIgnoreCase) >= 0 + && ex.Message.IndexOf("returned no body", StringComparison.OrdinalIgnoreCase) >= 0); + + /// + /// Best-effort search for an order the CA may have created despite the plugin's own + /// client-side timeout () — a timeout proves nothing about + /// what happened server-side. Scans the V2 orders report + /// ( via ListOrdersV2Async) for the + /// "UTC today" window, matches on domainName == domain (the only field this report + /// row model exposes — no OU/SAN/tag field is echoed there) plus + /// orderDate >= windowStartUtc - 5min to avoid grabbing an older, unrelated + /// order on the same long-reused , picks the single most-recent + /// match if more than one row qualifies, and cancels it (one attempt, never retried) if + /// it is not already terminal. Never throws — every failure path is folded into the + /// returned description string so the caller's Skip.If message always has something + /// actionable. is logged only (see ). + /// + private async Task TryCancelOrphanByWindowAsync(string domain, DateTime windowStartUtc, string probeTag) + { + try + { + using var client = new CERTInextClient(BuildV2Config()); + + string from = windowStartUtc.Date.ToString("yyyy-MM-dd"); + string to = windowStartUtc.Date.AddDays(1).ToString("yyyy-MM-dd"); + + OrderReportEntryV2 best = null; + DateTime bestDate = DateTime.MinValue; + int scanned = 0; + + await foreach (var row in client.ListOrdersV2Async(from, to, pageSize: 100)) + { + scanned++; + if (!string.Equals(row.DomainName, domain, StringComparison.OrdinalIgnoreCase)) + continue; + + DateTime rowDate = DateTime.TryParse( + row.OrderDate, null, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out var parsed) + ? parsed + : windowStartUtc; // unparseable date: don't exclude it from consideration on that basis alone + + if (rowDate < windowStartUtc.AddMinutes(-5)) + continue; + + if (best == null || rowDate >= bestDate) + { + best = row; + bestDate = rowDate; + } + } + + _output.WriteLine( + $"Orphan sweep (tag={probeTag}): scanned {scanned} report row(s) for domain '{domain}', " + + $"window >= {windowStartUtc:O} (-5min grace)."); + + if (best == null) + return "orphan sweep found no matching report row for this domain/window (nothing to cancel, " + + "or the order has not appeared in the report yet — try again later by hand if needed)"; + + string orderId = best.OrderNumber; + if (string.IsNullOrWhiteSpace(orderId)) + return $"orphan sweep found a matching report row for domain '{domain}' with no orderNumber — cannot cancel it programmatically"; + + var (family, status) = await client.ResolveAndTrackOrderV2WithFamilyAsync(orderId); + bool terminal = + string.Equals(status.Status, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase) || + string.Equals(status.Status, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase) || + string.Equals(status.Status, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase); + + if (terminal) + return $"orphan sweep found order {orderId} already terminal (status={status.Status}) — nothing to cancel"; + + try + { + var outcome = await client.CancelOrderV2Async( + family, orderId, + $"V2 full-lifecycle test orphan sweep — client-side timeout at submission, tag={probeTag}."); + return $"orphan sweep found order {orderId} (status was {status.Status}) and cancelled it (outcome={outcome})"; + } + catch (Exception cancelEx) + { + return $"orphan sweep found order {orderId} but the cancel call itself FAILED " + + $"({cancelEx.GetType().Name}: {cancelEx.Message}) — not retried; cancel it by hand in the CERTInext portal"; + } + } + catch (Exception ex) + { + return $"orphan sweep itself FAILED ({ex.GetType().Name}: {ex.Message}) — could not search for an orphaned order; check the CERTInext portal by hand"; + } + } + + /// + /// Cleans up a sandbox order this test created: revokes it via the plugin's real V2 + /// Revoke if it reached GENERATED, otherwise cancels it via the raw cancel endpoint + /// (the plugin has no V2 cancel method — ). Single attempt + /// only — never retries a cancel. Logs rather than throws on failure so a cleanup problem + /// never masks the test's own assertion result; failures are surfaced in test output for + /// manual follow-up in the CERTInext portal. + /// + private async Task CleanupOrderAsync(CERTInextCAPlugin plugin, string orderId) + { + if (string.IsNullOrWhiteSpace(orderId)) + return; + + try + { + var current = await plugin.GetSingleRecord(orderId); + if (current?.Status == (int)EndEntityStatus.GENERATED) + { + int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */); + _output.WriteLine($"Cleanup: revoked issued order {orderId} -> {revokeResult}."); + } + else + { + await V2RawHttpHelpers.CancelSslOrderRawAsync( + _v2ApiUrl, _v2ClientId, _v2ClientSecret, orderId, + "V2 full-lifecycle test cleanup — order not issued, cancelling."); + _output.WriteLine($"Cleanup: cancelled non-issued order {orderId} (status={current?.Status})."); + } + } + catch (Exception ex) + { + _output.WriteLine( + $"Cleanup FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}. " + + "Revoke/cancel it by hand in the CERTInext portal if it should not remain pending."); + } + } + + // --------------------------------------------------------------------------- + // 1. DV UCC — enroll (2+ SANs) -> track -> sync/GetSingleRecord -> revoke + // --------------------------------------------------------------------------- + + /// + /// Places one V2 DV SSL UCC order () with the + /// primary domain on the account's long-reused, likely-already-verified + /// CERTINEXT_DCV_DOMAIN, plus two fresh never-seen subdomains as additional SANs + /// (so the order itself places cleanly regardless of whether the extra SANs clear DCV). + /// Exercises Enroll -> GetSingleRecord -> Synchronize, then cleans up (revoke if + /// GENERATED, else cancel). + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async Task Enroll_V2_DvUcc_WithMultipleSans_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_DV_UCC") != "1", + "CERTINEXT_V2_LIFECYCLE_DV_UCC=1 not set — this places a real DV UCC sandbox order. Skipping."); + + string ts = Timestamp(); + string primary = _v2Domain; + string sanA = $"ucc-a-{ts}.{_v2Domain}"; + string sanB = $"ucc-b-{ts}.{_v2Domain}"; + + var config = BuildV2Config(); + var plugin = BuildV2Plugin(config); + + string orderId = null; + try + { + var productInfo = new EnrollmentProductInfo { ProductID = Constants.Products.DvSslUcc }; + + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(primary), + subject: $"CN={primary}", + san: new Dictionary { ["dns"] = new[] { sanA, sanB } }, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace( + "V2 DV UCC Enroll must return a non-empty CARequestID"); + orderId = enrollResult.CARequestID; + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"DV UCC Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}"); + _output.WriteLine($"DV UCC order {orderId}: Status={enrollResult.Status}, Primary={primary}, SANs=[{sanA}, {sanB}]"); + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull("GetSingleRecord must return a record for a just-placed DV UCC order"); + tracked.CARequestID.Should().Be(orderId); + _output.WriteLine($"Tracked: Status={tracked.Status}"); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().Contain(r => r.CARequestID == orderId, + $"the newly placed DV UCC order '{orderId}' must appear in a delta sync via V2 /reports/orders"); + + var syncedRecord = synced.First(r => r.CARequestID == orderId); + _output.WriteLine($"Synced status: {syncedRecord.Status}"); + if (syncedRecord.Status == (int)EndEntityStatus.GENERATED) + syncedRecord.Certificate.Should().NotBeNullOrWhiteSpace("an issued DV UCC order must carry a cert body via Synchronize"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 2. OV — enroll -> track -> sync -> revoke/cancel + // --------------------------------------------------------------------------- + + /// + /// Places one V2 OV SSL order (); productVariant + /// "ov" and the organization block are both derived/required automatically by + /// EnrollV2Async from the connector's OrganizationNumber. Sandbox OV orders commonly + /// park in a pending-vetting state rather than auto-issuing — this test asserts on + /// whatever state machine actually results (only FAILED at submission is treated as a + /// hard failure) rather than forcing GENERATED. See + /// for the 120s-vs-300s client timeout caveat. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async Task Enroll_V2_Ov_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_OV") != "1", + "CERTINEXT_V2_LIFECYCLE_OV=1 not set — this places a real OV sandbox order. Skipping."); + + string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null; + Skip.If(string.IsNullOrWhiteSpace(organizationNumber), + "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — OV requires a pre-vetted organization number. Skipping."); + + var config = BuildV2Config(organizationNumber: organizationNumber); + var plugin = BuildV2Plugin(config); + + string domain = _v2Domain; + string probeTag = $"ov-{Timestamp()}"; + DateTime windowStart = DateTime.UtcNow; + string orderId = null; + try + { + EnrollmentResult enrollResult; + try + { + enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(domain, probeTag), + subject: $"CN={domain},OU={probeTag}", + san: new Dictionary { ["dns"] = new[] { domain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.OvSsl }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) when (IsClientTimeout(ex)) + { + string sweepResult = await TryCancelOrphanByWindowAsync(domain, windowStart, probeTag); + Skip.If(true, + "OV order creation did not return within the plugin's hard-coded 120s V2 HTTP client " + + "timeout. That timeout is a known client-side limitation rather than a CA-side " + + "rejection, so this is an expected skip — but a client timeout does not prove the CA " + + $"never created the order; it likely did. {sweepResult}. " + + $"Observed: {ex.GetType().Name}: {ex.Message}"); + return; + } + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace("V2 OV Enroll must return a non-empty CARequestID"); + orderId = enrollResult.CARequestID; + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"OV Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}"); + _output.WriteLine($"OV order {orderId}: Status={enrollResult.Status}, Message={enrollResult.StatusMessage}"); + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull(); + _output.WriteLine($"Tracked OV order {orderId}: Status={tracked.Status} (OV sandbox orders commonly sit in a pending-vetting state rather than a forced GENERATED state)."); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().Contain(r => r.CARequestID == orderId, + $"the newly placed OV order '{orderId}' must appear in a delta sync"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 3. OV UCC — enroll (2+ SANs) -> track -> sync -> revoke/cancel + // --------------------------------------------------------------------------- + + /// + /// Places one V2 OV SSL UCC order () — the + /// organization-block requirement (OV/EV) and the UCC multi-SAN path (additionalDomains) + /// are exercised together in one order. Same pending-vetting behavior and timeout + /// caveat as the plain OV test above. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async Task Enroll_V2_OvUcc_WithMultipleSans_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_OV_UCC") != "1", + "CERTINEXT_V2_LIFECYCLE_OV_UCC=1 not set — this places a real OV UCC sandbox order. Skipping."); + + string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null; + Skip.If(string.IsNullOrWhiteSpace(organizationNumber), + "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — OV UCC requires a pre-vetted organization number. Skipping."); + + string ts = Timestamp(); + string primary = _v2Domain; + string sanA = $"ovucc-a-{ts}.{_v2Domain}"; + string sanB = $"ovucc-b-{ts}.{_v2Domain}"; + + var config = BuildV2Config(organizationNumber: organizationNumber); + var plugin = BuildV2Plugin(config); + + string probeTag = $"ovucc-{ts}"; + DateTime windowStart = DateTime.UtcNow; + string orderId = null; + try + { + EnrollmentResult enrollResult; + try + { + enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(primary, probeTag), + subject: $"CN={primary},OU={probeTag}", + san: new Dictionary { ["dns"] = new[] { sanA, sanB } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.OvSslUcc }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) when (IsClientTimeout(ex)) + { + string sweepResult = await TryCancelOrphanByWindowAsync(primary, windowStart, probeTag); + Skip.If(true, + "OV UCC order creation did not return within the plugin's hard-coded 120s V2 HTTP client " + + "timeout — same known client-side limitation as the plain OV test. A client timeout " + + $"does not prove the CA never created the order; it likely did. {sweepResult}. " + + $"Observed: {ex.GetType().Name}: {ex.Message}"); + return; + } + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace("V2 OV UCC Enroll must return a non-empty CARequestID"); + orderId = enrollResult.CARequestID; + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"OV UCC Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}"); + _output.WriteLine($"OV UCC order {orderId}: Status={enrollResult.Status}, Primary={primary}, SANs=[{sanA}, {sanB}]"); + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull(); + _output.WriteLine($"Tracked OV UCC order {orderId}: Status={tracked.Status}"); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().Contain(r => r.CARequestID == orderId, + $"the newly placed OV UCC order '{orderId}' must appear in a delta sync"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 4. EV — enroll -> track -> sync -> revoke/cancel + // --------------------------------------------------------------------------- + + /// + /// Places one V2 EV SSL order () — same + /// organization-block requirement as OV (ProductVariantsV2 mapping resolves "ev" + /// automatically), same pending-vetting behavior, same client-timeout caveat. Uses + /// CERTINEXT_EV_ORG_NUMBER — NOT CERTINEXT_ORG_NUMBER/ + /// , which is only pre-vetted for OV. EV + /// requires its own, separately-vetted organization number that this account does not + /// currently have; the test skips cleanly rather than guessing. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async Task Enroll_V2_Ev_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_EV") != "1", + "CERTINEXT_V2_LIFECYCLE_EV=1 not set — this places a real EV sandbox order. Skipping."); + + string organizationNumber = Environment.GetEnvironmentVariable("CERTINEXT_EV_ORG_NUMBER"); + Skip.If(string.IsNullOrWhiteSpace(organizationNumber), + "CERTINEXT_EV_ORG_NUMBER not set — EV requires its own pre-vetted organization number " + + "(distinct from CERTINEXT_ORG_NUMBER, which is only vetted for OV). Skipping."); + + var config = BuildV2Config(organizationNumber: organizationNumber); + var plugin = BuildV2Plugin(config); + + string domain = _v2Domain; + string orderId = null; + try + { + EnrollmentResult enrollResult; + try + { + enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(domain), + subject: $"CN={domain}", + san: new Dictionary { ["dns"] = new[] { domain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.EvSsl }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) when (IsClientTimeout(ex)) + { + Skip.If(true, + $"EV order creation did not return within the plugin's hard-coded 120s V2 HTTP " + + $"client timeout — same known client-side limitation flagged for OV. " + + $"Observed: {ex.GetType().Name}: {ex.Message}"); + return; + } + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace("V2 EV Enroll must return a non-empty CARequestID"); + orderId = enrollResult.CARequestID; + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"EV Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}"); + _output.WriteLine($"EV order {orderId}: Status={enrollResult.Status}, Message={enrollResult.StatusMessage}"); + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull(); + _output.WriteLine($"Tracked EV order {orderId}: Status={tracked.Status} (EV sandbox orders commonly sit in a pending-vetting state)."); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().Contain(r => r.CARequestID == orderId, + $"the newly placed EV order '{orderId}' must appear in a delta sync"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 5. Wildcard DV — both CSR shapes (wildcard-only, wildcard+apex SAN) + // --------------------------------------------------------------------------- + + /// + /// Resolves the wildcard domain to use: CERTINEXT_V2_WILDCARD_DOMAIN if set, + /// else the literal *.dcv-test.scrup.org — this repo's own always-reused sandbox + /// base domain, not customer data. + /// + private static string ResolveWildcardDomain() => + Environment.GetEnvironmentVariable("CERTINEXT_V2_WILDCARD_DOMAIN") ?? "*.dcv-test.scrup.org"; + + /// + /// Wildcard-only CSR shape: CN and sole SAN are both the wildcard + /// (). Expected to be accepted — wildcard is a + /// first-class DV SSL Wildcard product shape. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async Task Enroll_V2_WildcardDv_WildcardOnly_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_WILDCARD_DV") != "1", + "CERTINEXT_V2_LIFECYCLE_WILDCARD_DV=1 not set — this places real wildcard DV sandbox orders. Skipping."); + + string wildcard = ResolveWildcardDomain(); + var config = BuildV2Config(); + var plugin = BuildV2Plugin(config); + + string orderId = null; + try + { + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(wildcard), + subject: $"CN={wildcard}", + san: new Dictionary { ["dns"] = new[] { wildcard } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSslWildcard }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + _output.WriteLine($"Wildcard-only ({wildcard}): Status={enrollResult.Status}, Message={enrollResult.StatusMessage}"); + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"a wildcard-only CSR/SAN shape must not be rejected; message: {enrollResult.StatusMessage}"); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + orderId = enrollResult.CARequestID; + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull(); + _output.WriteLine($"Tracked: Status={tracked.Status}"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + /// + /// Wildcard+apex CSR shape: CN is the wildcard, SAN dictionary carries BOTH the wildcard + /// and its bare apex domain. The non-UCC V2 SAN guard (CERTInextCAPlugin.cs, EnrollV2Async) + /// explicitly exempts exactly this shape for a wildcard product — the guard computes + /// domain as the literal CN ("*.dcv-test.scrup.org" here), and without the + /// exemption the apex ("dcv-test.scrup.org") would match neither that nor its "www." + /// variant and be treated as a disallowed "extra SAN", even though a wildcard+apex + /// pairing is an extremely common, legitimate certificate shape. The order is therefore + /// expected to be accepted and issued. This test records the actual resulting behavior + /// rather than hard-asserting on it everywhere: if the order is rejected anyway, + /// it asserts the rejection is specifically this guard's (by message content) rather than + /// some unrelated failure; if accepted and issued, it parses the issued leaf (BouncyCastle) + /// and logs — as an observation only, not an assertion — whether the apex is covered by + /// the certificate's own SAN list (CERTInext may or may not add the apex to + /// additionalDomains automatically for a non-UCC wildcard product). + /// Expected sandbox order count: 0 or 1 (0 if CERTInext itself rejects a FAILED result + /// before any order is ever placed — see the FAILED branch below). + /// + [SkippableFact] + public async Task Enroll_V2_WildcardDv_WildcardPlusApexSan_RecordsActualBehavior() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_WILDCARD_DV") != "1", + "CERTINEXT_V2_LIFECYCLE_WILDCARD_DV=1 not set — this places real wildcard DV sandbox orders. Skipping."); + + string wildcard = ResolveWildcardDomain(); + string apex = wildcard.StartsWith("*.", StringComparison.Ordinal) ? wildcard.Substring(2) : wildcard; + + var config = BuildV2Config(); + var plugin = BuildV2Plugin(config); + + string orderId = null; + try + { + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(wildcard), + subject: $"CN={wildcard}", + san: new Dictionary { ["dns"] = new[] { wildcard, apex } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSslWildcard }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + _output.WriteLine($"Wildcard+apex ({wildcard} + {apex}): Status={enrollResult.Status}, Message={enrollResult.StatusMessage}"); + + if (enrollResult.Status == (int)EndEntityStatus.FAILED) + { + _output.WriteLine( + "RESULT: wildcard+apex was REJECTED before any CA call — the non-UCC SAN guard's " + + $"wildcard-apex exemption did not cover this case: domain==CN=='{wildcard}', and the " + + $"apex '{apex}' matched neither that nor its 'www.' variant."); + enrollResult.StatusMessage.Should().Contain("SAN", + "a FAILED result here must specifically be the non-UCC multi-SAN guard's rejection " + + "(StatusMessage mentions SAN/domain count), not some unrelated failure masquerading as it"); + enrollResult.CARequestID.Should().BeNullOrWhiteSpace( + "the guard rejects before PlaceOrderV2Async — no CARequestID should be minted"); + } + else + { + _output.WriteLine( + "RESULT: wildcard+apex was ACCEPTED — the non-UCC single-domain SAN guard's " + + "wildcard-apex exemption allows the bare apex alongside the wildcard CN."); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + orderId = enrollResult.CARequestID; + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull(); + _output.WriteLine($"Tracked: Status={tracked.Status}"); + + if (tracked.Status == (int)EndEntityStatus.GENERATED && !string.IsNullOrWhiteSpace(tracked.Certificate)) + { + var sans = ExtractDnsSansOrEmpty(tracked.Certificate); + _output.WriteLine($"Issued certificate SAN list: [{string.Join(", ", sans)}]"); + + bool apexCovered = sans.Any(s => string.Equals(s, apex, StringComparison.OrdinalIgnoreCase)); + _output.WriteLine(apexCovered + ? $"The apex '{apex}' IS covered by the issued certificate's SAN list." + : $"The apex '{apex}' is NOT covered by the issued certificate's SAN list " + + "(not asserted — CERTInext may or may not add the apex to additionalDomains " + + "automatically for a non-UCC wildcard product)."); + } + else + { + _output.WriteLine( + $"Order not yet issued (Status={tracked.Status}) — skipping the SAN-coverage observation."); + } + } + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 6. Renew and Reissue of an issued DV order + // --------------------------------------------------------------------------- + + /// + /// Enrolls a DV order (New), then calls Enroll again with EnrollmentType.Renew and then + /// EnrollmentType.Reissue for the same domain, passing the prior order's serial via + /// ProductParameters["PriorCertSN"] (the V1 RenewOrReissueAsync convention). V2's + /// EnrollV2Async never branches on enrollmentType beyond logging it — every enrollment + /// type is dispatched identically (CERTInextCAPlugin.cs: "V2 path: all enrollment types + /// go through EnrollV2Async", and EnrollV2Async itself never reads PriorCertSN or + /// enrollmentType except in log statements). This test records the actual resulting + /// behavior (distinct CARequestIDs, original never implicitly revoked) but does NOT pass + /// merely because the CA accepted each submission; a FAILED order at the CA must still + /// fail this test, since "records actual behavior" was never meant to license "observe + /// FAILED three times and call it a pass." + /// Expected sandbox order count: up to 3 (original + renew + reissue). + /// + [SkippableFact] + public async Task EnrollRenewReissue_V2_IssuedDvOrder_RecordsActualBehavior() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_RENEW_REISSUE") != "1", + "CERTINEXT_V2_LIFECYCLE_RENEW_REISSUE=1 not set — this places up to 3 real DV sandbox orders. Skipping."); + + var config = BuildV2Config(); + var plugin = BuildV2Plugin(config); + + string domain = _v2Domain; + string originalOrderId = null, renewOrderId = null, reissueOrderId = null; + try + { + var original = await plugin.Enroll( + csr: GenerateCsrPem(domain), + subject: $"CN={domain}", + san: new Dictionary { ["dns"] = new[] { domain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + original.Should().NotBeNull(); + original.CARequestID.Should().NotBeNullOrWhiteSpace(); + originalOrderId = original.CARequestID; + _output.WriteLine($"Original order {originalOrderId}: Status={original.Status}, Message={original.StatusMessage}"); + original.Status.Should().BeOneOf( + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }, + $"the original New enrollment must actually reach an in-flight or issued state for this to be a " + + $"meaningful renew/reissue lifecycle test, not FAILED; message: {original.StatusMessage}"); + + string priorSn = ExtractHexSerialOrEmpty(original.Certificate); + var priorParams = new Dictionary { ["PriorCertSN"] = priorSn }; + + var renewResult = await plugin.Enroll( + csr: GenerateCsrPem(domain), + subject: $"CN={domain}", + san: new Dictionary { ["dns"] = new[] { domain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, ProductParameters = priorParams }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.Renew); + + renewResult.Should().NotBeNull(); + renewResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + renewOrderId = renewResult.CARequestID; + renewOrderId.Should().NotBe(originalOrderId, + "V2 has no dedicated renew endpoint — EnrollV2Async dispatches every EnrollmentType " + + "identically, so Renew places a brand-new order with a new CARequestID rather than " + + "reusing or superseding the original's ID"); + _output.WriteLine($"Renew order {renewOrderId}: Status={renewResult.Status}, Message={renewResult.StatusMessage} (new order, distinct CARequestID)."); + renewResult.Status.Should().BeOneOf( + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }, + $"Renew must actually reach an in-flight or issued state, not FAILED; message: {renewResult.StatusMessage}"); + + var reissueResult = await plugin.Enroll( + csr: GenerateCsrPem(domain), + subject: $"CN={domain}", + san: new Dictionary { ["dns"] = new[] { domain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, ProductParameters = priorParams }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.Reissue); + + reissueResult.Should().NotBeNull(); + reissueResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + reissueOrderId = reissueResult.CARequestID; + reissueOrderId.Should().NotBe(originalOrderId); + reissueOrderId.Should().NotBe(renewOrderId); + _output.WriteLine($"Reissue order {reissueOrderId}: Status={reissueResult.Status}, Message={reissueResult.StatusMessage} (new order, distinct CARequestID)."); + reissueResult.Status.Should().BeOneOf( + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }, + $"Reissue must actually reach an in-flight or issued state, not FAILED; message: {reissueResult.StatusMessage}"); + + var originalAfter = await plugin.GetSingleRecord(originalOrderId); + originalAfter.Should().NotBeNull(); + originalAfter.Status.Should().NotBe((int)EndEntityStatus.REVOKED, + "neither Renew nor Reissue should implicitly revoke the original order under the V2 " + + "path — EnrollV2Async never calls Revoke on a prior order"); + _output.WriteLine($"Original order {originalOrderId} after renew+reissue: Status={originalAfter.Status} (unaffected, as expected)."); + } + finally + { + await CleanupOrderAsync(plugin, originalOrderId); + await CleanupOrderAsync(plugin, renewOrderId); + await CleanupOrderAsync(plugin, reissueOrderId); + } + } + + /// + /// Extracts the issued certificate's serial number as an uppercase hex string using + /// BouncyCastle (never BCL System.Security.Cryptography). Returns empty when + /// is null/blank/unparseable — e.g. a DV order still pending + /// DCV at enrollment time has no cert body yet, and PriorCertSN is not read at all by + /// EnrollV2Async under V2 (see this method's caller), so an empty value is harmless here. + /// + private static string ExtractHexSerialOrEmpty(string certPem) + { + if (string.IsNullOrWhiteSpace(certPem)) + return string.Empty; + + try + { + var match = System.Text.RegularExpressions.Regex.Match( + certPem, + @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", + System.Text.RegularExpressions.RegexOptions.Singleline); + if (!match.Success) + return string.Empty; + + string b64 = match.Groups[1].Value.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + return cert.SerialNumber.ToString(16).ToUpperInvariant(); + } + catch + { + return string.Empty; + } + } + + /// + /// Extracts the issued certificate's dNSName SAN entries using BouncyCastle (never BCL + /// System.Security.Cryptography) — mirrors the main plugin's own GeneralNameToSanEntry + /// dNSName handling, but reading the ISSUED certificate's own SAN extension rather than a + /// CSR's. Returns an empty list when is null/blank/unparseable, + /// or the certificate carries no SAN extension. + /// + private static List ExtractDnsSansOrEmpty(string certPem) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(certPem)) + return result; + + try + { + var match = System.Text.RegularExpressions.Regex.Match( + certPem, + @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", + System.Text.RegularExpressions.RegexOptions.Singleline); + if (!match.Success) + return result; + + string b64 = match.Groups[1].Value.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + + var sanExtensionOctets = cert.GetExtensionValue(X509Extensions.SubjectAlternativeName)?.GetOctets(); + if (sanExtensionOctets == null) + return result; + + var generalNames = GeneralNames.GetInstance( + Org.BouncyCastle.Asn1.Asn1Object.FromByteArray(sanExtensionOctets)); + + foreach (var generalName in generalNames.GetNames()) + { + if (generalName.TagNo == GeneralName.DnsName) + result.Add(Org.BouncyCastle.Asn1.DerIA5String.GetInstance(generalName.Name).GetString()); + } + } + catch + { + // Observation-only helper — an unparseable cert/extension just yields no SAN + // observations rather than failing the test. + } + + return result; + } + } +} diff --git a/CERTInext.IntegrationTests/V2LifecycleTests.cs b/CERTInext.IntegrationTests/V2LifecycleTests.cs new file mode 100644 index 0000000..c382b01 --- /dev/null +++ b/CERTInext.IntegrationTests/V2LifecycleTests.cs @@ -0,0 +1,773 @@ +// 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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Plugin-level integration tests for the V2 (OAuth2) API path — Tiers 1–3 (no DCV + /// build required). Unlike , which exercises + /// methods directly, these tests drive the full + /// IAnyCAPlugin surface (Enroll, Revoke, GetSingleRecord, + /// Synchronize) the way Keyfactor Command actually calls the plugin. + /// + /// All tests are gated behind CERTINEXT_USE_V2_API=1 plus valid V2 OAuth2 + /// credentials and skip gracefully otherwise. See for the + /// full list of required environment variables. + /// + public class V2LifecycleTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2ProductCode; + private readonly string _v2Domain; + private readonly bool _v2Enabled; + + public V2LifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2ProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /// + /// Builds a wired for the V2 API. A single + /// serves both modes — in V2 mode it is the V2 + /// base URL, and V2 auth reuses + /// /. + /// Deliberately does NOT set any V1-only field (ApiKey/AccountNumber/AuthMode) — proving + /// those are optional when UseV2Api is true is itself part of what these tests exercise + /// (Synchronize now uses V2 /reports/orders, not V1 GetOrderReport). + /// + private CERTInextConfig BuildV2Config(bool dcvEnabled = false, int? pageSize = null, int? syncLookbackHours = null) + { + return new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + + PageSize = pageSize ?? 100, + + // Default 72h (Constants.ApiV2.DefaultSyncLookbackHours) is always added on top + // of lastSync regardless of how recent it is — on a busy shared sandbox that + // means every delta-sync test touches several days of orders (each issued row + // costs a live certificate download) unless narrowed here. + V2SyncLookbackHours = syncLookbackHours ?? Constants.ApiV2.DefaultSyncLookbackHours, + + DcvEnabled = dcvEnabled, + DcvPropagationDelaySeconds = 5, + DcvTimeoutMinutes = 3 + }; + } + + /// + /// Constructs a plugin instance wired to a real + /// built from (or a fresh + /// if none is supplied). Uses the two-arg test constructor so no + /// Initialize call is required. + /// + private CERTInextCAPlugin BuildV2Plugin(CERTInextConfig config = null) + { + config ??= BuildV2Config(); + var client = new CERTInextClient(config); + return new CERTInextCAPlugin(client, config); + } + + /// + /// Generates a fresh RSA-2048 PKCS#10 CSR for the given common name using + /// BouncyCastle only. + /// + private static string GenerateCsrPem(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + /// + /// Runs a full synchronization via the plugin and returns all collected records. + /// + private static async Task> RunSyncAsync( + CERTInextCAPlugin plugin, DateTime? lastSync = null, bool fullSync = true) + { + var buffer = new BlockingCollection(boundedCapacity: 10_000); + var collected = new List(); + + var syncTask = Task.Run(async () => + { + await plugin.Synchronize( + buffer, + lastSync: lastSync, + fullSync: fullSync, + cancelToken: CancellationToken.None); + + if (!buffer.IsAddingCompleted) + buffer.CompleteAdding(); + }); + + foreach (var record in buffer.GetConsumingEnumerable()) + collected.Add(record); + + await syncTask; + return collected; + } + + /// + /// Polls until the order reaches + /// GENERATED or FAILED, or the poll budget is exhausted. + /// + private static async Task WaitForIssuanceAsync( + CERTInextCAPlugin plugin, string caRequestId, int maxPolls = 6, int delaySeconds = 15) + { + AnyCAPluginCertificate record = null; + for (int poll = 1; poll <= maxPolls; poll++) + { + record = await plugin.GetSingleRecord(caRequestId); + if (record?.Status == (int)EndEntityStatus.GENERATED + || record?.Status == (int)EndEntityStatus.FAILED) + break; + if (poll < maxPolls) + await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); + } + return record; + } + + private EnrollmentProductInfo BuildV2ProductInfo() => + new EnrollmentProductInfo + { + ProductID = _v2ProductCode, + ProductParameters = new Dictionary + { + [Constants.EnrollmentParam.ProductCode] = _v2ProductCode, + [Constants.EnrollmentParam.ProfileId] = _v2ProductCode, + } + }; + + /// + /// Resolves the order ID to exercise for tests that need a pre-existing V2 order. + /// Reads only CERTINEXT_V2_ORDER_ID — deliberately does not fall back to an + /// order ID produced by another test in this class, so results do not depend on + /// test run order. + /// + private static string ResolveOrderId() + => Environment.GetEnvironmentVariable("CERTINEXT_V2_ORDER_ID"); + + /// + /// Returns an issued (GENERATED) V2 order to exercise, plus the plugin instance + /// that owns it. Prefers CERTINEXT_V2_ORDER_ID if set; otherwise enrolls a + /// fresh order in this test and polls (bounded) for issuance, so tests using this + /// helper are self-contained and don't depend on env state or another test's run + /// order. Skip.Ifs (via ) when no env ID + /// is set and the freshly-enrolled order never reaches GENERATED within the poll + /// budget — sandboxes may require DCV to auto-issue. + /// + private async Task<(string orderId, CERTInextCAPlugin plugin)> EnsureIssuedOrderIdAsync() + { + var plugin = BuildV2Plugin(); + string envOrderId = ResolveOrderId(); + if (!string.IsNullOrWhiteSpace(envOrderId)) + return (envOrderId, plugin); + + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + _output.WriteLine( + $"EnsureIssuedOrderIdAsync: no CERTINEXT_V2_ORDER_ID set — enrolled fresh order {enrollResult.CARequestID}."); + + var record = await WaitForIssuanceAsync(plugin, enrollResult.CARequestID); + Skip.If(record?.Status != (int)EndEntityStatus.GENERATED, + $"Freshly-enrolled order '{enrollResult.CARequestID}' did not reach GENERATED within the poll " + + $"budget (status={record?.Status}) — sandbox may require DCV to auto-issue. Set " + + "CERTINEXT_V2_ORDER_ID to a known-issued order to bypass enrollment."); + + return (enrollResult.CARequestID, plugin); + } + + // --------------------------------------------------------------------------- + // Enroll() via the plugin, V2 path + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Enroll_V2_ReturnsCARequestID() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var plugin = BuildV2Plugin(); + + var result = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + result.CARequestID.Should().NotBeNullOrWhiteSpace( + "V2 Enroll must return a non-empty CARequestID — it is the stable foreign key for all future operations"); + result.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"V2 Enroll must not FAILED at submission time; message: {result.StatusMessage}"); + + _output.WriteLine($"CARequestID: {result.CARequestID}"); + _output.WriteLine($"Status: {result.Status}"); + _output.WriteLine($"Message: {result.StatusMessage}"); + } + + // --------------------------------------------------------------------------- + // Revoke() via the plugin, V2 path + // --------------------------------------------------------------------------- + + /// + /// Opt-in cleanup: revokes one explicit, already-issued order through the plugin's + /// V2 Revoke with reason superseded (4), outside Command. Used to clean up lab + /// orders Command never imported and to reproduce an out-of-band CA-side revoke. + /// Gated behind CERTINEXT_REVOKE_ORDER_ID; never retries. + /// + [SkippableFact] + public async Task Revoke_V2_ExplicitOrder_Superseded() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + string orderId = Environment.GetEnvironmentVariable("CERTINEXT_REVOKE_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), "CERTINEXT_REVOKE_ORDER_ID not set — skipping."); + + var plugin = BuildV2Plugin(); + var before = await plugin.GetSingleRecord(orderId); + _output.WriteLine($"Before: CARequestID={orderId}, Status={before?.Status}"); + Skip.If(before?.Status != (int)EndEntityStatus.GENERATED, + $"Order '{orderId}' is in status {before?.Status} (not GENERATED) — not revoking."); + + int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */); + _output.WriteLine($"Revoke result: {revokeResult}"); + + var after = await plugin.GetSingleRecord(orderId); + _output.WriteLine($"After: Status={after?.Status}, RevocationDate={after?.RevocationDate:o}, RevocationReason={after?.RevocationReason}"); + revokeResult.Should().Be((int)EndEntityStatus.REVOKED); + } + + [SkippableFact] + public async Task Revoke_V2_IssuedOrder_ReturnsRevoked() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var (orderId, plugin) = await EnsureIssuedOrderIdAsync(); + + var current = await plugin.GetSingleRecord(orderId); + Skip.If(current?.Status != (int)EndEntityStatus.GENERATED, + $"Order '{orderId}' is in status {current?.Status} (not GENERATED) — revocation requires an issued certificate; skipping."); + + int revokeResult; + try + { + revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 1 /* keyCompromise */); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed")) + { + // Documented sandbox-timing quirk: the CA reports 'issued' via GetSingleRecord + // while still internally finalizing the order, and rejects revoke with 422 + // ("Certificate Request still being processed") in that window. Retry once + // after a short delay before giving up — any other exception (or a second + // failure) must fail the test rather than be swallowed here. + _output.WriteLine($"Revoke rejected as still-processing; retrying once after 15s: {ex.Message}"); + await Task.Delay(TimeSpan.FromSeconds(15)); + try + { + revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 1); + } + catch (InvalidOperationException ex2) when (ex2.Message.Contains("still being processed")) + { + Skip.If(true, + $"Order '{orderId}' tracked as GENERATED but CA rejected revocation twice (sandbox timing): {ex2.Message}"); + return; // unreachable + } + } + + revokeResult.Should().Be((int)EndEntityStatus.REVOKED, + "V2 Revoke must return the REVOKED status code on success"); + } + + // --------------------------------------------------------------------------- + // GetSingleRecord() via the plugin, V2 path + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task GetSingleRecord_V2_Plugin_ReturnsDetails() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string orderId = ResolveOrderId(); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "No V2 order ID available — set CERTINEXT_V2_ORDER_ID to a real V2 order to run this test."); + + var plugin = BuildV2Plugin(); + var record = await plugin.GetSingleRecord(orderId); + + record.Should().NotBeNull("plugin.GetSingleRecord must return a record for a known V2 order"); + record.CARequestID.Should().Be(orderId); + _output.WriteLine($"CARequestID: {record.CARequestID}"); + _output.WriteLine($"Status: {record.Status}"); + _output.WriteLine($"ProductID: {record.ProductID}"); + } + + // --------------------------------------------------------------------------- + // Enroll -> Synchronize -> Revoke, full V2 lifecycle via the plugin + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Enroll_Synchronize_Revoke_V2_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + // Narrow lookback (1h): the plugin's default 72h margin makes an un-narrowed delta + // sync slow against this busy shared sandbox, and the order enrolled below is + // only seconds old. + var config = BuildV2Config(syncLookbackHours: 1); + var plugin = BuildV2Plugin(config); + + // --- Enroll --- + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"V2 Enroll must not FAILED at submission time; message: {enrollResult.StatusMessage}"); + + _output.WriteLine($"Enrolled V2 order {enrollResult.CARequestID}, status={enrollResult.Status}"); + + // --- Synchronize (V2 /reports/orders) --- + // Delta sync (fullSync=false, lastSync=recent) rather than a full historical + // pull — this sandbox account has accumulated 1000+ orders from prior test + // runs, and a full sync of the entire history is unnecessarily slow here; the + // order we just enrolled is recent, so a delta sync (with the configured + // lookback window) is sufficient to prove it surfaces via Synchronize. + var synced = await RunSyncAsync(BuildV2Plugin(config), lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().Contain( + r => r.CARequestID == enrollResult.CARequestID, + $"the newly enrolled V2 order '{enrollResult.CARequestID}' must appear in a delta sync " + + "via V2 /reports/orders"); + + var syncedRecord = synced.First(r => r.CARequestID == enrollResult.CARequestID); + _output.WriteLine($"Synced record status: {syncedRecord.Status}"); + + // --- Revoke — only if the sandbox has already auto-issued --- + if (syncedRecord.Status != (int)EndEntityStatus.GENERATED) + { + Skip.If(true, + $"Order '{enrollResult.CARequestID}' is in status {syncedRecord.Status} (not GENERATED) — " + + "sandbox may not auto-issue a V2 order without DCV; skipping revoke step."); + } + + int revokeResult; + try + { + revokeResult = await plugin.Revoke(enrollResult.CARequestID, hexSerialNumber: string.Empty, revocationReason: 1); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed")) + { + // Documented sandbox-timing quirk: the sandbox can report an order as 'issued' + // via TrackOrder/GetSingleRecord while still internally finalizing it, and + // reject a revoke attempted in that window with 422 "Certificate Request + // still being processed". Retry once after a short delay before giving up — + // any other exception must fail the test rather than be swallowed here. + _output.WriteLine($"Revoke rejected as still-processing; retrying once after 15s: {ex.Message}"); + await Task.Delay(TimeSpan.FromSeconds(15)); + try + { + revokeResult = await plugin.Revoke(enrollResult.CARequestID, hexSerialNumber: string.Empty, revocationReason: 1); + } + catch (InvalidOperationException ex2) when (ex2.Message.Contains("still being processed")) + { + Skip.If(true, + $"Order '{enrollResult.CARequestID}' tracked as GENERATED but CA rejected revocation " + + $"twice (sandbox timing): {ex2.Message}"); + return; // unreachable + } + } + + revokeResult.Should().Be((int)EndEntityStatus.REVOKED, + "Revoke must return the REVOKED status code on success"); + } + + // --------------------------------------------------------------------------- + // GetSingleRecord() cert-body check, V2 path + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task GetSingleRecord_V2_IssuedOrder_HasParseableCertBody() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var (orderId, plugin) = await EnsureIssuedOrderIdAsync(); + var record = await WaitForIssuanceAsync(plugin, orderId, maxPolls: 1); + + Skip.If(record?.Status != (int)EndEntityStatus.GENERATED, + $"Order '{orderId}' is not GENERATED (status={record?.Status}) — skipping cert-body check."); + + record!.Certificate.Should().NotBeNullOrWhiteSpace( + "GetSingleRecord must populate the PEM body for a GENERATED V2 order"); + record.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + + // record.Certificate may be the leaf cert alone, or the leaf followed by one or + // more chain PEM blocks (AssembleV2CertChain concatenates them) — extract only the + // FIRST block. Naively stripping every BEGIN/END marker and decoding the + // concatenation as one base64 blob breaks as soon as a chain is present, because + // each block's own '=' padding then lands mid-string, which is illegal base64. + var firstBlock = System.Text.RegularExpressions.Regex.Match( + record.Certificate, + @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", + System.Text.RegularExpressions.RegexOptions.Singleline); + firstBlock.Success.Should().BeTrue("the certificate body must contain at least one PEM block"); + + var b64 = firstBlock.Groups[1].Value + .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + + Action parse = () => new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + parse.Should().NotThrow("the issued V2 certificate's leaf PEM block must be parseable"); + } + + // --------------------------------------------------------------------------- + // GetSingleRecord() across all synced orders, V2-configured plugin + // --------------------------------------------------------------------------- + + /// + /// Runs a delta sync via V2 /reports/orders with a V2-configured (UseV2Api=true) + /// plugin, then calls GetSingleRecord for a sample of the resulting CARequestIDs. + /// All sampled IDs are now V2-native (from the V2 report itself, not a V1 listing), so + /// they are expected to resolve via the V2 family probe; + /// is tolerated only as a defensive allowance (e.g. an order deleted between sync and + /// this call) — this test's real job is to guard against any *other* unhandled exception + /// type escaping GetSingleRecord. + /// + [SkippableFact] + public async Task GetSingleRecord_V2_AllSyncedOrders_DoNotThrow() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + // Narrow lookback (1h) — this sandbox account has 1000+ historical orders, and the + // plugin's default 72h lookback margin is always added on top of lastSync + // regardless of how recent it is, so an un-narrowed delta sync here would touch + // several days of orders. Every issued row costs a live certificate download, and + // family resolution costs a sequential TrackOrder probe when not already known — + // an un-narrowed window can take several minutes against this shared sandbox. + var plugin = BuildV2Plugin(BuildV2Config(syncLookbackHours: 1)); + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().NotBeNull(); + synced.Should().NotBeEmpty( + "the delta sync window must return at least one record from this sandbox account to sample " + + "GetSingleRecord against — an empty sync makes the rest of this test vacuous"); + + var sample = synced.Take(10).ToList(); + _output.WriteLine($"Sampling {sample.Count} of {synced.Count} synced records for GetSingleRecord (V2-configured plugin)."); + + int ok = 0, keyNotFound = 0; + foreach (var rec in sample) + { + try + { + await plugin.GetSingleRecord(rec.CARequestID); + ok++; + } + catch (KeyNotFoundException) + { + // Tolerated defensively (e.g. sandbox timing/deletion) — every sampled ID + // came from the V2 report itself, so this should be rare, not expected. + keyNotFound++; + } + } + + _output.WriteLine($"GetSingleRecord results: {ok} succeeded, {keyNotFound} KeyNotFoundException."); + (ok + keyNotFound).Should().Be(sample.Count, + "every sampled GetSingleRecord call must either succeed or throw the tolerated " + + "KeyNotFoundException — any other exception type must escape this loop and fail the test"); + } + + // --------------------------------------------------------------------------- + // Synchronize() uses V2 /reports/orders when UseV2Api=true + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Sync_V2_UsesV2ReportsOrders_ReturnsRecords() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + // Narrow lookback (1h) — see the comment in GetSingleRecord_V2_AllSyncedOrders_DoNotThrow + // above for why the plugin's default 72h margin makes an un-narrowed delta sync slow + // against this shared, busy sandbox. + var plugin = BuildV2Plugin(BuildV2Config(syncLookbackHours: 1)); + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + + synced.Should().NotBeNull(); + synced.Should().NotBeEmpty( + "Synchronize must return the account's recent order inventory via V2 /reports/orders " + + "(Synchronize no longer falls back to V1 GetOrderReport when UseV2Api=true)"); + synced.Should().OnlyContain(r => !string.IsNullOrWhiteSpace(r.CARequestID)); + + _output.WriteLine($"Synchronize (V2 /reports/orders) returned {synced.Count} record(s)."); + foreach (var r in synced.Take(5)) + _output.WriteLine($" CARequestID={r.CARequestID}, Status={r.Status}, ProductID={r.ProductID}"); + } + + /// + /// Hard acceptance criterion: Synchronize with + /// UseV2Api=true must succeed and return records with ZERO V1 credentials + /// configured at all — no ApiKey, no AccountNumber, no AuthMode, no V1-shaped ApiUrl. + /// Builds its own config (rather than reusing 's default) so + /// the absence of every V1-only field is explicit and self-evident at the call site. + /// + [SkippableFact] + public async Task Sync_V2_WithZeroV1Credentials_Succeeds() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var config = new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = "Keyfactor Test", + RequestorEmail = "test@example.com", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + PageSize = 100, + // Narrowed to keep this test's live API call volume bounded against a busy + // shared sandbox. + V2SyncLookbackHours = 1 + // Deliberately NOT set: ApiKey, AccountNumber, AuthMode, OAuthTokenUrl — all + // V1-only fields. Their CERTInextConfig defaults (empty string / "AccessKey") + // are never read on this path once UseV2Api is true. + }; + + var client = new CERTInextClient(config); + var plugin = new CERTInextCAPlugin(client, config); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + + synced.Should().NotBeNull(); + _output.WriteLine( + $"Synchronize succeeded with UseV2Api=true and ZERO V1 credentials configured " + + $"(ApiKey/AccountNumber/AuthMode all unset). Returned {synced.Count} record(s)."); + } + + /// + /// Opt-in (walks the sandbox's entire order history — 1000+ orders per the other + /// tests' comments in this class): proves a full sync (fullSync=true, + /// lastSync=null) paginates to completion via V2 /reports/orders without + /// throwing or truncating silently. + /// + [SkippableFact] + public async Task Sync_V2_FullSync_PaginatesEntireHistory() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CERTINEXT_V2_FULL_SYNC_TEST")), + "CERTINEXT_V2_FULL_SYNC_TEST not set — a full sync walks this sandbox's entire order " + + "history and is opt-in to keep the default .V2 filter fast."); + + var plugin = BuildV2Plugin(); + var synced = await RunSyncAsync(plugin, lastSync: null, fullSync: true); + + synced.Should().NotBeNull(); + synced.Should().NotBeEmpty("a full sync of a non-empty sandbox account must return records"); + _output.WriteLine($"Full sync (V2, entire history) returned {synced.Count} record(s)."); + } + + /// + /// Forces multi-page traversal with a small page size (5) on a delta sync, proving + /// ListOrdersV2Async's pagination is exercised end-to-end through Synchronize + /// against the live sandbox (not just the WireMock-based client unit tests). + /// + [SkippableFact] + public async Task Sync_V2_SmallPageSize_PaginatesAcrossMultiplePages() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + // A narrow (2h) window with pageSize=5 still forces multi-page traversal whenever + // this busy shared sandbox has more than 5 matching orders — no need for a wide + // window (e.g. 30 days), which would also multiply live per-row download calls + // for no added pagination proof. + var config = BuildV2Config(pageSize: 5, syncLookbackHours: 1); + var plugin = BuildV2Plugin(config); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-2), fullSync: false); + + synced.Should().NotBeNull(); + _output.WriteLine( + $"Delta sync (2h window, pageSize=5) returned {synced.Count} record(s) — pageSize=5 " + + "forces multi-page traversal whenever the account has more than 5 matching orders."); + } + } + + /// + /// Shared helper for loading ~/.env_certinext_v2. V2 test classes must read their + /// values from the dictionary returns, never from process env: + /// keys the V1 also reads are deliberately NOT + /// promoted. Used by , V2DcvLifecycleTests, and the + /// other V2 test classes so they don't duplicate env-loading logic. + /// + internal static class V2EnvHelper + { + /// + /// Loads ~/.env_certinext_v2 and returns the merged environment dictionary (V2 file + /// values win over process env). V2-only file keys (e.g. CERTINEXT_CLIENT_ID, + /// CERTINEXT_USE_V2_API) are still promoted into process env; keys in + /// (CERTINEXT_API_URL and the rest) + /// are never written to process env. The V1 fixture lets real env vars override + /// ~/.env_certinext, so promoting the V2 values of those shared names would + /// corrupt the V1 fixture of any class constructed later in the same test process. + /// + public static Dictionary LoadAndPromote() + { + string v2Path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".env_certinext_v2"); + + var (env, fileKeys) = LoadEnvFile(v2Path); + + foreach (string key in PromotableKeys(fileKeys)) + if (env.TryGetValue(key, out string fv)) + Environment.SetEnvironmentVariable(key, fv); + + return env; + } + + /// + /// The V2-file keys may write into process env: every file + /// key except those the V1 side reads () + /// and the fixture's opt-in-only flags (). + /// Without the latter exclusion, a value left in ~/.env_certinext_v2 for + /// one of those flags (e.g. CERTINEXT_V2_OPS_TESTS, CERTINEXT_PRIVATE_PKI_LIVE) would be + /// read as unset by the first test class constructed in a run (before this method's + /// promotion step runs), then promoted into real process env, silently arming every + /// later-constructed test class in the same run even though no flag was ever exported in + /// the shell. Exposed internal for direct unit-testing. + /// + internal static List PromotableKeys(IEnumerable fileKeys) + { + var keys = new List(); + foreach (string key in fileKeys) + if (!IntegrationTestFixture.V1EnvKeys.Contains(key) + && !IntegrationTestFixture._optInOnlyFlags.Contains(key)) + keys.Add(key); + return keys; + } + + /// + /// Loads a KEY=VALUE env file and merges with process env vars. File values take + /// priority over process env because the fixture may have already promoted V1 + /// values (e.g. CERTINEXT_API_URL with /emSignHub-API suffix) into process env, + /// and the V2 base URL differs. Returns the merged dict and the set of keys + /// defined in the file. + /// + public static (Dictionary env, HashSet fileKeys) LoadEnvFile(string path) + { + var fileKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (System.Collections.DictionaryEntry de in Environment.GetEnvironmentVariables()) + { + string k = de.Key?.ToString(); + string v = de.Value?.ToString(); + if (!string.IsNullOrEmpty(k)) result[k] = v ?? string.Empty; + } + + if (File.Exists(path)) + { + foreach (string rawLine in File.ReadAllLines(path)) + { + string line = rawLine.Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue; + + int idx = line.IndexOf('='); + if (idx <= 0) continue; + + string key = line.Substring(0, idx).Trim(); + string val = line.Substring(idx + 1).Trim().Trim('"').Trim('\''); + result[key] = val; + fileKeys.Add(key); + } + } + + return (result, fileKeys); + } + + public static string GetEnv(Dictionary env, string key, string defaultValue = "") + => env.TryGetValue(key, out string v) && !string.IsNullOrWhiteSpace(v) ? v : defaultValue; + } +} diff --git a/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs b/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs new file mode 100644 index 0000000..65ab0c8 --- /dev/null +++ b/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs @@ -0,0 +1,268 @@ +// 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. +// +// Opt-in, read-only-by-default sweep over an arbitrary UTC date/time window of the V2 orders +// report (GET /api/certinext/v2/reports/orders). Takes an explicit, caller-supplied window and +// lists every order it finds in it — a general-purpose tool for manually auditing/cleaning up a +// date range after a batch of live-API work (e.g. a day's worth of V2 lifecycle tests). +// +// Env: +// CERTINEXT_V2_SWEEP_FROM / CERTINEXT_V2_SWEEP_TO — UTC ISO-8601 timestamps, e.g. +// 2026-09-30T00:00:00Z. Both required; the test skips (does not default to any window) if +// either is unset. +// CERTINEXT_V2_SWEEP_CANCEL_IDS — optional, comma-separated V2 order IDs. When unset, this +// test only LISTS orders in the window (orderId, domain, status, productCode, orderDate) — +// fully read-only. When set, it additionally cancels exactly those IDs, but only if each one +// is actually found in the listed window and is not already in a terminal state +// (cancelled/revoked/rejected). No bulk "cancel everything" mode exists here deliberately. +// +// Terminal state is decided by Track Order's own `status` field (via +// CERTInextClient.ResolveAndTrackOrderV2WithFamilyAsync), not the orders report's human-readable +// orderStatus/certificateStatus display strings. Exactly one cancel attempt per id; never +// retried, matching every other cleanup/sweep helper in this project +// (V2FullLifecycleTests.CleanupOrderAsync, etc.). +// +// The V2 orders report only filters by calendar date (YYYY-MM-DD) server-side — this test +// requests the covering date range, then re-applies the caller's precise sub-day window +// client-side against each row's own orderDate. +// +// Gating: requires the CERTINEXT_V2_OPS_TESTS=1 opt-in (listed in +// IntegrationTestFixture._optInOnlyFlags, read from the real process environment before +// V2EnvHelper.LoadAndPromote() runs) — this sweep can cancel real sandbox orders, so it needs an +// explicit go/no-go, shared with the other opt-in V2 ops/diagnostic tests. +// +// Logging: every domain value is passed through CERTInextClient.ApplyLoggingRedaction (same +// default-off PII posture as every other V2 live test in this repo) before being written via +// ITestOutputHelper. +// +// Run (list-only): +// set -a; . ~/.env_certinext; set +a +// export CERTINEXT_V2_OPS_TESTS=1 +// export CERTINEXT_V2_SWEEP_FROM=2026-09-25T00:00:00Z +// export CERTINEXT_V2_SWEEP_TO=2026-10-01T00:00:00Z +// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release -p:DcvSupport=false \ +// --filter "FullyQualifiedName~Sweep_ListRecentOrders_ByWindow" --logger "console;verbosity=detailed" +// +// Add CERTINEXT_V2_SWEEP_CANCEL_IDS=12345,67890 to also cancel those two specific orders (only +// if each is found in the window and is not already terminal). +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Threading.Tasks; + using FluentAssertions; + using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; + using Keyfactor.Extensions.CAPlugin.CERTInext.Client; + using Xunit; + using Xunit.Abstractions; + + public class V2OrderWindowSweepTests : IClassFixture + { + private const string OptInFlag = "CERTINEXT_V2_OPS_TESTS"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + private readonly bool _armed; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly bool _v2Enabled; + + public V2OrderWindowSweepTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + // Read the opt-in flag from the real process environment BEFORE promoting the V2 env + // file (mirrors PrivatePkiV2LiveTests) — a value left in ~/.env_certinext_v2 must + // never arm this file. IntegrationTestFixture's own _optInOnlyFlags list already + // keeps ~/.env_certinext from arming it either. + _armed = Environment.GetEnvironmentVariable(OptInFlag)?.Trim() == "1"; + + var env = V2EnvHelper.LoadAndPromote(); + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + private CERTInextClient BuildV2Client() => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + PageSize = 100 + }); + + /// + /// Redacts emails/other personal data before any row reaches ITestOutputHelper — same + /// default-off PII posture as every other V2 live test in this repo (see + /// CERTInextClient.ApplyLoggingRedaction; reachable here via + /// InternalsVisibleTo("CERTInext.IntegrationTests")). Domain names themselves are not + /// touched by this redaction. + /// + private static string RedactForLog(string value) => + CERTInextClient.ApplyLoggingRedaction(value, logSensitiveRequestData: false); + + [SkippableFact] + public async Task Sweep_ListRecentOrders_ByWindow_DryRun_ThenCancelExplicitIds() + { + Skip.If(!_armed, + $"{OptInFlag}=1 not set in the real process environment — this sweep can cancel real sandbox " + + "orders when CERTINEXT_V2_SWEEP_CANCEL_IDS is set, and requires an explicit go/no-go. Skipping."); + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string fromRaw = Environment.GetEnvironmentVariable("CERTINEXT_V2_SWEEP_FROM"); + string toRaw = Environment.GetEnvironmentVariable("CERTINEXT_V2_SWEEP_TO"); + Skip.If(string.IsNullOrWhiteSpace(fromRaw) || string.IsNullOrWhiteSpace(toRaw), + "CERTINEXT_V2_SWEEP_FROM and CERTINEXT_V2_SWEEP_TO (UTC ISO-8601) must both be set — this sweep " + + "does not default to any particular window. Skipping."); + + const DateTimeStyles utcStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal; + + if (!DateTime.TryParse(fromRaw, CultureInfo.InvariantCulture, utcStyles, out DateTime fromUtc)) + throw new ArgumentException($"CERTINEXT_V2_SWEEP_FROM='{fromRaw}' is not a parseable UTC ISO-8601 timestamp."); + if (!DateTime.TryParse(toRaw, CultureInfo.InvariantCulture, utcStyles, out DateTime toUtc)) + throw new ArgumentException($"CERTINEXT_V2_SWEEP_TO='{toRaw}' is not a parseable UTC ISO-8601 timestamp."); + if (toUtc <= fromUtc) + throw new ArgumentException($"CERTINEXT_V2_SWEEP_TO ({toUtc:O}) must be after CERTINEXT_V2_SWEEP_FROM ({fromUtc:O})."); + + var cancelIds = (Environment.GetEnvironmentVariable("CERTINEXT_V2_SWEEP_CANCEL_IDS") ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // The V2 orders report only filters by calendar date (YYYY-MM-DD) server-side — + // request the covering date range, then apply the caller's precise sub-day window + // client-side against each row's own orderDate. + string apiFrom = fromUtc.Date.ToString("yyyy-MM-dd"); + string apiTo = toUtc.Date.AddDays(1).ToString("yyyy-MM-dd"); + + _output.WriteLine("=== V2 order window sweep ==="); + _output.WriteLine($"Window: {fromUtc:O} .. {toUtc:O} (UTC). Report query date range: {apiFrom}..{apiTo}."); + _output.WriteLine(cancelIds.Count > 0 + ? $"Cancel targets (CERTINEXT_V2_SWEEP_CANCEL_IDS): {string.Join(", ", cancelIds)}" + : "No CERTINEXT_V2_SWEEP_CANCEL_IDS set — list-only dry run; nothing will be cancelled."); + + using CERTInextClient client = BuildV2Client(); + + var rowsInWindow = new List(); + int rowsScanned = 0; + + await foreach (var row in client.ListOrdersV2Async(apiFrom, apiTo, pageSize: 100)) + { + rowsScanned++; + + bool parsed = DateTime.TryParse(row.OrderDate, CultureInfo.InvariantCulture, utcStyles, out DateTime rowDate); + + // A row with an unparseable/missing orderDate is kept rather than silently + // dropped — this is a read-only listing, so erring toward showing more (and + // flagging the parse miss) beats erring toward hiding a row the operator + // actually wanted to see. + if (parsed && (rowDate < fromUtc || rowDate > toUtc)) + continue; + + rowsInWindow.Add(row); + + _output.WriteLine( + $"LISTED | OrderId={row.OrderNumber ?? ""} Domain={RedactForLog(row.DomainName)} " + + $"Status={row.OrderStatus ?? ""}/{row.CertificateStatus ?? ""} " + + $"ProductCode={row.ProductCode ?? ""} OrderDate={row.OrderDate ?? ""}" + + (parsed ? string.Empty : " (orderDate unparseable — kept anyway)")); + } + + _output.WriteLine($"Report rows scanned (date-range query): {rowsScanned}. Rows within the precise window: {rowsInWindow.Count}."); + + bool anyCancelFailed = false; + var matchedCancelIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string targetId in cancelIds) + { + var row = rowsInWindow.FirstOrDefault(r => string.Equals(r.OrderNumber, targetId, StringComparison.OrdinalIgnoreCase)); + if (row == null) + { + _output.WriteLine( + $"SUMMARY | OrderId={targetId} Cancel=SKIPPED (not found in the listed window — " + + "not touching an order outside it)"); + continue; + } + + matchedCancelIds.Add(targetId); + + try + { + var (family, status) = await client.ResolveAndTrackOrderV2WithFamilyAsync(targetId); + + bool terminal = + string.Equals(status.Status, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase) || + string.Equals(status.Status, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase) || + string.Equals(status.Status, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase); + + string cancelOutcome; + if (terminal) + { + cancelOutcome = $"SKIPPED (already {status.Status})"; + } + else + { + try + { + var outcome = await client.CancelOrderV2Async( + family, targetId, + "V2 order-window sweep — explicitly listed in CERTINEXT_V2_SWEEP_CANCEL_IDS."); + cancelOutcome = outcome.ToString(); + } + catch (Exception cancelEx) + { + anyCancelFailed = true; + cancelOutcome = $"FAILED ({cancelEx.GetType().Name}: {cancelEx.Message})"; + } + } + + _output.WriteLine( + $"SUMMARY | OrderId={targetId} Domain={RedactForLog(row.DomainName)} " + + $"StatusBefore={status.Status ?? ""} Cancel={cancelOutcome}"); + } + catch (Exception ex) + { + anyCancelFailed = true; + _output.WriteLine( + $"SUMMARY | OrderId={targetId} Domain={RedactForLog(row.DomainName)} " + + $"Cancel=FAILED (could not resolve product family/status: {ex.GetType().Name}: {ex.Message})"); + } + } + + _output.WriteLine(""); + _output.WriteLine( + $"SUMMARY | Sweep complete. RowsScanned={rowsScanned} RowsInWindow={rowsInWindow.Count} " + + $"CancelTargets={cancelIds.Count} CancelsMatched={matchedCancelIds.Count}"); + + anyCancelFailed.Should().BeFalse( + "one or more explicitly-listed orders could not be cancelled (or could not have their " + + "status/family resolved) during the sweep — see the FAILED outcome(s) logged above; this " + + "sweep does not retry a failed cancel automatically."); + } + } +} diff --git a/CERTInext.IntegrationTests/V2RawHttpHelpers.cs b/CERTInext.IntegrationTests/V2RawHttpHelpers.cs new file mode 100644 index 0000000..a2ce034 --- /dev/null +++ b/CERTInext.IntegrationTests/V2RawHttpHelpers.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. +// +// Raw-HTTP helpers for V2 order cleanup in test fixtures. The plugin has no V2 cancel +// method, so cleanup that needs to cancel a non-issued sandbox order goes directly +// against the V2 REST API rather than through the plugin surface. +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + using System; + using System.Text.Json; + using System.Threading.Tasks; + using RestSharp; + + internal static class V2RawHttpHelpers + { + /// + /// Builds a against , applying + /// when provided. + /// + public static RestClient NewApiClient(string baseUrl, TimeSpan? timeout = null) => + timeout.HasValue + ? new RestClient(new RestClientOptions(baseUrl) { Timeout = timeout.Value }) + : new RestClient(baseUrl); + + /// + /// Standalone OAuth2 client_credentials token fetch against {apiUrl}/oauth/token. + /// Throws if the token call itself is not successful. + /// + public static async Task GetV2AccessTokenAsync( + string apiUrl, string clientId, string clientSecret, TimeSpan? timeout = null) + { + string tokenUrl = apiUrl.TrimEnd('/') + "/oauth/token"; + using var tokenClient = NewApiClient(tokenUrl, timeout); + var tokenReq = new RestRequest(string.Empty, Method.Post); + tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded"); + tokenReq.AddParameter("grant_type", "client_credentials"); + tokenReq.AddParameter("client_id", clientId); + tokenReq.AddParameter("client_secret", clientSecret); + var tokenResp = await tokenClient.ExecuteAsync(tokenReq); + if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) + throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}"); + + using var tokenDoc = JsonDocument.Parse(tokenResp.Content); + return tokenDoc.RootElement.GetProperty("access_token").GetString(); + } + + /// + /// Cancels a V2 SSL order via POST {SslCertificatesPath}/{orderId}/cancel. Throws + /// on a non-success response. + /// + public static async Task CancelSslOrderRawAsync( + string apiUrl, string clientId, string clientSecret, string orderId, string reason, + TimeSpan? timeout = null) + { + string accessToken = await GetV2AccessTokenAsync(apiUrl, clientId, clientSecret, timeout); + + using var apiClient = NewApiClient(apiUrl.TrimEnd('/'), timeout); + var cancelReq = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}/cancel", Method.Post); + cancelReq.AddHeader("Authorization", $"Bearer {accessToken}"); + cancelReq.AddJsonBody(new { reason }); + var cancelResp = await apiClient.ExecuteAsync(cancelReq); + if (!cancelResp.IsSuccessful) + throw new Exception( + $"Cancel request failed: {(int)cancelResp.StatusCode} {cancelResp.Content}"); + } + } +} diff --git a/CERTInext.Tests/CERTInext.Tests.csproj b/CERTInext.Tests/CERTInext.Tests.csproj index 84ce7a6..1077d62 100644 --- a/CERTInext.Tests/CERTInext.Tests.csproj +++ b/CERTInext.Tests/CERTInext.Tests.csproj @@ -6,9 +6,9 @@ 12.0 false true - - false + + true $(DefineConstants);SUPPORTS_DCV @@ -18,9 +18,10 @@ + exist, so exclude these files unless SUPPORTS_DCV is defined. --> + diff --git a/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs new file mode 100644 index 0000000..6395617 --- /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 +{ + /// + /// 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/CERTInextCAPluginCoverageTests.cs b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs index f684f7d..e3c9617 100644 --- a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs @@ -259,6 +259,59 @@ 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. + // --------------------------------------------------------------------------- + + [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. @@ -736,6 +789,188 @@ public void Initialize_Succeeds_WithValidApiKeyConfig() act.Should().NotThrow(); } + // --------------------------------------------------------------------------- + // M1 compliance fix: Initialize must enforce the same https-or-loopback rule as + // ValidateCAConnectionInfo on ApiUrl (and, in V1 OAuth mode, OAuthTokenUrl) — a + // connector saved before that rule existed would otherwise sail through on every + // gateway restart and keep sending credentials in cleartext. + // --------------------------------------------------------------------------- + + [Fact] + public void Initialize_Throws_WhenApiUrlIsHttp_NonLoopback() + { + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "http://ca.example.com", + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "test-api-key-value", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().Throw() + .WithMessage("*ApiUrl*https*"); + } + + [Fact] + public void Initialize_Throws_WhenApiUrlIsHttp_NonLoopback_EvenWithInjectedClient() + { + // _client ??= in Initialize lets tests inject a mock client and skip building a + // real CERTInextClient — but the config validation itself must still run + // unconditionally; an injected client must not bypass the cleartext-credential + // check on the saved config. + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "http://ca.example.com", + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "test-api-key-value", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(NewMock().Object); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().Throw() + .WithMessage("*ApiUrl*https*"); + } + + [Theory] + [InlineData("http://localhost:8080")] + [InlineData("http://127.0.0.1:8080")] + [InlineData("http://[::1]:8080")] + public void Initialize_Succeeds_WhenApiUrlIsHttp_Loopback(string apiUrl) + { + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = apiUrl, + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "test-api-key-value", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_Succeeds_WhenApiUrlIsHttps() + { + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "test-api-key-value", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_Throws_WhenOAuthTokenUrlIsHttp_NonLoopback() + { + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AccountNumber"] = "12345", + ["AuthMode"] = "OAuth", + ["OAuthTokenUrl"] = "http://token.example.com", + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().Throw() + .WithMessage("*OAuthTokenUrl*https*"); + } + + [Fact] + public void Initialize_Succeeds_WhenOAuthTokenUrlIsHttp_Loopback() + { + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AccountNumber"] = "12345", + ["AuthMode"] = "OAuth", + ["OAuthTokenUrl"] = "http://localhost:9999", + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_DoesNotCheckOAuthTokenUrl_WhenAuthModeIsNotOAuth() + { + // AccessKey mode never reads OAuthTokenUrl (CERTInextClient only builds the OAuth + // authenticator when AuthMode is OAuth/OAuth2) — a stray http OAuthTokenUrl value + // left over in a connector's saved config from a prior AuthMode switch must not + // block startup. + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "test-api-key-value", + ["OAuthTokenUrl"] = "http://token.example.com", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().NotThrow(); + } + // --------------------------------------------------------------------------- // C3a: Enroll passes ValidityDays, AutoApprove, RequesterName, RequesterEmail, KeyType // --------------------------------------------------------------------------- diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index 837ae8d..ee2af68 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -35,7 +36,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 +47,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() => @@ -410,12 +417,12 @@ public async Task SetDomainValidatorFactory_SecondCall_OverridesFirst() [InlineData("5")] // OrderStatusId 5 = Order Rejected public async Task Dcv_Skipped_WhenOrderStatusIdIsTerminal_EvenIfDcvValidated(string terminalOrderStatusId) { - // Regression guard for the cached-DCV path: a cancelled or rejected order + // Guard for the cached-DCV path: a cancelled or rejected order // can still have domainVerification.Status="1" carried over from a prior // validated round. Without this guard the plugin would return true from // PerformDcvIfNeededAsync and the caller would spend the full // DcvWaitForIssuanceSeconds budget polling GetCertificate for a cert that - // is never going to issue. Per audit report B2 on PR #2. + // is never going to issue. var mock = NewMock(); mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); @@ -437,17 +444,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"); } @@ -519,7 +528,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())) @@ -531,17 +540,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. This must not throw and fail the + // whole order — the "unresolvable" domain may just be a non-DNS Subject CN with no + // config-level way to prevent it (SubmitNonDnsSans only filters the SAN list, not the + // subject). 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())) @@ -558,10 +569,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*"); + // A StageValidation failure must not throw and fail the whole order. 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); } @@ -594,7 +607,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())) @@ -611,8 +624,11 @@ public async Task Dcv_Throws_WhenGetDcvReturnsNoToken() Func act = () => Enroll(plugin); - await act.Should().ThrowAsync() - .WithMessage("*GetDcv returned no token*"); + // An empty token must not throw and fail the whole order. It is 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"); } // --------------------------------------------------------------------------- @@ -681,11 +697,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. + // A genuine server error (5xx, transport, auth) from GetDcv must not bubble up and + // fail the whole enrollment: that would orphan the order. GetDcv's 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 does not fail the call. var mock = NewMock(); mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" }); @@ -700,8 +721,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"); } // --------------------------------------------------------------------------- @@ -816,5 +837,694 @@ 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 + } + } + } + }; + } + + /// + /// "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. + /// + /// Non-DNS SANs are 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 must not throw for the whole order — it would escape Enroll (which + /// has no catch) after the order was already placed, leaving an orphaned order with no + /// TXT record staged for the *valid* domains beside it, and every sync retry would + /// re-throw 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. + 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); + } + + /// + /// The FQDN validation regex must not use ^...$ : 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' would therefore pass as "valid" and reach 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 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); + } + + /// + /// The generic per-domain catch blocks around GetDcvAsync and StageValidation must not + /// catch OperationCanceledException along with genuine GetDcv/DNS-provider failures by + /// 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); + } + + /// + /// The compensating cleanup call after an early exit from staging (chiefly the + /// shared DcvTimeoutMinutes-bound token firing mid-loop, which 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 avoid that but removes the cleanup call's timeout bound + /// entirely, so the correct approach 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"); + } + + /// + /// The routine, always-runs finally-block cleanup must run staged-domain cleanups + /// concurrently. Each cleanup call has its own independent + /// CleanupValidationTimeoutSeconds bound, but running them one after another would make + /// that bound 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 directly via — the number + /// of CleanupValidation calls the validator observed in flight at once — rather than total + /// wall-clock time. An elapsed-time threshold is an unreliable proxy because the + /// surrounding DCV flow carries ~4s of fixed overhead unrelated to cleanup concurrency + /// (DcvConfig's 1s propagation delay plus WaitForDcvVerificationAsync's separate, + /// hardcoded 3s poll interval, Constants.Dcv.SyncPropagationDelaySeconds). The finally + /// block runs cleanup via Task.WhenAll; measuring peak concurrency proves that directly, + /// without being coupled to unrelated fixed delays elsewhere in the flow. + /// + [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)); + + await Enroll(plugin); + + validator.CleanedUpKeys.Should().HaveCount(3, "all three staged domains must be cleaned up"); + + // Direct proof of concurrency: all three CleanupValidation calls must have been in + // flight at the same instant. If cleanup ran sequentially, PeakConcurrentCleanups would + // be 1 regardless of how long the whole call took — this assertion doesn't depend on any + // wall-clock budget or on the fixed overhead elsewhere in the DCV flow. + validator.PeakConcurrentCleanups.Should().Be(3, + "cleanup for independent domains must run concurrently, not sequentially — " + + "all three CleanupValidation calls should have been in flight at once"); + } + + /// + /// A StageValidation failure on one domain of a multi-domain order must not + /// leave the TXT records already published for the earlier domains orphaned. Every + /// requested SAN is submitted, so multi-domain staging is the normal case and the + /// staging loop must be covered by the try/finally that owns cleanup. + /// + [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); + + // A StageValidation failure on one domain of a multi-domain order must not + // abort the whole order (which would fail the enrollment with an orphaned CERTInext + // order and orphan the 'good' domain's already-published TXT record). 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"); + } + + // --------------------------------------------------------------------------- + // Wildcard domains — TXT hostname must be derived from the BASE domain + // --------------------------------------------------------------------------- + // + // A wildcard DV order's TXT host must not be staged as + // "_emsign-validation.*.example.com" — a literal '*' DNS label is + // not queryable and leaves the order stuck pending. CERTInext's own GetDcv/VerifyDcv/ + // TrackOrder calls must still use the original "*."-prefixed domain string (that is what + // Track Order reports back per-domain); only the DNS-side hostname/zone resolution uses + // the base domain. + + /// Builds a verified-status multi-domain TrackOrder response, mirroring + /// but with every listed domain already + /// validated — used to drive WaitForDcvVerificationAsync's post-stage poll. + private static TrackOrderResponse DcvVerifiedTrackResponseMultiDomain( + string orderNumber, params string[] domains) + { + var detail = DcvDetail(Constants.Dcv.StatusValidated); + var raw = new Dictionary(); + foreach (string d in domains) + raw[d] = detail; + + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "2", + CertificateStatusId = "24", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated, + RawDomainEntries = raw + } + } + }; + } + + [Fact] + public async Task Dcv_WildcardDomain_StagesBaseDomainHostname_ButCallsCaWithWildcardDomain() + { + const string order = MockCertificateData.DcvOrderId; + const string baseName = MockCertificateData.DcvDomain; + string wildcard = "*." + baseName; + + var (mock, validator) = HappyPathMocks(orderNumber: order, domain: wildcard); + + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // The TXT hostname must be built from the base domain, not the literal "*.example.com" + // — a "*" DNS label is not queryable. + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, baseName); + validator.StagedRecords.Should().ContainSingle() + .Which.key.Should().Be(expectedHostname); + validator.StagedRecords.Should().OnlyContain(r => !r.key.Contains('*'), + "a literal '*' DNS label can never be queried by the CA"); + + validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname); + + // CERTInext's own API must still see the original wildcard domain string — that is + // what Track Order reports back per-domain. + mock.Verify(c => c.GetDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny()), + Times.Once); + mock.Verify(c => c.VerifyDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Dcv_NonWildcardDomain_HostnameDerivationUnchanged() + { + // Baseline: an ordinary (non-wildcard) domain must stage a TXT + // hostname built from the domain directly — StripWildcardPrefix is a no-op + // when there is no leading "*.". + var (mock, validator) = HappyPathMocks(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain); + validator.StagedRecords.Should().ContainSingle() + .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + } + + [Fact] + public async Task Dcv_MultiDomain_ApexAndWildcardShareHostname_StagesOnceAndCleansUpOnce_ButVerifiesBothWithCa() + { + const string order = MockCertificateData.DcvOrderId; + const string apex = MockCertificateData.DcvDomain; + string wildcard = "*." + apex; + + 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, apex, wildcard)) + .ReturnsAsync(DcvVerifiedTrackResponseMultiDomain(order, apex, wildcard)); + + mock.Setup(c => c.GetDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-shared")); + mock.Setup(c => c.GetDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-shared")); + + mock.Setup(c => c.VerifyDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.VerifyDcvAsync(order, wildcard, 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); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // Both domains collapse to the same base-domain TXT hostname — exactly ONE record + // must be staged (and cleaned up), not two, even though the order lists both the + // apex and its wildcard as separate domain entries. + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, apex); + validator.StagedRecords.Should().ContainSingle( + "the apex and wildcard domains share one base-domain TXT hostname") + .Which.key.Should().Be(expectedHostname); + validator.CleanedUpKeys.Should().ContainSingle( + "the shared hostname must be cleaned up exactly once, not once per domain that used it") + .Which.Should().Be(expectedHostname); + + // CERTInext tracks DCV per domain entry, so both the apex and the wildcard still need + // their own CA-side GetDcv/VerifyDcv call even though they share one TXT record. + mock.Verify(c => c.GetDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + mock.Verify(c => c.GetDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Dcv_MultiDomain_ApexAndWildcardShareHostnameButDifferentTokens_StagesBothAndCleansUpBoth() + { + const string order = MockCertificateData.DcvOrderId; + const string apex = MockCertificateData.DcvDomain; + string wildcard = "*." + apex; + + 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, apex, wildcard)) + .ReturnsAsync(DcvVerifiedTrackResponseMultiDomain(order, apex, wildcard)); + + mock.Setup(c => c.GetDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-apex")); + mock.Setup(c => c.GetDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-wildcard")); + + mock.Setup(c => c.VerifyDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.VerifyDcvAsync(order, wildcard, 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); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // Same base-domain hostname but two different CA tokens: a single record cannot satisfy + // both, so BOTH values must be staged at that hostname and both cleaned up. + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, apex); + validator.StagedRecords.Should().HaveCount(2); + validator.StagedRecords.Select(r => r.key).Should().OnlyContain(k => k == expectedHostname); + validator.StagedRecords.Select(r => r.value).Should().BeEquivalentTo(new[] { "token-apex", "token-wildcard" }); + validator.CleanedUpKeys.Should().HaveCount(2); + validator.CleanedUpKeys.Should().OnlyContain(k => k == expectedHostname); + + mock.Verify(c => c.VerifyDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + } } } diff --git a/CERTInext.Tests/CERTInextCAPluginRevokeV2AuditLoggingTests.cs b/CERTInext.Tests/CERTInextCAPluginRevokeV2AuditLoggingTests.cs new file mode 100644 index 0000000..ed93861 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginRevokeV2AuditLoggingTests.cs @@ -0,0 +1,258 @@ +// 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.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// V2 revoke denials must leave an audit record (CARequestID, product + /// family, HTTP status, EMS code) even though the denial is surfaced via an exception rather + /// than a normal return. Pins the plugin-level ( → + /// internal RevokeV2Async) log lines for: 404 (not found/not revokable), 422 ("not in a + /// revocable state" pre-flight and the general 422 denial), and both outcomes of the + /// unspecified-reason → cessation-of-operation retry. + /// + /// Uses the same -swap capture seam as + /// CERTInextCAPluginAuditLoggingTests (the plugin's _logger is a per-instance + /// field resolved at construction time, unlike CERTInextClient.Logger, which is + /// static readonly and not swappable after first use — see that class's remarks for + /// why client-level V2 log content isn't independently assertable in this harness). + /// + [Collection("LogHandlerFactory-NoParallel")] + public class CERTInextCAPluginRevokeV2AuditLoggingTests + { + 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 CERTInextConfig V2Config() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + AccountNumber = "12345", + AuthMode = "AccessKey", + ApiKey = "v1-key", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + PickupRetries = 0 + }; + + /// + /// Runs plugin.Revoke(orderId, hexSerial, reason) against a capturing logger + /// factory and returns every rendered log line plus whatever the call threw (the revoke + /// is always expected to fail or succeed deterministically per test). + /// + private static async Task<(ConcurrentQueue Messages, Exception Thrown)> CaptureRevokeLogMessagesAsync( + Mock mock, string orderId, uint reason) + { + var provider = new CapturingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace)); + Exception thrown = null; + try + { + LogHandler.Factory = factory; + var plugin = new CERTInextCAPlugin(mock.Object, V2Config()); + try + { + await plugin.Revoke(orderId, "AABBCC", reason); + } + catch (Exception ex) + { + thrown = ex; + } + } + finally + { + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + factory.Dispose(); + } + + return (provider.Messages, thrown); + } + + private static string FindLine(ConcurrentQueue messages, string marker, params string[] mustContain) + { + foreach (var m in messages) + { + if (!m.Contains(marker)) continue; + bool allMatch = true; + foreach (var s in mustContain) + { + if (!m.Contains(s)) { allMatch = false; break; } + } + if (allMatch) return m; + } + return null; + } + + // --------------------------------------------------------------------------- + // 404 — "not found or not in a revokable state" + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeV2_404Denial_LogsWarningWithAuditFields() + { + string orderId = "audit-404-" + Guid.NewGuid().ToString("N"); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(orderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = orderId, Status = "issued" })); + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, orderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new System.Collections.Generic.KeyNotFoundException( + $"V2 order '{orderId}' in family '{Constants.ApiV2.FamilySsl}' not found or not in a revokable state. EMS-913")); + + var (messages, thrown) = await CaptureRevokeLogMessagesAsync(mock, orderId, 4u); + + thrown.Should().BeOfType(); + string line = FindLine(messages, orderId, "V2 revocation denied", "HttpStatus=404"); + line.Should().NotBeNull("a 404 revoke denial must be audited with an explicit HTTP status"); + line.Should().Contain("EmsCode=EMS-913"); + line.Should().Contain(Constants.ApiV2.FamilySsl); + } + + // --------------------------------------------------------------------------- + // Not-GENERATED pre-flight rejection + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeV2_NotGenerated_LogsErrorWithCurrentStatus() + { + string orderId = "audit-notgen-" + Guid.NewGuid().ToString("N"); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(orderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" })); + + var (messages, thrown) = await CaptureRevokeLogMessagesAsync(mock, orderId, 4u); + + thrown.Should().NotBeNull(); + thrown.Message.Should().Contain("cannot be revoked"); + string line = FindLine(messages, orderId, "not in a revocable state", "Status=pending-dcv"); + line.Should().NotBeNull("a not-GENERATED revoke rejection must be audited with the current CA status"); + line.Should().Contain(Constants.ApiV2.FamilySsl); + + // RevokeOrderV2Async must never have been called for a certificate that was never issued. + mock.Verify(c => c.RevokeOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------------- + // General 422 denial (not the retry-eligible "Invalid Revoke Reason ID" case) + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeV2_Generic422Denial_LogsWarningWithAuditFields() + { + string orderId = "audit-422-" + Guid.NewGuid().ToString("N"); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(orderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = orderId, Status = "issued" })); + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, orderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("V2 revoke rejected. EMS-969 Revoke reason ID missing")); + + var (messages, thrown) = await CaptureRevokeLogMessagesAsync(mock, orderId, 4u); + + thrown.Should().BeOfType(); + thrown.Message.Should().Contain("EMS-969"); + string line = FindLine(messages, orderId, "V2 revocation denied", "HttpStatus=422"); + line.Should().NotBeNull("a non-retry-eligible 422 revoke denial must be audited"); + line.Should().Contain("EmsCode=EMS-969"); + } + + // --------------------------------------------------------------------------- + // Unspecified-reason retry: success and failure outcomes both log. + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeV2_UnspecifiedReasonRetry_Success_LogsRetriedTrue() + { + string orderId = "audit-retry-ok-" + Guid.NewGuid().ToString("N"); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(orderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = orderId, Status = "issued" })); + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, orderId, It.IsAny(), It.IsAny())) + .Returns((string family, string id, V2RevokeRequest req, CancellationToken ct) => + { + if (req.Reason == Constants.RevocationReasonV2.Unspecified) + throw new InvalidOperationException("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID"); + return Task.CompletedTask; + }); + + var (messages, thrown) = await CaptureRevokeLogMessagesAsync(mock, orderId, 0u); + + thrown.Should().BeNull(); + string retryWarn = FindLine(messages, orderId, "retrying once with 'cessation-of-operation'", "HttpStatus=422"); + retryWarn.Should().NotBeNull("the first rejection must be audited before the retry is attempted"); + string completeLine = FindLine(messages, orderId, "V2 revocation complete", "RetriedFromReason=unspecified"); + completeLine.Should().NotBeNull("a successful retry must be reflected in the completion audit line"); + } + + [Fact] + public async Task RevokeV2_UnspecifiedReasonRetry_Failure_LogsErrorAndRethrows() + { + string orderId = "audit-retry-fail-" + Guid.NewGuid().ToString("N"); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(orderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = orderId, Status = "issued" })); + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, orderId, It.IsAny(), It.IsAny())) + .Returns((string family, string id, V2RevokeRequest req, CancellationToken ct) => + { + if (req.Reason == Constants.RevocationReasonV2.Unspecified) + throw new InvalidOperationException("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID"); + throw new InvalidOperationException("V2 revoke rejected. EMS-931 Order not in issued state"); + }); + + var (messages, thrown) = await CaptureRevokeLogMessagesAsync(mock, orderId, 0u); + + thrown.Should().BeOfType(); + thrown.Message.Should().Contain("EMS-931"); + string retryFailLine = FindLine(messages, orderId, "V2 revocation retry (cessation-of-operation) failed"); + retryFailLine.Should().NotBeNull("a failed retry attempt must leave its own audit record, not just surface via the exception"); + + // The retry failure must not be misreported as a successful completion. + FindLine(messages, orderId, "V2 revocation complete").Should().BeNull(); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index 3ec5df1..17a8f8d 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -17,6 +17,9 @@ using Keyfactor.Extensions.CAPlugin.CERTInext.Client; using Keyfactor.PKI.Enums.EJBCA; using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; using Xunit; namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests @@ -31,8 +34,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); @@ -164,6 +179,53 @@ await act.Should().ThrowAsync() .WithMessage("*valid absolute URI*"); } + [Fact] + public async Task ValidateCAConnectionInfo_Throws_WhenApiUrlIsHttp_NonLoopback() + { + var mock = NewMock(); + var plugin = BuildPlugin(mock.Object); + + var info = new Dictionary + { + ["ApiUrl"] = "http://ca.example.com", + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "some-key" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().ThrowAsync() + .WithMessage("*ApiUrl*https*"); + } + + [Theory] + [InlineData("http://localhost:8080")] + [InlineData("http://127.0.0.1:8080")] + [InlineData("http://[::1]:8080")] + public async Task ValidateCAConnectionInfo_AllowsHttp_ForLoopbackHosts(string apiUrl) + { + // Loopback http is allowed (e.g. a local WireMock/mock server in tests); this test + // only confirms the scheme check doesn't reject it — ApiKey mode fails on the next + // field it's missing (ApiKey), which still proves the ApiUrl check itself passed. + var mock = NewMock(); + var plugin = BuildPlugin(mock.Object); + + var info = new Dictionary + { + ["ApiUrl"] = apiUrl, + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "some-key" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + // No exception about ApiUrl specifically — any failure must come from the live + // connectivity check (NewMock's PingAsync is unstubbed under MockBehavior.Strict), + // not from the https scheme guard. + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().NotContain("ApiUrl"); + } + [Fact] public async Task ValidateCAConnectionInfo_Throws_WhenApiKeyMissingForApiKeyMode() { @@ -223,6 +285,92 @@ await act.Should().ThrowAsync() .WithMessage("*OAuthTokenUrl*required*"); } + // M2 compliance fix: OAuthTokenUrl receives the OAuth client secret on every token + // refresh (CERTInextClient.GetOrRefreshTokenAsync POSTs it there) — it must be held to + // the same https-or-loopback rule as ApiUrl, not just a non-empty check. + [Fact] + public async Task ValidateCAConnectionInfo_Throws_WhenOAuthTokenUrlIsHttp_NonLoopback() + { + var mock = NewMock(); + var plugin = BuildPlugin(mock.Object); + + var info = new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AccountNumber"] = "12345", + ["AuthMode"] = "OAuth", + ["OAuthTokenUrl"] = "http://token.example.com", + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("OAuthTokenUrl").And.Contain("https"); + mock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ValidateCAConnectionInfo_Throws_WhenOAuthTokenUrlIsNotUri() + { + var mock = NewMock(); + var plugin = BuildPlugin(mock.Object); + + var info = new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AccountNumber"] = "12345", + ["AuthMode"] = "OAuth", + ["OAuthTokenUrl"] = "not-a-url", + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("OAuthTokenUrl").And.Contain("valid absolute URI"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_AllowsHttp_ForLoopbackOAuthTokenUrl() + { + // Full round trip through a loopback WireMock server standing in for both the + // OAuth token endpoint (OAuthTokenUrl is used as-is, no path appended — see + // CERTInextClient.GetOrRefreshTokenAsync) and the V1 ValidateCredentials ping — + // proves http-loopback is accepted end to end, not just by the synchronous guard. + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server + .Given(Request.Create().WithPath("/ValidateCredentials").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"meta\":{\"status\":\"1\"}}")); + + var plugin = BuildPlugin(NewMock().Object); + + var info = new Dictionary + { + ["ApiUrl"] = server.Urls[0] + "/", + ["AccountNumber"] = "12345", + ["AuthMode"] = "OAuth", + ["OAuthTokenUrl"] = server.Urls[0], + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().NotThrowAsync(); + } + [Fact] public async Task ValidateCAConnectionInfo_Throws_WhenAuthModeIsInvalid() { @@ -289,6 +437,71 @@ await act.Should().ThrowAsync() .WithMessage("*ProfileId*required*"); } + // ValidateProductInfo builds its own CERTInextClient from connectionInfo (like + // ValidateCAConnectionInfo) rather than using the Moq-injected client, so these tests + // need a real WireMock server as ApiUrl. + + [Fact] + public async Task ValidateProductInfo_V1_Succeeds_WhenProductCodePresent() + { + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/GetProductDetails").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetProductDetailsJson())); + + var plugin = BuildPlugin(NewMock().Object); + var productInfo = new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = MockCertificateData.ProfileIdTls } + }; + var connInfo = new Dictionary + { + ["ApiUrl"] = server.Urls[0], + ["AuthMode"] = "AccessKey", + ["ApiKey"] = "key", + ["AccountNumber"] = "12345" + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V1_Throws_WhenProductCodeAbsent() + { + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/GetProductDetails").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetProductDetailsJson())); + + var plugin = BuildPlugin(NewMock().Object); + var productInfo = new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = "999999" } + }; + var connInfo = new Dictionary + { + ["ApiUrl"] = server.Urls[0], + ["AuthMode"] = "AccessKey", + ["ApiKey"] = "key", + ["AccountNumber"] = "12345" + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + // --------------------------------------------------------------------------- // Enroll — New // --------------------------------------------------------------------------- @@ -345,6 +558,127 @@ 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) + // --------------------------------------------------------------------------- + + [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.Tests/CERTInextCAPluginV2DcvTests.cs b/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs new file mode 100644 index 0000000..f649e94 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs @@ -0,0 +1,1387 @@ +// 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.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests confirming EMS-1080 ("Domain is already verified") from either + /// V2 DCV entry point (GetDcvV2Async or VerifyDcvV2Async) must be treated as + /// DCV already satisfied — skip TXT publish, proceed straight to tracking — not as a + /// failure deferred to the next sync cycle. Driven end-to-end through + /// (V2 path) so the assertions exercise the same + /// code path Command actually calls, using to observe + /// whether a TXT record was ever staged. + /// + public class CERTInextCAPluginV2DcvTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2DcvPlugin( + ICERTInextClient client, IDomainValidatorFactory factory, string dcvTxtRecordTemplate = null, + int pickupRetries = 0) => + new CERTInextCAPlugin(client, factory, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + AccountNumber = "12345", + AuthMode = "AccessKey", + ApiKey = "v1-key", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = pickupRetries, + PickupDelayInSeconds = 1, + DcvEnabled = true, + DcvTimeoutMinutes = 1, + DcvPropagationDelaySeconds = 1, + DcvTxtRecordTemplate = dcvTxtRecordTemplate + }); + + private static EnrollmentProductInfo MakeV2ProductInfo() => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + private static Task Enroll(CERTInextCAPlugin plugin) => + plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary { ["dns"] = new[] { "example.com" } }, + productInfo: MakeV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + private const string OrderId = "ord_ems1080_001"; + + private static V2CreateOrderResponse PlaceOrderResponse() => + new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-dcv" }; + + private static V2OrderStatusResponse PendingDcvStatus() => + new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv", Domain = "example.com" }; + + private static V2OrderStatusResponse IssuedStatus() => + new V2OrderStatusResponse { OrderId = OrderId, Status = "issued", Domain = "example.com" }; + + private static V2CertificateDownloadResponse DownloadResponse() => + new V2CertificateDownloadResponse + { + OrderId = OrderId, + SerialNumber = "AA11BB22", + CertificatePem = MockCertificateData.FakePemCertificate + }; + + // --------------------------------------------------------------------------- + // GetDcv returns EMS-1080 + // --------------------------------------------------------------------------- + + [Fact] + public async Task PerformDcvV2_GetDcvReturnsEms1080_TreatedAsSatisfied_NoStagingAndProceedsToTracking() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call: post-CSR check (pending-dcv). 2nd+: the PerformDcvV2IfNeededAsync poll + // loop and EnrollV2Async's post-DCV re-check both see "issued" immediately. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new System.Exception( + $"CERTInext V2 API error during 'V2 get DCV challenge'. HTTP 422. " + + "Unprocessable Entity: EMS-1080 Domain is already verified.")); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "EMS-1080 must be treated as DCV satisfied, not a failure — the order should " + + "proceed to tracking and come back issued"); + validator.StagedRecords.Should().BeEmpty( + "GetDcv returning EMS-1080 means there is no fresh challenge to publish"); + + // VerifyDcv must never be reached — there is nothing to verify when GetDcv itself + // reports the domain is already verified. + mock.Verify(c => c.VerifyDcvV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------------- + // VerifyDcv returns EMS-1080 + // --------------------------------------------------------------------------- + + [Fact] + public async Task PerformDcvV2_VerifyDcvReturnsEms1080_TreatedAsSatisfied_ProceedsToTracking() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + // GetDcv succeeds normally and returns a token to publish... + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse + { + Token = "dcv-token-abc123", + TokenExpiryDate = "2026-12-31 23:59:59" + }); + + // ...but by the time VerifyDcv is called, the domain became already-verified. + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ThrowsAsync(new System.Exception( + $"CERTInext V2 API error during 'V2 verify DCV'. HTTP 422. " + + "Unprocessable Entity: EMS-1080 Domain is already verified.")); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "EMS-1080 from VerifyDcv must be treated as verified, not a failure — the order " + + "should proceed to tracking and come back issued"); + + // The TXT record was staged (GetDcv succeeded) — this exercises the "verified between + // GetDcv and VerifyDcv" race rather than the GetDcv-level no-op. + validator.StagedRecords.Should().ContainSingle(); + validator.CleanedUpKeys.Should().ContainSingle( + "staged records are always cleaned up, including on the EMS-1080 verify path"); + } + + // --------------------------------------------------------------------------- + // Live GetDcv response shape has no fileNameContent + // --------------------------------------------------------------------------- + + /// + /// A live fresh-domain GetDcv challenge response comes back as exactly + /// {"tokenExpiryDate":"...","token":"..."} — no + /// orderNumber/domainName/dcvMethod/fileNameContent. + /// If modeled fileNameContent + /// instead of token, this shape would deserialize with a null token, which would + /// drive PerformDcvV2IfNeededAsync's null-token guard and leave the order stuck at + /// EXTERNALVALIDATION forever (no TXT ever staged, no exception, just a returned + /// false). This constructs the response exactly as the DTO + /// deserializes the real live body, and proves the plugin extracts and publishes the + /// token instead of deferring. + /// + [Fact] + public async Task PerformDcvV2_LiveShapeTokenOnly_StagesTxtRecord_DoesNotHitNullTokenGuard() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + // Real live shape: only Token/TokenExpiryDate are + // ever populated — no OrderNumber/DomainName/DcvMethod exist on the DTO. + const string liveToken = "D6026954B9EB7D31E3FE8B2194F07087"; + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse + { + Token = liveToken, + TokenExpiryDate = "2026-09-27 15:27:00" + }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "the live token-only shape must be extracted and published, not deferred by " + + "the null-token guard"); + + validator.StagedRecords.Should().ContainSingle( + "GetDcv returned a real token, so a TXT record must be staged from it") + .Which.Should().Be(("_emsign-validation.example.com", liveToken), + "the staged value must come from the Token property, not a " + + "FileNameContent property; the hostname uses the default " + + "DcvTxtRecordTemplate since none is configured here"); + + mock.Verify(c => c.VerifyDcvV2Async( + OrderId, "example.com", It.IsAny(), It.IsAny()), + Times.Once); + } + + // --------------------------------------------------------------------------- + // DcvTxtRecordTemplate must be honored by the V2 + // DCV path, not hardcoded to "_emudhra-challenge.{domain}" — mirrors V1's + // PerformDcvIfNeededAsync (config value if set, else Constants.Dcv.DefaultTxtRecordTemplate). + // --------------------------------------------------------------------------- + + [Fact] + public async Task PerformDcvV2_ConfiguredTxtRecordTemplate_IsHonored() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + const string token = "configured-template-token"; + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = token, TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin( + mock.Object, new FakeDomainValidatorFactory(validator), + dcvTxtRecordTemplate: "_custom-dcv-check.{0}"); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + validator.StagedRecords.Should().ContainSingle() + .Which.Should().Be(("_custom-dcv-check.example.com", token), + "the configured DcvTxtRecordTemplate must be used to build the TXT " + + "hostname, not a hardcoded '_emudhra-challenge' label"); + } + + [Fact] + public async Task PerformDcvV2_UnconfiguredTxtRecordTemplate_UsesV1Default() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + const string token = "default-template-token"; + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = token, TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + // No dcvTxtRecordTemplate override — must fall back to the same default V1 uses. + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + await Enroll(plugin); + + validator.StagedRecords.Should().ContainSingle() + .Which.Should().Be(("_emsign-validation.example.com", token), + "with no DcvTxtRecordTemplate configured, V2 must fall back to " + + "Constants.Dcv.DefaultTxtRecordTemplate (the same default V1 uses) rather " + + "than a separate, hardcoded V2 literal"); + } + + // --------------------------------------------------------------------------- + // The inline DCV path owns the in-call issuance wait — EnrollV2Async must + // not stack a second PickUpEnrolledCertificateV2Async poll on top of it. + // --------------------------------------------------------------------------- + + [Fact] + public async Task EnrollV2_DcvRan_SkipsPickupPoll_EvenThoughPickupIsEnabled() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call: post-CSR check (pending-dcv). 2nd: PerformDcvV2IfNeededAsync's own + // tracking poll (step 4) — moves to a *different* pending state so that inner loop + // breaks (DCV steps completed) without the order having actually issued. 3rd: + // EnrollV2Async's post-DCV re-check, observing the same still-pending state. If the + // pickup poll incorrectly ran afterward, a 4th TrackOrderV2Async call would occur. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-organization-verification", Domain = "example.com" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-organization-verification", Domain = "example.com" }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "dcv-token-xyz", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + var validator = new FakeDomainValidator(); + // PickupRetries > 0 and clamped to a fast 1s delay — if the dcvV2Ran gate didn't + // work, this budget is easily enough for the pickup poll to run and this test would + // observe extra TrackOrderV2Async/DownloadCertificateV2Async calls. + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator), pickupRetries: 5); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "the order never actually issued — DCV ran, but the order is still pending elsewhere"); + result.CARequestID.Should().Be(OrderId); + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(3), + "a pickup poll must not stack on top of the inline DCV wait — no 4th TrackOrderV2Async call"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // A REVOKED disposition discovered on the post-DCV status re-check must be + // mapped to FAILED, not returned as a body-less REVOKED record — mirrors + // EnrollV2_DcvRan_SkipsPickupPoll_EvenThoughPickupIsEnabled above, but the second + // TrackOrderV2Async observation is "revoked" instead of another pending state. + // --------------------------------------------------------------------------- + + [Fact] + public async Task EnrollV2_PostDcvRecheckRevoked_ReturnsFailed_NotBodylessRevoked() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call: post-CSR check (pending-dcv), triggers the inline DCV block. 2nd: + // PerformDcvV2SingleDomainAsync's own internal step-4 poll (it calls + // TrackOrderV2Async itself to wait out "pending-dcv" — see its doc comment) observes + // "revoked", which is != "pending-dcv" so that poll loop breaks and DCV reports done. + // 3rd: EnrollV2Async's own post-DCV re-check observes the same revoked status. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "revoked", Domain = "example.com" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "revoked", Domain = "example.com" }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "dcv-token-revoked", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + var validator = new FakeDomainValidator(); + // Pickup enabled to prove the poll is correctly skipped (dcvV2Ran) rather than + // masking the revoked status behind additional polling/downloads. + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator), pickupRetries: 5); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a REVOKED disposition discovered on the post-DCV re-check has no certificate " + + "body and must never be reported as REVOKED"); + result.Certificate.Should().BeNull(); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain(OrderId).And.Contain("revoked"); + + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(3), + "the pickup poll must not run on top of the inline DCV wait — no 4th TrackOrderV2Async call"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Synchronize (V2) — DCV-during-sync age-window / per-pass-cap gating. + // Reuses EvaluateDcvSyncEligibility/DcvSyncDecision — the same bounds V1 sync uses, + // applied to the V2 /reports/orders path. + // --------------------------------------------------------------------------- + + private static async IAsyncEnumerable AsyncEnumerable(params T[] items) + { + foreach (var item in items) + yield return item; + await Task.CompletedTask; + } + + private static OrderReportEntryV2 PendingDcvRow(string orderNumber, DateTime orderDateUtc) => + new OrderReportEntryV2 + { + OrderNumber = orderNumber, + OrderStatus = "Order Accepted", + CertificateStatus = "Pending for Approver", + DomainName = "example.com", + OrderDate = orderDateUtc.ToString("o") + }; + + [Fact] + public async Task SynchronizeV2_PendingDcvOrder_AgedOutOfWindow_SkipsDcv_EmitsPendingWithoutResolvingFamily() + { + var mock = NewMock(); + var oldRow = PendingDcvRow("ord_old_001", DateTime.UtcNow.AddHours(-48)); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(oldRow)); + + // Age window of 1h — the 48h-old order above is well outside it. + var config = new CERTInextConfig + { + UseV2Api = true, ApiUrl = "https://v2.certinext.io", + OAuthClientId = "c", OAuthClientSecret = "s", + DcvEnabled = true, DcvTimeoutMinutes = 1, DcvPropagationDelaySeconds = 1, + DcvSyncMaxOrderAgeHours = 1, DcvSyncMaxPerPass = 0 + }; + var plugin = new CERTInextCAPlugin(mock.Object, new FakeDomainValidatorFactory(new FakeDomainValidator()), config); + + var buffer = new BlockingCollection(100); + await plugin.Synchronize(buffer, DateTime.UtcNow.AddDays(-1), true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(r => r.CARequestID == "ord_old_001" + && r.Status == (int)EndEntityStatus.EXTERNALVALIDATION); + + // Aged-out rows must not even resolve a family — that's the point of the age gate. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.GetDcvV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SynchronizeV2_PendingDcvOrders_ExceedingPerPassCap_OnlyAttemptsUpToCap() + { + var mock = NewMock(); + var row1 = PendingDcvRow("ord_cap_001", DateTime.UtcNow.AddMinutes(-30)); + var row2 = PendingDcvRow("ord_cap_002", DateTime.UtcNow.AddMinutes(-20)); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row1, row2)); + + // Only the first (cap=1) row should ever have its family resolved. + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_cap_001", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_cap_001", Status = "pending-dcv", Domain = "example.com" + })); + + // Fail fast at GetDcv so PerformDcvV2IfNeededAsync returns false quickly without + // needing the full staging/verify chain mocked — the point of this test is the cap + // gate, not the DCV flow itself. + mock.Setup(c => c.GetDcvV2Async("ord_cap_001", It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("simulated transient GetDcv failure")); + + var config = new CERTInextConfig + { + UseV2Api = true, ApiUrl = "https://v2.certinext.io", + OAuthClientId = "c", OAuthClientSecret = "s", + DcvEnabled = true, DcvTimeoutMinutes = 1, DcvPropagationDelaySeconds = 1, + DcvSyncMaxOrderAgeHours = 0, DcvSyncMaxPerPass = 1 + }; + var plugin = new CERTInextCAPlugin(mock.Object, new FakeDomainValidatorFactory(new FakeDomainValidator()), config); + + var buffer = new BlockingCollection(100); + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().HaveCount(2); + records.Should().OnlyContain(r => r.Status == (int)EndEntityStatus.EXTERNALVALIDATION); + + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_cap_001", It.IsAny()), Times.Once); + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_cap_002", It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // UCC additionalDomains SANs must get DCV-validated too — the V2 DCV + // machinery must not only ever drive the order's primary domain. These tests exercise the + // generalized PerformDcvV2MultiDomainAsync path (driven from Track Order's + // verifications.domain.domains[] block) end to end through Enroll/Synchronize, the + // same way the EMS-1080 tests above exercise the single-domain path. + // --------------------------------------------------------------------------- + + private static V2DomainVerificationEntry DomainEntry( + string domain, string dcvStatus, string dcvMethod = null, string verifiedAt = null) => + new V2DomainVerificationEntry + { + Domain = domain, + DomainStatus = "ACTIVE", + DcvStatus = dcvStatus, + DcvMethod = dcvMethod, + VerifiedAt = verifiedAt, + CaaStatus = "SKIPPED" + }; + + private static V2OrderStatusResponse StatusWithDomains(string status, params V2DomainVerificationEntry[] domains) => + new V2OrderStatusResponse + { + OrderId = OrderId, + Status = status, + Domain = domains.FirstOrDefault()?.Domain, + Verifications = new V2Verifications + { + Domain = new V2DomainVerification { Status = "PENDING", Domains = domains.ToList() } + } + }; + + private const string UccPrimary = "example.com"; + private const string UccSanA = "a.pending-san.example.com"; + private const string UccSanB = "b.pending-san.example.com"; + + [Fact] + public async Task PerformDcvV2_Ucc_PrimaryVerified_TwoPendingSans_StagesAndVerifiesOnlyPendingSans_CleansUpAll() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st TrackOrder call (post-CSR check): primary already VERIFIED, both SANs PENDING. + // Every call after that (the WaitForDomainsVerifiedV2Async poll, and EnrollV2Async's + // own post-DCV re-check) sees the order fully issued with every domain VERIFIED. + int trackCalls = 0; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(() => + { + trackCalls++; + return trackCalls == 1 + ? StatusWithDomains("pending-dcv", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "PENDING"), + DomainEntry(UccSanB, "PENDING")) + : StatusWithDomains("issued", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "VERIFIED", "dns-txt"), + DomainEntry(UccSanB, "VERIFIED", "dns-txt")); + }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-a", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.GetDcvV2Async(OrderId, UccSanB, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-b", TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, UccSanB, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + validator.StagedRecords.Select(r => r.key).Should().BeEquivalentTo( + new[] { $"_emsign-validation.{UccSanA}", $"_emsign-validation.{UccSanB}" }, + "only the two pending SANs should be staged — the already-VERIFIED primary must never be re-challenged"); + + validator.CleanedUpKeys.Should().BeEquivalentTo( + new[] { $"_emsign-validation.{UccSanA}", $"_emsign-validation.{UccSanB}" }, + "every staged record must be cleaned up"); + + mock.Verify(c => c.GetDcvV2Async(OrderId, UccPrimary, It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.VerifyDcvV2Async(OrderId, UccPrimary, It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PerformDcvV2_Ucc_OneSanVerifyFails_OtherStillVerified_AllCleanedUp_OrderStaysPending() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call: post-CSR, both SANs pending. Every later call (the poll for the one SAN + // that DID verify, and EnrollV2Async's post-DCV re-check): SanA verified, SanB still + // pending — the order legitimately cannot advance further this pass. + int trackCalls = 0; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(() => + { + trackCalls++; + return trackCalls == 1 + ? StatusWithDomains("pending-dcv", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "PENDING"), + DomainEntry(UccSanB, "PENDING")) + : StatusWithDomains("pending-dcv", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "VERIFIED", "dns-txt"), + DomainEntry(UccSanB, "PENDING")); + }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-a", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.GetDcvV2Async(OrderId, UccSanB, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-b", TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, UccSanB, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("simulated transient verify failure for SanB")); + + var validator = new FakeDomainValidator(); + // PickupRetries > 0 (mirrors the single-domain test above): confirms dcvV2Ran still + // gates the pickup poll even on the partial-failure multi-domain path. + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator), pickupRetries: 5); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "SanB never verified, so the order must stay pending — not be treated as failed or issued"); + result.CARequestID.Should().Be(OrderId); + + validator.CleanedUpKeys.Should().BeEquivalentTo( + new[] { $"_emsign-validation.{UccSanA}", $"_emsign-validation.{UccSanB}" }, + "both staged records must be cleaned up regardless of SanB's verify failure"); + + mock.Verify(c => c.VerifyDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvV2Async(OrderId, UccSanB, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(3), + "a pickup poll must not stack on top of the inline DCV wait — no 4th TrackOrderV2Async call"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SynchronizeV2_Ucc_OneSanStillPendingFromAnEarlierPass_OnlyThatSanIsProcessed() + { + var mock = NewMock(); + const string syncOrderId = "ord_ucc_sync_001"; + var row = PendingDcvRow(syncOrderId, DateTime.UtcNow.AddMinutes(-10)); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + // Primary and SanA were already verified on an earlier sync pass; only SanB remains. + var pendingStatus = new V2OrderStatusResponse + { + OrderId = syncOrderId, + Status = "pending-dcv", + Domain = UccPrimary, + Verifications = new V2Verifications + { + Domain = new V2DomainVerification + { + Status = "PENDING", + Domains = new List + { + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "VERIFIED", "dns-txt"), + DomainEntry(UccSanB, "PENDING") + } + } + } + }; + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(syncOrderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, pendingStatus)); + + mock.Setup(c => c.GetDcvV2Async(syncOrderId, UccSanB, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-b", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.VerifyDcvV2Async(syncOrderId, UccSanB, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + var issuedStatus = new V2OrderStatusResponse + { + OrderId = syncOrderId, + Status = "issued", + Domain = UccPrimary, + Verifications = new V2Verifications + { + Domain = new V2DomainVerification + { + Status = "VERIFIED", + Domains = new List + { + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "VERIFIED", "dns-txt"), + DomainEntry(UccSanB, "VERIFIED", "dns-txt") + } + } + } + }; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), syncOrderId, It.IsAny())) + .ReturnsAsync(issuedStatus); + mock.Setup(c => c.ResolveAndTrackOrderV2Async(syncOrderId, It.IsAny())) + .ReturnsAsync(issuedStatus); + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async(syncOrderId, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = syncOrderId, SerialNumber = "AA11BB22", CertificatePem = MockCertificateData.FakePemCertificate + }); + + var config = new CERTInextConfig + { + UseV2Api = true, ApiUrl = "https://v2.certinext.io", + OAuthClientId = "c", OAuthClientSecret = "s", + DcvEnabled = true, DcvTimeoutMinutes = 1, DcvPropagationDelaySeconds = 1, + DcvSyncMaxOrderAgeHours = 0, DcvSyncMaxPerPass = 0 + }; + var validator = new FakeDomainValidator(); + var plugin = new CERTInextCAPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + var buffer = new BlockingCollection(100); + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().ContainSingle(r => r.CARequestID == syncOrderId); + + mock.Verify(c => c.GetDcvV2Async(syncOrderId, UccSanB, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.GetDcvV2Async(syncOrderId, UccSanA, It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.GetDcvV2Async(syncOrderId, UccPrimary, It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never, + "the no-domain single-domain-fallback overload must not be used once domainEntries is populated"); + + validator.StagedRecords.Should().ContainSingle(); + validator.CleanedUpKeys.Should().ContainSingle(); + } + + [Fact] + public async Task PerformDcvV2_DomainsArrayAbsent_UsesSingleDomainFallback_NeverThePerDomainOverload() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // No Verifications block anywhere in this sequence — the single-domain shape. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "legacy-token", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + validator.StagedRecords.Should().ContainSingle(); + + // The 4-argument (per-domain) overload is a distinct method — confirming it was + // never called proves the domainEntries-absent case took the single-domain path, + // not the generalized multi-domain one, keeping today's primary-domain + // behaviour exactly. + mock.Verify(c => c.GetDcvV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task PerformDcvV2_Ucc_Ems1080OnPendingSan_TreatedAsVerified_NotAnError_NoStagingOrVerify() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + int trackCalls = 0; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(() => + { + trackCalls++; + return trackCalls == 1 + ? StatusWithDomains("pending-dcv", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "PENDING")) + : StatusWithDomains("issued", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "VERIFIED", "dns-txt")); + }); + + // EMS-1080 at GetDcv for the pending SAN — the domain became verified CA-side + // between Track Order reporting it pending and this challenge fetch. + mock.Setup(c => c.GetDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception( + "CERTInext V2 API error during 'V2 get DCV challenge (per-domain)'. HTTP 422. " + + "Unprocessable Entity: EMS-1080 Domain is already verified.")); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "EMS-1080 on a pending SAN must be treated as already verified, not a failure"); + validator.StagedRecords.Should().BeEmpty( + "EMS-1080 at GetDcv means there is no fresh challenge to publish for this SAN"); + validator.CleanedUpKeys.Should().BeEmpty("nothing was staged, so there is nothing to clean up"); + + mock.Verify(c => c.VerifyDcvV2Async( + OrderId, UccSanA, It.IsAny(), It.IsAny()), + Times.Never, + "VerifyDcv must never be reached for a domain GetDcv already reported as verified"); + } + + [Fact] + public void V2OrderStatusResponse_Deserializes_PendingSanDomainsArray_WithoutDcvMethod() + { + // Pending-SAN challenge shape: pending entries carry no "dcvMethod" key at all; it + // only appears once an entry reaches VERIFIED. + const string rawJson = @"{ + ""orderId"": ""7465857196"", + ""status"": ""pending-approval"", + ""domain"": ""ucc-pending-202609290046.dcv-test.scrup.org"", + ""verifications"": { + ""domain"": { + ""status"": ""PENDING"", + ""domains"": [ + {""domain"":""a.pending-202609290046.example.com"",""domainStatus"":""ACTIVE"",""dcvStatus"":""PENDING"",""caaStatus"":""SKIPPED""}, + {""domain"":""b.pending-202609290046.example.com"",""domainStatus"":""ACTIVE"",""dcvStatus"":""PENDING"",""caaStatus"":""SKIPPED""}, + {""domain"":""ucc-pending-202609290046.dcv-test.scrup.org"",""domainStatus"":""ACTIVE"",""dcvMethod"":""dns-txt"",""dcvStatus"":""VERIFIED"",""verifiedAt"":""2026-09-29T00:46:09Z"",""caaStatus"":""PASSED""} + ] + }, + ""empty"": false + } + }"; + + // Null-forgiving here: System.Text.Json's Deserialize is annotated to return + // T?, but a successfully-parsed non-null JSON object (as above) never actually + // produces a null reference — the Should().NotBeNull() below is the runtime + // guarantee backing that, which the compiler's static analysis can't see through. + var result = JsonSerializer.Deserialize(rawJson)!; + + result.Should().NotBeNull(); + result.Verifications.Should().NotBeNull(); + result.Verifications.Domain.Should().NotBeNull(); + result.Verifications.Domain.Status.Should().Be("PENDING"); + result.Verifications.Domain.Domains.Should().HaveCount(3); + + var sanA = result.Verifications.Domain.Domains.Single(d => d.Domain == "a.pending-202609290046.example.com"); + sanA.DcvStatus.Should().Be("PENDING"); + sanA.DcvMethod.Should().BeNull("pending entries carry no dcvMethod key at all — absent, not present-but-null-looking"); + sanA.VerifiedAt.Should().BeNull(); + + var verifiedPrimary = result.Verifications.Domain.Domains.Single( + d => d.Domain == "ucc-pending-202609290046.dcv-test.scrup.org"); + verifiedPrimary.DcvStatus.Should().Be("VERIFIED"); + verifiedPrimary.DcvMethod.Should().Be("dns-txt"); + verifiedPrimary.VerifiedAt.Should().Be("2026-09-29T00:46:09Z"); + } + + // --------------------------------------------------------------------------- + // DCV exists only for the SSL/TLS family. Spec: the DCV endpoints live + // under /ssl-certificates only; Private PKI: "No DCV - your CA trusts you"; Document + // Signer has no DCV step. PerformDcvV2IfNeededAsync's family gate must stop every caller + // (Enroll, GetSingleRecord, Synchronize) from hitting a nonexistent + // /{family}/{orderId}/dcv endpoint for a non-SSL order that is merely pending. + // --------------------------------------------------------------------------- + + private static void SetupDcvCallsSoStrictMockDoesNotThrow(Mock mock) + { + // Set up (rather than leave unset) so a regression would be recorded as an invocation + // and caught by Verify(Times.Never), instead of throwing a MockException that + // PerformDcvV2IfNeededAsync's own error handling might swallow. + mock.Setup(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "should-never-be-fetched" }); + mock.Setup(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "should-never-be-fetched" }); + mock.Setup(c => c.VerifyDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse()); + } + + private static void VerifyNoDcvCalls(Mock mock, FakeDomainValidator validator) + { + mock.Verify(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.VerifyDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + validator.StagedRecords.Should().BeEmpty("no TXT record may be published for a non-SSL order"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_PendingOrder_NeverAttemptsDcv_EvenWithDcvEnabled() + { + const string pkiOrderId = "ord_pki_dcv_001"; + var mock = NewMock(); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = pkiOrderId, Status = "pending-csr" }); + mock.Setup(c => c.SubmitCsrV2Async( + Constants.ApiV2.FamilyPrivatePki, pkiOrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + // Even a (spec-impossible) pending-dcv status, with a domain block, must not trigger DCV. + mock.Setup(c => c.TrackOrderV2Async(Constants.ApiV2.FamilyPrivatePki, pkiOrderId, It.IsAny())) + .ReturnsAsync(StatusWithDomains("pending-dcv", DomainEntry("intranet.acme.local", "PENDING"))); + SetupDcvCallsSoStrictMockDoesNotThrow(mock); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=intranet.acme.local", + san: new Dictionary { ["dnsname"] = new[] { "intranet.acme.local" } }, + productInfo: new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "149", + ["ProductFamily"] = "private-pki", + ["ProductVariant"] = "intranet-ssl" + } + }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be(pkiOrderId); + VerifyNoDcvCalls(mock, validator); + } + + [Fact] + public async Task GetSingleRecord_V2_PrivatePkiOrder_PendingWithDomain_NeverAttemptsDcv() + { + const string pkiOrderId = "ord_pki_dcv_002"; + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(pkiOrderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilyPrivatePki, new V2OrderStatusResponse + { + OrderId = pkiOrderId, Status = "pending-dcv", Domain = "intranet.acme.local" + })); + SetupDcvCallsSoStrictMockDoesNotThrow(mock); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var record = await plugin.GetSingleRecord(pkiOrderId); + + record.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + VerifyNoDcvCalls(mock, validator); + } + + [Fact] + public async Task SynchronizeV2_PendingPrivatePkiOrder_ResolvedToPrivatePkiFamily_NeverAttemptsDcv() + { + // A private-pki order in pending-approval surfaces in /reports/orders exactly like a + // pending DV order ("Order Accepted" / "Pending for Approver", with a domainName) — sync + // must resolve its family correctly rather than calling /private-pki-certificates/{id}/dcv. + var mock = NewMock(); + var row = PendingDcvRow("ord_pki_sync_001", DateTime.UtcNow.AddMinutes(-10)); + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_pki_sync_001", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilyPrivatePki, new V2OrderStatusResponse + { + OrderId = "ord_pki_sync_001", Status = "pending-approval" + })); + SetupDcvCallsSoStrictMockDoesNotThrow(mock); + + var validator = new FakeDomainValidator(); + var config = new CERTInextConfig + { + UseV2Api = true, ApiUrl = "https://v2.certinext.io", + OAuthClientId = "c", OAuthClientSecret = "s", + DcvEnabled = true, DcvTimeoutMinutes = 1, DcvPropagationDelaySeconds = 1, + DcvSyncMaxOrderAgeHours = 0, DcvSyncMaxPerPass = 0 + }; + var plugin = new CERTInextCAPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + var buffer = new BlockingCollection(100); + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().ContainSingle(r => r.CARequestID == "ord_pki_sync_001" + && r.Status == (int)EndEntityStatus.EXTERNALVALIDATION); + VerifyNoDcvCalls(mock, validator); + } + + // --------------------------------------------------------------------------- + // Wildcard domains — TXT hostname must be derived from the BASE domain + // --------------------------------------------------------------------------- + // + // A wildcard V2 DV order's TXT host must not be staged as + // "_emsign-validation.*.example.com" — a literal '*' DNS label is + // not queryable and leaves the order stuck pending. CERTInext's own GetDcvV2/VerifyDcvV2/ + // TrackOrderV2 calls must still use the original "*."-prefixed domain string; only the + // DNS-side hostname/zone resolution uses the base domain. + + [Fact] + public async Task PerformDcvV2_WildcardSingleDomain_StagesBaseDomainHostname_ButCallsCaWithWildcardDomain() + { + const string baseName = "example.com"; + string wildcard = "*." + baseName; + + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv", Domain = wildcard }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued", Domain = wildcard }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued", Domain = wildcard }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "wildcard-token", TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = wildcard + } + }; + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: $"CN={wildcard}", + san: new Dictionary { ["dns"] = new[] { wildcard } }, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // The TXT hostname must be built from the base domain, not the literal + // "*.example.com" — a "*" DNS label is not queryable. + validator.StagedRecords.Should().ContainSingle() + .Which.key.Should().Be($"_emsign-validation.{baseName}"); + validator.StagedRecords.Should().OnlyContain(r => !r.key.Contains('*'), + "a literal '*' DNS label can never be queried by the CA"); + + // CERTInext's own API must still see the original wildcard domain string. + mock.Verify(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task PerformDcvV2_Ucc_ApexAndWildcardShareHostname_StagesOnceAndCleansUpOnce_ButVerifiesBothWithCa() + { + const string apex = "example.com"; + string wildcard = "*." + apex; + + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call (post-CSR check): both the apex and its wildcard are pending. Every later + // call (WaitForDomainsVerifiedV2Async's poll, and EnrollV2Async's post-DCV re-check) + // sees the order fully issued with both VERIFIED. + int trackCalls = 0; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(() => + { + trackCalls++; + return trackCalls == 1 + ? StatusWithDomains("pending-dcv", + DomainEntry(apex, "PENDING"), + DomainEntry(wildcard, "PENDING")) + : StatusWithDomains("issued", + DomainEntry(apex, "VERIFIED", "dns-txt"), + DomainEntry(wildcard, "VERIFIED", "dns-txt")); + }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-shared", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.GetDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-shared", TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // The apex and its wildcard collapse to the same base-domain TXT hostname AND carry the + // same token — exactly ONE record must be staged (and cleaned up), not two. + validator.StagedRecords.Should().ContainSingle( + "the apex and wildcard domains share one base-domain TXT hostname") + .Which.key.Should().Be($"_emsign-validation.{apex}"); + validator.CleanedUpKeys.Should().ContainSingle( + "the shared hostname must be cleaned up exactly once, not once per domain that used it") + .Which.Should().Be($"_emsign-validation.{apex}"); + + // CERTInext tracks DCV per domain entry, so both the apex and the wildcard still need + // their own CA-side GetDcv/VerifyDcv call even though they share one TXT record. + mock.Verify(c => c.GetDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.GetDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task PerformDcvV2_Ucc_ApexAndWildcardShareHostnameButDifferentTokens_StagesBothAndCleansUpBoth() + { + const string apex = "example.com"; + string wildcard = "*." + apex; + + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + int trackCalls = 0; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(() => + { + trackCalls++; + return trackCalls == 1 + ? StatusWithDomains("pending-dcv", + DomainEntry(apex, "PENDING"), + DomainEntry(wildcard, "PENDING")) + : StatusWithDomains("issued", + DomainEntry(apex, "VERIFIED", "dns-txt"), + DomainEntry(wildcard, "VERIFIED", "dns-txt")); + }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-apex", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.GetDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-wildcard", TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // Same base-domain hostname but two different CA tokens: a single record cannot satisfy + // both, so BOTH values must be staged at that hostname and both cleaned up. + string hostname = $"_emsign-validation.{apex}"; + validator.StagedRecords.Should().HaveCount(2); + validator.StagedRecords.Select(r => r.key).Should().OnlyContain(k => k == hostname); + validator.StagedRecords.Select(r => r.value).Should().BeEquivalentTo(new[] { "token-apex", "token-wildcard" }); + validator.CleanedUpKeys.Should().HaveCount(2); + validator.CleanedUpKeys.Should().OnlyContain(k => k == hostname); + + mock.Verify(c => c.VerifyDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny()), Times.Once); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginV2EnrollRevokedTests.cs b/CERTInext.Tests/CERTInextCAPluginV2EnrollRevokedTests.cs new file mode 100644 index 0000000..e0a23db --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginV2EnrollRevokedTests.cs @@ -0,0 +1,251 @@ +// 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.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// At enroll time the gateway never holds a stored + /// certificate body for a brand-new V2 order, so a REVOKED disposition surfaced from + /// EnrollV2Async — whether observed on the post-CSR-submit status check or during the + /// synchronous pickup poll (PickUpEnrolledCertificateV2Async) — is always body-less. + /// Letting that flow straight through to 's + /// caller as Status=REVOKED, Certificate=null is exactly the shape that poisons + /// the gateway (RevocationDate never clears; every later revoked-certificate + /// search calls FromDER(null) and 500s). These tests drive that handling end-to-end through + /// (V2 path), mirroring + /// CERTInextCAPluginV2PickupTests's mocking patterns. The DCV-gated variant of this + /// scenario (REVOKED observed on the post-DCV status re-check) lives in + /// CERTInextCAPluginV2DcvTests since it needs a domain validator factory and only + /// compiles when SUPPORTS_DCV is defined. + /// + public class CERTInextCAPluginV2EnrollRevokedTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2PluginWithPickup( + ICERTInextClient client, int retries, int delaySeconds = 1) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + AccountNumber = "12345", + AuthMode = "AccessKey", + ApiKey = "v1-key", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = retries, + PickupDelayInSeconds = delaySeconds + }); + + private static EnrollmentProductInfo MakeV2ProductInfo() => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + /// + /// Every V2 enrollment resolves the requested product's productTypeID from the + /// live Catalog to decide UCC-ness — any Strict-mock enroll test must stub this call + /// regardless of whether the test cares about UCC behavior (mirrors StubCatalog in + /// CERTInextCAPluginV2PickupTests.cs). + /// + private static void StubCatalog(Mock mock) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + + private const string OrderId = "ord_revoked_001"; + + private static Task Enroll(CERTInextCAPlugin plugin) => + plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + private static void StubPlaceAndSubmit(Mock mock, string initialStatus) => + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = initialStatus }); + + // --------------------------------------------------------------------------- + // REVOKED on the status check immediately after CSR submit -> FAILED + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_RevokedImmediatelyAfterCsrSubmit_ReturnsFailed_NotBodylessRevoked() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // The very first status check after CSR submission already reports the order as + // revoked — there was never a chance for a certificate body to exist. + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "revoked" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 3); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a REVOKED order observed right after CSR submission has no certificate body and " + + "must never be reported as REVOKED"); + result.Certificate.Should().BeNull(); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain(OrderId).And.Contain("revoked"); + + // A terminal (non-EXTERNALVALIDATION) disposition must never enter the pickup poll. + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Once, "a terminal REVOKED disposition observed pre-poll must not trigger the pickup poll"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // REVOKED discovered during the synchronous pickup poll -> FAILED + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_RevokedDuringPickupPoll_ReturnsFailed_NotBodylessRevoked() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Post-CSR check is still pending; the first (and only) poll attempt observes the + // order was revoked at the CA before ever issuing. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "revoked" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 3); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a REVOKED disposition discovered mid-poll has no certificate body and must never " + + "be reported as REVOKED"); + result.Certificate.Should().BeNull(); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain(OrderId).And.Contain("revoked"); + + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(2), "the poll must stop at the first terminal observation, not run all 3 retries"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // A genuinely FAILED disposition must pass through unchanged + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_FailedDuringPickupPoll_StaysFailed() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "rejected" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 3); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a genuinely FAILED disposition must pass through the REVOKED->FAILED " + + "normalization unchanged"); + result.CARequestID.Should().Be(OrderId); + } + + // --------------------------------------------------------------------------- + // A genuinely issued certificate must pass through unchanged + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_IssuedDuringPickupPoll_StaysGenerated_WithCertificate() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = OrderId, + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 2); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "a genuinely issued certificate must pass through the REVOKED->FAILED " + + "normalization unchanged"); + result.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + result.CARequestID.Should().Be(OrderId); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginV2PickupTests.cs b/CERTInext.Tests/CERTInextCAPluginV2PickupTests.cs new file mode 100644 index 0000000..5c92bbe --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginV2PickupTests.cs @@ -0,0 +1,282 @@ +// 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.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Coverage for the V2 synchronous certificate-pickup poll, analogous to + /// PickUpEnrolledCertificateAsync (V1). These tests exercise + /// PickUpEnrolledCertificateV2Async end-to-end through + /// (V2 path), the same way CERTInextCAPluginTests's "Synchronous certificate pickup" + /// section exercises the V1 method. No domain validator factory is configured here, so the + /// inline DCV block (when compiled) short-circuits immediately (factory null) and never sets + /// dcvV2Ran — DCV-vs-pickup interaction is covered separately in + /// CERTInextCAPluginV2DcvTests. + /// + public class CERTInextCAPluginV2PickupTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + // PickupDelay is clamped to a 1s floor and the loop adds a fixed 5s initial delay + // (Constants.Pickup.InitialDelaySeconds), so these tests are intentionally a few + // seconds each — mirrors BuildPluginWithPickup in CERTInextCAPluginTests.cs. + private static CERTInextCAPlugin BuildV2PluginWithPickup( + ICERTInextClient client, int retries, int delaySeconds = 1) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + AccountNumber = "12345", + AuthMode = "AccessKey", + ApiKey = "v1-key", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = retries, + PickupDelayInSeconds = delaySeconds + }); + + private static EnrollmentProductInfo MakeV2ProductInfo() => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + /// + /// Every V2 enrollment resolves the requested product's productTypeID from the + /// live Catalog to decide UCC-ness — any Strict-mock enroll test must stub this call + /// regardless of whether the test cares about UCC behavior (mirrors StubCatalog in + /// CERTInextCAPluginV2Tests.cs). + /// + private static void StubCatalog(Mock mock) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + + private const string OrderId = "ord_pickup_001"; + + private static Task Enroll(CERTInextCAPlugin plugin) => + plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + private static void StubPlaceAndSubmit(Mock mock, string initialStatus) => + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = initialStatus }); + + // --------------------------------------------------------------------------- + // pending -> issued on a later poll -> GENERATED + chain + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_ReturnsIssuedCert_WhenOrderIssuesDuringLaterPoll() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call: post-CSR check (still pending). 2nd: 1st poll attempt (still pending). + // 3rd: 2nd poll attempt — the order has now issued. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = OrderId, + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 2); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.CARequestID.Should().Be(OrderId); + result.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(3)); + mock.Verify(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Once); + } + + // --------------------------------------------------------------------------- + // issued but the first download fails -> retried -> GENERATED + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_RetriesDownload_WhenFirstDownloadAttemptFails() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Post-CSR check is still pending, so EnrollV2Async's own immediate-download branch + // is never reached — both "issued" observations below come from the pickup poll. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued" }); + + mock.SetupSequence(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ThrowsAsync(new Exception("simulated transient download failure")) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = OrderId, + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 2); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + mock.Verify(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(2), "the first download failure must not abort the poll"); + } + + // --------------------------------------------------------------------------- + // still pending at budget -> pending with CARequestID + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_ReturnsPendingWithCARequestID_WhenOrderNeverIssuesWithinBudget() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Every poll (including the post-CSR check) still reports pending. + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 1); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be(OrderId); + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.AtLeast(2), "an enabled pickup must actually poll before giving up"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // PickupRetries=0 -> no polling + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_Disabled_WhenPickupRetriesZero_ReturnsPendingWithoutPolling() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 0); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be(OrderId); + // Only the single post-CSR status check should have run — no pickup polling at all. + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Once, "PickupRetries=0 must disable the V2 synchronous pickup poll"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // FAILED mid-poll -> returned as-is + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_SurfacesTerminalFailedStatus_WhenOrderRejectedDuringPoll() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Post-CSR check is pending; the first (and only) poll attempt observes a terminal + // "rejected" status, which StatusMapper.V2StatusToRequestDisposition maps to FAILED. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "rejected" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 3); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a terminal status observed during pickup is surfaced immediately, not polled to exhaustion"); + result.CARequestID.Should().Be(OrderId); + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(2), "the poll must stop at the first terminal observation, not run all 3 retries"); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginV2Tests.cs b/CERTInext.Tests/CERTInextCAPluginV2Tests.cs new file mode 100644 index 0000000..a2e24c9 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginV2Tests.cs @@ -0,0 +1,2428 @@ +// 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.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Extensions.CAPlugin.CERTInext; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Moq-based unit tests that verify V2 dispatch in . + /// All V2 client methods are mocked — no network calls are made. + /// + public class CERTInextCAPluginV2Tests + { + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin( + ICERTInextClient client, + bool ignoreExpired = false, + string dcvTxtRecordTemplate = null, + string requestorIsdCode = null, + string requestorMobileNumber = null, + ICertificateDataReader certDataReader = null, + string organizationNumber = null, + string defaultProductCode = null) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + AccountNumber = "12345", + AuthMode = "AccessKey", + ApiKey = "v1-key", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + OrganizationNumber = organizationNumber, + DefaultProductCode = defaultProductCode, + PickupRetries = 0, + IgnoreExpired = ignoreExpired, + DcvTxtRecordTemplate = dcvTxtRecordTemplate, + RequestorIsdCode = requestorIsdCode, + RequestorMobileNumber = requestorMobileNumber + }, certDataReader); + + /// + /// A mock whose + /// returns a date, + /// simulating a gateway row that already holds a certificate body. Tests that exercise + /// revocation-detail/ProductId logic on a REVOKED-with-no-body record use this so the + /// bodyless-REVOKED guard () + /// lets the record through unchanged, keeping these tests focused on their own concern. + /// + private static ICertificateDataReader GatewayHoldsBodyReader(DateTime? expiry = null) + { + var mock = new Mock(); + mock.Setup(r => r.GetExpirationDateByRequestId(It.IsAny())) + .Returns(expiry ?? DateTime.UtcNow.AddDays(30)); + return mock.Object; + } + + private static EnrollmentProductInfo MakeV2ProductInfo( + string productCode = "842", + string productFamily = "ssl", + string productVariant = "dv", + string domainName = "example.com") + { + return new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = productFamily, + ["ProductVariant"] = productVariant, + ["DomainName"] = domainName + } + }; + } + + /// + /// Stubs — every V2 enrollment now + /// resolves the requested product's productTypeID from the live Catalog to decide + /// UCC-ness, so any Strict-mock enroll test must + /// stub this call regardless of whether the test cares about UCC behavior. + /// + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + // --------------------------------------------------------------------------- + // Ping routes to V2 + // --------------------------------------------------------------------------- + + [Fact] + public async Task Ping_V2Enabled_CallsPingV2Async() + { + var mock = NewMock(); + mock.Setup(c => c.PingV2Async(It.IsAny())) + .Returns(Task.CompletedTask); + + var plugin = BuildV2Plugin(mock.Object); + await plugin.Ping(); + + mock.Verify(c => c.PingV2Async(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Ping_V2Enabled_DoesNotCallV1Ping() + { + var mock = new Mock(); // Loose — verifying absence + mock.Setup(c => c.PingV2Async(It.IsAny())) + .Returns(Task.CompletedTask); + + var plugin = BuildV2Plugin(mock.Object); + await plugin.Ping(); + + mock.Verify(c => c.PingAsync(It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Enroll routes to V2 + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2Enabled_PlacesV2Order_PendingResult() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // non-UCC (DV SSL) + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse + { + OrderId = MockCertificateData.V2OrderId1, + RequestId = "req_001", + Status = "pending-dcv" + }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async( + It.IsAny(), MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = MockCertificateData.V2OrderId1, Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeV2ProductInfo(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.CARequestID.Should().Be(MockCertificateData.V2OrderId1); + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + } + + [Fact] + public async Task Enroll_V2Enabled_IssuedImmediately_DownloadsCert() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // non-UCC (DV SSL) + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued" + }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async( + It.IsAny(), MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = MockCertificateData.V2OrderId1, Status = "issued" }); + + mock.Setup(c => c.DownloadCertificateV2Async( + It.IsAny(), MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = MockCertificateData.V2OrderId1, + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeV2ProductInfo(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.CARequestID.Should().Be(MockCertificateData.V2OrderId1); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + } + + // By design: V2 has no distinct renewal endpoint the plugin uses — CERTInext's + // `/reissue` endpoint exists but is intentionally not called. RenewOrReissue places a + // brand-new order via the same PlaceOrderV2Async path as a fresh enrollment; the prior + // order/certificate is left issued rather than revoked or reused. + [Fact] + public async Task Enroll_V2Enabled_RenewOrReissue_PlacesNewOrderByDesign() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // non-UCC (DV SSL) + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse + { + OrderId = MockCertificateData.V2OrderId2, + Status = "pending-csr" + }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), MockCertificateData.V2OrderId2, + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async( + It.IsAny(), MockCertificateData.V2OrderId2, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = MockCertificateData.V2OrderId2, Status = "pending-validation" }); + + var plugin = BuildV2Plugin(mock.Object); + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeV2ProductInfo(), + RequestFormat.PKCS10, + EnrollmentType.RenewOrReissue); + + result.CARequestID.Should().Be(MockCertificateData.V2OrderId2); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------------- + // V2 product code resolution when no explicit ProductCode is configured. + // + // ProductVariant is not independent of ProductID — the plugin derives/validates it from + // the product, so an OV/EV ProductID with no explicit ProductVariant legitimately + // resolves to "ov"/"ev" and requires the organization block. These tests configure + // OrganizationNumber (so that guard is satisfied) and leave ProductVariant unset + // entirely, so the derived value isolates product-code resolution as the thing under test. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_ResolvesLiveCodeFromCatalog_NotStaleV1Table() + { + // ProductID "OV SSL" with NO ProductCode/ProfileId override. Falling back to + // Constants.Products.DefaultProductCodes["OV SSL"] = "842" would send that + // on the wire — which the live catalog (per this stub) actually maps to DV SSL, not OV + // SSL (a silent-misissuance risk). The code must instead be resolved + // from the catalog entry whose productTypeID matches OV SSL ("16") — "846" in this + // stub — a different value than the stale table's "842". + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true }, // DV SSL + new ProductDetail { ProductCode = "846", ProductTypeId = "16", Active = true }, // OV SSL + }); + + string capturedProductCode = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, code, __, ___) => capturedProductCode = code) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_resolve_001", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_resolve_001", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_resolve_001", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_resolve_001", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-TEST-001"); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary + { + ["ProductFamily"] = "ssl", + // No explicit ProductVariant — derives "ov" from ProductID. + ["DomainName"] = "example.com" + } + }; + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + productInfo, + RequestFormat.PKCS10, + EnrollmentType.New); + + result.CARequestID.Should().Be("ord_resolve_001"); + capturedProductCode.Should().Be("846", + "the resolved code must come from the catalog's OV SSL entry (productTypeID 16), not " + + "Constants.Products.DefaultProductCodes[\"OV SSL\"] (\"842\"), which the live catalog in " + + "this stub actually maps to DV SSL"); + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Once, + "the catalog fetched for code resolution must be the same call reused for UCC detection, not a second fetch"); + } + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_NoMatchingCatalogEntry_FailsFastInsteadOfSilentlyProceeding() + { + // No override configured, and the live catalog (stubbed here) has no entry with the + // productTypeID expected for EV SSL ("19") — must fail loudly with a clear message + // rather than silently falling back to a wrong/stale code or ordering an unintended + // product. + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true }, // DV SSL only + }); + + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-TEST-001"); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.EvSsl, + ProductParameters = new Dictionary + { + ["ProductFamily"] = "ssl", + // No explicit ProductVariant — derives "ev" from ProductID. + ["DomainName"] = "example.com" + } + }; + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + productInfo, + RequestFormat.PKCS10, + EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("could not resolve a live product code"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Product-selection ambiguity: the live catalog can carry MORE + // THAN ONE entry with the same productTypeID — e.g. two type-13 DV SSL entries, "917 + // SSL DV 1 month" (listed first) and "842 DV SSL Certificate". Picking the first match + // would silently order "917", which PUT /csr then rejects with + // 422 "PFX based certificate orders are not allowed" — failing every ProductId-only DV + // SSL enrollment. No explicit ProductCode + multiple matches must only resolve via the + // connector's DefaultProductCode, or reject naming the candidates. + // --------------------------------------------------------------------------- + + private static Mock StubAmbiguousDvCatalog() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "917", ProductName = "SSL DV 1 month", ProductTypeId = "13", Active = true }, + new ProductDetail { ProductCode = "842", ProductName = "DV SSL Certificate", ProductTypeId = "13", Active = true }, + }); + return mock; + } + + private static EnrollmentProductInfo MakeDvProductInfoNoExplicitCode() => new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary + { + ["ProductFamily"] = "ssl", + ["DomainName"] = "example.com" + } + }; + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_MultipleCatalogMatches_NoDefaultProductCode_RejectsWithCandidates() + { + var mock = StubAmbiguousDvCatalog(); + var plugin = BuildV2Plugin(mock.Object); // no DefaultProductCode configured + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeDvProductInfoNoExplicitCode(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().ContainEquivalentOf("multiple CERTInext catalog products match"); + result.StatusMessage.Should().Contain("917").And.Contain("842"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never, + "an ambiguous product match must never reach order placement — no silent first-pick"); + } + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_MultipleCatalogMatches_DefaultProductCodeMatchesOne_Resolves() + { + var mock = StubAmbiguousDvCatalog(); + string capturedProductCode = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, code, __, ___) => capturedProductCode = code) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_amb_001", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_amb_001", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_amb_001", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_amb_001", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, defaultProductCode: "842"); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeDvProductInfoNoExplicitCode(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.CARequestID.Should().Be("ord_amb_001"); + capturedProductCode.Should().Be("842", + "DefaultProductCode names one of the ambiguous matches, so it must be used instead of rejecting"); + } + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_MultipleCatalogMatches_DefaultProductCodeIsWrongType_RejectsWithCandidates() + { + // DefaultProductCode is configured, but it names a product of a DIFFERENT + // productTypeID than the one being resolved — must not be treated as a match. + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "917", ProductName = "SSL DV 1 month", ProductTypeId = "13", Active = true }, + new ProductDetail { ProductCode = "842", ProductName = "DV SSL Certificate", ProductTypeId = "13", Active = true }, + new ProductDetail { ProductCode = "846", ProductName = "OV SSL Certificate", ProductTypeId = "16", Active = true }, + }); + var plugin = BuildV2Plugin(mock.Object, defaultProductCode: "846"); // OV, not DV + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeDvProductInfoNoExplicitCode(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().ContainEquivalentOf("multiple CERTInext catalog products match"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_CatalogFetchFailed_BehaviorUnchanged_RejectsAsNotFound() + { + // A catalog-fetch failure must still fail the same way it did before ambiguity + // handling was added — "could not resolve", not an ambiguity-shaped message. + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ThrowsAsync(new Exception("catalog unreachable")); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeDvProductInfoNoExplicitCode(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("could not resolve a live product code"); + result.StatusMessage.Should().NotContainEquivalentOf("multiple CERTInext catalog products match"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // GetSingleRecord routes to V2 + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetSingleRecord_V2Enabled_UsesResolveAndTrack() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued", + ProductVariant = "dv" + })); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = MockCertificateData.V2OrderId1, + SerialNumber = "AABB", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.CARequestID.Should().Be(MockCertificateData.V2OrderId1); + record.Status.Should().Be((int)EndEntityStatus.GENERATED); + record.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetSingleRecord_V2Enabled_DoesNotCallV1GetCertificate() + { + var mock = new Mock(); // Loose + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = "ord_x", Status = "pending-dcv" })); + + var plugin = BuildV2Plugin(mock.Object); + await plugin.GetSingleRecord("ord_x"); + + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // GetSingleRecord — RevocationDate/RevocationReason + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetSingleRecord_V2Enabled_Revoked_PopulatesRevocationDateAndReason() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "revoked", + ProductVariant = "dv", + Revocation = new V2RevocationDetails + { + Status = "Certificate Revoked", + Reason = "cessation-of-operation", + ProcessedAt = new DateTime(2026, 9, 24, 20, 44, 41, DateTimeKind.Utc) + } + })); + + // This record has no certificate body, so the bodyless-REVOKED guard + // would otherwise downgrade it to FAILED — a reader that reports the gateway + // already holds a body keeps this test focused on revocation-detail population. + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Status.Should().Be((int)EndEntityStatus.REVOKED); + record.RevocationDate.Should().Be(new DateTime(2026, 9, 24, 20, 44, 41, DateTimeKind.Utc)); + record.RevocationReason.Should().Be(5); // cessation-of-operation + } + + [Fact] + public async Task GetSingleRecord_V2Enabled_NotRevoked_RevocationFieldsDefault() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued", + ProductVariant = "dv" + })); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = MockCertificateData.V2OrderId1, + SerialNumber = "AABB", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.RevocationDate.Should().BeNull(); + record.RevocationReason.Should().Be(0); + } + + // --------------------------------------------------------------------------- + // Revoke routes to V2 + // --------------------------------------------------------------------------- + + [Fact] + public async Task Revoke_V2Enabled_ResolvesAndRevokes() + { + var mock = new Mock(); // Loose + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued" + })); + + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var plugin = BuildV2Plugin(mock.Object); + var status = await plugin.Revoke(MockCertificateData.V2OrderId1, "AABB", 4u); + + status.Should().Be((int)EndEntityStatus.REVOKED); + } + + [Fact] + public async Task Revoke_V2Enabled_DoesNotCallV1RevokeCertificate() + { + var mock = new Mock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { Status = "issued", OrderId = "ord_x" })); + + mock.Setup(c => c.RevokeOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var plugin = BuildV2Plugin(mock.Object); + await plugin.Revoke("ord_x", "AA", 1u); + + mock.Verify(c => c.RevokeCertificateAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Reason code fallback: CERTInext rejects 4 of the 9 spec-documented + // reason values (422 "Invalid Revoke Reason ID") — unspecified (CRL + // 0), ca-compromise (CRL 2), certificate-hold (CRL 6), aa-compromise (CRL 10). The + // plugin retries exactly once with an accepted fallback for each: cessation-of- + // operation for unspecified/certificate-hold, key-compromise for the two + // *-compromise reasons. A reason outside that known-rejected set is never retried. + // --------------------------------------------------------------------------- + + private static Mock SetupRevokeReasonRejectionMock(List seenReasons) + { + var mock = new Mock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued" + })); + + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .Returns((string family, string orderId, V2RevokeRequest req, CancellationToken ct) => + { + seenReasons.Add(req.Reason); + bool firstAttemptRejected = + req.Reason == Constants.RevocationReasonV2.Unspecified || + req.Reason == Constants.RevocationReasonV2.CACompromise || + req.Reason == Constants.RevocationReasonV2.CertificateHold || + req.Reason == Constants.RevocationReasonV2.AACompromise; + if (firstAttemptRejected && seenReasons.Count == 1) + throw new InvalidOperationException("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID"); + return Task.CompletedTask; + }); + return mock; + } + + [Theory] + // Command's default when no explicit reason is given. + [InlineData(0u, Constants.RevocationReasonV2.Unspecified, Constants.RevocationReasonV2.CessationOfOperation)] + [InlineData(2u, Constants.RevocationReasonV2.CACompromise, Constants.RevocationReasonV2.KeyCompromise)] + [InlineData(6u, Constants.RevocationReasonV2.CertificateHold, Constants.RevocationReasonV2.CessationOfOperation)] + [InlineData(10u, Constants.RevocationReasonV2.AACompromise, Constants.RevocationReasonV2.KeyCompromise)] + public async Task Revoke_V2Enabled_KnownRejectedReason_RetriesWithExpectedFallback( + uint crlReason, string expectedOriginal, string expectedFallback) + { + var seenReasons = new List(); + var mock = SetupRevokeReasonRejectionMock(seenReasons); + + var plugin = BuildV2Plugin(mock.Object); + var status = await plugin.Revoke(MockCertificateData.V2OrderId1, "AABB", crlReason); + + status.Should().Be((int)EndEntityStatus.REVOKED); + seenReasons.Should().Equal(expectedOriginal, expectedFallback); + } + + [Fact] + public async Task Revoke_V2Enabled_RejectedReasonNotInFallbackSet_DoesNotRetry() + { + var mock = new Mock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued" + })); + + // CRL reason 4 (superseded) maps to "superseded", which is + // actually accepted — it is not in the known-rejected fallback set, so even + // if the CA somehow rejected it with the same "Invalid Revoke Reason ID" message, + // the plugin must surface the failure as-is rather than retry. + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID")); + + var plugin = BuildV2Plugin(mock.Object); + var ex = await Assert.ThrowsAsync( + () => plugin.Revoke(MockCertificateData.V2OrderId1, "AABB", 4u)); + + ex.Message.Should().Contain("Invalid Revoke Reason ID"); + mock.Verify(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------------- + // A revoke 404 after the family is already resolved + // must not be reported as a family miss. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Revoke_V2Enabled_RevokeReturns404AfterFamilyResolved_ReportsNotRevokable_NotFamilyMiss() + { + var mock = new Mock(); // Loose + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued" + })); + + // Order is confirmed to live in the SSL family (TrackOrder above succeeded), + // but the revoke call itself 404s — per spec that means "not revokable", + // not "wrong family". The plugin must not retry other families for it. + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .ThrowsAsync(new KeyNotFoundException( + $"V2 order '{MockCertificateData.V2OrderId1}' in family '{Constants.ApiV2.FamilySsl}' " + + "not found or not in a revokable state.")); + + var plugin = BuildV2Plugin(mock.Object); + var ex = await Assert.ThrowsAsync( + () => plugin.Revoke(MockCertificateData.V2OrderId1, "AABB", 4u)); + + ex.Message.Should().Contain("not found or not in a revokable state"); + ex.Message.Should().NotContain("any product family", + "a 404 after the family was already resolved must not be mislabeled as a family miss"); + + // Must not have probed the other two families. + mock.Verify(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilyPrivatePki, It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySignature, It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Synchronize (V2) — uses V2 /reports/orders, not V1 GetOrderReport. + // --------------------------------------------------------------------------- + + private static OrderReportEntryV2 ReportRow( + string orderNumber, string orderStatus, string certificateStatus, + string domainName = "example.com", string productCode = "842", + string certificateExpiryDate = null) => + new OrderReportEntryV2 + { + OrderNumber = orderNumber, + OrderStatus = orderStatus, + CertificateStatus = certificateStatus, + DomainName = domainName, + ProductCode = productCode, + OrderDate = System.DateTime.UtcNow.AddHours(-1).ToString("o"), + CertificateExpiryDate = certificateExpiryDate + }; + + [Fact] + public async Task Synchronize_V2Enabled_UsesListOrdersV2Async_NotV1ListCertificates() + { + var mock = new Mock(); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable()); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + mock.Verify(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.AtLeastOnce); + mock.Verify(c => c.ListCertificatesAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_V2Enabled_IssuedRow_DownloadsCertificateBody() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_001", "Order Fulfilled", "Certificate Downloaded"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_001", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_001", + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].CARequestID.Should().Be("ord_v2sync_001"); + records[0].Status.Should().Be((int)EndEntityStatus.GENERATED); + records[0].Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + records[0].ProductID.Should().Be("842"); + + // Recognised report-status strings must not need a live track fallback. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_V2Enabled_UnrecognizedStatus_FallsBackToLiveTrack() + { + var mock = new Mock(); + // "Something New" is deliberately not in the known display-string vocabulary, + // which is not guaranteed exhaustive. + var row = ReportRow("ord_v2sync_002", "Something New", "Also New"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_002", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_002", Status = "issued", Domain = "example.com" + })); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_002", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_002", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].Status.Should().Be((int)EndEntityStatus.GENERATED, + "an unrecognised report status must fall back to the authoritative live track " + + "call rather than being dropped or guessed"); + + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + "ord_v2sync_002", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Synchronize_V2Enabled_RevokedRow_EmittedAsRevoked() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_003", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + // This row has no certificate body, so the bodyless-REVOKED guard would + // otherwise downgrade/skip it — a reader that reports the gateway already holds a + // body keeps this test focused on "revoked rows never attempt a download". + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].Status.Should().Be((int)EndEntityStatus.REVOKED); + + // Revoked rows have no body to download. + mock.Verify(c => c.ResolveAndDownloadCertificateV2Async( + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Synchronize — RevocationDate/RevocationReason. OrderReportEntryV2 + // carries no revocation reason/date of its own — only a live TrackOrder response's + // nested `revocation` object does, so these fields require a resolved + // V2OrderStatusResponse regardless of which code path got there. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_V2Enabled_RevokedRow_ViaDisplayString_PopulatesRevocationDetails() + { + var mock = new Mock(); + // "Revoked"/"Certificate Revoked" resolve via the report's own display-string + // vocabulary (TryMapV2ReportDisplayStatus) with no live track call — the revocation + // detail must be fetched lazily, on top of that, specifically for this row. + var row = ReportRow("ord_v2sync_004", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_004", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_004", + Status = "revoked", + Revocation = new V2RevocationDetails + { + Status = "Certificate Revoked", + Reason = "key-compromise", + ProcessedAt = new System.DateTime(2026, 9, 24, 20, 44, 41, System.DateTimeKind.Utc) + } + })); + + // No certificate body on this row — a reader that reports the gateway + // already holds a body keeps this test focused on revocation-detail population + // rather than the bodyless-REVOKED guard (covered separately). + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].Status.Should().Be((int)EndEntityStatus.REVOKED); + records[0].RevocationDate.Should().Be(new System.DateTime(2026, 9, 24, 20, 44, 41, System.DateTimeKind.Utc)); + records[0].RevocationReason.Should().Be(1); // key-compromise + + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + "ord_v2sync_004", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Synchronize_V2Enabled_RevokedRow_ViaUnresolvedFallback_PopulatesRevocationDetails() + { + var mock = new Mock(); + // Deliberately unrecognized display strings so disposition resolves via the + // unresolved-status fallback, which already performs a live TrackOrder call — + // trackedStatus (and its Revocation) is already populated before the + // revocation-specific lazy-fetch in Synchronize would otherwise need to run one. + var row = ReportRow("ord_v2sync_005", "Something New", "Also New"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_005", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_005", + Status = "revoked", + Revocation = new V2RevocationDetails + { + Status = "Certificate Revoked", + Reason = "superseded", + ProcessedAt = new System.DateTime(2026, 1, 2, 3, 4, 5, System.DateTimeKind.Utc) + } + })); + + // No certificate body on this row — a reader that reports the gateway + // already holds a body keeps this test focused on revocation-detail population + // rather than the bodyless-REVOKED guard (covered separately). + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].RevocationDate.Should().Be(new System.DateTime(2026, 1, 2, 3, 4, 5, System.DateTimeKind.Utc)); + records[0].RevocationReason.Should().Be(4); // superseded + + // Only the one fallback call — the revocation-specific lazy-fetch must not + // double-call when trackedStatus is already populated. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + "ord_v2sync_005", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Synchronize_V2Enabled_NotRevokedRow_RevocationFieldsDefault() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_006", "Order Fulfilled", "Certificate Downloaded"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_006", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_006", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].RevocationDate.Should().BeNull(); + records[0].RevocationReason.Should().Be(0); + + // Not revoked — must not incur the revocation-detail lazy-fetch at all. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Synchronize — ProductID preference: report row's ProductCode vs. a lazily- + // fetched trackedStatus.ProductVariant. No new live call is added + // by this preference — it only reads whatever trackedStatus already exists in + // local scope from one of the three pre-existing lazy-fetch branches (unresolved- + // status fallback, DCV attempt, revoked-row lookup). + // --------------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_V2Enabled_ProductId_PrefersReportRowProductCode_OverTrackedStatus() + { + var mock = new Mock(); + // Unrecognised display strings force the unresolved-status fallback, which + // populates trackedStatus (with a *different* ProductVariant) — the report + // row's own non-empty ProductCode must still win. + var row = ReportRow("ord_v2sync_035a", "Something New", "Also New", productCode: "842"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_035a", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_035a", + Status = "revoked", + ProductVariant = "ov-ucc" + })); + + // No certificate body on this row — a reader that reports the gateway + // already holds a body keeps this test focused on ProductID preference rather than + // the bodyless-REVOKED guard (covered separately). + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].ProductID.Should().Be("842", "the report row's own ProductCode must be " + + "preferred over trackedStatus.ProductVariant whenever it is present"); + + // No extra call beyond the fallback the unresolved status already required. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + "ord_v2sync_035a", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Synchronize_V2Enabled_ProductId_FallsBackToTrackedStatusProductVariant_WhenReportRowEmpty() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_035b", "Something New", "Also New", productCode: ""); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_035b", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_035b", + Status = "revoked", + ProductVariant = "ov-ucc" + })); + + // No certificate body on this row — a reader that reports the gateway + // already holds a body keeps this test focused on ProductID preference rather than + // the bodyless-REVOKED guard (covered separately). + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].ProductID.Should().Be("ov-ucc", "when the report row's ProductCode is " + + "empty, an already-populated trackedStatus.ProductVariant must be used instead " + + "of leaving the field empty"); + } + + [Fact] + public async Task Synchronize_V2Enabled_ProductId_EmptyWhenReportRowEmptyAndNoTrackedStatusFetched() + { + var mock = new Mock(); + // Recognised display strings resolve disposition without any live track call, + // so trackedStatus is never populated for this row. + var row = ReportRow("ord_v2sync_035c", "Order Fulfilled", "Certificate Downloaded", productCode: ""); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_035c", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_035c", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].ProductID.Should().Be(string.Empty, "with no ProductCode and no " + + "trackedStatus fetched for this row, ProductID must stay empty rather than " + + "crash or guess a value"); + + // Confirms trackedStatus really is null here — no fetch was ever made for this row. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_V2Enabled_ProductId_EmptyWhenTrackedStatusProductVariantAlsoEmpty() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_035d", "Something New", "Also New", productCode: ""); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_035d", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_035d", + Status = "revoked", + ProductVariant = null + })); + + // No certificate body on this row — a reader that reports the gateway + // already holds a body keeps this test focused on ProductID preference rather than + // the bodyless-REVOKED guard (covered separately). + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].ProductID.Should().Be(string.Empty, "when both the report row's " + + "ProductCode and trackedStatus.ProductVariant are empty/null, ProductID must " + + "stay empty rather than guess a value"); + } + + [Fact] + public async Task Synchronize_V2Enabled_TerminalStatus_SkippedNotEmitted() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_004", "Order Cancelled", null); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().BeEmpty("terminal/cancelled orders are skipped, not emitted"); + } + + [Fact] + public async Task Synchronize_V2Enabled_IncrementalSync_RequestsLookbackWindow() + { + var mock = new Mock(); + string capturedFrom = null; + var lastSync = new System.DateTime(2026, 6, 15, 12, 0, 0, System.DateTimeKind.Utc); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((from, to, size, ct) => capturedFrom = from) + .Returns(AsyncEnumerable()); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, lastSync, fullSync: false, CancellationToken.None); + + // Default lookback is 72h (Constants.ApiV2.DefaultSyncLookbackHours) — the requested + // 'from' must be lastSync minus that window, not lastSync itself (the + // from/to filter's order-date-vs-issue-date semantics are not documented). + var expectedFrom = lastSync.AddHours(-Constants.ApiV2.DefaultSyncLookbackHours).ToString("yyyy-MM-dd"); + capturedFrom.Should().Be(expectedFrom); + } + + [Fact] + public async Task Synchronize_V2Enabled_FullSync_RequestsNoFromFilter() + { + var mock = new Mock(); + string capturedFrom = "unset"; + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((from, to, size, ct) => capturedFrom = from) + .Returns(AsyncEnumerable()); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, System.DateTime.UtcNow, fullSync: true, CancellationToken.None); + + capturedFrom.Should().BeNull("a full sync requests the entire order history, not a bounded window"); + } + + // --------------------------------------------------------------------------- + // Synchronize — IgnoreExpired. V1's Synchronize skips expired + // certs when IgnoreExpired is configured; SynchronizeV2Async must have an equivalent + // check using the report row (OrderReportEntryV2.CertificateExpiryDate). That field's + // format is not guaranteed, + // so an unparseable/missing value must NOT be skipped. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_V2Enabled_IgnoreExpired_ExpiredCert_Skipped() + { + var mock = new Mock(); + var row = ReportRow( + "ord_v2sync_expired_001", "Order Fulfilled", "Certificate Downloaded", + certificateExpiryDate: System.DateTime.UtcNow.AddDays(-30).ToString("o")); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var plugin = BuildV2Plugin(mock.Object, ignoreExpired: true); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().BeEmpty( + "an expired certificate must be skipped entirely when IgnoreExpired=true"); + + // Skipped before any status/download work — no live calls should be made for this row. + mock.Verify(c => c.ResolveAndDownloadCertificateV2Async( + It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_V2Enabled_IgnoreExpired_NonExpiredCert_Kept() + { + var mock = new Mock(); + var row = ReportRow( + "ord_v2sync_expired_002", "Order Fulfilled", "Certificate Downloaded", + certificateExpiryDate: System.DateTime.UtcNow.AddDays(30).ToString("o")); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_expired_002", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_expired_002", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object, ignoreExpired: true); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle( + "a certificate that has not yet expired must still be emitted even with " + + "IgnoreExpired=true"); + records[0].CARequestID.Should().Be("ord_v2sync_expired_002"); + } + + [Fact] + public async Task Synchronize_V2Enabled_IgnoreExpired_UnparseableExpiryDate_NotSkipped() + { + var mock = new Mock(); + var row = ReportRow( + "ord_v2sync_expired_003", "Order Fulfilled", "Certificate Downloaded", + certificateExpiryDate: "not-a-real-date"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_expired_003", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_expired_003", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object, ignoreExpired: true); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().ContainSingle( + "an unparseable CertificateExpiryDate must not be treated as expired — the row " + + "should be emitted, not silently dropped"); + } + + [Fact] + public async Task Synchronize_V2Enabled_IgnoreExpired_MissingExpiryDate_NotSkipped() + { + var mock = new Mock(); + var row = ReportRow( + "ord_v2sync_expired_004", "Order Fulfilled", "Certificate Downloaded", + certificateExpiryDate: null); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_expired_004", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_expired_004", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object, ignoreExpired: true); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().ContainSingle( + "a missing CertificateExpiryDate (empty until issuance per the DTO's doc " + + "comment) must not be treated as expired"); + } + + [Fact] + public async Task Synchronize_V2Enabled_IgnoreExpiredFalse_ExpiredCert_NotSkipped() + { + var mock = new Mock(); + var row = ReportRow( + "ord_v2sync_expired_005", "Order Fulfilled", "Certificate Downloaded", + certificateExpiryDate: System.DateTime.UtcNow.AddDays(-30).ToString("o")); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_expired_005", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_expired_005", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + // IgnoreExpired defaults to false — the filter must be opt-in. + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().ContainSingle( + "with IgnoreExpired left at its default (false), expired certs must still be emitted"); + } + + // --------------------------------------------------------------------------- + // Chain PEM assembly — Enroll V2 with chainPem + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_WithChainPem_ConcatenatesLeafAndIntermediate() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // non-UCC (DV SSL) + + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse + { + OrderId = "ord_chain_test", + Status = "issued" + }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_chain_test", + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async( + It.IsAny(), "ord_chain_test", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_chain_test", Status = "issued" }); + + // Download response includes a chain PEM entry + mock.Setup(c => c.DownloadCertificateV2Async( + It.IsAny(), "ord_chain_test", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_chain_test", + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate, + ChainPem = new System.Collections.Generic.List + { + MockCertificateData.FakeIntermediatePemCertificate + } + }); + + mock.Setup(c => c.Dispose()); + + mock.Setup(c => c.Dispose()); + + var plugin = BuildV2Plugin(mock.Object); + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com", + new Dictionary(), + MakeV2ProductInfo(productVariant: "dv"), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.CARequestID.Should().Be("ord_chain_test"); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + // Full chain must contain both leaf and intermediate + result.Certificate.Should().Contain("-----BEGIN CERTIFICATE-----"); + result.Certificate.Should().Contain("INTERMEDIATE", + because: "chain PEM from the CA should be appended to the leaf"); + } + + [Fact] + public async Task Enroll_V2_WithoutChainPem_ReturnsCertificatePemOnly() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // non-UCC (DV SSL) + + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_nochain", Status = "issued" }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_nochain", + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async( + It.IsAny(), "ord_nochain", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_nochain", Status = "issued" }); + + mock.Setup(c => c.DownloadCertificateV2Async( + It.IsAny(), "ord_nochain", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_nochain", + CertificatePem = MockCertificateData.FakePemCertificate, + ChainPem = null + }); + + mock.Setup(c => c.Dispose()); + + var plugin = BuildV2Plugin(mock.Object); + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com", + new Dictionary(), + MakeV2ProductInfo(productVariant: "dv"), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.Certificate.Should().Be(MockCertificateData.FakePemCertificate, + because: "no chainPem means only the leaf cert is returned"); + } + + // --------------------------------------------------------------------------- + // ValidateCAConnectionInfo — consolidated config. + // + // V2 mode: a single ApiUrl (required in both modes) plus OAuthClientId/OAuthClientSecret. + // V1-only fields (AccountNumber, AuthMode, ApiKey, ...) are NOT required when UseV2Api + // is true. + // --------------------------------------------------------------------------- + + [Fact] + public async Task ValidateCAConnectionInfo_V2_Throws_WhenApiUrlMissing() + { + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + // No ApiUrl + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().ThrowAsync() + .WithMessage("*ApiUrl*required*"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V2_Throws_WhenApiUrlIsNotUri() + { + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = "not-a-url", + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().ThrowAsync() + .WithMessage("*ApiUrl*valid absolute URI*"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V2_Throws_WhenOAuthClientIdMissing() + { + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = "https://v2.certinext.io", + // No OAuthClientId + ["OAuthClientSecret"] = "my-secret" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().ThrowAsync() + .WithMessage("*OAuthClientId*required*"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V2_Throws_WhenOAuthClientSecretMissing() + { + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = "https://v2.certinext.io", + ["OAuthClientId"] = "my-client" + // No OAuthClientSecret + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().ThrowAsync() + .WithMessage("*OAuthClientSecret*required*"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V2_DoesNotRequireV1Credentials() + { + // No AccountNumber, AuthMode, or ApiKey at all — V1 credentials must be optional + // when UseV2Api is true. Uses a real WireMock server so the live V2 + // ping (the only other thing this method does) succeeds. + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + server + .Given(Request.Create().WithPath("/api/certinext/v2/auth/me").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = server.Urls[0], + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret", + ["SignerPlace"] = "New York" // required for V2 + // No AccountNumber / AuthMode / ApiKey at all. + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().NotThrowAsync( + "V1 credentials (AccountNumber/AuthMode/ApiKey) must not be required when UseV2Api is true"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V1_UseV2ApiFalse_StillRequiresAccountNumberAndAuthMode() + { + // UseV2Api false (the default/legacy path) — V1 requirements are unchanged, and the + // (now nonexistent) V2-only fields must never appear in the resulting error. + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["ApiUrl"] = "https://v1.certinext.io", + ["UseV2Api"] = false + // No AccountNumber, no AuthMode/ApiKey. + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("AccountNumber") + .And.NotContain("ApiUrlV2").And.NotContain("OAuthClientId").And.NotContain("OAuthClientSecret"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_AllV2FieldsPresent_Passes() + { + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + server + .Given(Request.Create().WithPath("/api/certinext/v2/auth/me").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = server.Urls[0], + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret", + ["SignerPlace"] = "New York" // required for V2 + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().NotThrowAsync( + "ApiUrl and OAuthClientId/OAuthClientSecret are present and valid, and the live V2 ping succeeds"); + } + + // --------------------------------------------------------------------------- + // ValidateProductInfo — V2. ValidateProductInfo builds its own + // CERTInextClient from connectionInfo rather than using the Moq-injected client (like + // ValidateCAConnectionInfo), so these tests use a real WireMock server as ApiUrl. + // --------------------------------------------------------------------------- + + private static void StubV2TokenAndAuthMe(WireMockServer server) + { + server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + } + + private static Dictionary BuildV2ConnectionInfo(string apiUrl, string defaultProductCode = null) + { + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = apiUrl, + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + if (!string.IsNullOrWhiteSpace(defaultProductCode)) + info["DefaultProductCode"] = defaultProductCode; + return info; + } + + private static EnrollmentProductInfo BuildProductInfo(string productCode) => new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = productCode } + }; + + [Fact] + public async Task ValidateProductInfo_V2_Succeeds_WhenProductCodeInCatalog() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2NestedJson())); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = BuildProductInfo(MockCertificateData.ProfileIdTls); + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().NotThrowAsync(); + + // In V2 mode this must go through the V2 catalog, + // never the V1-only GetProductDetails endpoint. + server.LogEntries.Should().NotContain(e => e.RequestMessage.Path == "/GetProductDetails"); + server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/api/certinext/v2/catalog/products"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenProductCodeNotInCatalog() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2NestedJson())); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = BuildProductInfo("999999"); + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenCatalogEmpty() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2EmptyJson())); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = BuildProductInfo(MockCertificateData.ProfileIdTls); + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + // No soft-accept on an empty/unusable catalog — must match V1's strict behaviour. + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenCatalogReturnsError() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(500) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"secretApiKeyLeak\":\"should-not-appear-in-message\"}")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = BuildProductInfo(MockCertificateData.ProfileIdTls); + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + var ex = await act.Should().ThrowAsync() + .WithMessage("*Unable to validate*"); + ex.Which.Message.Should().NotContain("secretApiKeyLeak"); + } + + // --------------------------------------------------------------------------- + // productTypeID correctness check — catches a code that exists in the + // catalog but means a different product than the one selected, for both the + // explicit-override case and the no-override/fallback case. ValidateProductInfo + // must reject these, not just check catalog-existence. + // --------------------------------------------------------------------------- + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenProductCodeExistsButProductTypeIdMismatchesSelectedProduct() + { + // Template selects "OV SSL" (expects productTypeID "16") but overrides ProductCode to + // "842", which the live catalog (stubbed here) resolves to productTypeID "13" = DV + // SSL. The code exists — a pure existence check would pass this — but it means a + // different product than the one selected. + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary { ["ProductCode"] = "842" } + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowAsync() + .WithMessage("*does not correspond to the selected product*"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Succeeds_WhenNoExplicitProductCode_ResolvesByProductTypeId() + { + // No ProductCode/ProfileId override configured — must validate via the live catalog's + // productTypeID for the selected ProductId, not via Constants.Products.DefaultProductCodes + // (V1-only; wrong numbering for V2). + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""846"",""productName"":""OV SSL Certificate"",""productTypeID"":""16""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenNoExplicitProductCode_AndCatalogHasNoMatchingProductTypeId() + { + // No override configured, and the live catalog has no entry with the productTypeID + // expected for EV SSL ("19") — must fail loudly rather than silently pass. + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.EvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowAsync() + .WithMessage("*Could not find a CERTInext V2 catalog entry*"); + } + + // --------------------------------------------------------------------------- + // Product-selection ambiguity (e.g. two type-13 DV SSL entries, + // "917 SSL DV 1 month" listed before "842 DV SSL Certificate"). No explicit + // ProductCode + multiple catalog entries sharing the expected productTypeID must not + // silently pick the first match — only resolve via the connector's DefaultProductCode, + // or reject naming the candidates. Mirrors EnrollV2Async's own handling. + // --------------------------------------------------------------------------- + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenMultipleCatalogEntriesShareProductTypeId_AndNoDefaultProductCode() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""917"",""productName"":""SSL DV 1 month"",""productTypeID"":""13""}," + + @"{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); // no DefaultProductCode configured + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + var ex = await act.Should().ThrowAsync() + .WithMessage("*Multiple CERTInext catalog products match*"); + ex.Which.Message.Should().Contain("917").And.Contain("842"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Succeeds_WhenMultipleCatalogEntriesShareProductTypeId_AndDefaultProductCodeMatchesOne() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""917"",""productName"":""SSL DV 1 month"",""productTypeID"":""13""}," + + @"{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0], defaultProductCode: "842"); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenMultipleCatalogEntriesShareProductTypeId_AndDefaultProductCodeIsWrongType() + { + // DefaultProductCode is configured, but it names a product of a DIFFERENT + // productTypeID than the one being resolved — must not be treated as a match. + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""917"",""productName"":""SSL DV 1 month"",""productTypeID"":""13""}," + + @"{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}," + + @"{""productCode"":""846"",""productName"":""OV SSL Certificate"",""productTypeID"":""16""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0], defaultProductCode: "846"); // OV, not DV + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowAsync() + .WithMessage("*Multiple CERTInext catalog products match*"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Succeeds_WhenExactlyOneCatalogEntryMatchesProductTypeId() + { + // Single match for the productTypeID — must resolve automatically. + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().NotThrowAsync(); + } + + // --------------------------------------------------------------------------- + // Bodyless-REVOKED guard. A V2 REVOKED record with no certificate + // body must never reach the gateway buffer / be returned as-is unless + // ICertificateDataReader.GetExpirationDateByRequestId confirms the gateway + // already holds a body for that CARequestID (DecideBodylessRevokedRecord). + // --------------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_RevokedNoBody_GatewayHoldsBody_EmitsBodylessRevoked() + { + var mock = new Mock(); + var row = ReportRow("ord_bodyless_a", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_bodyless_a")) + .Returns(DateTime.UtcNow.AddDays(45)); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle( + "the gateway already holds a body for this order, so the revoke must " + + "still propagate as a bodyless REVOKED record") + .Which.Status.Should().Be((int)EndEntityStatus.REVOKED); + records[0].Certificate.Should().BeNullOrEmpty(); + + readerMock.Verify(r => r.GetExpirationDateByRequestId("ord_bodyless_a"), Times.Once); + } + + [Fact] + public async Task Synchronize_RevokedNoBody_GatewayRowHasNoBody_EmitsFailedNotRevoked() + { + var mock = new Mock(); + var row = ReportRow("ord_bodyless_b", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_bodyless_b")) + .Returns((DateTime?)null); // row exists, but the gateway holds no body + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle( + "a gateway row with no body must be downgraded to FAILED, never left as a " + + "bodyless REVOKED record") + .Which.Status.Should().Be((int)EndEntityStatus.FAILED); + records[0].Certificate.Should().BeNullOrEmpty(); + records[0].RevocationDate.Should().BeNull(); + records[0].RevocationReason.Should().Be(0); + } + + [Fact] + public async Task Synchronize_RevokedNoBody_NoGatewayRow_SkipsRecordEntirely() + { + var mock = new Mock(); + var row = ReportRow("ord_bodyless_c", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_bodyless_c")) + .Throws(new ArgumentException("No certificate/CA request exists for the specified request ID.")); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().BeEmpty( + "the gateway has never seen this order — creating a bodyless REVOKED row would " + + "poison it (RevocationDate is never cleared by the gateway)"); + } + + [Fact] + public async Task Synchronize_RevokedNoBody_ReaderThrowsUnexpectedException_SkipsRecord() + { + var mock = new Mock(); + var row = ReportRow("ord_bodyless_d", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_bodyless_d")) + .Throws(new InvalidOperationException("gateway database unavailable")); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().BeEmpty( + "an unexpected reader failure must not risk emitting a bodyless REVOKED record — " + + "the revoke is only delayed to a later sync"); + } + + [Fact] + public async Task Synchronize_RevokedNoBody_NoCertificateDataReaderInjected_SkipsRecord() + { + var mock = new Mock(); + var row = ReportRow("ord_bodyless_e", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + // No certDataReader supplied — BuildV2Plugin defaults it to null. + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().BeEmpty( + "with no reader available to consult, the plugin must not risk creating a " + + "poisoned gateway row"); + } + + [Fact] + public async Task Synchronize_BodylessRevokedGuard_GeneratedRow_NeverConsultsReader() + { + var mock = new Mock(); + var row = ReportRow("ord_bodyless_f", "Order Fulfilled", "Certificate Downloaded"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_bodyless_f", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_bodyless_f", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + // Strict with no setups — any call at all fails the test. Confirms the bodyless- + // REVOKED guard is scoped to REVOKED-with-no-body and never touches the GENERATED path. + var readerMock = new Mock(MockBehavior.Strict); + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle() + .Which.Status.Should().Be((int)EndEntityStatus.GENERATED); + records[0].Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + + readerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Synchronize_BodylessRevokedGuard_MixedBatch_NeverEmitsBodylessRevokedWithoutReaderConfirmation() + { + var mock = new Mock(); + var rowHoldsBody = ReportRow("ord_mix_holds", "Revoked", "Certificate Revoked"); + var rowNoBody = ReportRow("ord_mix_nobody", "Revoked", "Certificate Revoked"); + var rowNoRow = ReportRow("ord_mix_norow", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(rowHoldsBody, rowNoBody, rowNoRow)); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_mix_holds")) + .Returns(DateTime.UtcNow.AddDays(60)); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_mix_nobody")) + .Returns((DateTime?)null); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_mix_norow")) + .Throws(new ArgumentException("no such request id")); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + + // Core invariant: no record with Status=REVOKED and no certificate + // body may reach the gateway buffer unless the reader confirmed the gateway + // already holds a body for that specific CARequestID. + records.Should().NotContain(r => + r.Status == (int)EndEntityStatus.REVOKED && string.IsNullOrEmpty(r.Certificate) + && r.CARequestID != "ord_mix_holds"); + + records.Should().ContainSingle(r => r.CARequestID == "ord_mix_holds") + .Which.Status.Should().Be((int)EndEntityStatus.REVOKED); + records.Should().ContainSingle(r => r.CARequestID == "ord_mix_nobody") + .Which.Status.Should().Be((int)EndEntityStatus.FAILED); + records.Should().NotContain(r => r.CARequestID == "ord_mix_norow"); + } + + [Fact] + public async Task GetSingleRecord_RevokedNoBody_GatewayHoldsBody_ReturnsRevoked() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "revoked", + ProductVariant = "dv" + })); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.V2OrderId1)) + .Returns(DateTime.UtcNow.AddDays(10)); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Status.Should().Be((int)EndEntityStatus.REVOKED); + record.Certificate.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task GetSingleRecord_RevokedNoBody_GatewayRowHasNoBody_ReturnsFailed() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "revoked", + ProductVariant = "dv", + Revocation = new V2RevocationDetails + { + Status = "Certificate Revoked", + Reason = "cessation-of-operation", + ProcessedAt = DateTime.UtcNow + } + })); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.V2OrderId1)) + .Returns((DateTime?)null); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Status.Should().Be((int)EndEntityStatus.FAILED); + record.Certificate.Should().BeNullOrEmpty(); + record.RevocationDate.Should().BeNull(); + record.RevocationReason.Should().Be(0); + } + + [Fact] + public async Task GetSingleRecord_RevokedNoBody_NoGatewayRow_ReturnsFailed() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "revoked" + })); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.V2OrderId1)) + .Throws(new ArgumentException("no such request id")); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Should().NotBeNull( + "GetSingleRecord cannot skip — it must return something even when the gateway " + + "has no row for this order"); + record.Status.Should().Be((int)EndEntityStatus.FAILED); + record.Certificate.Should().BeNullOrEmpty(); + record.RevocationDate.Should().BeNull(); + } + + [Fact] + public async Task GetSingleRecord_RevokedNoBody_NoCertificateDataReaderInjected_ReturnsFailed() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "revoked" + })); + + // No certDataReader supplied — BuildV2Plugin defaults it to null. + var plugin = BuildV2Plugin(mock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Status.Should().Be((int)EndEntityStatus.FAILED); + } + + [Fact] + public async Task GetSingleRecord_BodylessRevokedGuard_GeneratedRecord_NeverConsultsReader() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued", + ProductVariant = "dv" + })); + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = MockCertificateData.V2OrderId1, + CertificatePem = MockCertificateData.FakePemCertificate + }); + + // Strict with no setups — confirms the bodyless-revoked guard never touches GENERATED. + var readerMock = new Mock(MockBehavior.Strict); + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Status.Should().Be((int)EndEntityStatus.GENERATED); + readerMock.VerifyNoOtherCalls(); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static async IAsyncEnumerable AsyncEnumerable(params T[] items) + { + foreach (var item in items) + yield return item; + await Task.CompletedTask; + } + } +} diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs index 4e59495..6bb281e 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 + // 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.Tests/CERTInextClientTests.cs b/CERTInext.Tests/CERTInextClientTests.cs index e473e89..3065a85 100644 --- a/CERTInext.Tests/CERTInextClientTests.cs +++ b/CERTInext.Tests/CERTInextClientTests.cs @@ -790,6 +790,42 @@ await act.Should().ThrowAsync() .WithMessage("*GetDcv failed*"); } + /// + /// 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. ExecuteWithRetryAsync + /// must surface that as OperationCanceledException rather than passing the response to + /// DeserializeOrThrow, which would wrap it in a plain Exception — indistinguishable from + /// a genuine API failure. A caller such as PerformDcvIfNeededAsync's per-domain + /// "catch (OperationCanceledException) { throw; }" guard (which stops a DCV timeout from + /// being mislabeled as an ordinary per-domain failure) depends on seeing the real + /// cancellation — 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/CERTInextClientV2Tests.cs b/CERTInext.Tests/CERTInextClientV2Tests.cs new file mode 100644 index 0000000..f6ae919 --- /dev/null +++ b/CERTInext.Tests/CERTInextClientV2Tests.cs @@ -0,0 +1,1605 @@ +// 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.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// WireMock-based tests for V2 REST API methods on . + /// A real WireMockServer handles the V2 token endpoint and all V2 REST paths so + /// serialisation, routing, and token caching are fully exercised. + /// + public class CERTInextClientV2Tests : IDisposable + { + private readonly WireMockServer _server; + private readonly string _baseUrl; + + public CERTInextClientV2Tests() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Urls[0]; + } + + public void Dispose() => _server.Stop(); + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private CERTInextClient BuildV2Client(string groupNumber = null) => + new CERTInextClient(new CERTInextConfig + { + // A single ApiUrl serves both V1 and V2. + ApiUrl = _baseUrl, + AuthMode = "AccessKey", + ApiKey = "test-v1-key", + AccountNumber = "12345", + UseV2Api = true, + OAuthClientId = "my-v2-client", + OAuthClientSecret = "my-v2-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + PageSize = 100, + // Unset by default (matches CERTInextConfig.GroupNumber's own default of + // string.Empty) — individual test cases override this explicitly. + GroupNumber = groupNumber ?? string.Empty + }); + + private void StubV2Token(int expiresIn = 3600) + { + _server + .Given(Request.Create() + .WithPath("/oauth/token") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson(expiresIn))); + } + + // --------------------------------------------------------------------------- + // Token fetch + // --------------------------------------------------------------------------- + + [Fact] + public async Task PingV2Async_FetchesTokenAndCallsAuthMe() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/auth/me") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + using var client = BuildV2Client(); + await client.PingV2Async(); + + // Verify both token and auth/me endpoints were called + _server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/oauth/token"); + _server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/api/certinext/v2/auth/me"); + } + + [Fact] + public async Task GetAuthMeV2Async_ReturnsAccountNumber() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/auth/me") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson("99887766"))); + + using var client = BuildV2Client(); + var result = await client.GetAuthMeV2Async(); + + result.AccountNumber.Should().Be("99887766"); + result.AuthType.Should().Be("oauth2"); + } + + [Fact] + public async Task PingV2Async_TokenCached_OnlyOneFetch() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/auth/me") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + using var client = BuildV2Client(); + await client.PingV2Async(); + await client.PingV2Async(); // second call — should reuse cached token + + var tokenCalls = 0; + foreach (var entry in _server.LogEntries) + if (entry.RequestMessage.Path == "/oauth/token") tokenCalls++; + + tokenCalls.Should().Be(1, "token should be cached after the first fetch"); + } + + // --------------------------------------------------------------------------- + // PlaceOrderV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task PlaceOrderV2Async_ReturnsPendingOrder() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/ssl-certificates") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, + "842", + new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "Test", Email = "t@t.com", Phone = "555", Designation = "IT" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + Subscription = new V2SubscriptionParams { ValidityYears = 1 }, + Agreement = new V2AgreementParams { SignerName = "Test", SignerIp = "1.2.3.4", SignerPlace = "NY", Accepted = true } + }); + + result.OrderId.Should().Be(MockCertificateData.V2OrderId1); + result.Status.Should().Be("pending-dcv"); + } + + [Fact] + public async Task PlaceOrderV2Async_SetsProductCodeHeader() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/ssl-certificates") + .UsingPost() + .WithHeader("X-Product-Code", "842")) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson())); + + using var client = BuildV2Client(); + var result = await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, "842", + new V2CreateSslOrderRequest + { + Requestor = new V2Requestor { Name = "T", Email = "t@t.com", Phone = "1", Designation = "IT" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + Subscription = new V2SubscriptionParams(), + Agreement = new V2AgreementParams { SignerName = "T", SignerIp = "1.1.1.1", SignerPlace = "NY", Accepted = true } + }); + + result.Should().NotBeNull(); + } + + // --------------------------------------------------------------------------- + // A null/blank product code must omit X-Product-Code + // entirely (spec: "Optional override" on SSL create — an empty override value + // is not itself valid) rather than sending the header empty. + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task PlaceOrderV2Async_Ssl_NullOrBlankProductCode_OmitsProductCodeHeader(string productCode) + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/ssl-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson())); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, productCode, + new V2CreateSslOrderRequest + { + Requestor = new V2Requestor { Name = "T", Email = "t@t.com", Phone = "1", Designation = "IT" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + Subscription = new V2SubscriptionParams(), + Agreement = new V2AgreementParams { SignerName = "T", SignerIp = "1.1.1.1", SignerPlace = "NY", Accepted = true } + }); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates"); + entry.RequestMessage.Headers.Should().NotContainKey("X-Product-Code", + "a null/blank product code is not a valid header override and must be omitted entirely"); + } + + [Fact] + public async Task PlaceOrderV2Async_Ssl_NonBlankProductCode_StillSendsHeader() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/ssl-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson())); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, "842", + new V2CreateSslOrderRequest + { + Requestor = new V2Requestor { Name = "T", Email = "t@t.com", Phone = "1", Designation = "IT" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + Subscription = new V2SubscriptionParams(), + Agreement = new V2AgreementParams { SignerName = "T", SignerIp = "1.1.1.1", SignerPlace = "NY", Accepted = true } + }); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates"); + entry.RequestMessage.Headers.Should().ContainKey("X-Product-Code") + .WhoseValue.Should().Contain("842"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task PlaceOrderV2Async_PrivatePki_NullOrBlankProductCode_OmitsProductCodeHeader(string productCode) + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/private-pki-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"orderId\":\"ord_pki_002\",\"requestId\":\"req_3\",\"status\":\"pending-csr\"," + + "\"variant\":\"intranet-ssl\",\"hostname\":\"intranet.example.com\"}")); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async(productCode, new V2CreatePrivatePkiOrderRequest + { + Variant = "intranet-ssl", + Hostname = "intranet.example.com", + Requestor = new V2Requestor { Name = "DevOps", Email = "devops@example.com" }, + Subscription = new V2SubscriptionParams { ValidityYears = 1 } + }); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/private-pki-certificates"); + entry.RequestMessage.Headers.Should().NotContainKey("X-Product-Code"); + } + + [Fact] + public async Task PlaceOrderV2Async_Signature_NullProductCode_OmitsProductCodeHeader() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/signature-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"orderId\":\"ord_sig_002\",\"requestId\":\"req_4\",\"status\":\"pending-documents\"," + + "\"subjectType\":\"natural-person\",\"subjectDisplayName\":\"Test Person\"}")); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async(null, new V2CreateSignatureOrderRequest + { + SubjectType = "natural-person", + Requestor = new V2Requestor { Name = "Test Person", Email = "test.person@example.com" }, + Subject = new V2SignatureSubject { FirstName = "Test", LastName = "Person", Email = "test.person@example.com" } + }); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/signature-certificates"); + entry.RequestMessage.Headers.Should().NotContainKey("X-Product-Code"); + } + + // --------------------------------------------------------------------------- + // V2CertificateParams.AdditionalDomains wire serialization + // --------------------------------------------------------------------------- + + [Fact] + public async Task PlaceOrderV2Async_UccOrder_IncludesAdditionalDomainsInWireBody() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/ssl-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson())); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, "844", + new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "T", Email = "t@t.com", Phone = "1", Designation = "IT" }, + Certificate = new V2CertificateParams + { + Domain = "example.com", + AdditionalDomains = new List { "san1.example.com", "san2.example.com" } + }, + Subscription = new V2SubscriptionParams(), + Agreement = new V2AgreementParams { SignerName = "T", SignerIp = "1.1.1.1", SignerPlace = "NY", Accepted = true } + }); + + string requestBody = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates") + .RequestMessage.Body; + requestBody.Should().Contain("\"additionalDomains\""); + requestBody.Should().Contain("san1.example.com"); + requestBody.Should().Contain("san2.example.com"); + } + + [Fact] + public async Task PlaceOrderV2Async_SingleDomainOrder_OmitsAdditionalDomainsFromWireBody() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/ssl-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson())); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, "842", + new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "T", Email = "t@t.com", Phone = "1", Designation = "IT" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, // AdditionalDomains left null + Subscription = new V2SubscriptionParams(), + Agreement = new V2AgreementParams { SignerName = "T", SignerIp = "1.1.1.1", SignerPlace = "NY", Accepted = true } + }); + + string requestBody = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates") + .RequestMessage.Body; + requestBody.Should().NotContain("additionalDomains", + "single-domain orders must not send additionalDomains at all"); + } + + // --------------------------------------------------------------------------- + // Family-specific PlaceOrderV2Async overloads + // --------------------------------------------------------------------------- + + [Fact] + public async Task PlaceOrderV2Async_PrivatePki_PostsPrivatePkiBodyToPrivatePkiPath_WithProductCodeHeader() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/private-pki-certificates") + .UsingPost() + .WithHeader("X-Product-Code", "149")) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"orderId\":\"ord_pki_001\",\"requestId\":\"req_1\",\"status\":\"pending-csr\"," + + "\"variant\":\"intranet-ssl\",\"hostname\":\"intranet.acme.local\",\"resolvedProductCode\":\"149\"}")); + + using var client = BuildV2Client(); + var result = await client.PlaceOrderV2Async("149", new V2CreatePrivatePkiOrderRequest + { + Variant = "intranet-ssl", + Hostname = "intranet.acme.local", + AdditionalHosts = new List { "portal.acme.local", "10.0.0.50" }, + Requestor = new V2Requestor { Name = "DevOps Team", Email = "devops@acme.com" }, + Subscription = new V2SubscriptionParams { ValidityYears = 1 } + }); + + result.OrderId.Should().Be("ord_pki_001"); + result.Status.Should().Be("pending-csr"); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/private-pki-certificates"); + entry.RequestMessage.Headers.Should().ContainKey("Idempotency-Key"); + entry.RequestMessage.Body.Should().NotBeNullOrEmpty(); + using var body = System.Text.Json.JsonDocument.Parse(entry.RequestMessage.Body ?? string.Empty); + body.RootElement.GetProperty("variant").GetString().Should().Be("intranet-ssl"); + body.RootElement.GetProperty("hostname").GetString().Should().Be("intranet.acme.local"); + body.RootElement.GetProperty("additionalHosts").EnumerateArray().Select(e => e.GetString()) + .Should().Equal("portal.acme.local", "10.0.0.50"); + body.RootElement.TryGetProperty("productVariant", out _).Should().BeFalse(); + body.RootElement.TryGetProperty("certificate", out _).Should().BeFalse(); + body.RootElement.TryGetProperty("agreement", out _).Should().BeFalse(); + _server.LogEntries.Should().NotContain(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates"); + } + + [Fact] + public async Task PlaceOrderV2Async_Signature_PostsSignatureBodyToSignaturePath_WithProductCodeHeader() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/signature-certificates") + .UsingPost() + .WithHeader("X-Product-Code", "819")) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"orderId\":\"ord_sig_001\",\"requestId\":\"req_2\",\"status\":\"pending-documents\"," + + "\"subjectType\":\"natural-person\",\"subjectDisplayName\":\"Sarah Johnson\",\"resolvedProductCode\":\"819\"}")); + + using var client = BuildV2Client(); + var result = await client.PlaceOrderV2Async("819", new V2CreateSignatureOrderRequest + { + SubjectType = "natural-person", + Requestor = new V2Requestor { Name = "Sarah Johnson", Email = "sarah.johnson@example.com" }, + Subject = new V2SignatureSubject { FirstName = "Sarah", LastName = "Johnson", Email = "sarah.johnson@example.com" } + }); + + result.OrderId.Should().Be("ord_sig_001"); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/signature-certificates"); + entry.RequestMessage.Body.Should().NotBeNullOrEmpty(); + using var body = System.Text.Json.JsonDocument.Parse(entry.RequestMessage.Body ?? string.Empty); + body.RootElement.GetProperty("subjectType").GetString().Should().Be("natural-person"); + body.RootElement.GetProperty("subject").GetProperty("email").GetString().Should().Be("sarah.johnson@example.com"); + } + + [Theory] + [InlineData(Constants.ApiV2.FamilyPrivatePki)] + [InlineData(Constants.ApiV2.FamilySignature)] + public async Task PlaceOrderV2Async_SslBody_ToNonSslFamily_Throws_AndSendsNothing(string family) + { + // The SSL overload must not substitute any slug into the URL unchecked, + // which would let a private-pki/signature template send the SSL body to the wrong family. + StubV2Token(); + + using var client = BuildV2Client(); + Func act = () => client.PlaceOrderV2Async( + family, "149", + new V2CreateSslOrderRequest + { + Requestor = new V2Requestor { Name = "T", Email = "t@t.com" }, + Certificate = new V2CertificateParams { Domain = "example.com" } + }); + + await act.Should().ThrowAsync().WithMessage($"*{family}*"); + _server.LogEntries.Should().NotContain(e => e.RequestMessage.Path.StartsWith("/api/certinext/v2/"), + "no order request may be sent when the SSL body is aimed at another family"); + } + + // --------------------------------------------------------------------------- + // TrackOrderV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task TrackOrderV2Async_Issued_ReturnsIssuedStatus() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TrackOrderIssuedJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + result.Status.Should().Be("issued"); + result.OrderId.Should().Be(MockCertificateData.V2OrderId1); + } + + [Fact] + public async Task TrackOrderV2Async_NotFound_ThrowsKeyNotFoundException() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/nonexistent") + .UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + using var client = BuildV2Client(); + await Assert.ThrowsAsync( + () => client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, "nonexistent")); + } + + // --------------------------------------------------------------------------- + // TrackOrderV2Async — nested `revocation` object + // --------------------------------------------------------------------------- + + [Fact] + public async Task TrackOrderV2Async_Revoked_DeserializesNestedRevocationObject() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TrackOrderRevokedJson( + MockCertificateData.V2OrderId1, + reason: "cessation-of-operation", + processedAt: "2026-09-24T20:44:41Z"))); + + using var client = BuildV2Client(); + var result = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + result.Status.Should().Be("revoked"); + // `revocation` is a nested object — not flat top-level + // revocationReason/revocationDate properties. + result.Revocation.Should().NotBeNull(); + result.Revocation!.Status.Should().Be("Certificate Revoked"); + result.Revocation.Reason.Should().Be("cessation-of-operation"); + result.Revocation.ProcessedAt.Should().Be( + new DateTime(2026, 9, 24, 20, 44, 41, DateTimeKind.Utc)); + } + + [Fact] + public async Task TrackOrderV2Async_NotRevoked_RevocationIsNull() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TrackOrderIssuedJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + // The `revocation` key is absent entirely (not present-but-null) on + // an order that has never been revoked. + result.Revocation.Should().BeNull(); + } + + // --------------------------------------------------------------------------- + // DownloadCertificateV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task DownloadCertificateV2Async_ReturnsPem() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/certificate") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CertificateDownloadJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.DownloadCertificateV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + result.CertificatePem.Should().StartWith("-----BEGIN CERTIFICATE-----"); + result.SerialNumber.Should().Be("0A1B2C3D4E5F"); + result.OrderId.Should().Be(MockCertificateData.V2OrderId1); + } + + // --------------------------------------------------------------------------- + // RevokeOrderV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeOrderV2Async_SuccessfulRevoke() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/revoke") + .UsingPost()) + .RespondWith(Response.Create().WithStatusCode(204)); + + using var client = BuildV2Client(); + // Should not throw + await client.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, + MockCertificateData.V2OrderId1, + new V2RevokeRequest { Reason = "superseded", Note = "Replaced." }); + } + + [Fact] + public async Task RevokeOrderV2Async_422_ThrowsInvalidOperationException() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/revoke") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(422) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson(422, "Unprocessable Entity", "Order not in issued state", "EMS-931"))); + + using var client = BuildV2Client(); + await Assert.ThrowsAsync( + () => client.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, + MockCertificateData.V2OrderId1, + new V2RevokeRequest { Reason = "superseded" })); + } + + // --------------------------------------------------------------------------- + // The 422 message must reflect the CA's actual detail + // rather than presuming "order not in issued state" for every 422. + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeOrderV2Async_422_LabelsByActualDetail_NotHardcodedIssuedStateMessage() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/revoke") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(422) + .WithHeader("Content-Type", "application/problem+json") + // Sandbox behavior for a revoke attempted while the + // order is still internally finalizing — no EMS code in this detail. + .WithBody(MockCertificateData.V2ProblemDetailsJson( + 422, "Unprocessable Entity", "Certificate Request still being processed"))); + + using var client = BuildV2Client(); + var ex = await Assert.ThrowsAsync( + () => client.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, + MockCertificateData.V2OrderId1, + new V2RevokeRequest { Reason = "superseded" })); + + ex.Message.Should().Contain("still being processed"); + ex.Message.Should().NotContain("not in issued state", + "the message must reflect the CA's actual detail text, not a hardcoded assumption"); + } + + [Fact] + public async Task RevokeOrderV2Async_422_DistinctEmsCode_LabelsByThatCode() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/revoke") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(422) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson( + 422, "Unprocessable Entity", "EMS-969 Revoke reason ID missing"))); + + using var client = BuildV2Client(); + var ex = await Assert.ThrowsAsync( + () => client.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, + MockCertificateData.V2OrderId1, + new V2RevokeRequest { Reason = "superseded" })); + + ex.Message.Should().Contain("EMS-969"); + ex.Message.Should().NotContain("not in issued state"); + } + + [Fact] + public async Task RevokeOrderV2Async_404_ThrowsNotFoundOrNotRevokable_NotGenericNotFound() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/revoke") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(404) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson( + 404, "Not Found", "Order not found or not in a revokable state."))); + + using var client = BuildV2Client(); + var ex = await Assert.ThrowsAsync( + () => client.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, + MockCertificateData.V2OrderId1, + new V2RevokeRequest { Reason = "superseded" })); + + ex.Message.Should().Contain("not found or not in a revokable state"); + } + + // --------------------------------------------------------------------------- + // Product-family resolution + // --------------------------------------------------------------------------- + + [Fact] + public async Task ResolveAndTrackOrderV2Async_FindsOrderInSslFamily() + { + StubV2Token(); + // SSL family finds the order + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TrackOrderIssuedJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.ResolveAndTrackOrderV2Async(MockCertificateData.V2OrderId1); + + result.OrderId.Should().Be(MockCertificateData.V2OrderId1); + result.Status.Should().Be("issued"); + } + + [Fact] + public async Task ResolveAndTrackOrderV2Async_FindsOrderInPrivatePkiFamily() + { + StubV2Token(); + // SSL → 404, private-pki → 200 + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId2}") + .UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/private-pki-certificates/{MockCertificateData.V2OrderId2}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TrackOrderIssuedJson(MockCertificateData.V2OrderId2))); + + using var client = BuildV2Client(); + var result = await client.ResolveAndTrackOrderV2Async(MockCertificateData.V2OrderId2); + + result.OrderId.Should().Be(MockCertificateData.V2OrderId2); + } + + [Fact] + public async Task ResolveAndTrackOrderV2Async_NotInAnyFamily_ThrowsKeyNotFoundException() + { + StubV2Token(); + foreach (var family in new[] { "ssl-certificates", "private-pki-certificates", "signature-certificates" }) + { + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/{family}/ord_missing") + .UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + } + + using var client = BuildV2Client(); + await Assert.ThrowsAsync( + () => client.ResolveAndTrackOrderV2Async("ord_missing")); + } + + // --------------------------------------------------------------------------- + // GetDcvV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetDcvV2Async_ReturnsChallengeWithToken() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2DcvChallengeJson("my-dcv-token"))); + + using var client = BuildV2Client(); + var result = await client.GetDcvV2Async(MockCertificateData.V2OrderId1, Constants.ApiV2.FamilySsl); + + result.Token.Should().Be("my-dcv-token"); + result.TokenExpiryDate.Should().Be("2026-12-31 23:59:59"); + } + + /// + /// The live GetDcv response on a fresh-domain order comes + /// back as exactly {"tokenExpiryDate":"...","token":"..."} — a shape that + /// matches neither the spec's worked example (orderNumber/domainName/ + /// dcvMethod/fileNameContent) nor + /// the spec's prose (method/txtToken). Deserializing against the wrong + /// shape leaves FileNameContent null (unmapped JSON properties are silently + /// ignored), which drives the plugin's null-token guard and strands the order at + /// EXTERNALVALIDATION forever. This pins the real field name (token) against + /// the exact live body, verbatim. + /// + [Fact] + public async Task GetDcvV2Async_LiveShape_DeserializesTokenField_NotFileNameContent() + { + StubV2Token(); + const string liveBody = @"{""tokenExpiryDate"":""2026-09-27 15:27:00"",""token"":""D6026954B9EB7D31E3FE8B2194F07087""}"; + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(liveBody)); + + using var client = BuildV2Client(); + var result = await client.GetDcvV2Async(MockCertificateData.V2OrderId1, Constants.ApiV2.FamilySsl); + + result.Should().NotBeNull(); + result.Token.Should().Be("D6026954B9EB7D31E3FE8B2194F07087", + "the live wire field is 'token', not 'fileNameContent'"); + result.TokenExpiryDate.Should().Be("2026-09-27 15:27:00"); + } + + [Fact] + public async Task GetDcvV2Async_NonSuccess_Throws() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(400) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson(400, "Bad Request", "Order not found"))); + + using var client = BuildV2Client(); + await Assert.ThrowsAsync( + () => client.GetDcvV2Async(MockCertificateData.V2OrderId1, Constants.ApiV2.FamilySsl)); + } + + // --------------------------------------------------------------------------- + // VerifyDcvV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task VerifyDcvV2Async_200Ok_ReturnsVerified() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv/verify") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2DcvVerifySuccessJson())); + + using var client = BuildV2Client(); + var result = await client.VerifyDcvV2Async(MockCertificateData.V2OrderId1, "example.com", Constants.ApiV2.FamilySsl); + + result.OverallStatus.Should().Be("VERIFIED"); + } + + [Fact] + public async Task VerifyDcvV2Async_204NoContent_ReturnsVerified() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv/verify") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(204)); + + using var client = BuildV2Client(); + var result = await client.VerifyDcvV2Async(MockCertificateData.V2OrderId1, "example.com", Constants.ApiV2.FamilySsl); + + result.OverallStatus.Should().Be("VERIFIED"); + } + + [Fact] + public async Task VerifyDcvV2Async_422_ThrowsInvalidOperationException() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv/verify") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(422) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson(422, "Unprocessable Entity", "DNS record not found"))); + + using var client = BuildV2Client(); + var ex = await Assert.ThrowsAsync( + () => client.VerifyDcvV2Async(MockCertificateData.V2OrderId1, "example.com", Constants.ApiV2.FamilySsl)); + + ex.Message.Should().Contain("DCV verification failed"); + } + + [Fact] + public async Task VerifyDcvV2Async_SendsDnsTxtMethod() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv/verify") + .UsingPost() + .WithBody(b => b != null && b.Contains("\"dns-txt\""))) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2DcvVerifySuccessJson())); + + using var client = BuildV2Client(); + var result = await client.VerifyDcvV2Async(MockCertificateData.V2OrderId1, "example.com", Constants.ApiV2.FamilySsl); + + result.OverallStatus.Should().Be("VERIFIED"); + } + + // --------------------------------------------------------------------------- + // DownloadCertificateV2Async — chain PEM assembly + // --------------------------------------------------------------------------- + + [Fact] + public async Task DownloadCertificateV2Async_WithChainPem_DeserializesChain() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/certificate") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CertificateDownloadWithChainJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.DownloadCertificateV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + result.CertificatePem.Should().StartWith("-----BEGIN CERTIFICATE-----"); + result.ChainPem.Should().NotBeNullOrEmpty("API returned a chainPem array"); + result.ChainPem.Should().HaveCount(1); + result.ChainPem[0].Should().Contain("INTERMEDIATE"); + } + + [Fact] + public async Task DownloadCertificateV2Async_WithoutChainPem_ChainIsNull() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/certificate") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CertificateDownloadJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.DownloadCertificateV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + result.CertificatePem.Should().StartWith("-----BEGIN CERTIFICATE-----"); + result.ChainPem.Should().BeNullOrEmpty("API did not return chainPem"); + } + + // --------------------------------------------------------------------------- + // Token refresh when expired + // --------------------------------------------------------------------------- + + [Fact] + public async Task Token_CachedAndReused_WhenNotNearExpiry() + { + // Restates PingV2Async_TokenCached_OnlyOneFetch's proof via GetAuthMeV2Async — kept + // as its own case (G11) so cache-reuse and expiry-refetch (below) are each one + // single-purpose test rather than folded into one. + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/auth/me") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + using var client = BuildV2Client(); + await client.GetAuthMeV2Async(); + await client.GetAuthMeV2Async(); + + TokenFetchCount(_server).Should().Be(1, "a non-expired cached token must be reused"); + } + + /// + /// G11: a cached token past its early-expiry window must be re-fetched via a fresh + /// client_credentials call — never via refresh_token. Per the V2 spec, + /// refresh tokens are single-use and refreshing invalidates the current access token, so + /// this locks in the client's current (safe) behaviour of only ever using + /// client_credentials. + /// + /// The real cache TTL floors at 30 seconds (Math.Max(expires_in - 60, 30) in + /// GetOrRefreshV2TokenAsync), which is too slow to wait out in a unit test — so this + /// reaches into the private _v2TokenExpiry field via reflection to simulate the + /// passage of time instead of actually waiting. + /// + [Fact] + public async Task Token_RefetchedViaClientCredentials_WhenPastEarlyExpiryWindow() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/auth/me") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + using var client = BuildV2Client(); + await client.GetAuthMeV2Async(); + TokenFetchCount(_server).Should().Be(1); + + // Simulate the cached token having entered/passed its early-expiry window. + SetV2TokenExpiry(client, DateTime.UtcNow.AddSeconds(-1)); + + await client.GetAuthMeV2Async(); + TokenFetchCount(_server).Should().Be(2, + "a token past its early-expiry window must be re-fetched, not reused"); + + // Every /oauth/token call must use client_credentials — never refresh_token, even + // though the stubbed token response includes a refresh_token field. + foreach (var entry in _server.LogEntries.Where(e => e.RequestMessage.Path == "/oauth/token")) + { + string body = entry.RequestMessage.Body ?? string.Empty; + body.Should().Contain("grant_type=client_credentials"); + body.Should().NotContain("grant_type=refresh_token", + "refresh tokens are single-use per the V2 spec — the client must never send this grant proactively"); + } + } + + private static int TokenFetchCount(WireMockServer server) => + server.LogEntries.Count(e => e.RequestMessage.Path == "/oauth/token"); + + private static void SetV2TokenExpiry(CERTInextClient client, DateTime value) + { + var field = typeof(CERTInextClient) + .GetField("_v2TokenExpiry", BindingFlags.NonPublic | BindingFlags.Instance); + field.Should().NotBeNull("test relies on CERTInextClient's private _v2TokenExpiry field existing"); + field!.SetValue(client, value); + } + + // --------------------------------------------------------------------------- + // G2: OAuth2 token failure hints (401 invalid_client vs 403 unauthorized_client) + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetOrRefreshV2Token_Throws_DistinctHint_On401InvalidClient() + { + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(401) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{""error"":""invalid_client"",""error_description"":""Client authentication failed.""}")); + + using var client = BuildV2Client(); + + Func act = () => client.PingV2Async(); + + await act.Should().ThrowAsync() + .WithMessage("*401*") + .Where(ex => ex.Message.Contains("ClientId", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("ClientSecret", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetOrRefreshV2Token_Throws_DistinctHint_On403UnauthorizedClient() + { + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(403) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{""error"":""unauthorized_client"",""error_description"":""Key not generated in OAuth mode.""}")); + + using var client = BuildV2Client(); + + Func act = () => client.PingV2Async(); + + await act.Should().ThrowAsync() + .WithMessage("*403*") + .Where(ex => ex.Message.Contains("OAuth mode", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetOrRefreshV2Token_401And403_ProduceDifferentMessages() + { + // The two hints must actually differ — otherwise the distinction above is cosmetic. + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(401) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{""error"":""invalid_client""}")); + using var client401 = BuildV2Client(); + Exception ex401 = null; + try { await client401.PingV2Async(); } catch (Exception ex) { ex401 = ex; } + + _server.Reset(); + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(403) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{""error"":""unauthorized_client""}")); + using var client403 = BuildV2Client(); + Exception ex403 = null; + try { await client403.PingV2Async(); } catch (Exception ex) { ex403 = ex; } + + ex401.Should().NotBeNull(); + ex403.Should().NotBeNull(); + ex401!.Message.Should().NotBe(ex403!.Message); + } + + [Fact] + public async Task GetOrRefreshV2Token_Throws_WhenTokenResponseLacksAccessToken() + { + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{""token_type"":""Bearer"",""expires_in"":3600}")); + + using var client = BuildV2Client(); + + Func act = () => client.PingV2Async(); + + await act.Should().ThrowAsync() + .WithMessage("*access_token*"); + } + + // --------------------------------------------------------------------------- + // G12: RFC 7807 field-level errors surfaced in the exception message + // --------------------------------------------------------------------------- + + [Fact] + public async Task ThrowOnV2Failure_IncludesFieldLevelErrors_FromProblemJson() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(400) + .WithHeader("Content-Type", "application/problem+json") + .WithBody( + @"{""type"":""https://api.certinext.io/errors/validation""," + + @"""title"":""Bad Request"",""status"":400," + + @"""detail"":""Body malformed""," + + @"""errors"":[{""field"":""certificate.domain"",""message"":""must not be blank""}]}")); + + using var client = BuildV2Client(); + + Func act = () => client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + await act.Should().ThrowAsync() + .WithMessage("*certificate.domain*must not be blank*"); + } + + // --------------------------------------------------------------------------- + // ListOrdersV2Async — V2 /reports/orders page enumeration + // --------------------------------------------------------------------------- + + [Fact] + public async Task ListOrdersV2Async_MultiplePages_EnumeratesAllRowsInOrder() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .WithParam("page", "1") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson( + page: 1, totalPages: 2, orderNumbers: new[] { "ord_p1_001", "ord_p1_002" }))); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .WithParam("page", "2") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson( + page: 2, totalPages: 2, orderNumbers: new[] { "ord_p2_001" }))); + + using var client = BuildV2Client(); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async(pageSize: 2)) + results.Add(row); + + results.Should().HaveCount(3); + results.ConvertAll(r => r.OrderNumber).Should().Equal("ord_p1_001", "ord_p1_002", "ord_p2_001"); + } + + [Fact] + public async Task ListOrdersV2Async_NoRows_ReturnsEmpty() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/reports/orders").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson(page: 1, totalPages: 1, orderNumbers: Array.Empty()))); + + using var client = BuildV2Client(); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async()) + results.Add(row); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ListOrdersV2Async_PassesFromAndToAsQueryParams() + { + StubV2Token(); + // Only matches if from/to were actually sent as query params — if the client + // dropped them, WireMock's default (unmatched) 404 response would make + // ThrowOnV2Failure throw, and the test would fail. + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .WithParam("from", "2026-01-01") + .WithParam("to", "2026-12-31") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson(page: 1, totalPages: 1, orderNumbers: Array.Empty()))); + + using var client = BuildV2Client(); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async(from: "2026-01-01", to: "2026-12-31")) + results.Add(row); + + results.Should().BeEmpty(); + } + + // --------------------------------------------------------------------------- + // ListOrdersV2Async — GroupNumber query param + // --------------------------------------------------------------------------- + + [Fact] + public async Task ListOrdersV2Async_GroupNumberConfigured_PassesGroupNumberQueryParam() + { + StubV2Token(); + // Only matches if groupNumber was actually sent as a query param — if the client + // dropped it, WireMock's default (unmatched) 404 response would make + // ThrowOnV2Failure throw, and the test would fail. + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .WithParam("groupNumber", "GRP-555") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson(page: 1, totalPages: 1, orderNumbers: Array.Empty()))); + + using var client = BuildV2Client(groupNumber: "GRP-555"); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async()) + results.Add(row); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ListOrdersV2Async_GroupNumberBlank_OmitsGroupNumberQueryParam() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson(page: 1, totalPages: 1, orderNumbers: Array.Empty()))); + + using var client = BuildV2Client(groupNumber: string.Empty); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async()) + results.Add(row); + + results.Should().BeEmpty(); + + string rawQuery = _server.LogEntries + .Last(e => e.RequestMessage.Path == "/api/certinext/v2/reports/orders") + .RequestMessage.RawQuery ?? string.Empty; + rawQuery.Should().NotContain("groupNumber", + "an unconfigured GroupNumber must not appear on the orders-report query string"); + } + + [Fact] + public async Task ListOrdersV2Async_PageSizeOver100_ClampedServerRequest() + { + StubV2Token(); + // Only matches size=100 — if the client sent the raw 500 through, this would 404. + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .WithParam("size", "100") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson(page: 1, totalPages: 1, orderNumbers: Array.Empty()))); + + using var client = BuildV2Client(); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async(pageSize: 500)) + results.Add(row); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ListOrdersV2Async_NonSuccessResponse_Throws() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/reports/orders").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(500) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(@"{""title"":""Internal Server Error"",""status"":500,""detail"":""boom""}")); + + using var client = BuildV2Client(); + + Func act = async () => + { + await foreach (var _ in client.ListOrdersV2Async()) { } + }; + + await act.Should().ThrowAsync().WithMessage("*V2 list orders*"); + } + + // --------------------------------------------------------------------------- + // GetProductDetailsV2Async / ParseProductDetailsV2Response + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetProductDetailsV2Async_NestedCategoryEnvelope_FlattensProducts() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2NestedJson())); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().HaveCount(2); + products.Should().ContainSingle(p => p.ProductCode == MockCertificateData.ProfileIdTls + && p.ProductName == "TLS Server" + && p.ProductType == "SSL/TLS Certificates" + && p.ProductTypeId == "13" + && p.Active); + products.Should().ContainSingle(p => p.ProductCode == MockCertificateData.ProfileIdClient); + } + + // --------------------------------------------------------------------------- + // GetProductDetailsV2Async — GroupNumber query param + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetProductDetailsV2Async_GroupNumberConfigured_PassesGroupNumberQueryParam() + { + StubV2Token(); + // Only matches if groupNumber was actually sent as a query param — if the client + // dropped it, WireMock's default (unmatched) 404 response would make + // ThrowOnV2Failure throw, and the test would fail. + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/catalog/products") + .WithParam("groupNumber", "GRP-555") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2NestedJson())); + + using var client = BuildV2Client(groupNumber: "GRP-555"); + List products = await client.GetProductDetailsV2Async(); + + products.Should().HaveCount(2); + } + + [Fact] + public async Task GetProductDetailsV2Async_GroupNumberBlank_OmitsGroupNumberQueryParam() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/catalog/products") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2NestedJson())); + + using var client = BuildV2Client(groupNumber: string.Empty); + List products = await client.GetProductDetailsV2Async(); + + products.Should().HaveCount(2); + + string rawQuery = _server.LogEntries + .Last(e => e.RequestMessage.Path == "/api/certinext/v2/catalog/products") + .RequestMessage.RawQuery ?? string.Empty; + rawQuery.Should().NotContain("groupNumber", + "an unconfigured GroupNumber must not appear on the catalog query string"); + } + + // --------------------------------------------------------------------------- + // ProductTypeId flattening: productTypeID + // must survive every catalog response shape so EnrollV2Async can detect UCC products. + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetProductDetailsV2Async_NestedCategoryEnvelope_UccProductTypeId_Preserved() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{ + ""products"":[ + { ""categoryName"":""SSL/TLS Certificates"", ""categoryID"":""1"", ""currencyType"":""USD"", + ""products"":[ + {""productCode"":""844"",""productName"":""DV SSL Certificate UCC"",""productTypeID"":""15""}, + {""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""} + ] + } + ] +}")); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().ContainSingle(p => p.ProductCode == "844" && p.ProductTypeId == "15", + "UCC product's productTypeID must survive the nested-category flattening"); + products.Should().ContainSingle(p => p.ProductCode == "842" && p.ProductTypeId == "13"); + } + + [Fact] + public async Task GetProductDetailsV2Async_FlatProductIdRow_UccProductTypeId_Preserved() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{ + ""products"":[ + {""productId"":""845"",""productName"":""DV SSL Certificate Wildcard UCC"",""masterProductName"":""DV SSL Certificate Wildcard UCC"",""productTypeID"":""21""} + ] +}")); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().ContainSingle(p => p.ProductCode == "845" && p.ProductTypeId == "21", + "UCC product's productTypeID must survive the flat productId row shape"); + } + + [Fact] + public async Task GetProductDetailsV2Async_FlatProductCodeRow_UccProductTypeId_Preserved() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[ + {""productCode"":""851"",""productName"":""EV SSL Certificate UCC"",""productType"":""SSL/TLS Certificates"",""productTypeID"":""20"",""active"":true} +]")); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().ContainSingle(p => p.ProductCode == "851" && p.ProductTypeId == "20", + "UCC product's productTypeID must survive the flat productCode row shape (direct DTO deserialize)"); + } + + [Fact] + public async Task GetProductDetailsV2Async_FlatProductIdRows_MapsToProductCode() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2FlatJson())); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().HaveCount(2); + products.Should().Contain(p => p.ProductCode == MockCertificateData.ProfileIdTls && p.Active); + } + + [Fact] + public async Task GetProductDetailsV2Async_BareArray_Parses() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2BareArrayJson())); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().ContainSingle(p => p.ProductCode == MockCertificateData.ProfileIdTls); + } + + [Fact] + public async Task GetProductDetailsV2Async_EmptyCatalog_ReturnsEmptyList() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2EmptyJson())); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().BeEmpty(); + } + } +} diff --git a/CERTInext.Tests/EmailNotificationsEnrollmentTests.cs b/CERTInext.Tests/EmailNotificationsEnrollmentTests.cs new file mode 100644 index 0000000..47ba28d --- /dev/null +++ b/CERTInext.Tests/EmailNotificationsEnrollmentTests.cs @@ -0,0 +1,297 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests that the V2 order body's emailNotifications field honors the connector's + /// EmailNotifications config rather than a hardcoded "all". These tests exercise + /// EnrollV2Async end-to-end (through ) against a + /// Strict mock, plus a direct DTO serialization check for + /// , following the pattern established + /// by V2SubscriptionEnrollmentTests.cs. + /// + /// Mapping under test: "1" -> "all", "0" -> "0", + /// blank/whitespace/unset -> null (omitted; CA defaults to "all"), any other value + /// (including the literal "all") fails the enrollment before any CA call. + /// + public class EmailNotificationsEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + // Default here intentionally matches CERTInextConfig's own property default ("0") — NOT + // a `?? "0"`-style fallback applied to the parameter, which would silently coerce an + // explicitly-passed null (a real Theory case below) back to "0" and defeat that test case. + private static CERTInextCAPlugin BuildV2Plugin( + ICERTInextClient client, + string emailNotifications = "0") => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0, + EmailNotifications = emailNotifications + }); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task<(EnrollmentResult Result, V2CreateSslOrderRequest Captured)> RunEnrollAsync( + Mock mock, CERTInextCAPlugin plugin, string orderId = "ord_email_001") + { + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + return (result, captured); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — valid mapped values + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_EmailNotifications_1_MapsToAll() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, "ord_email_001"); + + var plugin = BuildV2Plugin(mock.Object, emailNotifications: "1"); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_email_001"); + + result.CARequestID.Should().Be("ord_email_001"); + captured.Should().NotBeNull(); + captured!.EmailNotifications.Should().Be("all", + "EmailNotifications=\"1\" must map to the CA's full notification set (\"all\")"); + } + + [Fact] + public async Task Enroll_V2_EmailNotifications_0_StaysZero() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_email_002"); + + var plugin = BuildV2Plugin(mock.Object, emailNotifications: "0"); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_email_002"); + + result.CARequestID.Should().Be("ord_email_002"); + captured!.EmailNotifications.Should().Be("0", + "EmailNotifications=\"0\" must be forwarded as the literal \"0\", which " + + "suppresses order-creation emails on V2, the same as V1"); + } + + [Fact] + public async Task Enroll_V2_EmailNotifications_1_IsTrimmedBeforeMapping() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_email_003"); + + var plugin = BuildV2Plugin(mock.Object, emailNotifications: " 1 "); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_email_003"); + + result.CARequestID.Should().Be("ord_email_003"); + captured!.EmailNotifications.Should().Be("all", + "surrounding whitespace must be trimmed before comparing against \"1\"/\"0\""); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — blank/unset omits the field entirely + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_V2_EmailNotifications_Blank_OmitsField(string configValue) + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_email_004"); + + var plugin = BuildV2Plugin(mock.Object, emailNotifications: configValue); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_email_004"); + + result.CARequestID.Should().Be("ord_email_004"); + captured!.EmailNotifications.Should().BeNull( + "a blank/unset EmailNotifications must leave the field null so it is omitted on the " + + "wire and the CA's own default (\"all\") applies"); + } + + [Fact] + public void V2CreateSslOrderRequest_Serialization_OmitsEmailNotifications_WhenNull() + { + var request = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + EmailNotifications = null, + Certificate = new V2CertificateParams { Domain = "example.com" } + }; + + string json = JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + + json.Should().NotContain("emailNotifications", + "the emailNotifications key itself must be absent when unset, not present-but-null, " + + "under the client's actual serializer options"); + } + + [Fact] + public void V2CreateSslOrderRequest_Serialization_IncludesEmailNotifications_WhenSet() + { + var request = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + EmailNotifications = "0", + Certificate = new V2CertificateParams { Domain = "example.com" } + }; + + string json = JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + + json.Should().Contain("\"emailNotifications\":\"0\""); + } + + // Mirrors the client's actual GetJsonOptions() (private) — see + // V2SubscriptionEnrollmentTests.ClientEquivalentJsonOptions for the same rationale: plain + // JsonSerializer.Serialize(req) without these options would show "emailNotifications":null + // instead of omitting the key. + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + // --------------------------------------------------------------------------- + // EnrollV2Async — invalid EmailNotifications fails fast, before any CA call + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("all")] // deliberately invalid: the user's mapping only accepts "0"/"1"/blank, + // even though "all" is the CA's own wire value for "1" — an admin + // must not be able to bypass the mapping by writing the CA's literal. + [InlineData("yes")] + [InlineData("2")] + public async Task Enroll_V2_EmailNotifications_Invalid_FailsEnrollment_NoHttpCallMade(string configValue) + { + // Strict mock with NO setups at all: if EnrollV2Async made any client call before + // failing validation, Moq would throw a MockException for the unstubbed invocation + // and this test would fail — that, plus the explicit Verify(Times.Never) calls below, + // together confirm zero HTTP requests are sent for an invalid config value. + var mock = NewMock(); + + var plugin = BuildV2Plugin(mock.Object, emailNotifications: configValue); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("EmailNotifications", + "the failure message must name the offending config field"); + + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Never); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/CERTInext.Tests/EnrollmentParamsTests.cs b/CERTInext.Tests/EnrollmentParamsTests.cs new file mode 100644 index 0000000..9fa52e9 --- /dev/null +++ b/CERTInext.Tests/EnrollmentParamsTests.cs @@ -0,0 +1,102 @@ +// 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.Collections.Generic; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Coverage for 's V1-only fallback + /// (): only V1 dispatch uses it; V2 + /// dispatch (EnrollV2Async / ValidateProductInfo) does not. These tests exercise directly (it is + /// internal; this project has InternalsVisibleTo access) so the V1-unaffected + /// claim is pinned at the unit that both V1 and V2 share, not just re-derived from other + /// tests continuing to pass. + /// + public class EnrollmentParamsTests + { + private static EnrollmentProductInfo MakeProductInfo(string productId, Dictionary parameters = null) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = parameters ?? new Dictionary() + }; + + [Fact] + public void ProductCode_NoOverride_FallsBackToV1DefaultProductCodesTable_Unchanged() + { + // This is the V1 path's fallback: V1 dispatch + // (EnrollNewAsync/RenewOrReissueAsync) relies on this exact value. + var ep = new EnrollmentParams(MakeProductInfo(Constants.Products.OvSsl)); + + ep.HasExplicitProductCode.Should().BeFalse(); + ep.ProductCode.Should().Be(Constants.Products.DefaultProductCodes[Constants.Products.OvSsl]); + ep.ProductCode.Should().Be("842", "the V1-era table value must not change as part of the V2 fix"); + ep.ProfileId.Should().Be(ep.ProductCode, "ProfileId remains a pure alias for ProductCode"); + } + + [Theory] + [InlineData(Constants.EnrollmentParam.ProductCode)] + [InlineData(Constants.EnrollmentParam.ProfileId)] + public void ProductCode_ExplicitOverride_TakesPrecedenceOverDefaultTable(string parameterKey) + { + var ep = new EnrollmentParams(MakeProductInfo( + Constants.Products.OvSsl, + new Dictionary { [parameterKey] = "999" })); + + ep.HasExplicitProductCode.Should().BeTrue(); + ep.ProductCode.Should().Be("999"); + } + + [Fact] + public void HasExplicitProductCode_False_ForEveryDefaultProductCodesEntry_MatchesV1FallbackBehavior() + { + // Sanity sweep across all 10 V1 product names: with no override, every one of them + // must report HasExplicitProductCode=false and resolve to the exact + // DefaultProductCodes value — proving the shared getter's V1 behavior is bit-for-bit + // unchanged by the V2 fix. + foreach (var kvp in Constants.Products.DefaultProductCodes) + { + var ep = new EnrollmentParams(MakeProductInfo(kvp.Key)); + + ep.HasExplicitProductCode.Should().BeFalse($"ProductId '{kvp.Key}' has no override configured"); + ep.ProductCode.Should().Be(kvp.Value, $"ProductId '{kvp.Key}' must still resolve via the V1 table"); + } + } + + // Private-pki validation must tell "ProductVariant not set" apart from an + // explicit value, because the getter's SSL-only "dv" default masks the difference. + [Theory] + [InlineData(null, false, "dv")] + [InlineData("", false, "dv")] + [InlineData(" ", false, "dv")] + [InlineData("dv", true, "dv")] + [InlineData(" intranet-ssl ", true, "intranet-ssl")] + public void HasExplicitProductVariant_DistinguishesUnsetFromExplicit(string configured, bool expectedExplicit, string expectedVariant) + { + var parameters = new Dictionary(); + if (configured != null) + parameters[Constants.EnrollmentParam.ProductVariant] = configured; + + var ep = new EnrollmentParams(MakeProductInfo(Constants.Products.DvSsl, parameters)); + + ep.HasExplicitProductVariant.Should().Be(expectedExplicit); + ep.ProductVariant.Should().Be(expectedVariant, "the ProductVariant getter's own default is unchanged"); + } + } +} diff --git a/CERTInext.Tests/ExtractErrorMessageTests.cs b/CERTInext.Tests/ExtractErrorMessageTests.cs new file mode 100644 index 0000000..5b9daac --- /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 +{ + /// + /// 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/ExtractSerialFromPemTests.cs b/CERTInext.Tests/ExtractSerialFromPemTests.cs index f8064dd..d5daa8b 100644 --- a/CERTInext.Tests/ExtractSerialFromPemTests.cs +++ b/CERTInext.Tests/ExtractSerialFromPemTests.cs @@ -127,5 +127,59 @@ public void ExtractSerialFromPem_EmptyBody_ReturnsEmptyPem() InvokeExtractSerialFromPem("-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----") .Should().Be("(empty-pem)"); } + + /// + /// Functional coverage for the minimal chain-PEM shape (acceptance criterion: "leaf + + /// intermediate chain PEM -> the leaf's serial"), built the same way V2 enroll/sync + /// assemble it (AssembleV2CertChain: leaf PEM, then each intermediate PEM + /// appended after a newline, each block keeping its own BEGIN/END markers and base64 + /// padding). Whether a specific 2-block combination exercises the base64 + /// padding path depends on the leaf's DER byte length modulo 3 (whether its base64 + /// body needs '=' padding) — see + /// + /// for the three-block (ChainPemCount=2) chain. Both must return the leaf's serial, + /// never the intermediate's. + /// + [Fact] + public void ExtractSerialFromPem_LeafPlusIntermediateChainPem_ReturnsLeafSerial() + { + var leafSerial = new BigInteger("7994334872", 10); + var intermediateSerial = new BigInteger("00E0353B0E133906D77D5137E5E5D6A1", 16); + + string leafPem = GeneratePemWithSerial(leafSerial); + string intermediatePem = GeneratePemWithSerial(intermediateSerial); + + // Mirrors CERTInextCAPlugin.AssembleV2CertChain: leaf.TrimEnd() + "\n" + intermediate.TrimEnd(). + string chainPem = leafPem.TrimEnd() + "\n" + intermediatePem.TrimEnd(); + + string result = InvokeExtractSerialFromPem(chainPem); + + result.Should().Be(Convert.ToHexString(leafSerial.ToByteArrayUnsigned()).ToUpperInvariant(), + "the audit log must report the leaf certificate's serial, matching what Command records"); + result.Should().NotBe(Convert.ToHexString(intermediateSerial.ToByteArrayUnsigned()).ToUpperInvariant(), + "the intermediate's serial must never be mistaken for the leaf's"); + } + + /// + /// Leaf + two intermediates (three PEM blocks total, ChainPemCount=2): the leaf's + /// serial must be extracted rather than "(parse-error)", and never an intermediate's. + /// + [Fact] + public void ExtractSerialFromPem_LeafPlusTwoIntermediatesChainPem_ReturnsLeafSerial() + { + var leafSerial = new BigInteger("9817499991", 10); + var intermediateSerial1 = new BigInteger("00FEABDFF1B29657D9AF75ABC6CDCAAE", 16); + var intermediateSerial2 = new BigInteger("DEADBEEF", 16); + + string leafPem = GeneratePemWithSerial(leafSerial); + string intermediatePem1 = GeneratePemWithSerial(intermediateSerial1); + string intermediatePem2 = GeneratePemWithSerial(intermediateSerial2); + + string chainPem = leafPem.TrimEnd() + "\n" + intermediatePem1.TrimEnd() + "\n" + intermediatePem2.TrimEnd(); + + string result = InvokeExtractSerialFromPem(chainPem); + + result.Should().Be(Convert.ToHexString(leafSerial.ToByteArrayUnsigned()).ToUpperInvariant()); + } } } diff --git a/CERTInext.Tests/FakeDomainValidator.cs b/CERTInext.Tests/FakeDomainValidator.cs index 6b42475..73aaef4 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,67 @@ 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(); + + // Tracks how many CleanupValidation calls were in flight (past the increment below, + // still inside CleanupDelay) at the same time. This is the direct, wall-clock-independent + // proof that cleanup ran concurrently rather than sequentially — see + // Dcv_CleanupOfMultipleDomains_RunsConcurrently_NotSequentially, which asserts on this + // instead of total elapsed time (elapsed time also includes fixed overhead from the + // surrounding DCV flow — propagation delay + verification poll interval — unrelated to + // cleanup concurrency, which made a wall-clock threshold an unreliable proxy). + private int _inFlightCleanups; + + /// + /// The maximum number of calls observed executing + /// concurrently (i.e. inside the artificial ) at once. + /// + public int PeakConcurrentCleanups { get; private set; } + + public async Task CleanupValidation(string key, CancellationToken cancellationToken) { - CleanedUpKeys.Add(key); - return Task.FromResult(new DomainValidationResult { Success = true }); + int inFlight = Interlocked.Increment(ref _inFlightCleanups); + lock (_cleanupLock) + { + if (inFlight > PeakConcurrentCleanups) + PeakConcurrentCleanups = inFlight; + } + try + { + if (CleanupDelay > TimeSpan.Zero) + await Task.Delay(CleanupDelay, cancellationToken); + } + finally + { + Interlocked.Decrement(ref _inFlightCleanups); + } + lock (_cleanupLock) + { + CleanedUpKeys.Add(key); + CleanupTokens.Add(cancellationToken); + } + return new DomainValidationResult { Success = true }; } public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask; @@ -53,15 +113,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/MaskEmailTests.cs b/CERTInext.Tests/MaskEmailTests.cs new file mode 100644 index 0000000..7a14d48 --- /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 +{ + /// + /// 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/MockCertificateData.cs b/CERTInext.Tests/MockCertificateData.cs index ee6644b..dcd5fc4 100644 --- a/CERTInext.Tests/MockCertificateData.cs +++ b/CERTInext.Tests/MockCertificateData.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// 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, @@ -232,6 +232,45 @@ public static string GetProductDetailsJson() => public static string GetProductDetailsEmptyJson() => $@"{{""meta"":{SuccessMetaJson()},""productDetails"":[]}}"; + // GET /api/certinext/v2/catalog/products — nested category envelope, the shape + // returned by the sandbox account. Same structure as the V1 GetProductDetails + // category envelope, just under a + // top-level "products" key instead of "productDetails". + public static string GetCatalogProductsV2NestedJson() => + $@"{{ + ""products"":[ + {{ + ""currencyType"":""USD"", + ""categoryName"":""SSL/TLS Certificates"", + ""categoryID"":""3"", + ""products"":[ + {{""productCode"":""{ProfileIdTls}"",""productName"":""TLS Server"",""productTypeID"":""13""}}, + {{""productCode"":""{ProfileIdClient}"",""productName"":""Client Authentication"",""productTypeID"":""14""}} + ] + }} + ] +}}"; + + // Flat shape from the spec's "List Products" example response — kept as a + // fallback branch in the parser even though the live account returns the nested shape. + public static string GetCatalogProductsV2FlatJson() => + $@"{{ + ""products"":[ + {{""productId"":""{ProfileIdTls}"",""productName"":""TLS Server"",""masterProductName"":""TLS Server""}}, + {{""productId"":""{ProfileIdClient}"",""productName"":""Client Authentication"",""masterProductName"":""Client Authentication""}} + ] +}}"; + + // Bare-array shape (no wrapper object) — legacy branch already handled by + // ParseProductDetailsV2Response. + public static string GetCatalogProductsV2BareArrayJson() => + $@"[ + {{""productCode"":""{ProfileIdTls}"",""productName"":""TLS Server"",""productType"":""SSL/TLS Certificates"",""active"":true}} +]"; + + public static string GetCatalogProductsV2EmptyJson() => + $@"{{""products"":[]}}"; + // Generic API failure body (meta.status = "0") public static string ApiFailureJson(string errorCode = "EMS-100", string errorMessage = "An error occurred") => $@"{{""meta"":{FailureMetaJson(errorCode, errorMessage)}}}"; @@ -294,6 +333,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. + 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). @@ -478,6 +531,104 @@ public static string ServerErrorJson() => public static string UnauthorizedJson() => @"{""error"":""UNAUTHORIZED"",""message"":""Invalid API key."",""statusCode"":401}"; + // ----------------------------------------------------------------------- + // V2 API JSON factories + // ----------------------------------------------------------------------- + + // V2 well-known order IDs + public const string V2OrderId1 = "ord_abc001"; + public const string V2OrderId2 = "ord_abc002"; + + /// Standard OAuth2 client_credentials token response. + public static string V2TokenResponseJson(int expiresIn = 3600) => + $@"{{""access_token"":""eyJhbGciOiJSUzI1NiJ9.test-token"",""token_type"":""Bearer"",""expires_in"":{expiresIn},""refresh_token"":""refresh-opaque-token""}}"; + + /// V2 create order response (status = pending-dcv). + public static string V2CreateOrderPendingJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""requestId"":""req_xyz001"",""status"":""pending-dcv"",""_links"":{{""self"":{{""href"":""/api/certinext/v2/ssl-certificates/{orderId}""}}}}}}"; + + /// V2 create order response (status = issued — unlikely on fresh order but usable for testing). + public static string V2CreateOrderIssuedJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""requestId"":""req_xyz001"",""status"":""issued"",""_links"":{{""self"":{{""href"":""/api/certinext/v2/ssl-certificates/{orderId}""}}}}}}"; + + /// V2 track order response — pending DCV. + public static string V2TrackOrderPendingJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""requestId"":""req_xyz001"",""status"":""pending-dcv"",""productVariant"":""dv"",""domain"":""example.com"",""_links"":{{""self"":{{""href"":""/api/certinext/v2/ssl-certificates/{orderId}""}}}}}}"; + + /// V2 track order response — issued. + public static string V2TrackOrderIssuedJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""requestId"":""req_xyz001"",""status"":""issued"",""productVariant"":""dv"",""domain"":""example.com"",""_links"":{{""certificate"":{{""href"":""/api/certinext/v2/ssl-certificates/{orderId}/certificate""}}}}}}"; + + /// + /// V2 track order response — revoked. Nested revocation object shape, as seen + /// against a real revoked order — NOT the flat + /// revocationReason/revocationDate shape. + /// + public static string V2TrackOrderRevokedJson( + string orderId = "ord_abc001", + string reason = "cessation-of-operation", + string processedAt = "2026-09-24T20:44:41Z") => + $@"{{""orderId"":""{orderId}"",""requestId"":""req_xyz001"",""status"":""revoked"",""productVariant"":""dv"",""domain"":""example.com"",""revocation"":{{""status"":""Certificate Revoked"",""reason"":""{reason}"",""processedAt"":""{processedAt}""}},""_links"":{{}}}}"; + + /// V2 certificate download response (leaf PEM only). + public static string V2CertificateDownloadJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""serialNumber"":""0A1B2C3D4E5F"",""subject"":""CN=example.com"",""issuer"":""CN=CERTInext TLS Intermediate"",""notBefore"":""2026-01-01T00:00:00Z"",""notAfter"":""2027-01-01T00:00:00Z"",""certificatePem"":""{EscapeForJson(FakePemCertificate)}""}}"; + + /// V2 auth/me response. + public static string V2AuthMeJson(string accountNumber = "99887766") => + $@"{{""accountNumber"":""{accountNumber}"",""authType"":""oauth2""}}"; + + /// RFC 7807 problem+json error response. + public static string V2ProblemDetailsJson(int status = 403, string title = "Forbidden", string detail = "OAuth2 not enabled", string type = "EMS-2022") => + $@"{{""type"":""{type}"",""title"":""{title}"",""status"":{status},""detail"":""{detail}"",""instance"":null}}"; + + /// + /// V2 DCV challenge response. Matches the real wire shape: exactly token and + /// tokenExpiryDate — no + /// orderNumber/domainName/dcvMethod/fileNameContent. + /// + public static string V2DcvChallengeJson(string token = "emudhra-dcv-abc123", string tokenExpiryDate = "2026-12-31 23:59:59") => + $@"{{""tokenExpiryDate"":""{tokenExpiryDate}"",""token"":""{token}""}}"; + + /// V2 DCV verify response (success). + public static string V2DcvVerifySuccessJson(string domain = "example.com") => + $@"{{""overallStatus"":""VERIFIED"",""method"":""dns-txt"",""verifiedAt"":""2026-09-21T10:00:00Z""}}"; + + /// V2 DCV verify response (failure). + public static string V2DcvVerifyFailedJson() => + $@"{{""overallStatus"":""FAILED"",""method"":""dns-txt"",""verifiedAt"":null}}"; + + /// V2 certificate download response with chain PEM. + public static string V2CertificateDownloadWithChainJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""serialNumber"":""0A1B2C3D4E5F"",""subject"":""CN=example.com"",""issuer"":""CN=CERTInext TLS Intermediate"",""notBefore"":""2026-01-01T00:00:00Z"",""notAfter"":""2027-01-01T00:00:00Z"",""certificatePem"":""{EscapeForJson(FakePemCertificate)}"",""chainPem"":[""{EscapeForJson(FakeIntermediatePemCertificate)}""]}}"; + + public static readonly string FakeIntermediatePemCertificate = + "-----BEGIN CERTIFICATE-----\nMIIBfakeBASE64INTERMEDIATE==\n-----END CERTIFICATE-----"; + + /// + /// V2 /reports/orders page envelope. Rows default to a + /// pending-DCV-shaped display-string pair ("Order Accepted" / "Pending for Approver") — + /// override / for other + /// scenarios. Field names match the live field table. + /// + public static string V2OrdersReportJson( + int page, int totalPages, string[] orderNumbers, + int size = 50, long? totalElements = null, + string orderStatus = "Order Accepted", string certificateStatus = "Pending for Approver") + { + var rows = new List(); + foreach (string id in orderNumbers) + { + rows.Add( + $@"{{""orderNumber"":""{id}"",""requestNumber"":""{id}-req"",""orderStatus"":""{orderStatus}""," + + $@"""certificateStatus"":""{certificateStatus}"",""domainName"":""example.com""," + + $@"""productCode"":""842"",""orderDate"":""2026-01-01T00:00:00Z""}}"); + } + long total = totalElements ?? orderNumbers.Length; + return $@"{{""content"":[{string.Join(",", rows)}],""page"":{page},""size"":{size}," + + $@"""totalElements"":{total},""totalPages"":{totalPages}}}"; + } + // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- diff --git a/CERTInext.Tests/RedactPersonalDataTests.cs b/CERTInext.Tests/RedactPersonalDataTests.cs new file mode 100644 index 0000000..5289e63 --- /dev/null +++ b/CERTInext.Tests/RedactPersonalDataTests.cs @@ -0,0 +1,741 @@ +// 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.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// 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. These tests pin and + /// against realistic V1 and V2 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. + /// + 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 + }; + + // --------------------------------------------------------------------------- + // 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" }, + 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***\""); + } + + // --------------------------------------------------------------------------- + // V2 order create request — requestor / technicalPointOfContact / agreement all carry + // bare name/email/phone/designation/signerName keys; certificate/subscription don't. + // --------------------------------------------------------------------------- + + private static string BuildV2OrderRequestJson() + { + var request = new V2CreateSslOrderRequest + { + ProductVariant = "ov", + EmailNotifications = "0", + Requestor = new V2Requestor + { + Name = "Jane Doe", + Email = "jane.doe@example.com", + Phone = "+15551234567", + Designation = "IT Administrator" + }, + Organization = new V2OrganizationParams { OrganizationNumber = "1234567", PreVetted = true }, + Certificate = new V2CertificateParams + { + Domain = "example.com", + AdditionalDomains = new System.Collections.Generic.List { "alt.example.com" } + }, + Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false }, + Agreement = new V2AgreementParams + { + SignerName = "John Signer", + SignerIp = "203.0.113.10", + SignerPlace = "Austin" + }, + TechnicalPointOfContact = new V2TechnicalPointOfContact + { + Name = "Tech Contact", + Email = "tech.contact@example.com", + Phone = "+15559876543", + Designation = "PKI Manager" + }, + GroupNumber = "2345678901" + }; + + return JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + } + + [Fact] + public void RedactPersonalData_V2OrderRequest_RemovesAllPersonFields() + { + string output = CERTInextClient.RedactPersonalData(BuildV2OrderRequestJson()); + + output.Should().NotContain("Jane Doe"); + output.Should().NotContain("jane.doe@example.com"); + output.Should().NotContain("+15551234567"); + 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("+15559876543"); + output.Should().NotContain("PKI Manager"); + } + + [Fact] + public void RedactPersonalData_V2OrderRequest_MasksEmailsKeepingDomain() + { + string output = CERTInextClient.RedactPersonalData(BuildV2OrderRequestJson()); + + // requestor.email ("jane.doe@example.com") and technicalPointOfContact.email + // ("tech.contact@example.com") both use the same bare "email" key but have distinct + // local parts — both must be masked independently (domain kept in each). + output.Should().Contain("\"email\":\"j***@example.com\""); + output.Should().Contain("\"email\":\"t***@example.com\""); + } + + [Fact] + public void RedactPersonalData_V2OrderRequest_PreservesNonPersonalFields() + { + string output = CERTInextClient.RedactPersonalData(BuildV2OrderRequestJson()); + + output.Should().Contain("\"productVariant\":\"ov\""); + output.Should().Contain("\"domain\":\"example.com\""); + output.Should().Contain("alt.example.com"); + output.Should().Contain("\"organizationNumber\":\"1234567\""); + output.Should().Contain("\"groupNumber\":\"2345678901\""); + output.Should().Contain("\"validityYears\":1"); + } + + [Fact] + public void RedactPersonalData_V2OrderRequest_NestedNameFieldsRedactedToPlaceholder() + { + string output = CERTInextClient.RedactPersonalData(BuildV2OrderRequestJson()); + + // Both requestor.name and technicalPointOfContact.name must be caught even though + // they are nested inside different objects using the same bare "name" key. + var nameMatches = System.Text.RegularExpressions.Regex.Matches(output, "\"name\":\"\\*\\*\\*REDACTED\\*\\*\\*\""); + nameMatches.Count.Should().Be(2, "both requestor.name and technicalPointOfContact.name must be redacted"); + + output.Should().Contain("\"phone\":\"***REDACTED***\""); + output.Should().Contain("\"designation\":\"***REDACTED***\""); + output.Should().Contain("\"signerName\":\"***REDACTED***\""); + output.Should().Contain("\"signerIp\":\"***REDACTED***\""); + output.Should().Contain("\"signerPlace\":\"***REDACTED***\""); + } + + // V2 place-order response echoes the agreement as subscriberAgreement with the key + // "signedPlace" (not the request's "signerPlace"), plus an orderedBy contact. Shape taken + // from a sandbox response; values here are fictitious. + private const string V2OrderResponseJson = + "{\"orderId\":\"4898663698\",\"status\":\"pending-approval\",\"productVariant\":\"dv\"," + + "\"domain\":\"example.com\",\"resolvedProductCode\":\"842\"," + + "\"requestor\":{\"name\":\"Jane Doe\",\"email\":\"jane.doe@example.com\"}," + + "\"orderedBy\":{\"name\":\"Account Owner\",\"email\":\"owner@example.com\"}," + + "\"subscriberAgreement\":{\"signed\":true,\"signerName\":\"John Signer\"," + + "\"signedAt\":\"2026-09-26T15:58:51Z\",\"signedPlace\":\"Austin\"}}"; + + [Fact] + public void RedactPersonalData_V2OrderResponse_RedactsSubscriberAgreementAndOrderedBy() + { + string output = CERTInextClient.RedactPersonalData(V2OrderResponseJson); + + output.Should().NotContain("Austin"); + output.Should().Contain("\"signedPlace\":\"***REDACTED***\""); + output.Should().NotContain("John Signer"); + output.Should().NotContain("Jane Doe"); + output.Should().NotContain("Account Owner"); + output.Should().Contain("\"email\":\"o***@example.com\""); + output.Should().Contain("\"orderId\":\"4898663698\""); + output.Should().Contain("\"domain\":\"example.com\""); + output.Should().Contain("\"signedAt\":\"2026-09-26T15:58:51Z\""); + } + + // --------------------------------------------------------------------------- + // Whitespace tolerance — a pretty-printed body must redact identically to a compact one. + // --------------------------------------------------------------------------- + + [Fact] + public void RedactPersonalData_TolerantOfWhitespaceAroundKeyValueSeparator() + { + string input = "{\n \"requestor\" : {\n \"name\" : \"Jane Doe\",\n \"email\":\"jane.doe@example.com\"\n }\n}"; + + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().NotContain("Jane Doe"); + output.Should().Contain("j***@example.com"); + } + + // --------------------------------------------------------------------------- + // 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); + } + + // --------------------------------------------------------------------------- + // V2 Private PKI and Document Signer (signature) order bodies. Built from the + // real DTOs so a JSON property rename that would silently defeat the redactor fails here. + // --------------------------------------------------------------------------- + + // Values mirror the spec's "Create - Natural Person" example body. + private static string BuildV2SignatureOrderRequestJson() + { + var request = new V2CreateSignatureOrderRequest + { + SubjectType = "natural-person", + EmailNotifications = "all", + Requestor = new V2Requestor { Name = "Sarah Johnson", Email = "sarah.johnson@example.com", Phone = "+12025551234", Designation = "Document Signer" }, + Subject = new V2SignatureSubject + { + FirstName = "Sarah", + LastName = "Johnson", + Email = "sarah.johnson@example.com", + Phone = "+12025551234", + IdentityDocumentType = "passport", + IdentificationNumber = "X12345678", + StreetAddress1 = "1600 Pennsylvania Avenue NW", + StreetAddress2 = "Apt 7", + Locality = "Washington", + State = "DC", + PostalCode = "20500", + CountryCode = "US" + }, + Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false }, + Agreement = new V2AgreementParams { SignerName = "Sarah Johnson", SignerPlace = "Washington, DC", Accepted = true }, + Remarks = "Document Signer - Natural Person, US" + }; + return JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + } + + [Fact] + public void RedactPersonalData_V2SignatureOrderRequest_RemovesSubjectPersonFields() + { + string output = CERTInextClient.RedactPersonalData(BuildV2SignatureOrderRequestJson()); + + // Name, identity-document and street-address values must all be gone. + foreach (var raw in new[] + { + "Sarah", "Johnson", "+12025551234", "Document Signer\"", "passport", "X12345678", + "1600 Pennsylvania Avenue NW", "Apt 7", "\"Washington\"", "20500", "Washington, DC" + }) + { + output.Should().NotContain(raw, $"'{raw}' is subject/requestor personal data"); + } + + // subject.email and requestor.email are masked to their domain, not dropped. + output.Should().NotContain("sarah.johnson@"); + output.Should().Contain("s***@example.com"); + } + + [Fact] + public void RedactPersonalData_V2SignatureOrderRequest_PreservesNonPersonalFields() + { + string output = CERTInextClient.RedactPersonalData(BuildV2SignatureOrderRequestJson()); + + // Deliberately-not-redacted keys: the discriminator, coarse location, and the + // agreement flag carry no personal identity on their own. + output.Should().Contain("\"subjectType\":\"natural-person\""); + output.Should().Contain("\"state\":\"DC\""); + output.Should().Contain("\"countryCode\":\"US\""); + output.Should().Contain("\"accepted\":true"); + } + + [Fact] + public void RedactPersonalData_V2SignatureCreateResponse_RedactsSubjectDisplayName() + { + // Spec "Create - Natural Person" response: subjectDisplayName is the full name for a + // natural / legal person. PlaceOrderV2Async logs this response body at Trace. + string input = "{\"orderId\":\"ord_sig_1\",\"status\":\"pending-documents\",\"subjectType\":\"natural-person\"," + + "\"subjectDisplayName\":\"Sarah Johnson\",\"resolvedProductCode\":\"819\"}"; + + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().NotContain("Sarah Johnson"); + output.Should().Contain("\"subjectDisplayName\":\"***REDACTED***\""); + output.Should().Contain("\"orderId\":\"ord_sig_1\""); + output.Should().Contain("\"resolvedProductCode\":\"819\""); + } + + [Fact] + public void RedactPersonalData_LegalEntitySubject_KeepsOrganizationFields() + { + // Organization identity is not personal data, and "organizationName" is also a V1 + // order/report key for an OV organization — deliberately excluded from the key set. + var request = new V2CreateSignatureOrderRequest + { + SubjectType = "legal-entity", + Requestor = new V2Requestor { Name = "Acme Corporation Compliance", Email = "pki-ops@acme.com" }, + Subject = new V2SignatureSubject + { + OrganizationName = "Acme Corporation", + OrganizationUnit = "Compliance", + BusinessCategory = "Business Entity", + OrganizationIdentificationNumber = "EIN-12-3456789", + Email = "pki-ops@acme.com" + } + }; + string json = JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + + string output = CERTInextClient.RedactPersonalData(json); + + output.Should().Contain("\"organizationName\":\"Acme Corporation\""); + output.Should().Contain("\"organizationIdentificationNumber\":\"EIN-12-3456789\""); + output.Should().NotContain("Acme Corporation Compliance", "requestor.name is still personal/contact data"); + output.Should().NotContain("pki-ops@"); + } + + [Fact] + public void ApplyLoggingRedaction_V2SignatureOrderRequest_FlagOn_LeavesSubjectInFull() + { + string output = CERTInextClient.ApplyLoggingRedaction(BuildV2SignatureOrderRequestJson(), logSensitiveRequestData: true); + + output.Should().Contain("\"firstName\":\"Sarah\""); + output.Should().Contain("\"identificationNumber\":\"X12345678\""); + output.Should().Contain("sarah.johnson@example.com"); + } + + [Fact] + public void ApplyLoggingRedaction_V2PrivatePkiOrderRequest_FlagOff_RedactsRequestorAndContact_KeepsHosts() + { + // Private PKI adds no new personal keys (requestor / technicalPointOfContact reuse the + // bare name/email/phone/designation keys); hostname / additionalHosts are diagnostic + // host data and must survive redaction. + var request = new V2CreatePrivatePkiOrderRequest + { + Variant = "intranet-ssl", + Hostname = "intranet.acme.local", + AdditionalHosts = new System.Collections.Generic.List { "portal.acme.local", "10.0.0.50" }, + Requestor = new V2Requestor { Name = "DevOps Team", Email = "devops@acme.com", Phone = "+14155551234", Designation = "Platform Engineering" }, + TechnicalPointOfContact = new V2TechnicalPointOfContact { Name = "Tech Person", Email = "tech@acme.com", Phone = "+14155550000", Designation = "Technical Contact" }, + Subscription = new V2SubscriptionParams { ValidityYears = 1 } + }; + string json = JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + + string output = CERTInextClient.ApplyLoggingRedaction(json, logSensitiveRequestData: false); + + foreach (var raw in new[] { "DevOps Team", "devops@", "+14155551234", "Platform Engineering", "Tech Person", "tech@", "+14155550000" }) + output.Should().NotContain(raw); + output.Should().Contain("\"hostname\":\"intranet.acme.local\""); + output.Should().Contain("portal.acme.local"); + output.Should().Contain("10.0.0.50"); + output.Should().Contain("\"variant\":\"intranet-ssl\""); + } + + // --------------------------------------------------------------------------- + // 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_V2SanArrays_MaskEmailElements_DefenceInDepth() + { + // V2 filters these to DNS/IP before submission; a mis-typed email must still be masked, + // and DNS/IP values must be untouched. + CERTInextClient.RedactPersonalData("{\"certificate\":{\"domain\":\"example.com\",\"additionalDomains\":[\"www.example.com\",\"bob@example.com\"]}}") + .Should().Be("{\"certificate\":{\"domain\":\"example.com\",\"additionalDomains\":[\"www.example.com\",\"b***@example.com\"]}}"); + CERTInextClient.RedactPersonalData("{\"hostname\":\"h.acme.local\",\"additionalHosts\":[\"10.0.0.50\",\"bob@acme.local\",\"::1\"]}") + .Should().Be("{\"hostname\":\"h.acme.local\",\"additionalHosts\":[\"10.0.0.50\",\"b***@acme.local\",\"::1\"]}"); + } + + [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/RequestorDesignationEnrollmentTests.cs b/CERTInext.Tests/RequestorDesignationEnrollmentTests.cs new file mode 100644 index 0000000..efca48e --- /dev/null +++ b/CERTInext.Tests/RequestorDesignationEnrollmentTests.cs @@ -0,0 +1,390 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Moq; +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 +{ + /// + /// Tests that the V2 order body's requestor.designation field is sourced from a + /// connector config field rather than hardcoded (the V2 spec's own example value for this + /// Optional free-text field is "IT Administrator"). V1 does not send + /// requestorDesignation unless configured — the DTO carries the property. + /// + /// Covers both paths: + /// - V2 ( → EnrollV2Async): exercised end-to-end + /// against a Strict mock, mirroring + /// V2SubscriptionEnrollmentTests / V2TechnicalContactEnrollmentTests. + /// - V1 ( / + /// ): exercised against a WireMock HTTP + /// stub, mirroring CERTInextClientRequestShapeTests, since the V1 wire shape is built + /// by the concrete client rather than behind . + /// + public class RequestorDesignationEnrollmentTests : IDisposable + { + // --------------------------------------------------------------------------- + // V2 — EnrollV2Async wiring: RequestorDesignation config -> Requestor.Designation + // --------------------------------------------------------------------------- + + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextConfig BaseV2Config() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, CERTInextConfig config) => + new CERTInextCAPlugin(client, config); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task RunV2EnrollAndCaptureOrderAsync( + CERTInextConfig config, string orderId = "ord_desig_001") + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, orderId); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, config); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be(orderId); + captured.Should().NotBeNull(); + return captured; + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_V2_RequestorDesignationBlank_SetsDesignationNull(string configValue) + { + var config = BaseV2Config(); + config.RequestorDesignation = configValue; + + var captured = await RunV2EnrollAndCaptureOrderAsync(config, orderId: "ord_desig_002"); + + captured.Requestor.Should().NotBeNull(); + captured.Requestor.Designation.Should().BeNull( + "a blank/unset RequestorDesignation must leave Requestor.Designation null so the " + + "key is omitted from the wire JSON, instead of sending any default designation value"); + } + + [Fact] + public async Task Enroll_V2_RequestorDesignationSet_SendsTrimmedValue() + { + var config = BaseV2Config(); + config.RequestorDesignation = " PKI Manager "; + + var captured = await RunV2EnrollAndCaptureOrderAsync(config, orderId: "ord_desig_003"); + + captured.Requestor.Designation.Should().Be("PKI Manager", + "a configured RequestorDesignation must be forwarded trimmed of surrounding whitespace"); + } + + // --------------------------------------------------------------------------- + // V2 — DTO serialization: requestor.designation key present/absent on the wire + // + // V2Requestor.Designation carries no per-property [JsonIgnore(WhenWritingNull)] of its + // own; it relies solely on the client's global serializer options + // (CERTInextClient.GetJsonOptions, DefaultIgnoreCondition = WhenWritingNull) to omit it + // when null. GetJsonOptions() is private, so this test builds an equivalent + // JsonSerializerOptions inline to verify that global-option omission actually applies to + // this property, rather than relying on plain JsonSerializer.Serialize (whose default + // options do NOT ignore nulls and would show "designation":null instead of omitting it). + // --------------------------------------------------------------------------- + + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + [Fact] + public void V2Requestor_Serialization_OmitsDesignation_WhenNull() + { + var requestor = new V2Requestor + { + Name = "Jane Doe", + Email = "jane@example.com", + Phone = "+15550000000", + Designation = null + }; + + string json = JsonSerializer.Serialize(requestor, ClientEquivalentJsonOptions()); + + json.Should().NotContain("designation", + "the designation key itself must be absent when unset, not present-but-null, " + + "under the client's actual serializer options"); + } + + [Fact] + public void V2Requestor_Serialization_IncludesDesignation_WhenSet() + { + var requestor = new V2Requestor + { + Name = "Jane Doe", + Email = "jane@example.com", + Phone = "+15550000000", + Designation = "PKI Manager" + }; + + string json = JsonSerializer.Serialize(requestor, ClientEquivalentJsonOptions()); + + json.Should().Contain("\"designation\":\"PKI Manager\""); + } + + // --------------------------------------------------------------------------- + // V1 — BuildOrderRequestFromLegacyEnrollRequest / RenewCertificateAsync wiring: + // RequestorDesignation config -> requestorInformation.requestorDesignation + // + // Uses a WireMock HTTP stub (mirroring CERTInextClientRequestShapeTests) because the V1 + // wire shape is built inside the concrete CERTInextClient, not behind ICERTInextClient. + // RequestorInformation.RequestorDesignation already carries its own + // [JsonIgnore(Condition = WhenWritingNull)] (API/CertificateRequest.cs), so a blank config + // value is expected to omit the key without needing any client-level options change. + // --------------------------------------------------------------------------- + + private readonly WireMockServer _server = WireMockServer.Start(); + + public void Dispose() => _server.Stop(); + + private CERTInextClient BuildV1Client(CERTInextConfig config) + { + config.ApiUrl = _server.Urls[0]; + return new CERTInextClient(config); + } + + private static CERTInextConfig MinimalV1Config() => new CERTInextConfig + { + 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 + }; + + 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 JsonElement CapturedGenerateOrderSslBody() + { + var generateOrderRequests = _server.LogEntries + .Where(e => e.RequestMessage.Path == "/GenerateOrderSSL") + .ToList(); + generateOrderRequests.Should().HaveCount(1, + "exactly one GenerateOrderSSL POST should have been emitted"); + string body = generateOrderRequests[0].RequestMessage.Body; + body.Should().NotBeNullOrEmpty(); + return JsonDocument.Parse(body!).RootElement.GetProperty("orderDetails"); + } + + private static EnrollCertificateRequest BasicEnrollRequest() => new EnrollCertificateRequest + { + ProfileId = "842", + Csr = MockCertificateData.FakeCsrPem, + Subject = "CN=test.example.com", + Comment = "Unit test" + }; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task EnrollCertificateAsync_RequestorDesignationBlank_OmitsFieldFromRequestorInformation(string configValue) + { + StubHappyEnroll(); + var cfg = MinimalV1Config(); + cfg.RequestorDesignation = configValue; + + await BuildV1Client(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var requestorInfo = CapturedGenerateOrderSslBody().GetProperty("requestorInformation"); + requestorInfo.TryGetProperty("requestorDesignation", out _).Should().BeFalse( + "a blank/unset RequestorDesignation must omit requestorDesignation from the wire " + + "JSON entirely (V1 does not send this field unless configured)"); + } + + [Fact] + public async Task EnrollCertificateAsync_RequestorDesignationSet_SendsTrimmedValue() + { + StubHappyEnroll(); + var cfg = MinimalV1Config(); + cfg.RequestorDesignation = " IT Administrator "; + + await BuildV1Client(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var requestorInfo = CapturedGenerateOrderSslBody().GetProperty("requestorInformation"); + requestorInfo.GetProperty("requestorDesignation").GetString().Should().Be("IT Administrator", + "a configured RequestorDesignation must be forwarded trimmed of surrounding whitespace"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RenewCertificateAsync_RequestorDesignationBlank_OmitsFieldFromRequestorInformation(string configValue) + { + StubHappyEnroll(); + var cfg = MinimalV1Config(); + cfg.RequestorDesignation = configValue; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "842", + ValidityDays = 365, + Comment = "Renewal test" + }; + + await BuildV1Client(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var requestorInfo = CapturedGenerateOrderSslBody().GetProperty("requestorInformation"); + requestorInfo.TryGetProperty("requestorDesignation", out _).Should().BeFalse( + "a blank/unset RequestorDesignation must omit requestorDesignation from renewal " + + "orders too, mirroring the new-enrollment path"); + } + + [Fact] + public async Task RenewCertificateAsync_RequestorDesignationSet_SendsTrimmedValue() + { + StubHappyEnroll(); + var cfg = MinimalV1Config(); + cfg.RequestorDesignation = " PKI Manager "; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "842", + ValidityDays = 365, + Comment = "Renewal test" + }; + + await BuildV1Client(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var requestorInfo = CapturedGenerateOrderSslBody().GetProperty("requestorInformation"); + requestorInfo.GetProperty("requestorDesignation").GetString().Should().Be("PKI Manager", + "a configured RequestorDesignation must be forwarded trimmed of surrounding whitespace " + + "on renewal orders too"); + } + } +} diff --git a/CERTInext.Tests/SanLogMaskingTests.cs b/CERTInext.Tests/SanLogMaskingTests.cs new file mode 100644 index 0000000..9802476 --- /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 +{ + /// + /// 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 SAN log masking: 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/SanSubmissionTests.cs b/CERTInext.Tests/SanSubmissionTests.cs new file mode 100644 index 0000000..83c18eb --- /dev/null +++ b/CERTInext.Tests/SanSubmissionTests.cs @@ -0,0 +1,699 @@ +// 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 +{ + /// + /// Tests for UCC SAN submission. + /// + /// The AnyCA REST Gateway keys its SAN dictionary dnsname, so MapSanType + /// must recognize it (as well as dns): otherwise every DNS SAN would be filtered out + /// by the DNS-only test when building certificateInformation.additionalDomains, and + /// the order would reach CERTInext with no additional domains at all — yielding a + /// certificate holding only the CN. Because CERTInext ignores the CSR's subjectAltName + /// extension entirely, SANs present on the CSR do 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 miss problems in the + /// interaction with the downstream filter that could lose 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 + // ======================================================================= + + /// + /// "dnsname" is the key the real gateway sends (e.g. + /// SANs=dnsname:host.example.com; dnsname:host.ad.example.com); both names must + /// reach additionalDomains. + /// + [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() + { + // The CSR is not unioned in on top of whatever the gateway supplied: 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 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"); + } + + /// + /// The CSR-fallback trigger must not 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" }); + } + + /// + /// BuildSanList's audit log must report post-filter counts: with a mixed + /// DNS/non-DNS gateway SAN dictionary and SubmitNonDnsSans=false, the "Resolved N SAN(s)" + /// log line must not report the pre-filter gateway count (3) alongside the post-filter + /// total (1). This pins the payload-level data the log line is computed from: 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. + /// + [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. + /// + /// GeneralNameToValue must not fall back to BouncyCastle's ASN.1 stringification: a + /// Windows-generated CSR carrying a UPN otherName would otherwise 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. 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(); + } + + /// + /// BuildSanList must not log "N SAN(s) ... have been added to the order" for CSR-fallback + /// entries and then filter exactly those entries back out when SubmitNonDnsSans is false — + /// a self-contradicting claim in the same call. The method filters first and logs 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 — SANs are carried onto the renewal order + // ======================================================================= + + /// + /// 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 must carry the SANs onto the renewal order"); + renewReq.Sans.Select(s => s.Value) + .Should().BeEquivalentTo(new[] { "host.example.com", "alt.example.com" }); + } + } +} diff --git a/CERTInext.Tests/SensitiveRequestDataConfigTests.cs b/CERTInext.Tests/SensitiveRequestDataConfigTests.cs new file mode 100644 index 0000000..25dac06 --- /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 +{ + /// + /// LogSensitiveRequestData is an 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(); + } + } +} diff --git a/CERTInext.Tests/StatusMapperV2Tests.cs b/CERTInext.Tests/StatusMapperV2Tests.cs new file mode 100644 index 0000000..00765a3 --- /dev/null +++ b/CERTInext.Tests/StatusMapperV2Tests.cs @@ -0,0 +1,205 @@ +// 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.Collections.Generic; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Unit tests for the V2-specific mapping methods on . + /// + public class StatusMapperV2Tests + { + // --------------------------------------------------------------------------- + // V2StatusToRequestDisposition + // --------------------------------------------------------------------------- + + // All 11 status values documented by the V2 spec's `/reports/orders` `status` + // filter — every one must map explicitly, not fall through the + // "unmapped" default arm, even where the resulting disposition (FAILED) is the + // same as the default's. `unknown-future`/empty/null exercise the true default + // arm below. `expired` is a deliberate GENERATED mapping (see test below), not a + // default-arm case. + [Theory] + [InlineData("issued", (int)EndEntityStatus.GENERATED)] + [InlineData("ISSUED", (int)EndEntityStatus.GENERATED)] // case-insensitive + [InlineData("pending-dcv", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("pending-csr", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("pending-agreement", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("pending-organization-verification", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("pending-documents", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("pending-approval", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("revoked", (int)EndEntityStatus.REVOKED)] + [InlineData("cancelled", (int)EndEntityStatus.FAILED)] + [InlineData("rejected", (int)EndEntityStatus.FAILED)] + // Expired-but-not-revoked certs remain issued inventory — mirrors V1's + // ToRequestDisposition convention and the sync/report path's own "expired" case. + [InlineData("expired", (int)EndEntityStatus.GENERATED)] + // The spec-documented `unknown` means "may still be live" — pending, not FAILED. + [InlineData("unknown", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("UNKNOWN", (int)EndEntityStatus.EXTERNALVALIDATION)] // case-insensitive + [InlineData("Unknown", (int)EndEntityStatus.EXTERNALVALIDATION)] + public void V2StatusToRequestDisposition_MapsCorrectly(string v2Status, int expectedDisposition) + { + StatusMapper.V2StatusToRequestDisposition(v2Status).Should().Be(expectedDisposition); + } + + // --------------------------------------------------------------------------- + // A status string that is NOT one of the 11 spec + // values must still degrade gracefully to FAILED via the default arm, rather + // than throwing or being silently treated as "still pending". This is the + // "truly unrecognized" case, distinct from the deliberate FAILED mappings + // (cancelled/rejected) tested above. + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("unknown-future")] + [InlineData("not-a-real-status")] // garbage still defaults to FAILED, unlike `unknown` + [InlineData("")] + [InlineData(null)] + public void V2StatusToRequestDisposition_UnrecognizedStatus_DefaultsToFailed(string v2Status) + { + StatusMapper.V2StatusToRequestDisposition(v2Status).Should().Be((int)EndEntityStatus.FAILED); + } + + // --------------------------------------------------------------------------- + // ToV2RevocationReason + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(0u, Constants.RevocationReasonV2.Unspecified)] + [InlineData(1u, Constants.RevocationReasonV2.KeyCompromise)] + [InlineData(2u, Constants.RevocationReasonV2.CACompromise)] + [InlineData(3u, Constants.RevocationReasonV2.AffiliationChanged)] + [InlineData(4u, Constants.RevocationReasonV2.Superseded)] + [InlineData(5u, Constants.RevocationReasonV2.CessationOfOperation)] + [InlineData(6u, Constants.RevocationReasonV2.CertificateHold)] + [InlineData(8u, Constants.RevocationReasonV2.Unspecified)] // removeFromCRL: CRL-only, not a valid revoke reason + [InlineData(9u, Constants.RevocationReasonV2.PrivilegeWithdrawn)] + [InlineData(10u, Constants.RevocationReasonV2.AACompromise)] + [InlineData(99u, Constants.RevocationReasonV2.Unspecified)] // unknown → unspecified + public void ToV2RevocationReason_MapsCorrectly(uint crlReason, string expectedV2Reason) + { + StatusMapper.ToV2RevocationReason(crlReason).Should().Be(expectedV2Reason); + } + + // --------------------------------------------------------------------------- + // Round-trip: ToV2RevocationReason never returns null or empty + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(0u), InlineData(1u), InlineData(3u), InlineData(4u), InlineData(5u), InlineData(9u)] + public void ToV2RevocationReason_NeverReturnsNullOrEmpty(uint crlReason) + { + StatusMapper.ToV2RevocationReason(crlReason).Should().NotBeNullOrEmpty(); + } + + // --------------------------------------------------------------------------- + // Every CRL reason code Keyfactor Command can send + // to IAnyCAPlugin.Revoke must map to a value in the V2 spec's kebab-case + // `reason` enum ("Revoke Certificate" in the V2 spec), never to a camelCase string that would get HTTP 400. + // --------------------------------------------------------------------------- + + /// + /// The V2 spec's `reason` enum, hardcoded from the spec text rather than from + /// so this test still catches a + /// future accidental edit to that class drifting away from the spec. + /// + private static readonly HashSet SpecRevocationReasonEnum = new() + { + "unspecified", + "key-compromise", + "ca-compromise", + "affiliation-changed", + "superseded", + "cessation-of-operation", + "certificate-hold", + "privilege-withdrawn", + "aa-compromise", + }; + + // RFC 5280 CRLReason codes that Keyfactor Command can pass through to + // IAnyCAPlugin.Revoke's revocationReason parameter (0-10, minus the two + // codes RFC 5280 never assigns: 7 and, for a *request* reason, 8 + // (removeFromCRL is CRL-only) is still exercised here to prove it degrades + // safely to "unspecified" rather than to an invalid string). + [Theory] + [InlineData(0u)] + [InlineData(1u)] + [InlineData(2u)] + [InlineData(3u)] + [InlineData(4u)] + [InlineData(5u)] + [InlineData(6u)] + [InlineData(8u)] + [InlineData(9u)] + [InlineData(10u)] + public void ToV2RevocationReason_EveryCrlCode_MapsToASpecEnumValue(uint crlReason) + { + string v2Reason = StatusMapper.ToV2RevocationReason(crlReason); + + SpecRevocationReasonEnum.Should().Contain(v2Reason, + $"CRL reason code {crlReason} mapped to '{v2Reason}', which is not one of the V2 spec's " + + "kebab-case reason values — sending it would get HTTP 400."); + } + + // --------------------------------------------------------------------------- + // V2RevocationReasonToCrlCode — the inverse of ToV2RevocationReason, + // used to populate AnyCAPluginCertificate.RevocationReason from a Track Order + // response's nested revocation.reason string. + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(Constants.RevocationReasonV2.Unspecified, 0)] + [InlineData(Constants.RevocationReasonV2.KeyCompromise, 1)] + [InlineData(Constants.RevocationReasonV2.CACompromise, 2)] + [InlineData(Constants.RevocationReasonV2.AffiliationChanged, 3)] + [InlineData(Constants.RevocationReasonV2.Superseded, 4)] + [InlineData(Constants.RevocationReasonV2.CessationOfOperation, 5)] + [InlineData(Constants.RevocationReasonV2.CertificateHold, 6)] + [InlineData(Constants.RevocationReasonV2.PrivilegeWithdrawn, 9)] + [InlineData(Constants.RevocationReasonV2.AACompromise, 10)] + [InlineData("KEY-COMPROMISE", 1)] // case-insensitive + [InlineData("not-a-real-reason", 0)] // unrecognized → unspecified + [InlineData(null, 0)] // absent/null → unspecified + public void V2RevocationReasonToCrlCode_MapsCorrectly(string v2Reason, int expectedCrlCode) + { + StatusMapper.V2RevocationReasonToCrlCode(v2Reason).Should().Be(expectedCrlCode); + } + + // Round-trip: every CRL code ToV2RevocationReason can produce must map back to the + // same code through V2RevocationReasonToCrlCode (the codes ToV2RevocationReason never + // emits — 7, and CRL-only 8 — are out of scope, matching ToV2RevocationReason's own + // documented behavior of mapping "no V2 equivalent" codes to "unspecified"). + [Theory] + [InlineData(0u)] + [InlineData(1u)] + [InlineData(2u)] + [InlineData(3u)] + [InlineData(4u)] + [InlineData(5u)] + [InlineData(6u)] + [InlineData(9u)] + [InlineData(10u)] + public void V2RevocationReasonToCrlCode_RoundTripsWithToV2RevocationReason(uint crlReason) + { + string v2Reason = StatusMapper.ToV2RevocationReason(crlReason); + StatusMapper.V2RevocationReasonToCrlCode(v2Reason).Should().Be((int)crlReason); + } + } +} diff --git a/CERTInext.Tests/TESTING.md b/CERTInext.Tests/TESTING.md index e56c35a..4809bea 100644 --- a/CERTInext.Tests/TESTING.md +++ b/CERTInext.Tests/TESTING.md @@ -6,19 +6,28 @@ The `CERTInext.Tests` project contains unit and contract tests for the CERTInext REST plugin. No external services are required — all HTTP I/O is handled in-process by WireMock.Net or replaced by Moq strict mocks. -The project is split into several focused test classes: +The project is split into focused test classes: | Class | Layer under test | Isolation technique | |---|---|---| -| `CERTInextClientTests` | `CERTInextClient` HTTP transport | WireMock.Net (real loopback HTTP) | -| `CERTInextClientRequestShapeTests` | `CERTInextClient` request body construction | WireMock.Net | -| `CERTInextCAPluginTests` | `CERTInextCAPlugin` IAnyCAPlugin logic | Moq strict mock of `ICERTInextClient` | -| `CERTInextCAPluginCoverageTests` | Additional plugin logic paths | Moq strict mock | -| `CERTInextCAPluginPublicSurfaceTests` | Binary-compat / no-DCV surface contract | Reflection only | -| `BoundedDcvSyncTests` | DCV sync age/cap filter logic | Pure unit (no I/O) | -| `RateLimitRetryTests` | Rate-limit back-off helpers | Pure unit (no I/O) | -| `ExtractSerialFromPemTests` | PEM serial-number extraction | Pure unit (no I/O) | -| `RedactCredentialsTests` | Log credential-redaction helper | Pure unit (no I/O) | +| `CERTInextClientTests`, `CERTInextClientCoverageTests` | `CERTInextClient` V1 HTTP transport, auth, and error branches | WireMock.Net (real loopback HTTP) | +| `CERTInextClientRequestShapeTests` | V1 `GenerateOrderSSL` request body construction | WireMock.Net | +| `CERTInextClientV2Tests` | `CERTInextClient` V2 transport: token caching, order create/track/CSR/revoke/cancel, DCV, catalog, orders report | WireMock.Net | +| `CERTInextCAPluginTests`, `CERTInextCAPluginCoverageTests` | `CERTInextCAPlugin` V1 logic: enroll, renew, revoke, sync, validation | Moq strict mock of `ICERTInextClient` | +| `CERTInextCAPluginV2Tests` | V2 dispatch in the plugin: ping, enroll, revoke, single record, sync, validation | Moq strict mock | +| `CERTInextCAPluginDcvTests`, `CERTInextCAPluginV2DcvTests` | V1 and V2 DNS-01 DCV orchestration | Moq strict mock + `FakeDomainValidator` | +| `CERTInextCAPluginV2PickupTests`, `CERTInextCAPluginV2EnrollRevokedTests` | V2 synchronous pickup poll; a revoked order observed during enrollment | Moq strict mock | +| `V2UccEnrollmentTests`, `V2WildcardEnrollmentTests`, `V2UccNonDnsSanLoggingTests` | V2 multi-domain and wildcard handling, SAN guard, non-DNS SAN logging | Moq strict mock | +| `V2PrivatePkiEnrollmentTests`, `V2FamilyOrderRequestSerializationTests` | V2 Private PKI enrollment and the Private PKI / Document Signer request bodies | Moq strict mock; DTO serialization | +| `V2ProductVariantDerivationTests`, `V2SignerPlaceRequiredTests`, `V2OrganizationEnrollmentTests`, `V2GroupNumberEnrollmentTests`, `V2SubscriptionEnrollmentTests`, `V2TechnicalContactEnrollmentTests`, `EmailNotificationsEnrollmentTests`, `RequestorDesignationEnrollmentTests` | How each connector and template setting reaches the V2 order body, and the checks that run before an order is placed | Moq strict mock; DTO serialization | +| `V2SslOrderBodyGoldenTests` | The V2 SSL create-order body is byte-for-byte stable | Moq strict mock; golden JSON | +| `V2CsrTransportFailureTests`, `V2OrphanedOrderCancelTests` | What happens when CSR submission fails: cancel the orphaned order, or track it first after a transport error | Moq strict mock | +| `V2UnknownStatusTests`, `StatusMapperV2Tests`, `V2OrderStatusResponseTests` | V2 status mapping, the `unknown` status, revocation reason mapping, status-response parsing | Pure unit | +| `CERTInextCAPluginPublicSurfaceTests` | The plugin's gateway-visible public surface | Reflection only | +| `CERTInextCAPluginAuditLoggingTests`, `CERTInextCAPluginRevokeV2AuditLoggingTests` | Audit log lines for enrollment and V2 revocation | Captured logger | +| `RedactPersonalDataTests`, `RedactCredentialsTests`, `MaskEmailTests`, `SanLogMaskingTests`, `SensitiveRequestDataConfigTests` | Log redaction and `LogSensitiveRequestData` | Pure unit | +| `SanSubmissionTests` | UCC SAN submission: gateway SAN keys, CSR fallback, non-DNS SANs | WireMock.Net and pure unit | +| `EnrollmentParamsTests`, `ExtractErrorMessageTests`, `V1NonSuccessResponseTests`, `ExtractSerialFromPemTests`, `BoundedDcvSyncTests`, `RateLimitRetryTests` | Template parameter parsing, error-message extraction, PEM serial extraction, DCV sync bounds, rate-limit back-off | Pure unit (no I/O) | If a test fails in `CERTInextClientTests` or `CERTInextClientRequestShapeTests`, the bug is in HTTP transport or request serialisation. If it fails in `CERTInextCAPluginTests` or @@ -29,7 +38,7 @@ HTTP transport or request serialisation. If it fails in `CERTInextCAPluginTests` ## Running the Tests **Prerequisites:** -- .NET 8 or .NET 10 SDK +- .NET 10 SDK (the test project targets `net8.0` and references the plugin, which also builds `net10.0`) - NuGet packages restored (`dotnet restore`) - No external services required @@ -56,19 +65,22 @@ stops it in `Dispose()`, so tests are isolated and can run in parallel without p ## Authentication model -The real CERTInext API uses HTTP POST for **all** endpoints. There is no Authorization header +The CERTInext V1 API uses HTTP POST for **all** endpoints. There is no Authorization header for AccessKey mode. Instead, every request body includes a `meta` block containing: - `authKey` — `SHA256(accessKey + requestTs + requestTxnId)` (lowercase hex) - `ts` — ISO 8601 timestamp -- `txn` — unique transaction UUID +- `txn` — random numeric transaction ID The raw access key is never transmitted — only the derived hash is sent. `AuthMode` accepted values: -- `AccessKey` (primary) — HMAC signed body -- `OAuth` (alternative) — bearer token via client credentials flow -- `ApiKey`, `AccessKeyLegacy`, `OAuthLegacy` — legacy aliases accepted for backward compatibility +- `AccessKey` (primary) — `authKey` in the request body +- `OAuth` (alternative) — bearer token via client credentials flow, sent in an `Authorization` header with an empty `authKey` +- `ApiKey` and `OAuth2` — legacy aliases accepted for backward compatibility + +The V2 API (`CERTInextClientV2Tests`) authenticates with an OAuth2 `client_credentials` bearer token +requested from `{ApiUrl}/oauth/token`. --- @@ -158,6 +170,34 @@ CERTInext has no dedicated renewal endpoint. `RenewCertificateAsync` submits a n | `GetProfilesAsync_ReturnsProfiles_WhenServerResponds` | `POST /GetProductDetails` → two products in nested category envelope | Result has 2 items; `ProfileIdTls` and `ProfileIdClient` present; all `Active == true` | | `GetProfilesAsync_ReturnsEmptyList_WhenNoProductsReturned` | `POST /GetProductDetails` → empty `productDetails` array | Result is empty | +### GetProductDetailsV2Async — GET /api/certinext/v2/catalog/products (`CERTInextClientV2Tests`) + +The V2 catalog returns the same nested category envelope as V1's `GetProductDetails`, under a +top-level `"products"` key. `ParseProductDetailsV2Response` flattens each shape into +`ProductDetail`; the flat `productId` and bare-array shapes are kept as fallback branches for other +accounts and API versions. + +| Test | Stub | Assertion | +|------|------|-----------| +| `GetProductDetailsV2Async_NestedCategoryEnvelope_FlattensProducts` | `GET catalog/products` → nested category envelope | 2 products; `ProductCode`/`ProductName`/`ProductType` populated, `Active == true` | +| `GetProductDetailsV2Async_FlatProductIdRows_MapsToProductCode` | `GET catalog/products` → flat `productId` rows | `productId` mapped to `ProductCode` | +| `GetProductDetailsV2Async_BareArray_Parses` | `GET catalog/products` → bare JSON array | Parses without a wrapper object | +| `GetProductDetailsV2Async_EmptyCatalog_ReturnsEmptyList` | `GET catalog/products` → `{"products":[]}` | Returns an empty list (no throw) | + +### ValidateProductInfo — `CERTInextCAPluginTests` (V1) / `CERTInextCAPluginV2Tests` (V2) + +`ValidateProductInfo` builds its own `CERTInextClient` from `connectionInfo` (ignoring the +Moq-injected client), so these tests use a real WireMock server as `ApiUrl`. + +| Test | Mode | Stub | Assertion | +|------|------|------|-----------| +| `ValidateProductInfo_V1_Succeeds_WhenProductCodePresent` | V1 | `POST /GetProductDetails` → nested envelope containing the code | Does not throw | +| `ValidateProductInfo_V1_Throws_WhenProductCodeAbsent` | V1 | Same stub, unknown code | Throws `AnyCAValidationException` `*not found*` | +| `ValidateProductInfo_V2_Succeeds_WhenProductCodeInCatalog` | V2 | `GET catalog/products` → nested envelope containing the code | Does not throw; no request ever hits `/GetProductDetails` | +| `ValidateProductInfo_V2_Throws_WhenProductCodeNotInCatalog` | V2 | Same stub, unknown code | Throws `AnyCAValidationException` `*not found*` | +| `ValidateProductInfo_V2_Throws_WhenCatalogEmpty` | V2 | `GET catalog/products` → `{"products":[]}` | Throws `*not found*` — no soft-accept, matches V1 | +| `ValidateProductInfo_V2_Throws_WhenCatalogReturnsError` | V2 | `GET catalog/products` → HTTP 500 | Throws `*Unable to validate*`; message excludes the response body | + ### DCV endpoints | Test | Stub | Assertion | @@ -277,9 +317,10 @@ already called it throws `InvalidOperationException`. ## CERTInextCAPluginPublicSurfaceTests -Reflection-based contract tests that verify the no-DCV build does not expose any public types, -fields, methods, or constructors that reference `IDomainValidatorFactory` or other IAnyCAPlugin -3.3-only types. These tests ensure the default build loads cleanly on AnyCA Gateway 25.5.x hosts. +Reflection-based contract tests that pin the plugin's gateway-visible public surface: no public +constructor, field, method, or nested type references `IDomainValidatorFactory` or another +IAnyCAPlugin 3.3-only type, so the plugin class loads even when the host doesn't supply the factory +type, and DCV is then simply inactive. | Test | What it checks | |------|---------------| @@ -359,6 +400,10 @@ block with `status: "1"` (success) or `status: "0"` (failure). | `OrderReportEmptyJson()` | `POST /GetOrderReport` | Empty `ordersArray`, `noOfPages=0` | | `GetProductDetailsJson()` | `POST /GetProductDetails` | Nested category envelope with two products | | `GetProductDetailsEmptyJson()` | `POST /GetProductDetails` | Empty `productDetails` array | +| `GetCatalogProductsV2NestedJson()` | `GET catalog/products` | Nested category envelope (the live sandbox shape) | +| `GetCatalogProductsV2FlatJson()` | `GET catalog/products` | Flat `productId` rows (alternate shape, fallback branch) | +| `GetCatalogProductsV2BareArrayJson()` | `GET catalog/products` | Bare JSON array, no wrapper object | +| `GetCatalogProductsV2EmptyJson()` | `GET catalog/products` | `{"products":[]}` | | `ApiFailureJson(code, msg)` | Any endpoint | Generic `meta.status="0"` failure | | `GetDcvSuccessJson(token)` | `POST /GetDcv` | `dcvDetails.token` | | `GetDcvFailureJson(code, msg)` | `POST /GetDcv` | Failure meta | diff --git a/CERTInext.Tests/V1NonSuccessResponseTests.cs b/CERTInext.Tests/V1NonSuccessResponseTests.cs new file mode 100644 index 0000000..4940129 --- /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 +{ + /// + /// 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 one returned when the V1 GetOrderReport + /// call is 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/CERTInext.Tests/V2CsrTransportFailureTests.cs b/CERTInext.Tests/V2CsrTransportFailureTests.cs new file mode 100644 index 0000000..6c1c347 --- /dev/null +++ b/CERTInext.Tests/V2CsrTransportFailureTests.cs @@ -0,0 +1,213 @@ +// 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.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// A transport-level failure or timeout from SubmitCsrV2Async does + /// not tell whether CERTInext actually received + /// the CSR — only that no successful response was seen. Cancelling unconditionally (as is done for a *definitive* CA rejection, covered in + /// V2OrphanedOrderCancelTests) can orphan an order the CA genuinely accepted. + /// + /// Covers the three ambiguous-failure branches: still pending-csr after tracking (cancel), progressed past pending-csr (continue the normal flow, no cancel), and tracking + /// itself failing (return pending without cancelling). Also pins the pure classification logic + /// in . + /// + public class V2CsrTransportFailureTests + { + private const string OrderId = "ord_transport_001"; + private const string Family = Constants.ApiV2.FamilySsl; + + // Mirrors ThrowOnV2Failure's generic fallback shape for a response that never arrived + // (RestSharp's ThrowOnAnyError=false swallows the transport failure into a non-successful + // response with the default HttpStatusCode, which is 0). + private const string TransportFailureText = + "CERTInext V2 API error during 'V2 submit CSR'. HTTP 0. See gateway logs for raw response."; + + private static CERTInextConfig BaseConfig() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "DevOps Team", + RequestorEmail = "devops@acme.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "4155551234", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + private static EnrollmentProductInfo SslProductInfo() => new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + private static Mock MockPlacingOrder() + { + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-csr" }); + return mock; + } + + private static Task EnrollAsync(ICERTInextClient client) => + new CERTInextCAPlugin(client, BaseConfig()).Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: SslProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + // --------------------------------------------------------------------------- + // Still pending-csr after tracking -> cancel (same outcome as a definitive rejection). + // --------------------------------------------------------------------------- + + [Fact] + public async Task TransportFailure_StillPendingCsrAfterTracking_Cancels_ReturnsFailed() + { + var mock = MockPlacingOrder(); + mock.Setup(c => c.SubmitCsrV2Async(Family, OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception(TransportFailureText)); + mock.Setup(c => c.TrackOrderV2Async(Family, OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = Constants.ApiV2.StatusPendingCsr }); + mock.Setup(c => c.CancelOrderV2Async(Family, OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(V2CancelOrderOutcome.Cancelled); + + var result = await EnrollAsync(mock.Object); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain("The orphaned order was cancelled."); + mock.Verify(c => c.TrackOrderV2Async(Family, OrderId, It.IsAny()), Times.Once); + mock.Verify(c => c.CancelOrderV2Async( + Family, OrderId, CERTInextCAPlugin.OrphanedOrderCancelReason, It.IsAny()), Times.Once); + mock.Verify(c => c.SubmitCsrV2Async(Family, OrderId, It.IsAny(), It.IsAny()), + Times.Once, "the CSR submit is never retried"); + } + + // --------------------------------------------------------------------------- + // Progressed past pending-csr -> continue the normal flow, do not cancel a valid order. + // --------------------------------------------------------------------------- + + [Fact] + public async Task TransportFailure_ProgressedPastPendingCsr_DoesNotCancel_ContinuesNormalFlow() + { + var mock = MockPlacingOrder(); + mock.Setup(c => c.SubmitCsrV2Async(Family, OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception(TransportFailureText)); + // CERTInext actually received the CSR despite the transport error on our side — the + // order has already moved on to pending-approval. + mock.Setup(c => c.TrackOrderV2Async(Family, OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = Constants.ApiV2.StatusPendingApproval }); + + var result = await EnrollAsync(mock.Object); + + result.Status.Should().NotBe((int)EndEntityStatus.FAILED, + "the CSR was actually accepted — this must not be reported as a failed enrollment"); + result.CARequestID.Should().Be(OrderId); + mock.Verify(c => c.CancelOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.TrackOrderV2Async(Family, OrderId, It.IsAny()), Times.Once); + mock.Verify(c => c.SubmitCsrV2Async(Family, OrderId, It.IsAny(), It.IsAny()), + Times.Once, "the CSR submit is never retried"); + } + + // --------------------------------------------------------------------------- + // Tracking itself fails -> don't cancel; return pending with the known orderId. + // --------------------------------------------------------------------------- + + [Fact] + public async Task TransportFailure_TrackingAlsoFails_DoesNotCancel_ReturnsPendingWithOrderId() + { + var mock = MockPlacingOrder(); + mock.Setup(c => c.SubmitCsrV2Async(Family, OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception(TransportFailureText)); + mock.Setup(c => c.TrackOrderV2Async(Family, OrderId, It.IsAny())) + .ThrowsAsync(new Exception("CERTInext V2 API error during 'V2 track order'. HTTP 0. See gateway logs for raw response.")); + + var result = await EnrollAsync(mock.Object); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be(OrderId); + result.Certificate.Should().BeNull(); + result.StatusMessage.Should().Contain("sync will resolve"); + mock.Verify(c => c.CancelOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Pure classification logic. + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("CERTInext V2 API error during 'V2 submit CSR'. HTTP 0. See gateway logs for raw response.", true)] + [InlineData("CERTInext V2 API error during 'V2 submit CSR'. HTTP 503. Service unavailable.", true)] + [InlineData("Some unrecognized failure shape with no HTTP status at all.", true)] + [InlineData("CERTInext V2 API error during 'V2 submit CSR'. HTTP 400. CSR rejected.", false)] + [InlineData("CERTInext V2 API error during 'V2 submit CSR'. HTTP 404. Order not found.", false)] + [InlineData("CERTInext V2 API error during 'V2 submit CSR'. HTTP 499. Client closed request.", false)] + public void IsTransportLevelCsrFailure_ClassifiesByParsedHttpStatus(string message, bool expectedTransportLevel) + { + CERTInextCAPlugin.IsTransportLevelCsrFailure(new Exception(message)).Should().Be(expectedTransportLevel); + } + + [Fact] + public void IsTransportLevelCsrFailure_OperationCanceledException_IsTransportLevel() + { + CERTInextCAPlugin.IsTransportLevelCsrFailure(new OperationCanceledException()).Should().BeTrue(); + } + + [Fact] + public void IsTransportLevelCsrFailure_HttpRequestException_IsTransportLevel() + { + CERTInextCAPlugin.IsTransportLevelCsrFailure(new HttpRequestException("connection refused")).Should().BeTrue(); + } + + [Fact] + public void IsTransportLevelCsrFailure_TimeoutException_IsTransportLevel() + { + CERTInextCAPlugin.IsTransportLevelCsrFailure(new TimeoutException()).Should().BeTrue(); + } + } +} diff --git a/CERTInext.Tests/V2FamilyOrderRequestSerializationTests.cs b/CERTInext.Tests/V2FamilyOrderRequestSerializationTests.cs new file mode 100644 index 0000000..fd2d295 --- /dev/null +++ b/CERTInext.Tests/V2FamilyOrderRequestSerializationTests.cs @@ -0,0 +1,384 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Wire-shape tests for the Private PKI and Document Signer create-order DTOs, checked + /// against the example request bodies in the V2 API spec. Each spec + /// example body is copied verbatim below; the DTO is + /// populated with the same values, serialized with the client's serializer options, and + /// compared structurally (key names, nesting, values — not whitespace or key order). + /// + public class V2FamilyOrderRequestSerializationTests + { + // Mirrors CERTInextClient.GetJsonOptions() (private). + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static string Serialize(T value) => JsonSerializer.Serialize(value, ClientEquivalentJsonOptions()); + + /// Order-insensitive canonical form of a JSON document (object keys sorted). + private static string Canonical(string json) + { + using var doc = JsonDocument.Parse(json); + var sb = new StringBuilder(); + WriteCanonical(doc.RootElement, sb); + return sb.ToString(); + } + + private static void WriteCanonical(JsonElement e, StringBuilder sb) + { + switch (e.ValueKind) + { + case JsonValueKind.Object: + sb.Append('{'); + bool first = true; + foreach (var p in e.EnumerateObject().OrderBy(p => p.Name, System.StringComparer.Ordinal)) + { + if (!first) sb.Append(','); + first = false; + sb.Append(JsonSerializer.Serialize(p.Name)).Append(':'); + WriteCanonical(p.Value, sb); + } + sb.Append('}'); + break; + case JsonValueKind.Array: + sb.Append('['); + bool firstItem = true; + foreach (var item in e.EnumerateArray()) + { + if (!firstItem) sb.Append(','); + firstItem = false; + WriteCanonical(item, sb); + } + sb.Append(']'); + break; + case JsonValueKind.String: + sb.Append(JsonSerializer.Serialize(e.GetString())); + break; + default: + sb.Append(e.GetRawText()); + break; + } + } + + // --------------------------------------------------------------------------- + // Private PKI — spec "Private PKI Certificates" -> "Create - Intranet SSL" + // --------------------------------------------------------------------------- + + private const string SpecCreateIntranetSslBody = """ + { + "variant": "intranet-ssl", + "hostname": "intranet.acme.local", + "additionalHosts": [ + "portal.acme.local", + "reports.acme.local", + "10.0.0.50" + ], + "emailNotifications": "all", + "subscription": { "validityYears": 1 }, + "requestor": { + "name": "DevOps Team", + "email": "devops@acme.com", + "phone": "+14155551234", + "designation": "Platform Engineering" + } + } + """; + + [Fact] + public void PrivatePki_CreateIntranetSslSpecExample_SerializesToTheSpecBody() + { + var request = new V2CreatePrivatePkiOrderRequest + { + Variant = "intranet-ssl", + Hostname = "intranet.acme.local", + AdditionalHosts = new List { "portal.acme.local", "reports.acme.local", "10.0.0.50" }, + EmailNotifications = "all", + Subscription = new V2SubscriptionParams { ValidityYears = 1 }, + Requestor = new V2Requestor + { + Name = "DevOps Team", Email = "devops@acme.com", Phone = "+14155551234", Designation = "Platform Engineering" + } + }; + + // The only difference from the spec example is subscription.autoRenew, which the + // shared V2SubscriptionParams always writes (spec: "Optional (default ON)" — the plugin + // always sends the connector's explicit choice, exactly as for SSL). + string expected = SpecCreateIntranetSslBody.Replace( + "\"subscription\": { \"validityYears\": 1 }", + "\"subscription\": { \"validityYears\": 1, \"autoRenew\": false }"); + + Canonical(Serialize(request)).Should().Be(Canonical(expected)); + } + + [Fact] + public void PrivatePki_OptionalBlocks_AreOmittedWhenNull() + { + var request = new V2CreatePrivatePkiOrderRequest + { + Variant = "igtf-host", + Hostname = "compute01.hpc.example.edu", + Requestor = new V2Requestor { Name = "HPC Operations", Email = "hpc-ops@example.edu" } + }; + + using var doc = JsonDocument.Parse(Serialize(request)); + doc.RootElement.EnumerateObject().Select(p => p.Name).Should().BeEquivalentTo( + new[] { "variant", "requestor", "hostname" }, + "only the spec's strictly-mandatory fields remain when nothing optional is set"); + } + + // --------------------------------------------------------------------------- + // Document Signer — spec "Document Signer Certificates" create examples + // --------------------------------------------------------------------------- + + private const string SpecCreateNaturalPersonBody = """ + { + "subjectType": "natural-person", + "emailNotifications": "all", + "requestor": { + "name": "Sarah Johnson", + "email": "sarah.johnson@example.com", + "phone": "+12025551234", + "designation": "Document Signer" + }, + "subject": { + "firstName": "Sarah", + "lastName": "Johnson", + "email": "sarah.johnson@example.com", + "phone": "+12025551234", + "identityDocumentType": "passport", + "identificationNumber": "X12345678", + "streetAddress1": "1600 Pennsylvania Avenue NW", + "locality": "Washington", + "state": "DC", + "postalCode": "20500", + "countryCode": "US" + }, + "subscription": { "validityYears": 1, "autoRenew": false }, + "agreement": { + "signerName": "Sarah Johnson", + "signerPlace": "Washington, DC", + "accepted": true + }, + "remarks": "Document Signer - Natural Person, US" + } + """; + + [Fact] + public void Signature_CreateNaturalPersonSpecExample_SerializesToExactlyTheSpecBody() + { + var request = new V2CreateSignatureOrderRequest + { + SubjectType = "natural-person", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = "Sarah Johnson", Email = "sarah.johnson@example.com", Phone = "+12025551234", Designation = "Document Signer" + }, + Subject = new V2SignatureSubject + { + FirstName = "Sarah", + LastName = "Johnson", + Email = "sarah.johnson@example.com", + Phone = "+12025551234", + IdentityDocumentType = "passport", + IdentificationNumber = "X12345678", + StreetAddress1 = "1600 Pennsylvania Avenue NW", + Locality = "Washington", + State = "DC", + PostalCode = "20500", + CountryCode = "US" + }, + Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false }, + // signerIp deliberately null: not in the signature field table, and the spec's + // Accept Agreement note says it "will be ignored" if sent. + Agreement = new V2AgreementParams { SignerName = "Sarah Johnson", SignerPlace = "Washington, DC", Accepted = true }, + Remarks = "Document Signer - Natural Person, US" + }; + + Canonical(Serialize(request)).Should().Be(Canonical(SpecCreateNaturalPersonBody)); + } + + private const string SpecCreateLegalPersonBody = """ + { + "subjectType": "legal-person", + "emailNotifications": "all", + "requestor": { + "name": "Michael Chen", + "email": "michael.chen@acme.com", + "phone": "+14155551234", + "designation": "VP Engineering" + }, + "subject": { + "firstName": "Michael", + "lastName": "Chen", + "email": "michael.chen@acme.com", + "phone": "+14155551234", + "designation": "VP Engineering", + "organizationName": "Acme Corporation", + "organizationUnit": "Engineering", + "organizationIdentificationNumber": "EIN-12-3456789", + "identityDocumentType": "passport", + "identificationNumber": "P98765432", + "streetAddress1": "500 Market Street", + "streetAddress2": "Suite 300", + "locality": "San Francisco", + "state": "CA", + "postalCode": "94105", + "countryCode": "US" + }, + "subscription": { "validityYears": 1, "autoRenew": false }, + "agreement": { + "signerName": "Michael Chen", + "signerPlace": "San Francisco, CA", + "accepted": true + }, + "remarks": "Document Signer - Legal Person, employee of Acme Corp" + } + """; + + [Fact] + public void Signature_CreateLegalPersonSpecExample_SerializesToExactlyTheSpecBody() + { + var request = new V2CreateSignatureOrderRequest + { + SubjectType = "legal-person", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = "Michael Chen", Email = "michael.chen@acme.com", Phone = "+14155551234", Designation = "VP Engineering" + }, + Subject = new V2SignatureSubject + { + FirstName = "Michael", + LastName = "Chen", + Email = "michael.chen@acme.com", + Phone = "+14155551234", + Designation = "VP Engineering", + OrganizationName = "Acme Corporation", + OrganizationUnit = "Engineering", + OrganizationIdentificationNumber = "EIN-12-3456789", + IdentityDocumentType = "passport", + IdentificationNumber = "P98765432", + StreetAddress1 = "500 Market Street", + StreetAddress2 = "Suite 300", + Locality = "San Francisco", + State = "CA", + PostalCode = "94105", + CountryCode = "US" + }, + Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false }, + Agreement = new V2AgreementParams { SignerName = "Michael Chen", SignerPlace = "San Francisco, CA", Accepted = true }, + Remarks = "Document Signer - Legal Person, employee of Acme Corp" + }; + + Canonical(Serialize(request)).Should().Be(Canonical(SpecCreateLegalPersonBody)); + } + + private const string SpecCreateLegalEntityBody = """ + { + "subjectType": "legal-entity", + "emailNotifications": "all", + "requestor": { + "name": "Acme Corporation Compliance", + "email": "pki-ops@acme.com", + "phone": "+14155551234", + "designation": "PKI Operations" + }, + "subject": { + "organizationName": "Acme Corporation", + "organizationUnit": "Compliance", + "businessCategory": "Business Entity", + "organizationIdentificationNumber": "EIN-12-3456789", + "email": "pki-ops@acme.com", + "phone": "+14155551234", + "streetAddress1": "500 Market Street", + "streetAddress2": "Suite 300", + "locality": "San Francisco", + "state": "CA", + "postalCode": "94105", + "countryCode": "US" + }, + "subscription": { "validityYears": 1, "autoRenew": false }, + "agreement": { + "signerName": "Acme Corp PKI Operations", + "signerPlace": "San Francisco, CA", + "accepted": true + }, + "remarks": "Document Signer - Legal Entity (org-only subject)" + } + """; + + [Fact] + public void Signature_CreateLegalEntitySpecExample_SerializesToExactlyTheSpecBody_WithNoPersonNameKeys() + { + var request = new V2CreateSignatureOrderRequest + { + SubjectType = "legal-entity", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = "Acme Corporation Compliance", Email = "pki-ops@acme.com", Phone = "+14155551234", Designation = "PKI Operations" + }, + Subject = new V2SignatureSubject + { + OrganizationName = "Acme Corporation", + OrganizationUnit = "Compliance", + BusinessCategory = "Business Entity", + OrganizationIdentificationNumber = "EIN-12-3456789", + Email = "pki-ops@acme.com", + Phone = "+14155551234", + StreetAddress1 = "500 Market Street", + StreetAddress2 = "Suite 300", + Locality = "San Francisco", + State = "CA", + PostalCode = "94105", + CountryCode = "US" + }, + Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false }, + Agreement = new V2AgreementParams { SignerName = "Acme Corp PKI Operations", SignerPlace = "San Francisco, CA", Accepted = true }, + Remarks = "Document Signer - Legal Entity (org-only subject)" + }; + + string json = Serialize(request); + + Canonical(json).Should().Be(Canonical(SpecCreateLegalEntityBody)); + json.Should().NotContain("firstName").And.NotContain("lastName", + "subject.firstName/lastName are conditional on natural-/legal-person and must be omitted, not sent null"); + } + + [Fact] + public void Signature_SubjectEmail_IsAlwaysWritten_EvenWhenOtherSubjectFieldsAreAbsent() + { + // subject.email is the one strictly-mandatory subject field ("400 if missing"). + var json = Serialize(new V2SignatureSubject { Email = "signer@example.com" }); + + Canonical(json).Should().Be(Canonical("{\"email\":\"signer@example.com\"}")); + } + } +} diff --git a/CERTInext.Tests/V2GroupNumberEnrollmentTests.cs b/CERTInext.Tests/V2GroupNumberEnrollmentTests.cs new file mode 100644 index 0000000..974fc0c --- /dev/null +++ b/CERTInext.Tests/V2GroupNumberEnrollmentTests.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.Generic; +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.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests that the V2 order body carries the connector's configured GroupNumber + /// (V1's DelegationInformation.GroupNumber equivalent), so V2 orders bill to the + /// configured group rather than always the account's default group. These tests exercise + /// EnrollV2Async end-to-end (through ) against a Strict + /// mock, plus direct DTO serialization checks for the new + /// property. + /// + public class V2GroupNumberEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, string groupNumber = "") => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + GroupNumber = groupNumber, + PickupRetries = 0 + }); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — GroupNumber populated / omitted on the order body + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_GroupNumberConfigured_PopulatesGroupNumberOnOrderBody() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, "ord_grp_001"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_grp_001", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, groupNumber: "GRP-12345"); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_grp_001"); + captured.Should().NotBeNull(); + captured!.GroupNumber.Should().Be("GRP-12345", + "a configured GroupNumber must be forwarded to the V2 order body, mirroring V1's " + + "DelegationInformation.GroupNumber"); + } + + [Fact] + public async Task Enroll_V2_GroupNumberBlank_OmitsGroupNumberFromOrderBody() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_grp_002"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_grp_002", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, groupNumber: string.Empty); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_grp_002"); + captured.Should().NotBeNull(); + captured!.GroupNumber.Should().BeNull( + "an unconfigured GroupNumber must be omitted (null), not sent as an empty string — " + + "the account's default billing group should apply, same as V1's fallback behavior"); + } + + // --------------------------------------------------------------------------- + // DTO serialization — groupNumber key present/absent on the wire + // --------------------------------------------------------------------------- + + [Fact] + public void V2CreateSslOrderRequest_Serialization_OmitsGroupNumber_WhenNull() + { + var req = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "Jane Doe", Email = "jane@example.com" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + GroupNumber = null + }; + + string json = JsonSerializer.Serialize(req); + + json.Should().NotContain("groupNumber", + "the groupNumber key itself must be absent when unset, not present-but-null"); + } + + [Fact] + public void V2CreateSslOrderRequest_Serialization_IncludesGroupNumber_WhenSet() + { + var req = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "Jane Doe", Email = "jane@example.com" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + GroupNumber = "GRP-999" + }; + + string json = JsonSerializer.Serialize(req); + + json.Should().Contain("\"groupNumber\":\"GRP-999\""); + } + } +} diff --git a/CERTInext.Tests/V2OrderStatusResponseTests.cs b/CERTInext.Tests/V2OrderStatusResponseTests.cs new file mode 100644 index 0000000..338f775 --- /dev/null +++ b/CERTInext.Tests/V2OrderStatusResponseTests.cs @@ -0,0 +1,77 @@ +// 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.Json; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Pure DTO deserialization tests for 's nested + /// revocation object. No HTTP layer involved — these assert directly + /// against , isolated from the client and plugin code that + /// consumes this type. + /// + public class V2OrderStatusResponseTests + { + /// + /// Raw Track Order response body, as returned against a real revoked SSL order + /// (family ssl-certificates). Verbatim except for whitespace; unmapped fields (requestor, orderedBy, + /// subscriberAgreement, subscription, verifications) are expected to be ignored by + /// System.Text.Json's default unmapped-member handling. + /// + private const string LiveRevokedTrackOrderJson = + @"{""orderId"":""6758681362"",""requestId"":""1279754567"",""status"":""revoked"",""orderState"":""Order Accepted"",""certificateState"":""Certificate Revoked"",""productVariant"":""dv"",""domain"":""nt2-20260924204408874594000.dcv-test.scrup.org"",""expiresAt"":""2026-12-23T20:44:23Z"",""requestor"":{""name"":""Keyfactor Plugin Test"",""email"":""plugin-test@keyfactor.com"",""phone"":""+0000000000"",""designation"":""Plugin Test""},""orderedBy"":{""name"":""Sean"",""email"":""sbailey@keyfactor.com""},""csrSubmitted"":true,""subscriberAgreement"":{""signed"":true,""signerName"":""Keyfactor Plugin Test"",""signedAt"":""2026-09-25T14:18:14Z"",""signedPlace"":""Gateway Lab""},""subscription"":{""validityYears"":1,""endDate"":""2027-09-24T20:44:27Z"",""status"":""active""},""revocation"":{""status"":""Certificate Revoked"",""reason"":""cessation-of-operation"",""processedAt"":""2026-09-24T20:44:41Z""},""verifications"":{""domain"":{""status"":""VERIFIED"",""domains"":[{""domain"":""nt2-20260924204408874594000.dcv-test.scrup.org"",""domainStatus"":""ACTIVE"",""dcvMethod"":""dns-txt"",""dcvStatus"":""VERIFIED"",""verifiedAt"":""2026-09-24T20:44:13Z"",""caaStatus"":""PASSED""}]},""empty"":false}}"; + + [Fact] + public void Deserialize_LiveRevokedOrderBody_PopulatesNestedRevocationObject() + { + var result = JsonSerializer.Deserialize(LiveRevokedTrackOrderJson); + + result.Should().NotBeNull(); + result!.OrderId.Should().Be("6758681362"); + result.Status.Should().Be("revoked"); + result.ProductVariant.Should().Be("dv"); + result.Domain.Should().Be("nt2-20260924204408874594000.dcv-test.scrup.org"); + + // `revocation` is a nested object — status/reason/processedAt — not + // flat top-level revocationReason/revocationDate properties. + result.Revocation.Should().NotBeNull(); + result.Revocation!.Status.Should().Be("Certificate Revoked"); + result.Revocation.Reason.Should().Be("cessation-of-operation", + "the wire reason is RFC 5280-style hyphenated, not V1's camelCase convention"); + result.Revocation.ProcessedAt.Should().Be( + new DateTime(2026, 9, 24, 20, 44, 41, DateTimeKind.Utc), + "processedAt is standard ISO 8601 UTC and should bind directly with no custom converter"); + } + + [Fact] + public void Deserialize_NotRevokedOrderBody_RevocationIsNull() + { + // The `revocation` key is absent entirely when an order has never been revoked — + // not present-but-null. + const string issuedJson = + @"{""orderId"":""ord_abc001"",""requestId"":""req_xyz001"",""status"":""issued"",""productVariant"":""dv"",""domain"":""example.com""}"; + + var result = JsonSerializer.Deserialize(issuedJson); + + result.Should().NotBeNull(); + result!.Status.Should().Be("issued"); + result.Revocation.Should().BeNull(); + } + } +} diff --git a/CERTInext.Tests/V2OrganizationEnrollmentTests.cs b/CERTInext.Tests/V2OrganizationEnrollmentTests.cs new file mode 100644 index 0000000..8e9a55f --- /dev/null +++ b/CERTInext.Tests/V2OrganizationEnrollmentTests.cs @@ -0,0 +1,259 @@ +// 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.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.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests that the V2 organization block is populated for OV/EV product variants; + /// CERTInext hard-rejects an OV/EV order that omits it (HTTP 422 + /// [EMS-1180] Organization Name cannot be empty). These tests exercise + /// EnrollV2Async end-to-end (through ) against a + /// Strict mock, plus direct DTO serialization checks for the + /// new block on . + /// + public class V2OrganizationEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, string organizationNumber = "") => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + OrganizationNumber = organizationNumber, + PickupRetries = 0 + }); + + // ProductVariant must agree with ProductId (the plugin derives/validates + // one from the other), so callers pass both explicitly rather than this helper hardcoding + // a single ProductID ("OV SSL") for every variant under test. + private static EnrollmentProductInfo MakeV2ProductInfo(string productId, string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-organization-verification" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — organization block populated / omitted per productVariant + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("ov", Constants.Products.OvSsl, "846", "16")] + [InlineData("ev", Constants.Products.EvSsl, "847", "19")] + [InlineData("OV", Constants.Products.OvSsl, "846", "16")] + [InlineData("Ev", Constants.Products.EvSsl, "847", "19")] + public async Task Enroll_V2_OvOrEvProduct_PopulatesOrganizationBlockFromConfig( + string productVariant, string productId, string catalogCode, string catalogTypeId) + { + var mock = NewMock(); + StubCatalog(mock, catalogCode, catalogTypeId); + StubHappyOrderPlacement(mock, "ord_org_001"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_org_001", Status = "pending-organization-verification" }); + + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-12345"); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo(productId, catalogCode, productVariant), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_org_001"); + captured.Should().NotBeNull(); + captured!.Organization.Should().NotBeNull( + "organization is mandatory for OV/EV orders per the V2 spec's field table"); + captured.Organization.OrganizationNumber.Should().Be("ORG-12345"); + captured.Organization.PreVetted.Should().BeTrue(); + } + + [Fact] + public async Task Enroll_V2_DvProduct_OmitsOrganizationBlock_EvenWhenOrganizationNumberConfigured() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, "ord_dv_001"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_dv_001", Status = "pending-dcv" }); + + // OrganizationNumber IS configured — a DV order must still omit the block per spec + // ("organization | Conditional - Mandatory for OV / EV", not DV). + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-12345"); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo(Constants.Products.DvSsl, "842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_dv_001"); + captured.Should().NotBeNull(); + captured!.Organization.Should().BeNull( + "DV orders must not send an organization block even when OrganizationNumber is configured"); + } + + [Theory] + [InlineData("ov", Constants.Products.OvSsl)] + [InlineData("ev", Constants.Products.EvSsl)] + public async Task Enroll_V2_OvOrEvProduct_MissingOrganizationNumber_FailsFastWithoutCallingCa( + string productVariant, string productId) + { + // Strict mock with NOTHING stubbed: proves the guard fires before any catalog lookup + // or order-placement call — mirrors the CSR-SAN-count guard's own Strict-mock test. + var mock = NewMock(); + var plugin = BuildV2Plugin(mock.Object, organizationNumber: string.Empty); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo(productId, "846", productVariant), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("OrganizationNumber"); + + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Never, + "the OrganizationNumber guard must reject before any catalog lookup is attempted"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // DTO serialization — organization block shape on the wire + // --------------------------------------------------------------------------- + + [Fact] + public void V2CreateSslOrderRequest_Serialization_IncludesOrganizationBlock_ForOvEv() + { + var req = new V2CreateSslOrderRequest + { + ProductVariant = "ov", + Requestor = new V2Requestor { Name = "Jane Doe", Email = "jane@example.com" }, + Organization = new V2OrganizationParams + { + OrganizationNumber = "ORG-999", + PreVetted = true + }, + Certificate = new V2CertificateParams { Domain = "example.com" } + }; + + string json = JsonSerializer.Serialize(req); + + json.Should().Contain("\"organization\""); + json.Should().Contain("\"organizationNumber\":\"ORG-999\""); + json.Should().Contain("\"preVetted\":true"); + // preVettingToken is optional and unset here — must be omitted, not sent as null. + json.Should().NotContain("preVettingToken"); + } + + [Fact] + public void V2CreateSslOrderRequest_Serialization_OmitsOrganizationBlock_ForDv() + { + var req = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "Jane Doe", Email = "jane@example.com" }, + Organization = null, + Certificate = new V2CertificateParams { Domain = "example.com" } + }; + + string json = JsonSerializer.Serialize(req); + + json.Should().NotContain("\"organization\"", + "the organization key itself (not just its sub-fields) must be absent for DV orders, " + + "not present-but-empty — an empty/placeholder block risks a different CA-side 422"); + } + } +} diff --git a/CERTInext.Tests/V2OrphanedOrderCancelTests.cs b/CERTInext.Tests/V2OrphanedOrderCancelTests.cs new file mode 100644 index 0000000..1fae5a2 --- /dev/null +++ b/CERTInext.Tests/V2OrphanedOrderCancelTests.cs @@ -0,0 +1,332 @@ +// 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.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.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// When SubmitCsrV2Async throws after the V2 order was placed, the order + /// sits at pending-csr and Command never learns its ID. EnrollV2Async must make + /// exactly one best-effort CancelOrderV2Async call for that order's family and return + /// FAILED with the orderId — never throwing, never retrying. + /// + public class V2OrphanedOrderCancelTests + { + private const string OrderId = "ord_orphan_001"; + private const string CsrFailureText = "CERTInext V2 API error during 'V2 submit CSR'. HTTP 400. CSR rejected."; + + private static CERTInextConfig BaseConfig() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "DevOps Team", + RequestorEmail = "devops@acme.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "4155551234", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + private static EnrollmentProductInfo SslProductInfo() => new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + private static EnrollmentProductInfo PrivatePkiProductInfo() => new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "private-pki", + ["ProductVariant"] = "intranet-ssl", + ["ProductCode"] = "149", + ["DomainName"] = "intranet.acme.local" + } + }; + + /// Strict mock that places an order in . + private static Mock MockPlacingOrder(string family) + { + var mock = new Mock(MockBehavior.Strict); + if (family == Constants.ApiV2.FamilyPrivatePki) + { + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-csr" }); + } + else + { + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-csr" }); + } + return mock; + } + + private static void SubmitCsrThrows(Mock mock, string family) => + mock.Setup(c => c.SubmitCsrV2Async(family, OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception(CsrFailureText)); + + private static Task EnrollAsync(ICERTInextClient client, EnrollmentProductInfo productInfo) => + new CERTInextCAPlugin(client, BaseConfig()).Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + // --------------------------------------------------------------------------- + // Plugin: EnrollV2Async + // --------------------------------------------------------------------------- + + public static IEnumerable Families() => new[] + { + new object[] { Constants.ApiV2.FamilySsl }, + new object[] { Constants.ApiV2.FamilyPrivatePki } + }; + + private static EnrollmentProductInfo ProductInfoFor(string family) => + family == Constants.ApiV2.FamilyPrivatePki ? PrivatePkiProductInfo() : SslProductInfo(); + + [Theory] + [MemberData(nameof(Families))] + public async Task Enroll_SubmitCsrThrows_CancelsOnceInSameFamily_ReturnsFailedWithOrderId(string family) + { + var mock = MockPlacingOrder(family); + SubmitCsrThrows(mock, family); + mock.Setup(c => c.CancelOrderV2Async(family, OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(V2CancelOrderOutcome.Cancelled); + + var result = await EnrollAsync(mock.Object, ProductInfoFor(family)); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().Be(OrderId); + result.Certificate.Should().BeNull(); + result.StatusMessage.Should().Contain("CSR submission failed") + .And.Contain("CSR rejected") + .And.Contain("The orphaned order was cancelled."); + + mock.Verify(c => c.CancelOrderV2Async( + family, OrderId, CERTInextCAPlugin.OrphanedOrderCancelReason, It.IsAny()), Times.Once); + mock.Verify(c => c.CancelOrderV2Async( + It.Is(f => f != family), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.SubmitCsrV2Async(family, OrderId, It.IsAny(), It.IsAny()), + Times.Once, "the CSR submit is never retried"); + // Strict mock: no TrackOrderV2Async / DownloadCertificateV2Async setup, so any + // post-CSR call would have thrown out of Enroll. + } + + [Fact] + public void OrphanedOrderCancelReason_IsNonEmpty_AndCarriesNoExceptionDetail() + { + CERTInextCAPlugin.OrphanedOrderCancelReason.Should().NotBeNullOrWhiteSpace("an empty reason is rejected with EMS-984"); + CERTInextCAPlugin.OrphanedOrderCancelReason.Should().NotContain("CSR rejected"); + } + + [Fact] + public async Task Enroll_SubmitCsrThrows_CancelReturns422_StillFailed_SaysNotCancelled() + { + var mock = MockPlacingOrder(Constants.ApiV2.FamilySsl); + SubmitCsrThrows(mock, Constants.ApiV2.FamilySsl); + mock.Setup(c => c.CancelOrderV2Async(Constants.ApiV2.FamilySsl, OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(V2CancelOrderOutcome.AlreadyTerminal); + + var result = await EnrollAsync(mock.Object, SslProductInfo()); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain("NOT cancelled").And.Contain("422"); + result.StatusMessage.Should().NotContain("The orphaned order was cancelled."); + mock.Verify(c => c.CancelOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Theory] + [MemberData(nameof(Families))] + public async Task Enroll_SubmitCsrThrows_CancelThrows_StillFailed_DoesNotThrow_NoRetry(string family) + { + var mock = MockPlacingOrder(family); + SubmitCsrThrows(mock, family); + mock.Setup(c => c.CancelOrderV2Async(family, OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("CERTInext V2 API error during 'V2 cancel order'. HTTP 500.")); + + EnrollmentResult result = null; + Func act = async () => result = await EnrollAsync(mock.Object, ProductInfoFor(family)); + await act.Should().NotThrowAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain("CSR submission failed") + .And.Contain("NOT cancelled") + .And.Contain("Cancel it manually"); + mock.Verify(c => c.CancelOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once, "the cancel is best-effort and never retried"); + } + + [Theory] + [MemberData(nameof(Families))] + public async Task Enroll_SubmitCsrSucceeds_NeverCancels(string family) + { + var mock = MockPlacingOrder(family); + mock.Setup(c => c.SubmitCsrV2Async(family, OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(family, OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-approval" }); + + var result = await EnrollAsync(mock.Object, ProductInfoFor(family)); + + result.CARequestID.Should().Be(OrderId); + result.Status.Should().NotBe((int)EndEntityStatus.FAILED); + mock.Verify(c => c.CancelOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } + + /// + /// WireMock coverage for : spec + /// "Cancel Order" is POST /api/certinext/v2/{family}/:orderId/cancel with body + /// { "reason": ... }; 204 = cancelled, 422 = already in a terminal state. + /// + public class CERTInextClientCancelOrderV2Tests : IDisposable + { + private const string OrderId = "ord_cancel_001"; + private readonly WireMockServer _server; + + public CERTInextClientCancelOrderV2Tests() + { + _server = WireMockServer.Start(); + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson(3600))); + } + + public void Dispose() => _server.Stop(); + + private CERTInextClient BuildClient() => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "test-v1-key", + AccountNumber = "12345", + UseV2Api = true, + OAuthClientId = "my-v2-client", + OAuthClientSecret = "my-v2-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + PageSize = 100, + GroupNumber = string.Empty + }); + + [Theory] + [InlineData(Constants.ApiV2.FamilySsl, Constants.ApiV2.SslCertificatesPath)] + [InlineData(Constants.ApiV2.FamilyPrivatePki, Constants.ApiV2.PrivatePkiCertificatesPath)] + [InlineData(Constants.ApiV2.FamilySignature, Constants.ApiV2.SignatureCertificatesPath)] + public async Task CancelOrderV2Async_204_PostsReasonToFamilyPath_ReturnsCancelled(string family, string familyPath) + { + string path = $"{familyPath}/{OrderId}/cancel"; + _server + .Given(Request.Create().WithPath(path).UsingPost()) + .RespondWith(Response.Create().WithStatusCode(204)); + + using var client = BuildClient(); + var outcome = await client.CancelOrderV2Async(family, OrderId, "Keyfactor test reason."); + + outcome.Should().Be(V2CancelOrderOutcome.Cancelled); + var entry = _server.LogEntries.Single(e => e.RequestMessage.Path == path); + entry.RequestMessage.Method.Should().Be("POST"); + using var body = JsonDocument.Parse(entry.RequestMessage.Body ?? "{}"); + body.RootElement.GetProperty("reason").GetString().Should().Be("Keyfactor test reason."); + entry.RequestMessage.Headers.Should().ContainKey("Idempotency-Key"); + } + + [Fact] + public async Task CancelOrderV2Async_422_ReturnsAlreadyTerminal_DoesNotThrow() + { + _server + .Given(Request.Create().WithPath($"{Constants.ApiV2.SslCertificatesPath}/{OrderId}/cancel").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(422) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson( + 422, "Unprocessable Entity", "Order is already issued; use POST /{orderId}/revoke instead of /cancel."))); + + using var client = BuildClient(); + var outcome = await client.CancelOrderV2Async(Constants.ApiV2.FamilySsl, OrderId, "reason"); + + outcome.Should().Be(V2CancelOrderOutcome.AlreadyTerminal); + } + + [Fact] + public async Task CancelOrderV2Async_500_Throws() + { + _server + .Given(Request.Create().WithPath($"{Constants.ApiV2.SslCertificatesPath}/{OrderId}/cancel").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(500)); + + using var client = BuildClient(); + await Assert.ThrowsAsync( + () => client.CancelOrderV2Async(Constants.ApiV2.FamilySsl, OrderId, "reason")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CancelOrderV2Async_BlankReason_ThrowsBeforeAnyHttpCall(string reason) + { + using var client = BuildClient(); + await Assert.ThrowsAsync( + () => client.CancelOrderV2Async(Constants.ApiV2.FamilySsl, OrderId, reason)); + _server.LogEntries.Should().BeEmpty(); + } + } +} diff --git a/CERTInext.Tests/V2PrivatePkiEnrollmentTests.cs b/CERTInext.Tests/V2PrivatePkiEnrollmentTests.cs new file mode 100644 index 0000000..f232995 --- /dev/null +++ b/CERTInext.Tests/V2PrivatePkiEnrollmentTests.cs @@ -0,0 +1,601 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +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 +{ + /// + /// Tests that EnrollV2Async builds a family-specific create body rather than the + /// SSL/TLS one () for every product family. A + /// ProductFamily=private-pki template must place a + /// (spec: variant, hostname, + /// additionalHosts; no organization / certificate / agreement block) through the + /// Private PKI client overload, with IP SANs carried into additionalHosts; a + /// ProductFamily=signature template must fail fast with no CA call (open design + /// decision). Driven end-to-end through against a Strict + /// mock, plus WireMock-backed + /// coverage. + /// + public class V2PrivatePkiEnrollmentTests + { + private const string OrderId = "ord_pki_001"; + private const string Hostname = "intranet.acme.local"; + + // Spec "Private PKI Certificates" field table — every key the create body may carry. + private static readonly HashSet SpecPrivatePkiTopLevelKeys = new HashSet + { + "variant", "caProfileId", "masterProductId", "saveAsDraft", "requestId", "emailNotifications", + "groupNumber", "requestor", "hostname", "additionalHosts", "subscription", "csr", "remarks", + "tags", "customFields", "technicalPointOfContact" + }; + + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextConfig BaseConfig() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "DevOps Team", + RequestorEmail = "devops@acme.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "4155551234", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, CERTInextConfig config = null) => + new CERTInextCAPlugin(client, config ?? BaseConfig()); + + private static EnrollmentProductInfo MakePrivatePkiProductInfo( + string variant = "intranet-ssl", string productCode = "149", string domainName = Hostname) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "private-pki" + }; + if (variant != null) parameters["ProductVariant"] = variant; + if (productCode != null) parameters["ProductCode"] = productCode; + if (domainName != null) parameters["DomainName"] = domainName; + + // GetProductIds() only advertises SSL/TLS product names, so a real private-pki + // template is necessarily attached to one of them. + return new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, ProductParameters = parameters }; + } + + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// Stubs the Private PKI placement + CSR submit + track (+ download when issued) and + /// returns an accessor for the captured create body. + /// + private static Func StubPrivatePkiOrder( + Mock mock, string trackStatus = "pending-approval") + { + V2CreatePrivatePkiOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, req, __) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-csr" }); + + mock.Setup(c => c.SubmitCsrV2Async( + Constants.ApiV2.FamilyPrivatePki, OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(Constants.ApiV2.FamilyPrivatePki, OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = trackStatus }); + + if (trackStatus == "issued") + { + mock.Setup(c => c.DownloadCertificateV2Async(Constants.ApiV2.FamilyPrivatePki, OrderId, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = OrderId, + SerialNumber = "0A1B2C", + CertificatePem = MockCertificateData.FakePemCertificate + }); + } + + return () => captured; + } + + private static Task EnrollAsync( + CERTInextCAPlugin plugin, + EnrollmentProductInfo productInfo, + Dictionary san, + string csr = null) => + plugin.Enroll( + csr: csr ?? MockCertificateData.FakeCsrPem, + subject: $"CN={Hostname}", + san: san, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + // BouncyCastle only (project crypto policy). DNS and IP SANs in the extensionRequest. + private static string GenerateCsrPem(string cn, string[] dnsSans, string[] ipSans) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var names = (dnsSans ?? Array.Empty()).Select(d => new GeneralName(GeneralName.DnsName, d)) + .Concat((ipSans ?? Array.Empty()).Select(ip => new GeneralName(GeneralName.IPAddress, ip))) + .ToArray(); + + Org.BouncyCastle.Asn1.Asn1Set attributes = null; + if (names.Length > 0) + { + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, critical: false, + extValue: new GeneralNames(names)); + attributes = new Org.BouncyCastle.Asn1.DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, + new Org.BouncyCastle.Asn1.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-----"; + } + + // --------------------------------------------------------------------------- + // Body shape + routing + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_PrivatePki_PlacesPrivatePkiBody_ThroughPrivatePkiOverload_NotTheSslBody() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock, trackStatus: "issued"); + + var config = BaseConfig(); + config.GroupNumber = "GRP-9"; + config.EmailNotifications = "1"; + config.RequestorDesignation = "Platform Engineering"; + config.SubscriptionAutoRenew = "1"; + config.SubscriptionRenewCriteriaDays = "20"; + // OV/EV-only and agreement-only settings must have no effect on a Private PKI body. + config.OrganizationNumber = "ORG-001"; + config.AutoSecureWww = "1"; + + var result = await EnrollAsync(BuildV2Plugin(mock.Object, config), MakePrivatePkiProductInfo(), + new Dictionary { ["dnsname"] = new[] { Hostname, "portal.acme.local" } }); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.CARequestID.Should().Be(OrderId); + + var req = captured(); + req.Should().NotBeNull("a private-pki enrollment must use the Private PKI PlaceOrderV2Async overload"); + req.Variant.Should().Be("intranet-ssl"); + req.Hostname.Should().Be(Hostname); + req.AdditionalHosts.Should().Equal("portal.acme.local"); + req.EmailNotifications.Should().Be("all"); + req.GroupNumber.Should().Be("GRP-9"); + req.Requestor.Name.Should().Be("DevOps Team"); + req.Requestor.Email.Should().Be("devops@acme.com"); + req.Requestor.Phone.Should().Be("+14155551234"); + req.Requestor.Designation.Should().Be("Platform Engineering"); + req.Subscription.ValidityYears.Should().Be(1); + req.Subscription.AutoRenew.Should().BeTrue(); + req.Subscription.RenewBeforeDays.Should().Be(20); + req.TechnicalPointOfContact.Name.Should().Be("DevOps Team", "blank TechnicalContact* falls back to Requestor*, same as SSL"); + req.TechnicalPointOfContact.Designation.Should().Be(Constants.ApiV2.DefaultTechnicalContactDesignation); + + // The SSL overload and the catalog lookup (SSL UCC detection only) are never touched. + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Never); + mock.Verify(c => c.PlaceOrderV2Async("149", It.IsAny(), It.IsAny()), + Times.Once, "the explicit ProductCode is sent as X-Product-Code as-is"); + + // CSR submit / track / download all hit the private-pki family. + mock.Verify(c => c.SubmitCsrV2Async(Constants.ApiV2.FamilyPrivatePki, OrderId, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.DownloadCertificateV2Async(Constants.ApiV2.FamilyPrivatePki, OrderId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_SerializedBody_UsesOnlySpecFieldNames_AndNoSslBlocks() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + var config = BaseConfig(); + config.GroupNumber = "GRP-9"; + config.EmailNotifications = "1"; + config.RequestorDesignation = "Platform Engineering"; + + await EnrollAsync(BuildV2Plugin(mock.Object, config), MakePrivatePkiProductInfo(), + new Dictionary + { + ["dnsname"] = new[] { Hostname, "portal.acme.local" }, + ["ipaddress"] = new[] { "10.0.0.50" } + }); + + string json = JsonSerializer.Serialize(captured(), ClientEquivalentJsonOptions()); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var keys = root.EnumerateObject().Select(p => p.Name).ToList(); + + keys.Should().BeEquivalentTo(new[] + { + "variant", "emailNotifications", "groupNumber", "requestor", "hostname", "additionalHosts", + "subscription", "remarks", "technicalPointOfContact" + }); + keys.Should().OnlyContain(k => SpecPrivatePkiTopLevelKeys.Contains(k), + "every key must come from the spec's Private PKI field table"); + keys.Should().NotContain(new[] { "productVariant", "certificate", "organization", "agreement", "domain" }, + "Private PKI has no DCV, no organization block, and no Subscriber Agreement (spec)"); + + root.GetProperty("variant").GetString().Should().Be("intranet-ssl"); + root.GetProperty("hostname").GetString().Should().Be(Hostname); + root.GetProperty("additionalHosts").EnumerateArray().Select(e => e.GetString()) + .Should().Equal("portal.acme.local", "10.0.0.50"); + root.GetProperty("requestor").EnumerateObject().Select(p => p.Name) + .Should().BeEquivalentTo(new[] { "name", "email", "phone", "designation" }); + root.GetProperty("technicalPointOfContact").EnumerateObject().Select(p => p.Name) + .Should().BeEquivalentTo(new[] { "name", "email", "phone", "designation" }); + root.GetProperty("subscription").GetProperty("validityYears").GetInt32().Should().Be(1); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_VariantMatchIsCaseInsensitive_AndSentInSpecCase() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + + await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(variant: " IGTF-Host "), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + captured().Variant.Should().Be(Constants.ApiV2.PrivatePkiVariantIgtfHost); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_HostnameFallsBackToSubjectCn_WhenDomainNameUnset() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + + await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(domainName: null), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + captured().Hostname.Should().Be(Hostname, "spec: \"hostname - primary CN\""); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_PendingApproval_ReturnsPendingWithOrderId() + { + var mock = NewMock(); + StubPrivatePkiOrder(mock, trackStatus: "pending-approval"); + + var result = await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be(OrderId, "the order was placed; sync must be able to find it"); + } + + // --------------------------------------------------------------------------- + // SAN mapping -> additionalHosts + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_PrivatePki_AdditionalHosts_CarriesDnsAndIpSans_ExcludesPrimaryDuplicatesAndNonHostTypes() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + + await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(), + new Dictionary + { + ["dnsname"] = new[] { Hostname, "portal.acme.local", "PORTAL.acme.local" }, + ["ipaddress"] = new[] { "10.0.0.50", "fd00::50" }, + ["rfc822name"] = new[] { "ops@acme.com" }, + ["uri"] = new[] { "https://intranet.acme.local/" } + }); + + captured().AdditionalHosts.Should().Equal( + new[] { "portal.acme.local", "10.0.0.50", "fd00::50" }, + "additionalHosts is a 'SAN list (DNS names or IPv4 / IPv6)' per the spec: IPs are kept, the " + + "primary hostname is not repeated, duplicates collapse, and email/URI SANs are excluded"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_CsrFallback_CarriesIpSansFromCsr_WhenGatewaySanDictionaryIsNull() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + string csr = GenerateCsrPem(Hostname, + dnsSans: new[] { Hostname, "reports.acme.local" }, + ipSans: new[] { "10.0.0.60" }); + + await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(), san: null, csr: csr); + + captured().AdditionalHosts.Should().Equal( + new[] { "reports.acme.local", "10.0.0.60" }, + "with no gateway SAN dictionary the CSR's SANs are used, IP SAN rendered as text"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_MultiSanCsr_IsNotRejectedBySslSingleDomainGuard() + { + // The same CSR on a non-UCC SSL product is rejected before any order is placed (multi-SAN + // guard). Private PKI's additionalHosts is multi-entry for every variant. + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + string csr = GenerateCsrPem(Hostname, + dnsSans: new[] { Hostname, "portal.acme.local", "reports.acme.local" }, + ipSans: null); + + var result = await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(), + new Dictionary { ["dnsname"] = new[] { Hostname, "portal.acme.local", "reports.acme.local" } }, + csr); + + result.Status.Should().NotBe((int)EndEntityStatus.FAILED); + captured().AdditionalHosts.Should().Equal("portal.acme.local", "reports.acme.local"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_SubmitNonDnsSansFalse_StillSubmitsIpSans() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + var config = BaseConfig(); + config.SubmitNonDnsSans = false; + + await EnrollAsync(BuildV2Plugin(mock.Object, config), MakePrivatePkiProductInfo(), + new Dictionary + { + ["dnsname"] = new[] { Hostname }, + ["ipaddress"] = new[] { "10.0.0.50" } + }); + + captured().AdditionalHosts.Should().Equal(new[] { "10.0.0.50" }, + "IP literals are native to additionalHosts, so the V1-era SubmitNonDnsSans switch is not consulted"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_NoAdditionalSans_OmitsAdditionalHostsFromWireBody() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + + await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + captured().AdditionalHosts.Should().BeNull(); + JsonSerializer.Serialize(captured(), ClientEquivalentJsonOptions()) + .Should().NotContain("additionalHosts"); + } + + // --------------------------------------------------------------------------- + // Fail-fast validation (no CA call of any kind) + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] // not set -> SSL-only "dv" default + [InlineData("dv")] + [InlineData("ov")] + [InlineData("igtf-personal")] // appears only in the spec's create-*response* echo enum + public async Task Enroll_V2_PrivatePki_InvalidOrMissingVariant_FailsFastWithoutAnyCaCall(string variant) + { + var mock = NewMock(); + + var result = await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(variant: variant), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().BeEmpty(); + result.StatusMessage.Should().Contain("ProductVariant") + .And.Contain("intranet-ssl").And.Contain("igtf-host"); + mock.Invocations.Should().BeEmpty("validation must happen before any CA call"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_NoExplicitProductCode_FailsFastWithoutAnyCaCall() + { + // Without an override, the SSL ProductTypeIdsV2 table would resolve the attached SSL + // product (DV SSL) — i.e. send an SSL product code to the Private PKI endpoint. + var mock = NewMock(); + + var result = await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(productCode: null), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("ProductCode"); + mock.Invocations.Should().BeEmpty("validation must happen before any CA call"); + } + + // --------------------------------------------------------------------------- + // signature (Document Signer): open design decision -> fail fast + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_Signature_FailsFastWithoutAnyCaCall_InsteadOfSendingTheSslBody() + { + var mock = NewMock(); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "signature", + ["ProductCode"] = "819" + } + }; + + var result = await BuildV2Plugin(mock.Object).Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=Sarah Johnson", + san: new Dictionary(), + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().BeEmpty(); + result.StatusMessage.Should().Contain("signature").And.Contain("not yet supported"); + mock.Invocations.Should().BeEmpty( + "nothing is sent to /signature-certificates (no SSL body is sent to the wrong family endpoint)"); + } + + // --------------------------------------------------------------------------- + // ValidateProductInfo (builds its own CERTInextClient -> WireMock) + // --------------------------------------------------------------------------- + + private static void StubToken(WireMockServer server) => + server.Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + + private static void StubCatalog(WireMockServer server, string productCode, string productTypeId) => + server.Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody($"[{{\"productCode\":\"{productCode}\",\"productName\":\"Test Product\",\"productTypeID\":\"{productTypeId}\"}}]")); + + private static Dictionary V2ConnectionInfo(string apiUrl) => new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = apiUrl, + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + [Fact] + public async Task ValidateProductInfo_V2_PrivatePki_Succeeds_WhenExplicitCodeIsAPrivatePkiProduct() + { + // The SSL ProductId (DV SSL -> productTypeID 13) must not be cross-checked against a + // Private PKI code (productTypeID 39) — otherwise "does not correspond to the selected + // product" would be thrown and no private-pki template could ever be saved. + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalog(server, "149", Constants.ApiV2.PrivatePkiProductTypeId); + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(MakePrivatePkiProductInfo(), V2ConnectionInfo(server.Urls[0])); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V2_PrivatePki_Throws_WhenExplicitCodeIsNotAPrivatePkiProduct() + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalog(server, "842", "13"); // DV SSL + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(MakePrivatePkiProductInfo(productCode: "842"), V2ConnectionInfo(server.Urls[0])); + + await act.Should().ThrowAsync().WithMessage("*not a Private PKI product*"); + } + + [Fact] + public async Task ValidateProductInfo_V2_PrivatePki_Throws_WhenCodeNotInCatalog() + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalog(server, "100", Constants.ApiV2.PrivatePkiProductTypeId); + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(MakePrivatePkiProductInfo(productCode: "149"), V2ConnectionInfo(server.Urls[0])); + + await act.Should().ThrowAsync().WithMessage("*not found*"); + } + + [Theory] + [InlineData(null, "149", "ProductVariant")] + [InlineData("dv", "149", "ProductVariant")] + [InlineData("intranet-ssl", null, "ProductCode")] + public async Task ValidateProductInfo_V2_PrivatePki_RejectsBadTemplateParams_BeforeAnyCatalogCall( + string variant, string productCode, string expectedField) + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalog(server, "149", Constants.ApiV2.PrivatePkiProductTypeId); + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(MakePrivatePkiProductInfo(variant: variant, productCode: productCode), + V2ConnectionInfo(server.Urls[0])); + + await act.Should().ThrowAsync().WithMessage($"*{expectedField}*"); + server.LogEntries.Should().NotContain(e => e.RequestMessage.Path == "/api/certinext/v2/catalog/products"); + } + + // --------------------------------------------------------------------------- + // Shared validation helper + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("intranet-ssl", "149", "intranet-ssl")] + [InlineData("IGTF-HOST", "149", "igtf-host")] + public void ValidatePrivatePkiEnrollmentParams_ValidParams_ReturnsNull_AndNormalizesVariant( + string variant, string productCode, string expectedVariant) + { + var ep = new Models.EnrollmentParams(MakePrivatePkiProductInfo(variant: variant, productCode: productCode)); + + CERTInextCAPlugin.ValidatePrivatePkiEnrollmentParams(ep, out string normalized).Should().BeNull(); + normalized.Should().Be(expectedVariant); + } + + [Fact] + public void ValidatePrivatePkiEnrollmentParams_UnsetVariant_SaysNotSetRatherThanEchoingTheSslDefault() + { + var ep = new Models.EnrollmentParams(MakePrivatePkiProductInfo(variant: null)); + + string error = CERTInextCAPlugin.ValidatePrivatePkiEnrollmentParams(ep, out string normalized); + + error.Should().Contain("not set"); + normalized.Should().BeNull(); + } + } +} diff --git a/CERTInext.Tests/V2ProductVariantDerivationTests.cs b/CERTInext.Tests/V2ProductVariantDerivationTests.cs new file mode 100644 index 0000000..70dc337 --- /dev/null +++ b/CERTInext.Tests/V2ProductVariantDerivationTests.cs @@ -0,0 +1,451 @@ +// 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.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests guarding against the V2 + /// SSL create body's productVariant defaulting to "dv" regardless of the selected + /// product: an OV/EV template with no explicit ProductVariant enrollment parameter + /// must not send a DV-shaped body — skipping the mandatory OV/EV organization block + /// — nor should an explicit-but-contradictory override (e.g. "dv" configured for "OV SSL") pass + /// through unchecked. Covers directly, + /// end-to-end through , and through + /// (template-save-time rejection). + /// + public class V2ProductVariantDerivationTests + { + // --------------------------------------------------------------------------- + // ResolveSslProductVariant — pure unit tests, no CA calls + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(Constants.Products.DvSsl, "dv")] + [InlineData(Constants.Products.DvSslWildcard, "dv")] + [InlineData(Constants.Products.OvSsl, "ov")] + [InlineData(Constants.Products.OvSslWildcard, "ov")] + [InlineData(Constants.Products.EvSsl, "ev")] + [InlineData(Constants.Products.EvSslUcc, "ev")] + public void ResolveSslProductVariant_NoExplicitVariant_DerivesFromProduct(string productId, string expectedVariant) + { + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = new Dictionary() + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + error.Should().BeNull(); + resolved.Should().Be(expectedVariant); + } + + [Theory] + [InlineData(Constants.Products.OvSsl, "OV", "ov")] + [InlineData(Constants.Products.EvSsl, "Ev", "ev")] + [InlineData(Constants.Products.DvSsl, "DV", "dv")] + public void ResolveSslProductVariant_ExplicitVariant_AgreesWithProduct_ReturnsItLowercased( + string productId, string explicitVariant, string expectedResolved) + { + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = new Dictionary { ["ProductVariant"] = explicitVariant } + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + error.Should().BeNull(); + resolved.Should().Be(expectedResolved); + } + + [Fact] + public void ResolveSslProductVariant_ExplicitVariant_ContradictsProduct_ReturnsActionableError() + { + // An OV product with the SSL-only "dv" + // default explicitly configured. + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary { ["ProductVariant"] = "dv" } + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + resolved.Should().BeNull(); + error.Should().NotBeNull(); + error.Should().Contain(Constants.EnrollmentParam.ProductVariant) + .And.Contain("'dv'") + .And.Contain(Constants.Products.OvSsl) + .And.Contain("'ov'"); + } + + [Fact] + public void ResolveSslProductVariant_ExplicitVariant_ContradictsProduct_EvVsDv() + { + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary { ["ProductVariant"] = "ev" } + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + resolved.Should().BeNull(); + error.Should().Contain("'ev'").And.Contain(Constants.Products.DvSsl).And.Contain("'dv'"); + } + + [Fact] + public void ResolveSslProductVariant_UnmappedProductId_NoExplicitVariant_KeepsCurrentSslDefault() + { + // No entry in Constants.Products.ProductVariantsV2 for this ProductId (none of the 10 + // real SSL products are named this) — don't invent a mapping, keep + // the SSL-only "dv" default rather than guessing. + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = "Some Unmapped Product", + ProductParameters = new Dictionary() + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + error.Should().BeNull(); + resolved.Should().Be("dv"); + } + + [Fact] + public void ResolveSslProductVariant_UnmappedProductId_ExplicitVariant_NotCrossChecked() + { + // Same "don't invent a mapping" rule applied to the explicit-override path: with no + // authoritative product->variant mapping, an explicit override is passed through + // rather than rejected against a guess. + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = "Some Unmapped Product", + ProductParameters = new Dictionary { ["ProductVariant"] = "ev" } + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + error.Should().BeNull(); + resolved.Should().Be("ev"); + } + + // --------------------------------------------------------------------------- + // Enroll_V2 — end-to-end through CERTInextCAPlugin.Enroll + // --------------------------------------------------------------------------- + + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, string organizationNumber = null) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + OrganizationNumber = organizationNumber, + PickupRetries = 0 + }); + + private static EnrollmentProductInfo MakeProductInfo(string productId, string productVariant = null) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = productVariant == null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "ssl", + ["DomainName"] = "example.com" + } + : new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId, string status = "pending-dcv") + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = status }); + } + + [Fact] + public async Task Enroll_V2_OvProduct_NoExplicitProductVariant_SendsOvAndOrganizationBlock() + { + var mock = NewMock(); + StubCatalog(mock, "846", "16"); // OV SSL, non-UCC + StubHappyOrderPlacement(mock, "ord_variant_ov", "pending-organization-verification"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_variant_ov", Status = "pending-organization-verification" }); + + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-TEST"); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeProductInfo(Constants.Products.OvSsl), // no ProductVariant set + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_variant_ov"); + captured.Should().NotBeNull(); + captured!.ProductVariant.Should().Be("ov", + "with no explicit ProductVariant, the wire value must be derived from ProductID 'OV SSL'"); + captured.Organization.Should().NotBeNull( + "deriving 'ov' must engage the same OV/EV organization-block requirement as an explicit 'ov'"); + captured.Organization.OrganizationNumber.Should().Be("ORG-TEST"); + } + + [Fact] + public async Task Enroll_V2_EvProduct_NoExplicitProductVariant_SendsEvAndOrganizationBlock() + { + var mock = NewMock(); + StubCatalog(mock, "847", "19"); // EV SSL, non-UCC + StubHappyOrderPlacement(mock, "ord_variant_ev", "pending-organization-verification"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_variant_ev", Status = "pending-organization-verification" }); + + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-TEST"); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeProductInfo(Constants.Products.EvSsl), // no ProductVariant set + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_variant_ev"); + captured!.ProductVariant.Should().Be("ev"); + captured.Organization.Should().NotBeNull(); + } + + [Fact] + public async Task Enroll_V2_DvProduct_NoExplicitProductVariant_StaysDv_NoOrganizationBlock() + { + // Guard: the DV default path (the overwhelming majority of existing + // templates) must be completely unaffected by variant derivation. + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL, non-UCC + StubHappyOrderPlacement(mock, "ord_variant_dv"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_variant_dv", Status = "pending-dcv" }); + + // Deliberately no OrganizationNumber configured — a DV order must not need it. + var plugin = BuildV2Plugin(mock.Object, organizationNumber: null); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeProductInfo(Constants.Products.DvSsl), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_variant_dv"); + captured!.ProductVariant.Should().Be("dv"); + captured.Organization.Should().BeNull(); + } + + [Theory] + [InlineData(Constants.Products.OvSsl, "dv")] + [InlineData(Constants.Products.DvSsl, "ov")] + [InlineData(Constants.Products.EvSsl, "dv")] + public async Task Enroll_V2_ExplicitProductVariantContradictsProduct_FailsFastWithoutAnyCaCall( + string productId, string contradictingVariant) + { + // Strict mock with NOTHING stubbed: the mismatch must be rejected before any CA call + // at all (no catalog lookup, no order placement) — mirrors the private-pki variant + // guard's own Strict-mock test. + var mock = NewMock(); + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-TEST"); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeProductInfo(productId, contradictingVariant), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().BeEmpty(); + result.StatusMessage.Should().Contain(Constants.EnrollmentParam.ProductVariant); + mock.Invocations.Should().BeEmpty("the mismatch must be rejected before any CA call"); + } + + // --------------------------------------------------------------------------- + // ValidateProductInfo — rejected at template save, before any catalog call + // --------------------------------------------------------------------------- + + private static void StubToken(WireMockServer server) => + server.Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + + private static void StubCatalogServer(WireMockServer server, string productCode, string productTypeId) => + server.Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody($"[{{\"productCode\":\"{productCode}\",\"productName\":\"Test Product\",\"productTypeID\":\"{productTypeId}\"}}]")); + + private static Dictionary V2ConnectionInfo(string apiUrl) => new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = apiUrl, + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + [Fact] + public async Task ValidateProductInfo_V2_Ssl_NoExplicitProductVariant_Succeeds() + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalogServer(server, "846", "16"); // OV SSL + + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(productInfo, V2ConnectionInfo(server.Urls[0])); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V2_Ssl_ExplicitProductVariantMatchesProduct_Succeeds() + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalogServer(server, "846", "16"); // OV SSL + + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary { ["ProductVariant"] = "ov" } + }; + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(productInfo, V2ConnectionInfo(server.Urls[0])); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V2_Ssl_ExplicitProductVariantContradictsProduct_ThrowsBeforeAnyCatalogCall() + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalogServer(server, "846", "16"); // OV SSL — never reached + + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary { ["ProductVariant"] = "dv" } + }; + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(productInfo, V2ConnectionInfo(server.Urls[0])); + + await act.Should().ThrowAsync() + .WithMessage($"*{Constants.EnrollmentParam.ProductVariant}*"); + server.LogEntries.Should().NotContain(e => e.RequestMessage.Path == "/api/certinext/v2/catalog/products", + "the ProductVariant/ProductId mismatch must be rejected before any catalog call"); + } + + [Fact] + public async Task ValidateProductInfo_V2_PrivatePki_NotAffectedByThisSslCheck() + { + // Sanity: a private-pki template's own ProductVariant (intranet-ssl/igtf-host) must + // never be run through the SSL cross-check this issue adds — it's checked by + // ValidatePrivatePkiEnrollmentParams instead, unaffected here. + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalogServer(server, "149", Constants.ApiV2.PrivatePkiProductTypeId); + + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary + { + ["ProductFamily"] = "private-pki", + ["ProductVariant"] = "intranet-ssl", + ["ProductCode"] = "149" + } + }; + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(productInfo, V2ConnectionInfo(server.Urls[0])); + + await act.Should().NotThrowAsync(); + } + } +} diff --git a/CERTInext.Tests/V2SignerPlaceRequiredTests.cs b/CERTInext.Tests/V2SignerPlaceRequiredTests.cs new file mode 100644 index 0000000..a54bca0 --- /dev/null +++ b/CERTInext.Tests/V2SignerPlaceRequiredTests.cs @@ -0,0 +1,272 @@ +// 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.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests for signerPlace: the V2 spec marks SSL create + /// agreement.signerPlace "Conditional - required if `agreement` sent", and the plugin + /// always sends agreement on V2 SSL orders, so a blank signerPlace must not be dropped. + /// V2 connectors require SignerPlace in + /// (before any network call), and EnrollV2Async fails fast for an SSL order whose + /// resolved signer place is blank. V1 and V2 Private PKI (no agreement) are unaffected. + /// + public class V2SignerPlaceRequiredTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static Dictionary V2ConnectionInfo(string apiUrl, object signerPlace) + { + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = apiUrl, + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + if (signerPlace != null) info["SignerPlace"] = signerPlace; + return info; + } + + private static CERTInextConfig V2Config(string signerPlace) => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = signerPlace, + PickupRetries = 0 + }; + + private static EnrollmentProductInfo SslProductInfo(string templateSignerPlace = null) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + }; + if (templateSignerPlace != null) parameters["SignerPlace"] = templateSignerPlace; + return new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, ProductParameters = parameters }; + } + + // --------------------------------------------------------------------------- + // ValidateCAConnectionInfo + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] // key absent + [InlineData("")] + [InlineData(" ")] + public async Task ValidateCAConnectionInfo_V2_BlankSignerPlace_RejectedWithoutAnyHttpCall(string signerPlace) + { + // ApiUrl points at a live WireMock server with no stubs: if validation reached the + // connectivity test, the server would record the token request. + using var server = WireMockServer.Start(); + var plugin = new CERTInextCAPlugin(NewMock().Object, V2Config("New York")); + + Func act = () => plugin.ValidateCAConnectionInfo(V2ConnectionInfo(server.Urls[0], signerPlace)); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("'SignerPlace' is required when UseV2Api is true") + .And.Contain("Subscriber Agreement"); + server.LogEntries.Should().BeEmpty("the SignerPlace check must run before any network call"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V2_SignerPlaceSet_Passes() + { + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + server + .Given(Request.Create().WithPath("/api/certinext/v2/auth/me").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + var plugin = new CERTInextCAPlugin(NewMock().Object, V2Config("New York")); + + Func act = () => plugin.ValidateCAConnectionInfo(V2ConnectionInfo(server.Urls[0], "San Francisco, CA")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V1_BlankSignerPlace_NotReportedAsError() + { + // V1 config that fails for an unrelated reason (no AccountNumber) with SignerPlace + // blank: the aggregated error list must not name SignerPlace — V1 is unchanged. + var plugin = new CERTInextCAPlugin(NewMock().Object, V2Config("New York")); + var info = new Dictionary + { + ["ApiUrl"] = "https://v1.certinext.io/emSignHub-API/", + ["UseV2Api"] = false, + ["SignerPlace"] = "" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("AccountNumber").And.NotContain("SignerPlace"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V1_BlankSignerPlace_Passes() + { + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/ValidateCredentials").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"meta\":{\"status\":\"1\"}}")); + + var plugin = new CERTInextCAPlugin(NewMock().Object, V2Config("New York")); + var info = new Dictionary + { + ["ApiUrl"] = server.Urls[0] + "/", + ["UseV2Api"] = false, + ["AccountNumber"] = "12345", + ["AuthMode"] = "AccessKey", + ["ApiKey"] = "v1-key", + ["SignerPlace"] = "" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().NotThrowAsync("V1 does not require SignerPlace"); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async defence in depth + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_V2Ssl_BlankResolvedSignerPlace_FailsFastWithNoCaCall(string connectorSignerPlace) + { + // Strict mock with no setups: any client call (catalog, PlaceOrder, ...) throws. + var mock = NewMock(); + var plugin = new CERTInextCAPlugin(mock.Object, V2Config(connectorSignerPlace)); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=example.com", new Dictionary(), + SslProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().BeEmpty(); + result.StatusMessage.Should().Contain("requires a signer place") + .And.Contain("No order was placed"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + mock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Enroll_V2Ssl_TemplateSignerPlaceOverridesBlankConnector_SendsTemplateValue() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_sp_001", Status = "pending-csr" }); + mock.Setup(c => c.SubmitCsrV2Async(It.IsAny(), "ord_sp_001", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_sp_001", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_sp_001", Status = "pending-approval" }); + + var plugin = new CERTInextCAPlugin(mock.Object, V2Config("")); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=example.com", new Dictionary(), + SslProductInfo(templateSignerPlace: "Austin, TX"), RequestFormat.PKCS10, EnrollmentType.New); + + result.Status.Should().NotBe((int)EndEntityStatus.FAILED); + captured.Should().NotBeNull(); + captured!.Agreement.SignerPlace.Should().Be("Austin, TX"); + } + + [Fact] + public async Task Enroll_V2PrivatePki_BlankSignerPlace_StillPlacesOrder() + { + // Private PKI has no Subscriber Agreement, so a blank SignerPlace must not block it. + var mock = NewMock(); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_pki_sp", Status = "pending-csr" }); + mock.Setup(c => c.SubmitCsrV2Async( + Constants.ApiV2.FamilyPrivatePki, "ord_pki_sp", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(Constants.ApiV2.FamilyPrivatePki, "ord_pki_sp", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_pki_sp", Status = "pending-approval" }); + + var plugin = new CERTInextCAPlugin(mock.Object, V2Config("")); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "private-pki", + ["ProductVariant"] = "intranet-ssl", + ["ProductCode"] = "149", + ["DomainName"] = "intranet.acme.local" + } + }; + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=intranet.acme.local", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be("ord_pki_sp"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + } +} diff --git a/CERTInext.Tests/V2SslOrderBodyGoldenTests.cs b/CERTInext.Tests/V2SslOrderBodyGoldenTests.cs new file mode 100644 index 0000000..9268ff9 --- /dev/null +++ b/CERTInext.Tests/V2SslOrderBodyGoldenTests.cs @@ -0,0 +1,199 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Golden-body guard: branching EnrollV2Async on product family must not + /// change a single byte of the SSL/TLS create-order body. Each test drives a full V2 SSL + /// enrollment against a Strict mock, captures the handed + /// to the client, serializes it with the client's own serializer options, and compares the + /// result to a golden JSON string. + /// + public class V2SslOrderBodyGoldenTests + { + // Mirrors CERTInextClient.GetJsonOptions() (private) — the options PlaceOrderV2Async + // uses to produce the actual wire body. + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static async Task<(string Json, string FamilySlug, string ProductCode)> CaptureSslBodyAsync( + CERTInextConfig config, + EnrollmentProductInfo productInfo, + string catalogCode, + string catalogTypeId, + Dictionary san, + string subject) + { + const string orderId = "ord_golden_001"; + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = catalogCode, ProductTypeId = catalogTypeId, Active = true } + }); + + V2CreateSslOrderRequest captured = null; + string capturedSlug = null; + string capturedCode = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((slug, code, req, _) => + { + capturedSlug = slug; + capturedCode = code; + captured = req; + }) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async(It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + + var plugin = new CERTInextCAPlugin(mock.Object, config); + await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: subject, + san: san, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + captured.Should().NotBeNull("the SSL enrollment must reach PlaceOrderV2Async"); + return (JsonSerializer.Serialize(captured, ClientEquivalentJsonOptions()), capturedSlug, capturedCode); + } + + [Fact] + public async Task SslOvUccOrder_AllOptionalConfigSet_WireBodyMatchesGolden() + { + var config = new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5551234567", + RequestorDesignation = "PKI Admin", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + OrganizationNumber = "ORG-001", + GroupNumber = "GRP-9", + EmailNotifications = "1", + SubscriptionAutoRenew = "1", + SubscriptionRenewCriteriaDays = "15", + AutoSecureWww = "1", + TechnicalContactName = "Tech Person", + TechnicalContactEmail = "tech@example.com", + TechnicalContactIsdCode = "44", + TechnicalContactMobileNumber = "7700900000", + PickupRetries = 0 + }; + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSslUcc, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "848", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "ov", + ["DomainName"] = "example.com", + ["ValidityYears"] = "2" + } + }; + + var (json, slug, code) = await CaptureSslBodyAsync( + config, productInfo, "848", "18", + new Dictionary { ["dnsname"] = new[] { "example.com", "san1.example.com", "san2.example.com" } }, + "CN=example.com"); + + slug.Should().Be(Constants.ApiV2.FamilySsl); + code.Should().Be("848"); + json.Should().Be( + "{\"productVariant\":\"ov\",\"emailNotifications\":\"all\"," + + "\"requestor\":{\"name\":\"Test User\",\"email\":\"test@example.com\",\"phone\":\"\\u002B15551234567\",\"designation\":\"PKI Admin\"}," + + "\"organization\":{\"organizationNumber\":\"ORG-001\",\"preVetted\":true}," + + "\"certificate\":{\"domain\":\"example.com\",\"autoSecureWww\":true,\"additionalDomains\":[\"san1.example.com\",\"san2.example.com\"]}," + + "\"subscription\":{\"validityYears\":2,\"autoRenew\":true,\"renewBeforeDays\":15}," + + "\"agreement\":{\"signerName\":\"Test User\",\"signerIp\":\"1.2.3.4\",\"signerPlace\":\"New York\",\"accepted\":true}," + + "\"technicalPointOfContact\":{\"name\":\"Tech Person\",\"email\":\"tech@example.com\",\"phone\":\"\\u002B447700900000\",\"designation\":\"Technical Contact\"}," + + "\"remarks\":\"Issued via Keyfactor Command AnyCA REST Gateway.\",\"groupNumber\":\"GRP-9\"}"); + } + + [Fact] + public async Task SslDvOrder_DefaultConfig_WireBodyMatchesGolden() + { + var config = new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + // SignerPlace is required for V2 SSL orders (spec: agreement.signerPlace + // "Conditional - required if `agreement` sent"), so the "default config" fixture sets it + // and the expected agreement block below carries signerPlace. + SignerPlace = "Austin", + PickupRetries = 0 + }; + // No ProductFamily / ProductVariant / DomainName: exercises the ssl + dv defaults and + // the CN-derived domain. + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842" + } + }; + + var (json, slug, code) = await CaptureSslBodyAsync( + config, productInfo, "842", "13", + new Dictionary { ["dnsname"] = new[] { "example.com" } }, + "CN=example.com"); + + slug.Should().Be(Constants.ApiV2.FamilySsl); + code.Should().Be("842"); + json.Should().Be( + "{\"productVariant\":\"dv\",\"emailNotifications\":\"0\"," + + "\"requestor\":{\"name\":\"Test User\",\"email\":\"test@example.com\",\"phone\":\"\"}," + + "\"certificate\":{\"domain\":\"example.com\",\"autoSecureWww\":false}," + + "\"subscription\":{\"validityYears\":1,\"autoRenew\":false,\"renewBeforeDays\":30}," + + "\"agreement\":{\"signerName\":\"Test User\",\"signerPlace\":\"Austin\",\"accepted\":true}," + + "\"technicalPointOfContact\":{\"name\":\"Test User\",\"email\":\"test@example.com\",\"phone\":\"\",\"designation\":\"Technical Contact\"}," + + "\"remarks\":\"Issued via Keyfactor Command AnyCA REST Gateway.\"}"); + } + } +} diff --git a/CERTInext.Tests/V2SubscriptionEnrollmentTests.cs b/CERTInext.Tests/V2SubscriptionEnrollmentTests.cs new file mode 100644 index 0000000..982e6e6 --- /dev/null +++ b/CERTInext.Tests/V2SubscriptionEnrollmentTests.cs @@ -0,0 +1,323 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests that the V2 order body's subscription block is sourced from the connector's + /// SubscriptionAutoRenew/SubscriptionRenewCriteriaDays config rather than + /// hardcoded autoRenew=false / renewBeforeDays=30. These tests exercise + /// EnrollV2Async end-to-end (through ) against a + /// Strict mock, plus direct DTO serialization checks for + /// . + /// + public class V2SubscriptionEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + // Defaults here intentionally match CERTInextConfig's own property defaults ("0"/"30") — + // NOT a `?? "30"`-style fallback applied to the parameter, which would silently coerce an + // explicitly-passed null (a real Theory case below) back to "30" and defeat that test case. + private static CERTInextCAPlugin BuildV2Plugin( + ICERTInextClient client, + string subscriptionAutoRenew = "0", + string subscriptionRenewCriteriaDays = "30") => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0, + SubscriptionAutoRenew = subscriptionAutoRenew, + SubscriptionRenewCriteriaDays = subscriptionRenewCriteriaDays + }); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task<(EnrollmentResult Result, V2CreateSslOrderRequest Captured)> RunEnrollAsync( + Mock mock, CERTInextCAPlugin plugin, string orderId = "ord_sub_001") + { + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + return (result, captured); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — SubscriptionAutoRenew -> Subscription.AutoRenew + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_SubscriptionAutoRenew_1_SetsAutoRenewTrue() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, "ord_sub_001"); + + var plugin = BuildV2Plugin(mock.Object, subscriptionAutoRenew: "1"); + + var (result, captured) = await RunEnrollAsync(mock, plugin); + + result.CARequestID.Should().Be("ord_sub_001"); + captured.Should().NotBeNull(); + captured!.Subscription.Should().NotBeNull(); + captured.Subscription.AutoRenew.Should().BeTrue( + "SubscriptionAutoRenew=\"1\" must set Subscription.AutoRenew=true, the same bare " + + "\"1\"-means-true comparison AutoSecureWww already uses"); + } + + [Theory] + [InlineData("0")] + [InlineData("")] + [InlineData("yes")] + [InlineData("true")] + public async Task Enroll_V2_SubscriptionAutoRenew_NonOneValues_SetsAutoRenewFalse(string configValue) + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_sub_002"); + + var plugin = BuildV2Plugin(mock.Object, subscriptionAutoRenew: configValue); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_sub_002"); + + result.CARequestID.Should().Be("ord_sub_002"); + captured!.Subscription.AutoRenew.Should().BeFalse( + $"only the literal value \"1\" should set AutoRenew=true — '{configValue}' must not"); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — SubscriptionRenewCriteriaDays -> Subscription.RenewBeforeDays + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_V2_SubscriptionRenewCriteriaDays_Blank_OmitsRenewBeforeDays(string configValue) + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_sub_003"); + + var plugin = BuildV2Plugin(mock.Object, subscriptionRenewCriteriaDays: configValue); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_sub_003"); + + result.CARequestID.Should().Be("ord_sub_003"); + captured!.Subscription.RenewBeforeDays.Should().BeNull( + "a blank/unset SubscriptionRenewCriteriaDays must leave RenewBeforeDays null so the " + + "field is omitted and the CA's documented default of 30 applies"); + } + + [Fact] + public async Task Enroll_V2_SubscriptionRenewCriteriaDays_ValidValue_SetsRenewBeforeDays() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_sub_004"); + + var plugin = BuildV2Plugin(mock.Object, subscriptionRenewCriteriaDays: "45"); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_sub_004"); + + result.CARequestID.Should().Be("ord_sub_004"); + captured!.Subscription.RenewBeforeDays.Should().Be(45, + "a configured SubscriptionRenewCriteriaDays must be forwarded to RenewBeforeDays " + + "as-is, independent of the AutoRenew value"); + } + + [Fact] + public async Task Enroll_V2_SubscriptionRenewCriteriaDays_ZeroIsValid_SetsRenewBeforeDaysZero() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_sub_005"); + + var plugin = BuildV2Plugin(mock.Object, subscriptionRenewCriteriaDays: "0"); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_sub_005"); + + result.CARequestID.Should().Be("ord_sub_005"); + captured!.Subscription.RenewBeforeDays.Should().Be(0, + "zero is a valid non-negative integer and must be sent as-is, not treated as blank"); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async — bad SubscriptionRenewCriteriaDays fails fast, before any CA call + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("abc")] + [InlineData("-1")] + public async Task Enroll_V2_SubscriptionRenewCriteriaDays_Invalid_FailsEnrollment_NoHttpCallMade(string configValue) + { + // Strict mock with NO setups at all: if EnrollV2Async made any client call before + // failing validation, Moq would throw a MockException for the unstubbed invocation + // and this test would fail — that, plus the explicit Verify(Times.Never) calls below, + // together confirm zero HTTP requests are sent for an invalid config value. + var mock = NewMock(); + + var plugin = BuildV2Plugin(mock.Object, subscriptionRenewCriteriaDays: configValue); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("SubscriptionRenewCriteriaDays", + "the failure message must name the offending config field"); + + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Never); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // DTO serialization — renewBeforeDays key present/absent on the wire + // + // Unlike GroupNumber/PreVettingToken (which carry their own per-property + // [JsonIgnore(Condition = WhenWritingNull)]), RenewBeforeDays relies solely on the + // client's global serializer options (CERTInextClient.GetJsonOptions, + // DefaultIgnoreCondition = WhenWritingNull) to omit it when null — by design. + // GetJsonOptions() is private, so these tests build an equivalent + // JsonSerializerOptions inline to verify that global-option omission actually works for + // this property, rather than relying on plain JsonSerializer.Serialize(req) (whose default + // options do NOT ignore nulls and would show "renewBeforeDays":null instead of omitting it). + // --------------------------------------------------------------------------- + + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + [Fact] + public void V2SubscriptionParams_Serialization_OmitsRenewBeforeDays_WhenNull() + { + var subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = false, + RenewBeforeDays = null + }; + + string json = JsonSerializer.Serialize(subscription, ClientEquivalentJsonOptions()); + + json.Should().NotContain("renewBeforeDays", + "the renewBeforeDays key itself must be absent when unset, not present-but-null, " + + "under the client's actual serializer options"); + } + + [Fact] + public void V2SubscriptionParams_Serialization_IncludesRenewBeforeDays_WhenSet() + { + var subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = true, + RenewBeforeDays = 45 + }; + + string json = JsonSerializer.Serialize(subscription, ClientEquivalentJsonOptions()); + + json.Should().Contain("\"renewBeforeDays\":45"); + } + } +} diff --git a/CERTInext.Tests/V2TechnicalContactEnrollmentTests.cs b/CERTInext.Tests/V2TechnicalContactEnrollmentTests.cs new file mode 100644 index 0000000..0ca9473 --- /dev/null +++ b/CERTInext.Tests/V2TechnicalContactEnrollmentTests.cs @@ -0,0 +1,305 @@ +// 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.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.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests that the V2 SSL order body carries a technicalPointOfContact block, as V1's + /// equivalent (TechnicalPointOfContact) does, populated from the connector's + /// TechnicalContact* config fields (falling back to the corresponding + /// Requestor* value when blank). These tests exercise EnrollV2Async end-to-end + /// (through ) against a Strict + /// mock, plus direct DTO serialization checks for the new + /// property and the new + /// ISD-code + mobile-number composition helper. + /// + public class V2TechnicalContactEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextConfig BaseConfig() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, CERTInextConfig config) => + new CERTInextCAPlugin(client, config); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task RunEnrollAndCaptureOrderAsync( + CERTInextConfig config, string orderId = "ord_tpc_001") + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, orderId); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, config); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be(orderId); + captured.Should().NotBeNull(); + return captured; + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — technicalPointOfContact populated on the order body + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_TechnicalContactConfigured_PopulatesTechnicalPointOfContactOnOrderBody() + { + var config = BaseConfig(); + config.TechnicalContactName = "TPoC Name"; + config.TechnicalContactEmail = "tpoc@example.com"; + config.TechnicalContactIsdCode = "44"; + config.TechnicalContactMobileNumber = "7911123456"; + + var captured = await RunEnrollAndCaptureOrderAsync(config); + + captured.TechnicalPointOfContact.Should().NotBeNull( + "the technicalPointOfContact block must always be populated, even though every " + + "subfield is spec-Optional"); + captured.TechnicalPointOfContact.Name.Should().Be("TPoC Name"); + captured.TechnicalPointOfContact.Email.Should().Be("tpoc@example.com"); + captured.TechnicalPointOfContact.Phone.Should().Be("+447911123456", + "the configured TechnicalContactIsdCode and TechnicalContactMobileNumber must be " + + "combined into a single E.164-style phone value for the V2 shape"); + captured.TechnicalPointOfContact.Designation.Should().Be("Technical Contact"); + } + + [Fact] + public async Task Enroll_V2_TechnicalContactBlank_FallsBackToRequestorValues() + { + var config = BaseConfig(); + // TechnicalContact* fields left at their default (blank) values. + + var captured = await RunEnrollAndCaptureOrderAsync(config, orderId: "ord_tpc_002"); + + captured.TechnicalPointOfContact.Should().NotBeNull( + "V1's fallback-to-Requestor* semantics mean the block is populated, never omitted, " + + "even when no TechnicalContact* field is configured"); + captured.TechnicalPointOfContact.Name.Should().Be(config.RequestorName, + "a blank TechnicalContactName must fall back to RequestorName, mirroring V1"); + captured.TechnicalPointOfContact.Email.Should().Be(config.RequestorEmail, + "a blank TechnicalContactEmail must fall back to RequestorEmail, mirroring V1"); + captured.TechnicalPointOfContact.Phone.Should().Be("+15550000000", + "a blank TechnicalContactIsdCode/TechnicalContactMobileNumber must fall back to " + + "RequestorIsdCode/RequestorMobileNumber, mirroring V1, composed into one phone value"); + captured.TechnicalPointOfContact.Designation.Should().Be("Technical Contact"); + } + + [Fact] + public async Task Enroll_V2_TechnicalContactPartiallyConfigured_FallsBackFieldByField() + { + var config = BaseConfig(); + // Only name is overridden; email/isd/mobile stay blank and must each fall back + // independently to their own Requestor* counterpart (matching V1's per-field, not + // all-or-nothing, fallback semantics). + config.TechnicalContactName = "Override Name Only"; + + var captured = await RunEnrollAndCaptureOrderAsync(config, orderId: "ord_tpc_003"); + + captured.TechnicalPointOfContact.Name.Should().Be("Override Name Only"); + captured.TechnicalPointOfContact.Email.Should().Be(config.RequestorEmail); + captured.TechnicalPointOfContact.Phone.Should().Be("+15550000000"); + } + + // --------------------------------------------------------------------------- + // Requestor.Phone — ISD-code + mobile-number composition. + // Requestor.Phone combines RequestorIsdCode with RequestorMobileNumber rather than sending + // the raw mobile number only, the same way TechnicalPointOfContact.Phone (above) uses + // ComposeV2Phone. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_RequestorPhone_ComposesIsdAndMobile() + { + var config = BaseConfig(); + // BaseConfig: RequestorIsdCode="1", RequestorMobileNumber="5550000000". + + var captured = await RunEnrollAndCaptureOrderAsync(config, orderId: "ord_req_phone_001"); + + captured.Requestor.Should().NotBeNull(); + captured.Requestor.Phone.Should().Be("+15550000000", + "Requestor.Phone must combine RequestorIsdCode and RequestorMobileNumber the same " + + "way TechnicalPointOfContact.Phone already does, via ComposeV2Phone"); + } + + [Fact] + public async Task Enroll_V2_RequestorPhone_UsesConfiguredIsdCode_NotDefault() + { + var config = BaseConfig(); + config.RequestorIsdCode = "44"; + config.RequestorMobileNumber = "7911123456"; + + var captured = await RunEnrollAndCaptureOrderAsync(config, orderId: "ord_req_phone_002"); + + captured.Requestor.Phone.Should().Be("+447911123456", + "a non-default RequestorIsdCode must be reflected in Requestor.Phone, not just " + + "the TechnicalPointOfContact fallback path"); + } + + [Fact] + public async Task Enroll_V2_RequestorPhone_BlankIsdCode_FallsBackToDefault() + { + var config = BaseConfig(); + config.RequestorIsdCode = ""; + config.RequestorMobileNumber = "5550000000"; + + var captured = await RunEnrollAndCaptureOrderAsync(config, orderId: "ord_req_phone_003"); + + captured.Requestor.Phone.Should().Be("+15550000000", + "a blank RequestorIsdCode must fall back to the same default ('1') used " + + "elsewhere in EnrollV2Async, not an unprefixed raw mobile number"); + } + + // --------------------------------------------------------------------------- + // DTO serialization — technicalPointOfContact key/shape on the wire + // --------------------------------------------------------------------------- + + [Fact] + public void V2CreateSslOrderRequest_Serialization_IncludesTechnicalPointOfContact_WhenSet() + { + var req = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "Jane Doe", Email = "jane@example.com" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + TechnicalPointOfContact = new V2TechnicalPointOfContact + { + Name = "TPoC Name", + Email = "tpoc@example.com", + Phone = "+447911123456", + Designation = "Technical Contact" + } + }; + + string json = JsonSerializer.Serialize(req); + + // System.Text.Json escapes '+' as + by default, so the phone value is checked + // by parsing the JSON rather than substring-matching the raw serialized text (which + // would never contain a literal '+'). + using var doc = JsonDocument.Parse(json); + var tpc = doc.RootElement.GetProperty("technicalPointOfContact"); + tpc.GetProperty("name").GetString().Should().Be("TPoC Name"); + tpc.GetProperty("email").GetString().Should().Be("tpoc@example.com"); + tpc.GetProperty("phone").GetString().Should().Be("+447911123456"); + tpc.GetProperty("designation").GetString().Should().Be("Technical Contact"); + } + + // --------------------------------------------------------------------------- + // ComposeV2Phone — ISD-code + mobile-number composition helper + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("1", "5550000000", "+15550000000")] + [InlineData("44", "7911123456", "+447911123456")] + [InlineData("+1", "5550000000", "+15550000000")] + [InlineData("", "5550000000", "5550000000")] + [InlineData(null, "5550000000", "5550000000")] + [InlineData("1", "", "")] + [InlineData("1", null, "")] + [InlineData(null, null, "")] + public void ComposeV2Phone_ComposesExpectedValue(string isdCode, string mobileNumber, string expected) + { + CERTInextCAPlugin.ComposeV2Phone(isdCode, mobileNumber).Should().Be(expected); + } + } +} diff --git a/CERTInext.Tests/V2UccEnrollmentTests.cs b/CERTInext.Tests/V2UccEnrollmentTests.cs new file mode 100644 index 0000000..6a7eaeb --- /dev/null +++ b/CERTInext.Tests/V2UccEnrollmentTests.cs @@ -0,0 +1,446 @@ +// 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.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +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; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests for V2 UCC (multi-SAN) order-create + /// support. (V2 path) is driven end-to-end against a + /// Strict mock so the assertions exercise + /// EnrollV2Async's actual UCC-detection and additionalDomains-population logic, + /// not a re-implementation of it. + /// + public class V2UccEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode) => + new EnrollmentProductInfo + { + ProductID = "DV SSL UCC", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId = "ord_ucc_001") + { + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + // --------------------------------------------------------------------------- + // CSR generation (BouncyCastle — project crypto policy). Mirrors SanSubmissionTests' + // helper of the same shape; duplicated locally per this repo's existing convention of + // small per-test-file helpers (see NewMock()/BuildV2Plugin() duplicated across the V2 + // test files) rather than sharing test infrastructure across files. + // --------------------------------------------------------------------------- + + private static string GenerateCsrPem(string cn, params string[] dnsSans) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + Org.BouncyCastle.Asn1.Asn1Set attributes = null; + if (dnsSans != null && dnsSans.Length > 0) + { + var names = dnsSans.Select(d => new GeneralName(GeneralName.DnsName, d)).ToArray(); + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, critical: false, + extValue: new GeneralNames(names)); + + attributes = new Org.BouncyCastle.Asn1.DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, + new Org.BouncyCastle.Asn1.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-----"; + } + + // --------------------------------------------------------------------------- + // UCC detection + additionalDomains population + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_UccProduct_PopulatesAdditionalDomainsFromGatewaySanDictionary() + { + var mock = NewMock(); + StubCatalog(mock, "844", "15"); // DV SSL Certificate UCC + StubHappyOrderPlacement(mock); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_ucc_001", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), // CSR carries ONLY the primary domain — spec requirement for UCC + subject: "CN=example.com", + san: new Dictionary + { + ["dns"] = new[] { "example.com", "san1.example.com", "san2.example.com" } + }, + productInfo: MakeV2ProductInfo("844"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_ucc_001"); + captured.Should().NotBeNull(); + V2CreateSslOrderRequest req = captured!; + req.Certificate.Domain.Should().Be("example.com"); + req.Certificate.AdditionalDomains.Should().BeEquivalentTo( + new[] { "san1.example.com", "san2.example.com" }, + "the primary domain must not be repeated in additionalDomains, and the SAN dictionary " + + "(not the CSR) is the source for a UCC order's additional domains"); + } + + [Fact] + public async Task Enroll_V2_NonUccProduct_SanDictionaryCarriesExtras_StillFailsFastWithNoPlaceOrderCall() + { + // A CSR-only guard (looking at the CSR alone) would miss the case where the + // CSR carries only the primary domain but the SAN dictionary's extra + // domain silently vanishes — a non-UCC order never sends additionalDomains at all, so + // there is nowhere for it to go. The guard must consider the SAN dictionary too + // and reject, the same way it already rejects CSR-borne extras + // (Enroll_V2_NonUccProduct_CsrCarriesExtraSans_StillFailsFastWithNoPlaceOrderCall). + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: new Dictionary + { + ["dns"] = new[] { "example.com", "san1.example.com" } + }, + productInfo: MakeV2ProductInfo("842"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("1 SAN(s) beyond"); + result.StatusMessage.Should().Contain("single domain"); + + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never, + "a rejected non-UCC request must never reach order placement, whether the extra " + + "SAN came from the CSR or the SAN dictionary"); + } + + [Fact] + public async Task Enroll_V2_NonUccProduct_SanDictionaryHasOnlyPrimary_ButCsrCarriesExtraSan_StillFailsFastWithNoPlaceOrderCall() + { + // A non-null SAN dictionary that carries only the primary domain must not make the + // guard defer to the dictionary and skip the CSR. SubmitCsrV2Async sends the CSR to + // CERTInext verbatim regardless of what the SAN dictionary contains, so a + // CSR-embedded extra domain still reaches the CA even when the dictionary is + // single-domain — the guard must reject this exactly like the CSR-only case + // (Enroll_V2_NonUccProduct_CsrCarriesExtraSans_StillFailsFastWithNoPlaceOrderCall). + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com", "example.com", "other.example.com"), + subject: "CN=example.com", + san: new Dictionary { ["dns"] = new[] { "example.com" } }, + productInfo: MakeV2ProductInfo("842"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("1 SAN(s) beyond"); + result.StatusMessage.Should().Contain("single domain"); + + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never, + "a non-null SAN dictionary carrying only the primary domain must not make the " + + "guard defer to it and miss a CSR-embedded extra SAN"); + } + + [Fact] + public async Task Enroll_V2_NonUccProduct_SanDictionaryHasOnlyPrimaryAndWwwVariant_Allowed() + { + // The guard's existing primary-domain / www. allowance must still apply when + // those names arrive via the SAN dictionary rather than the CSR. + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_nonucc_002", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_nonucc_002", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_nonucc_002", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_nonucc_002", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: new Dictionary + { + ["dns"] = new[] { "example.com", "www.example.com" } + }, + productInfo: MakeV2ProductInfo("842"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_nonucc_002"); + captured.Should().NotBeNull(); + captured!.Certificate.AdditionalDomains.Should().BeNull( + "non-UCC V2 products must keep the single-domain wire shape even when the " + + "dictionary only ever carried allowed names"); + } + + [Fact] + public async Task Enroll_V2_NonUccProduct_SanDictionaryHasOnlyNonDnsExtras_Allowed() + { + // dnsOnly semantics: a non-DNS SAN dictionary entry can never appear in + // additionalDomains and must not trip the reject guard either. + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_nonucc_003", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_nonucc_003", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_nonucc_003", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_nonucc_003", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: new Dictionary + { + ["dns"] = new[] { "example.com" }, + ["rfc822name"] = new[] { "admin@example.com" } + }, + productInfo: MakeV2ProductInfo("842"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_nonucc_003"); + captured.Should().NotBeNull(); + captured!.Certificate.AdditionalDomains.Should().BeNull( + "a non-DNS dictionary entry must not trip the non-UCC reject guard"); + } + + [Fact] + public async Task Enroll_V2_UccProduct_CsrCarriesExtraSans_OrderIsPlacedWithSansAsAdditionalDomains() + { + // The CSR-SAN-count guard must not run unconditionally before UCC + // detection: a real UCC CSR enrollment (the CSR itself carries the extra DNS + // SANs, as Command actually builds it for CSR-based enrollments) must not be + // rejected before any CA order is placed. UCC products must + // be exempt: the guard should not fire, and BuildSanList's own CSR fallback + // (triggered here via san: null) should carry those same CSR SANs into + // additionalDomains, exactly like the gateway-SAN-dictionary case already covered by + // Enroll_V2_UccProduct_PopulatesAdditionalDomainsFromGatewaySanDictionary above. + var mock = NewMock(); + StubCatalog(mock, "844", "15"); // DV SSL Certificate UCC + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_ucc_002", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_ucc_002", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_ucc_002", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_ucc_002", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com", "example.com", "extra1.example.com", "extra2.example.com"), + subject: "CN=example.com", + san: null, // no gateway SAN dictionary — BuildSanList falls back to the CSR's own SANs + productInfo: MakeV2ProductInfo("844"), // UCC product code + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "the order should proceed to CA placement rather than fail fast"); + result.CARequestID.Should().Be("ord_ucc_002"); + captured.Should().NotBeNull(); + V2CreateSslOrderRequest req = captured!; + req.Certificate.Domain.Should().Be("example.com"); + req.Certificate.AdditionalDomains.Should().BeEquivalentTo( + new[] { "extra1.example.com", "extra2.example.com" }, + "a UCC product's CSR-embedded extra SANs must flow into additionalDomains instead of " + + "tripping the single-domain guard"); + } + + [Fact] + public async Task Enroll_V2_NonUccProduct_CsrCarriesExtraSans_StillFailsFastWithNoPlaceOrderCall() + { + // Counterpart to the UCC case above: a non-UCC product with the same multi-SAN CSR + // must keep the fail-fast behavior — FAILED, no order placed — even though + // the guard now necessarily runs after the (live) catalog lookup that determines + // UCC-ness, rather than before it. + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com", "example.com", "extra1.example.com", "extra2.example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842"), // non-UCC product code + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("2 SAN(s) beyond"); + result.StatusMessage.Should().Contain("single domain"); + result.StatusMessage.Should().NotContain("The V2 API only supports single-domain certificates", + "the message must not claim V2 is single-domain-only in general — it's only true for non-UCC products"); + + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Once, + "UCC-ness can only be known after the catalog lookup, so the guard now runs after it"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never, + "a rejected non-UCC request must never reach order placement"); + } + + [Fact] + public async Task Enroll_V2_CatalogLookupFails_TreatedAsNonUcc_EnrollmentStillSucceeds() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ThrowsAsync(new Exception("simulated transient catalog failure")); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_fallback_001", Status = "pending-dcv" }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_fallback_001", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_fallback_001", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_fallback_001", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + + // A catalog-lookup failure fails UCC-ness safe to false, so a non-empty + // SAN dictionary extra here would trip the dictionary-aware non-UCC reject + // guard instead of exercising this test's actual intent. Single-domain SAN data keeps + // the test focused on the catalog-lookup fallback it's named for. + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: new Dictionary { ["dns"] = new[] { "example.com" } }, + productInfo: MakeV2ProductInfo("844"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_fallback_001", + "a catalog lookup failure must not block enrollment"); + captured.Should().NotBeNull(); + V2CreateSslOrderRequest req = captured!; + req.Certificate.AdditionalDomains.Should().BeNull( + "on a catalog failure, UCC-ness must fail safe to false rather than guess"); + } + } +} diff --git a/CERTInext.Tests/V2UccNonDnsSanLoggingTests.cs b/CERTInext.Tests/V2UccNonDnsSanLoggingTests.cs new file mode 100644 index 0000000..1879979 --- /dev/null +++ b/CERTInext.Tests/V2UccNonDnsSanLoggingTests.cs @@ -0,0 +1,320 @@ +// 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.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests for non-DNS SAN logging on the V2 SSL UCC path: BuildSanList's V1-worded + /// warning claims non-DNS SANs "are submitted rather than dropped on purpose", which is not + /// true for V2 because EnrollV2Async always strips them from additionalDomains. + /// The V2 path must log its own "excluded from V2 additionalDomains" message (SAN types only, no + /// values), V1 must keep its original wording and wire behaviour, and private-pki must be + /// unaffected. + /// + /// Log capture: CERTInextCAPlugin._logger is a per-instance field resolved from + /// at construction, so swapping the factory before building + /// the plugin captures its messages (same seam as , + /// and the same non-parallel collection). Other test classes may log through the swapped + /// factory concurrently, so assertions only consider messages carrying this call's unique + /// subject marker. + /// + [Collection("LogHandlerFactory-NoParallel")] + public class V2UccNonDnsSanLoggingTests + { + private const string V1SubmittedWording = "submitted rather than dropped"; + private const string V1DroppedWording = "DROPPED because SubmitNonDnsSans is false"; + private const string V2ExcludedWording = "non-DNS SAN(s) excluded from V2 additionalDomains"; + private const string EmailSan = "admin@example.com"; + + 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)); + } + } + + /// + /// Builds the plugin (after swapping ), runs + /// , and returns every captured message mentioning + /// . + /// + private static async Task> CaptureAsync( + string marker, Func buildPlugin, Func enroll) + { + var provider = new CapturingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace)); + try + { + LogHandler.Factory = factory; + var plugin = buildPlugin(); // constructed AFTER the swap so _logger resolves through it + await enroll(plugin); + } + finally + { + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + factory.Dispose(); + } + + return provider.Messages.Where(m => m != null && m.Contains(marker)).ToList(); + } + + private static string NewMarker() => "sanlog-" + Guid.NewGuid().ToString("N"); + + // BouncyCastle only (project crypto policy). CN only — UCC CSRs carry only the primary domain. + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static Dictionary MixedSans(string primary) => new() + { + ["dnsname"] = new[] { primary, "san1." + primary }, + ["ipaddress"] = new[] { "192.0.2.10" }, + ["rfc822name"] = new[] { EmailSan } + }; + + // --------------------------------------------------------------------------- + // V2 SSL UCC + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Enroll_V2_Ucc_NonDnsSans_LogsV2ExclusionNotV1Wording_WireStaysDnsOnly(bool submitNonDnsSans) + { + string marker = NewMarker(); + string primary = marker + ".example.com"; + + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "844", ProductTypeId = "15", Active = true } // DV SSL UCC + }); + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_ucc_nondns", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_ucc_nondns", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_ucc_nondns", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_ucc_nondns", Status = "pending-dcv" }); + + var config = new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0, + SubmitNonDnsSans = submitNonDnsSans + }; + var productInfo = new EnrollmentProductInfo + { + ProductID = "DV SSL UCC", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "844", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = primary + } + }; + + var messages = await CaptureAsync(marker, + () => new CERTInextCAPlugin(mock.Object, config), + p => p.Enroll(GenerateCsrPem(primary), $"CN={primary}", MixedSans(primary), productInfo, + RequestFormat.PKCS10, EnrollmentType.New)); + + // Wire unchanged: additionalDomains stays DNS-only regardless of SubmitNonDnsSans. + captured.Should().NotBeNull(); + captured!.Certificate.AdditionalDomains.Should().Equal(new[] { "san1." + primary }); + + messages.Should().NotContain(m => m.Contains(V1SubmittedWording), + "V2 additionalDomains never carries non-DNS SANs, so the V1 'submitted' claim is false here"); + messages.Should().NotContain(m => m.Contains(V1DroppedWording), + "SubmitNonDnsSans is a V1 switch and does not decide the V2 outcome"); + + var v2 = messages.Where(m => m.Contains(V2ExcludedWording)).ToList(); + v2.Should().ContainSingle(); + v2[0].Should().StartWith("EnrollV2Async: 2 non-DNS SAN(s) excluded from V2 additionalDomains"); + v2[0].Should().Contain("Types=[ip, email]"); + + // Scoped to the SAN-resolution log sites this path owns. The Enroll-wide "Enrollment + // attempt started" audit line (shared with V1) logs the raw SAN dictionary and is + // out of scope here. + v2[0].Should().NotContain(EmailSan, + "an email SAN value is personal data; the V2 exclusion message logs SAN types only"); + messages.Where(m => m.StartsWith("Resolved ")).Should().ContainSingle() + .Which.Should().NotContain(EmailSan, + "in DNS-only mode the resolved-SAN audit line describes only what V2 submits"); + } + + // --------------------------------------------------------------------------- + // V1 — original wording and wire behaviour preserved + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V1_NonDnsSans_StillLogsSubmittedWording_AndSubmitsThem() + { + string marker = NewMarker(); + 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-UCC-NONDNS", Status = "issued", Certificate = MockCertificateData.FakePemCertificate + }); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842" + } + }; + + var messages = await CaptureAsync(marker, + () => new CERTInextCAPlugin(mock.Object, new CERTInextConfig { PickupRetries = 0 }), + p => p.Enroll(GenerateCsrPem(primary), $"CN={primary}", MixedSans(primary), productInfo, + RequestFormat.PKCS10, EnrollmentType.New)); + + captured.Should().NotBeNull(); + captured!.Sans.Select(s => s.Value).Should().Contain(new[] { "192.0.2.10", EmailSan }, + "V1 submits non-DNS SANs on purpose when SubmitNonDnsSans is true (the default)"); + + messages.Should().Contain(m => m.Contains(V1SubmittedWording)); + messages.Should().NotContain(m => m.Contains(V2ExcludedWording)); + } + + // --------------------------------------------------------------------------- + // Private PKI — unaffected + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_PrivatePki_IpSansStillInAdditionalHosts_NoSslSanWording() + { + string marker = NewMarker(); + const string hostname = "intranet.acme.local"; + + var mock = new Mock(MockBehavior.Strict); + V2CreatePrivatePkiOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, req, __) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_pki_nondns", Status = "pending-csr" }); + mock.Setup(c => c.SubmitCsrV2Async( + Constants.ApiV2.FamilyPrivatePki, "ord_pki_nondns", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(Constants.ApiV2.FamilyPrivatePki, "ord_pki_nondns", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_pki_nondns", Status = "pending-approval" }); + + var config = new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "DevOps Team", + RequestorEmail = "devops@acme.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "4155551234", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0, + SubmitNonDnsSans = false + }; + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "private-pki", + ["ProductVariant"] = "intranet-ssl", + ["ProductCode"] = "149", + ["DomainName"] = hostname + } + }; + + var messages = await CaptureAsync(marker, + () => new CERTInextCAPlugin(mock.Object, config), + p => p.Enroll(MockCertificateData.FakeCsrPem, $"CN={hostname}, OU={marker}", + new Dictionary + { + ["dnsname"] = new[] { hostname }, + ["ipaddress"] = new[] { "10.0.0.50" } + }, + productInfo, RequestFormat.PKCS10, EnrollmentType.New)); + + captured.Should().NotBeNull(); + captured!.AdditionalHosts.Should().Equal(new[] { "10.0.0.50" }, + "private-pki carries IP SANs natively and never consults SubmitNonDnsSans"); + + messages.Should().NotContain(m => m.Contains(V2ExcludedWording)); + messages.Should().NotContain(m => m.Contains(V1SubmittedWording)); + messages.Should().NotContain(m => m.Contains(V1DroppedWording)); + } + } +} diff --git a/CERTInext.Tests/V2UnknownStatusTests.cs b/CERTInext.Tests/V2UnknownStatusTests.cs new file mode 100644 index 0000000..11d870c --- /dev/null +++ b/CERTInext.Tests/V2UnknownStatusTests.cs @@ -0,0 +1,107 @@ +// 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.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests for the `unknown` status: the V2 spec lists `unknown` among its documented order + /// statuses, and StatusMapper.V2StatusToRequestDisposition maps it to + /// EXTERNALVALIDATION (not FAILED), since the order may still be live; these tests cover the enroll and single-record callers end to end. + /// + public class V2UnknownStatusTests + { + private const string OrderId = "ord_unknown_001"; + + private static CERTInextConfig V2Config() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + [Fact] + public async Task Enroll_V2_PostCsrStatusUnknown_ReturnsPendingWithOrderId() + { + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-csr" }); + mock.Setup(c => c.SubmitCsrV2Async(It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "unknown" }); + + var plugin = new CERTInextCAPlugin(mock.Object, V2Config()); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=example.com", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "a spec-documented `unknown` order may still be live and must not be reported as FAILED"); + result.CARequestID.Should().Be(OrderId); + } + + [Fact] + public async Task GetSingleRecord_V2_StatusUnknown_ReturnsPendingRecord() + { + var mock = new Mock(); // Loose: only the resolve/track result matters + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(OrderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = OrderId, Status = "unknown" })); + + var plugin = new CERTInextCAPlugin(mock.Object, V2Config()); + var record = await plugin.GetSingleRecord(OrderId); + + record.CARequestID.Should().Be(OrderId); + record.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.ResolveAndDownloadCertificateV2Async(It.IsAny(), It.IsAny()), + Times.Never, "a pending order has no certificate to download"); + } + } +} diff --git a/CERTInext.Tests/V2WildcardEnrollmentTests.cs b/CERTInext.Tests/V2WildcardEnrollmentTests.cs new file mode 100644 index 0000000..f79117e --- /dev/null +++ b/CERTInext.Tests/V2WildcardEnrollmentTests.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.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Tests for the non-UCC V2 single-domain SAN guard's wildcard-apex exemption: + /// a wildcard product's CSR/SAN dictionary routinely also carries the bare apex alongside + /// the wildcard domain itself (e.g. "example.com" alongside "*.example.com"), and the guard + /// must not reject that apex as an "extra SAN" the way it would for any other non-UCC + /// product. Only the apex is exempt — any other extra SAN is still rejected, and non-wildcard + /// products are unaffected. + /// + /// NOTE: these tests only confirm the guard's accept/reject decision. What is actually sent + /// to the CA for the apex (whether additionalDomains needs it, or CERTInext handles it + /// automatically for a wildcard product) has not been confirmed against the live API — see + /// the comment in EnrollV2Async above the guard. + /// + public class V2WildcardEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, string organizationNumber = null) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + OrganizationNumber = organizationNumber, + PickupRetries = 0 + }); + + private static EnrollmentProductInfo MakeWildcardProductInfo( + string productId, string productCode, string domainName) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + // No explicit ProductVariant — it is derived from ProductID + // ("dv"/"ov"), avoiding a mismatch reject for the OV wildcard test. + ["DomainName"] = domainName + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId = "ord_wc_001") + { + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + [Fact] + public async Task Enroll_V2_DvWildcard_ApexInSanDictionary_IsNotRejected() + { + var mock = NewMock(); + StubCatalog(mock, "839", "14"); // DV SSL Wildcard + StubHappyOrderPlacement(mock); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=*.example.com", + san: new Dictionary + { + // Apex alongside the wildcard domain — routine for a wildcard CSR. + ["dns"] = new[] { "*.example.com", "example.com" } + }, + productInfo: MakeWildcardProductInfo(Constants.Products.DvSslWildcard, "839", "*.example.com"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "the apex must be exempted from the single-domain guard for a wildcard product, " + + "not rejected as an extra SAN"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_V2_OvWildcard_ApexInSanDictionary_IsNotRejected() + { + var mock = NewMock(); + StubCatalog(mock, "843", "17"); // OV SSL Wildcard + StubHappyOrderPlacement(mock); + + // OV requires an organization block. + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-TEST-001"); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=*.example.com", + san: new Dictionary + { + ["dns"] = new[] { "*.example.com", "example.com" } + }, + productInfo: MakeWildcardProductInfo(Constants.Products.OvSslWildcard, "843", "*.example.com"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_V2_DvWildcard_NonApexExtraSan_StillRejected_WithoutSuggestingUcc() + { + var mock = NewMock(); + StubCatalog(mock, "839", "14"); // DV SSL Wildcard + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=*.example.com", + san: new Dictionary + { + // The apex is exempt, but an unrelated extra domain is not. + ["dns"] = new[] { "*.example.com", "example.com", "other.example.com" } + }, + productInfo: MakeWildcardProductInfo(Constants.Products.DvSslWildcard, "839", "*.example.com"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("1 SAN(s) beyond"); + result.StatusMessage.Should().NotContain("UCC", + "a wildcard enrollment rejection must not suggest buying a UCC product"); + + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_V2_NonWildcardProduct_ApexNotExempt_StillRejected_AndSuggestsUcc() + { + // Sanity check that the exemption is wildcard-specific: a non-wildcard, non-UCC + // product's "extra" SAN set is unaffected by this fix, and its rejection message + // still offers the UCC suggestion (only wildcard products' wording changes). + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-wildcard, non-UCC) + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary + { + ["dns"] = new[] { "example.com", "other.example.com" } + }, + productInfo: MakeWildcardProductInfo(Constants.Products.DvSsl, "842", "example.com"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("UCC"); + + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs index 7f02df0..e52d85d 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; } @@ -622,6 +626,36 @@ 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; } + + /// + /// 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; without them a renewed UCC certificate + /// would hold only its primary domain. + /// + [JsonPropertyName("sans")] + [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/API/CertificateResponse.cs b/CERTInext/API/CertificateResponse.cs index 3b3103f..c8ee66a 100644 --- a/CERTInext/API/CertificateResponse.cs +++ b/CERTInext/API/CertificateResponse.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// 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, @@ -587,6 +587,7 @@ public List FlattenProducts() ProductCode = p.ProductCode, ProductName = p.ProductName, ProductType = cat.CategoryName, + ProductTypeId = p.ProductTypeId, Active = true // API does not return an active flag at this level }); } @@ -658,6 +659,15 @@ public class ProductDetail [JsonPropertyName("productType")] public string ProductType { get; set; } + /// + /// Numeric product type ID from the wire (), + /// e.g. "13" for DV SSL. UCC (multi-SAN) family values are 15/18/20/21/22. + /// Not populated by every parse path (only set + /// where the source shape actually carries a productTypeID field). + /// + [JsonPropertyName("productTypeID")] + public string ProductTypeId { get; set; } + /// /// Always true for products returned by the API — the API only /// returns products that are available on the account. diff --git a/CERTInext/API/V2/CertificateRequestV2.cs b/CERTInext/API/V2/CertificateRequestV2.cs new file mode 100644 index 0000000..eaaa832 --- /dev/null +++ b/CERTInext/API/V2/CertificateRequestV2.cs @@ -0,0 +1,525 @@ +// 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.Serialization; +using System.Text.Json; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.API.V2 +{ + // --------------------------------------------------------------------------- + // V2 REST API — Request DTOs + // + // Auth: POST {ApiUrl}/oauth/token (form-encoded client_credentials; ApiUrl is the V2 base + // URL when UseV2Api=true) + // Product code: X-Product-Code header (not in body) + // Idempotency: Idempotency-Key header sent on order-create/revoke, but the spec doesn't + // document it for those endpoints and only says "parsed today, enforced in a future release" + // for the endpoints (Verify DCV, Domains) it does document it on. + // --------------------------------------------------------------------------- + + /// + /// Requestor information block sent with every V2 order. + /// + public class V2Requestor + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("email")] + public string Email { get; set; } + + [JsonPropertyName("phone")] + public string Phone { get; set; } + + [JsonPropertyName("designation")] + public string Designation { get; set; } + } + + /// + /// Certificate parameters block for V2 SSL orders. + /// + public class V2CertificateParams + { + [JsonPropertyName("domain")] + public string Domain { get; set; } + + [JsonPropertyName("autoSecureWww")] + public bool AutoSecureWww { get; set; } = false; + + /// + /// SAN list for UCC (multi-SAN) product variants — DV/OV/EV UCC and DV/OV Wildcard UCC + /// (Catalog productTypeID 15/18/20/21/22). Each entry must be a valid FQDN + /// (wildcards allowed only for the Wildcard UCC variants). Per the V2 spec's Submit CSR + /// guidance, these SANs come from the order, not the CSR — the CSR must carry only the + /// primary domain in CN for UCC orders. Omitted from the wire body for non-UCC products + /// (single-domain orders are unaffected). + /// + [JsonPropertyName("additionalDomains")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public System.Collections.Generic.List AdditionalDomains { get; set; } + } + + /// + /// Organization block for V2 SSL orders. Per the V2 spec's field table (SSL/TLS + /// Certificates folder description), this block is "Conditional — Mandatory for OV / EV" + /// and every OV/EV create example in the spec sends exactly these three fields. Submitting + /// an OV order with no organization block gets + /// HTTP 422 [EMS-1180] Organization Name cannot be empty — CERTInext resolves the + /// certificate's organization name server-side from organizationNumber, so an + /// absent/empty block leaves it with nothing to resolve. There is no separate + /// "organization name" field to send; supplying a valid, pre-vetted + /// organizationNumber is what the CA needs. + /// + public class V2OrganizationParams + { + [JsonPropertyName("organizationNumber")] + public string OrganizationNumber { get; set; } + + /// + /// Re-uses an existing vetted organization instead of queuing the order for manual + /// vetting. Mirrors the V1 OrganizationDetails.PreVetting="1" semantics — sent + /// as JSON true whenever OrganizationNumber is configured. + /// + [JsonPropertyName("preVetted")] + public bool PreVetted { get; set; } = true; + + /// + /// Optional per spec (re-vetting flow token). Not currently surfaced as plugin config; + /// omitted from the wire body when null/empty. + /// + [JsonPropertyName("preVettingToken")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string PreVettingToken { get; set; } + } + + /// + /// Subscription parameters block for V2 orders (validity, auto-renewal). Per the V2 spec, + /// omitting this block entirely defaults auto-renew to ON (1-year, 30-day window) at the CA, + /// so EnrollV2Async always sends it, driving / + /// from the connector's SubscriptionAutoRenew/ + /// SubscriptionRenewCriteriaDays config. + /// + public class V2SubscriptionParams + { + [JsonPropertyName("validityYears")] + public int ValidityYears { get; set; } = 1; + + [JsonPropertyName("autoRenew")] + public bool AutoRenew { get; set; } = false; + + /// + /// Days before expiry CERTInext auto-renews; only meaningful when + /// is true. Null when the connector's SubscriptionRenewCriteriaDays is blank/unset — the + /// client's global JSON serializer options (CERTInextClient.GetJsonOptions, + /// DefaultIgnoreCondition = WhenWritingNull) omit the field from the wire in that + /// case, letting the CA fall back to its documented default of 30. + /// + [JsonPropertyName("renewBeforeDays")] + public int? RenewBeforeDays { get; set; } + } + + /// + /// Technical point-of-contact block for V2 orders. Per the V2 spec's field table (SSL/TLS + /// Certificates folder description — identical for the Document Signer and + /// Private PKI folders; and + /// reuse this type), all four + /// subfields are documented Optional. Unlike + /// V1's , + /// which sends ISD code and mobile number as two separate fields + /// (tpcIsdCode/tpcMobileNumber), the V2 shape has a single phone field — + /// composed from the connector's ISD-code + mobile-number config pair by + /// . + /// Despite being spec-Optional, EnrollV2Async always populates this block (never omits + /// it), mirroring V1's fallback-to-Requestor* defaulting so a blank connector config never + /// results in a silently-blank contact. + /// + public class V2TechnicalPointOfContact + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("email")] + public string Email { get; set; } + + [JsonPropertyName("phone")] + public string Phone { get; set; } + + [JsonPropertyName("designation")] + public string Designation { get; set; } + } + + /// + /// Subscriber agreement block required for V2 SSL orders. + /// + public class V2AgreementParams + { + [JsonPropertyName("signerName")] + public string SignerName { get; set; } + + /// Optional in V2. Omitted from serialisation when null or empty. + [JsonPropertyName("signerIp")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string SignerIp { get; set; } + + /// Optional in V2. Omitted from serialisation when null or empty. + [JsonPropertyName("signerPlace")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string SignerPlace { get; set; } + + [JsonPropertyName("accepted")] + public bool Accepted { get; set; } = true; + } + + /// + /// Request body for POST /api/certinext/v2/ssl-certificates. + /// Product code is sent as the X-Product-Code header (not in this body). + /// + public class V2CreateSslOrderRequest + { + [JsonPropertyName("productVariant")] + public string ProductVariant { get; set; } = "dv"; + + /// + /// "all" = full notification set, "0" = silent, null = omitted (CA defaults to "all"). + /// See + /// for the connector config mapping. No default here — relies solely + /// on the client's global DefaultIgnoreCondition = WhenWritingNull serializer option + /// to omit the key when null, the same pattern + /// uses. + /// + [JsonPropertyName("emailNotifications")] + public string EmailNotifications { get; set; } + + [JsonPropertyName("requestor")] + public V2Requestor Requestor { get; set; } + + /// + /// Mandatory for OV/EV, omitted entirely for DV (per spec, "Conditional — Mandatory + /// for OV / EV"). + /// leaves this null for DV orders rather than sending an empty/placeholder block that + /// could itself trigger a different validation error. + /// + [JsonPropertyName("organization")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2OrganizationParams Organization { get; set; } + + [JsonPropertyName("certificate")] + public V2CertificateParams Certificate { get; set; } + + [JsonPropertyName("subscription")] + public V2SubscriptionParams Subscription { get; set; } + + [JsonPropertyName("agreement")] + public V2AgreementParams Agreement { get; set; } + + /// + /// Optional per spec, but always populated by EnrollV2Async — see + /// for the fallback/composition rules. + /// + [JsonPropertyName("technicalPointOfContact")] + public V2TechnicalPointOfContact TechnicalPointOfContact { get; set; } + + [JsonPropertyName("remarks")] + public string Remarks { get; set; } + + /// + /// Optional billing group to attribute this order to. Mirrors V1's + /// — + /// omitted entirely (rather than sent empty) when the connector has no + /// GroupNumber configured, so the order falls back to the account's default group. + /// + [JsonPropertyName("groupNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string GroupNumber { get; set; } + } + + /// + /// Request body for POST /api/certinext/v2/private-pki-certificates. Modelled + /// from the V2 spec's "Private PKI Certificates" folder field table ("Field requirements (in + /// body order)"): variant, requestor.name, requestor.email and + /// hostname are strictly mandatory ("400 if missing"); everything else is Optional. + /// Per the spec, "Private PKI has no DCV, no organization block, and no Subscriber + /// Agreement" — so, unlike , there is no + /// productVariant, organization, certificate or agreement block + /// here. SANs go in , which (unlike SSL's FQDN-only + /// additionalDomains) accepts IP literals. + /// + /// Only the fields EnrollV2Async populates are modelled. Spec-Optional fields the + /// plugin has no source for (caProfileId, masterProductId — both "derived from + /// X-Product-Code" — saveAsDraft, requestId, csr, tags, + /// customFields) are deliberately omitted, matching how the SSL DTO treats its own + /// unused optional fields. The CSR is submitted by the separate Submit CSR call, per the + /// spec's Private PKI workflow (Create -> Submit CSR -> Track -> Download). + /// Product code is sent as the X-Product-Code header (not in this body). + /// + public class V2CreatePrivatePkiOrderRequest + { + /// + /// Mandatory. Spec enum for create: intranet-ssl / igtf-host + /// (). + /// + [JsonPropertyName("variant")] + public string Variant { get; set; } + + /// + /// Optional (spec default all). Same connector mapping as + /// ; null is omitted on the wire. + /// + [JsonPropertyName("emailNotifications")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string EmailNotifications { get; set; } + + /// Optional. Omitted when the connector has no GroupNumber configured. + [JsonPropertyName("groupNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string GroupNumber { get; set; } + + /// Mandatory (name + email strictly mandatory; phone/designation optional). + [JsonPropertyName("requestor")] + public V2Requestor Requestor { get; set; } + + /// Mandatory. Spec: "hostname - primary CN". + [JsonPropertyName("hostname")] + public string Hostname { get; set; } + + /// + /// Optional. Spec: "additionalHosts[] - SAN list (DNS names or IPv4 / IPv6)". + /// Omitted from the wire body when null (no additional SANs). + /// + [JsonPropertyName("additionalHosts")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public System.Collections.Generic.List AdditionalHosts { get; set; } + + /// Optional (autoRenew defaults ON at the CA when omitted — always sent, as for SSL). + [JsonPropertyName("subscription")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2SubscriptionParams Subscription { get; set; } + + [JsonPropertyName("remarks")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Remarks { get; set; } + + /// Optional per spec; populated with the same fallbacks the SSL body uses. + [JsonPropertyName("technicalPointOfContact")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2TechnicalPointOfContact TechnicalPointOfContact { get; set; } + } + + /// + /// subject block of a V2 Document Signer (signature) order. Modelled from + /// the V2 spec's "Document Signer Certificates" folder field table. Only + /// is strictly mandatory ("400 if missing"); the rest are Optional or Conditional on + /// subjectType: + /// - firstName / lastName: "required for natural-person / legal-person" + /// - organizationName: "required for legal-person / legal-entity" + /// - organizationIdentificationNumber: "typically required for legal-entity" + /// - businessCategory: "legal-entity" + /// - countryCode: "Optional (ISO 3166-1 alpha-2)" + /// Every non-mandatory field is omitted from the wire when null so a legal-entity body never + /// carries empty person-name keys (and vice versa). + /// + public class V2SignatureSubject + { + [JsonPropertyName("firstName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string FirstName { get; set; } + + [JsonPropertyName("lastName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string LastName { get; set; } + + /// Mandatory for every subjectType. + [JsonPropertyName("email")] + public string Email { get; set; } + + [JsonPropertyName("phone")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Phone { get; set; } + + [JsonPropertyName("designation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Designation { get; set; } + + [JsonPropertyName("organizationName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string OrganizationName { get; set; } + + [JsonPropertyName("organizationUnit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string OrganizationUnit { get; set; } + + [JsonPropertyName("organizationIdentificationNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string OrganizationIdentificationNumber { get; set; } + + /// Spec examples: Business Entity | Government | Non-Commercial Entity. + [JsonPropertyName("businessCategory")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string BusinessCategory { get; set; } + + /// Spec examples: passport | driving-license | national-id. + [JsonPropertyName("identityDocumentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string IdentityDocumentType { get; set; } + + [JsonPropertyName("identificationNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string IdentificationNumber { get; set; } + + [JsonPropertyName("streetAddress1")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string StreetAddress1 { get; set; } + + [JsonPropertyName("streetAddress2")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string StreetAddress2 { get; set; } + + [JsonPropertyName("locality")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Locality { get; set; } + + [JsonPropertyName("state")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string State { get; set; } + + [JsonPropertyName("postalCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string PostalCode { get; set; } + + [JsonPropertyName("countryCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string CountryCode { get; set; } + } + + /// + /// Request body for POST /api/certinext/v2/signature-certificates. Modelled from + /// the V2 spec's "Document Signer Certificates" folder field table. Strictly mandatory ("400 + /// if missing"): subjectType, requestor.name, requestor.email, + /// subject.email; "The subject.* fields beyond email vary by subjectType - + /// the backend applies stricter per-type rules." + /// + /// Not yet wired into EnrollV2Async. The body shape is fully determined by the + /// spec, but several of its mandatory/conditional values (subjectType, + /// subject.email, the per-type subject name/organization fields) have no settled + /// source in the Command enrollment inputs yet. Until that is decided, + /// EnrollV2Async fails a ProductFamily=signature enrollment fast instead of + /// sending any body. The DTO and its client overload exist so that wiring is a pure + /// source-mapping change. + /// + /// reuses (the spec's signature and + /// SSL agreement tables are identical: signerName, signerPlace, + /// accepted). Leave null for this family — + /// the spec's signature Accept Agreement note says "Do not send signerIp in the body - + /// it will be ignored", and the create field table does not list it. + /// Product code is sent as the X-Product-Code header (not in this body). + /// + public class V2CreateSignatureOrderRequest + { + /// + /// Mandatory. Spec enum: natural-person / legal-person / legal-entity + /// (). + /// + [JsonPropertyName("subjectType")] + public string SubjectType { get; set; } + + [JsonPropertyName("emailNotifications")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string EmailNotifications { get; set; } + + [JsonPropertyName("groupNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string GroupNumber { get; set; } + + /// Mandatory (name + email strictly mandatory). + [JsonPropertyName("requestor")] + public V2Requestor Requestor { get; set; } + + /// Mandatory; see for the per-type rules. + [JsonPropertyName("subject")] + public V2SignatureSubject Subject { get; set; } + + [JsonPropertyName("subscription")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2SubscriptionParams Subscription { get; set; } + + /// Spec: "Optional - required before issuance". + [JsonPropertyName("agreement")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2AgreementParams Agreement { get; set; } + + [JsonPropertyName("remarks")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Remarks { get; set; } + + [JsonPropertyName("technicalPointOfContact")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2TechnicalPointOfContact TechnicalPointOfContact { get; set; } + } + + /// + /// Request body for PUT /api/certinext/v2/{family}-certificates/{orderId}/csr. The spec + /// documents the identical { "csr", "attested" } body for the SSL/TLS, Private PKI + /// and Document Signer families, so one shape serves all three. + /// + public class V2SubmitCsrRequest + { + [JsonPropertyName("csr")] + public string Csr { get; set; } + + [JsonPropertyName("attested")] + public bool Attested { get; set; } = false; + } + + /// + /// Request body for POST /api/certinext/v2/{family}-certificates/{orderId}/revoke. + /// + public class V2RevokeRequest + { + /// + /// RFC 5280 string reason, kebab-case per the V2 spec. Valid values: unspecified, + /// key-compromise, ca-compromise, affiliation-changed, superseded, + /// cessation-of-operation, certificate-hold, privilege-withdrawn (plus + /// aa-compromise on the signature-certificates / private-pki-certificates + /// endpoints). Sending camelCase gets HTTP 400. + /// + [JsonPropertyName("reason")] + public string Reason { get; set; } = "unspecified"; + + [JsonPropertyName("note")] + public string Note { get; set; } + } + + /// + /// Request body for POST /api/certinext/v2/{family}-certificates/{orderId}/cancel + /// ("Cancel Order"). The SSL spec entry marks reason as "required free-text. + /// Persisted in the audit log"; an empty reason is rejected with EMS-984. + /// + public class V2CancelOrderRequest + { + [JsonPropertyName("reason")] + public string Reason { get; set; } + } + + /// + /// Outcome of a V2 Cancel Order call that did not throw. + /// + public enum V2CancelOrderOutcome + { + /// HTTP 2xx (spec: 204 No Content) — the order is cancelled. + Cancelled, + + /// HTTP 422 — spec: "order already in a terminal state"; nothing was cancelled. + AlreadyTerminal + } +} diff --git a/CERTInext/API/V2/CertificateResponseV2.cs b/CERTInext/API/V2/CertificateResponseV2.cs new file mode 100644 index 0000000..022d4ec --- /dev/null +++ b/CERTInext/API/V2/CertificateResponseV2.cs @@ -0,0 +1,503 @@ +// 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.Text.Json.Serialization; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.API.V2 +{ + // --------------------------------------------------------------------------- + // V2 REST API — Response DTOs + // --------------------------------------------------------------------------- + + /// + /// Standard OAuth2 client_credentials token response (flat shape — no tokenDetails wrapper). + /// POST {ApiUrl}/oauth/token with form-encoded body (ApiUrl is the V2 base URL when + /// UseV2Api=true). + /// + public class V2TokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } + + /// Lifetime in seconds. Typically 3600 (1 hour). + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } = 3600; + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + } + + /// + /// HATEOAS link object present in V2 responses. + /// + public class V2Link + { + [JsonPropertyName("href")] + public string Href { get; set; } + } + + /// + /// _links map returned by V2 order responses. + /// Known keys: self, dcv, csr, agreement, certificate, cancel, revoke. + /// + public class V2Links + { + [JsonPropertyName("self")] + public V2Link Self { get; set; } + + [JsonPropertyName("dcv")] + public V2Link Dcv { get; set; } + + [JsonPropertyName("csr")] + public V2Link Csr { get; set; } + + [JsonPropertyName("agreement")] + public V2Link Agreement { get; set; } + + [JsonPropertyName("certificate")] + public V2Link Certificate { get; set; } + + [JsonPropertyName("cancel")] + public V2Link Cancel { get; set; } + + [JsonPropertyName("revoke")] + public V2Link Revoke { get; set; } + } + + /// + /// Response body for POST /api/certinext/v2/{family}-certificates (201 Created). + /// + public class V2CreateOrderResponse + { + [JsonPropertyName("orderId")] + public string OrderId { get; set; } + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } + + /// + /// Initial order status. Typically "pending-dcv" for SSL DV orders. + /// + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("_links")] + public V2Links Links { get; set; } + } + + /// + /// Response body for GET /api/certinext/v2/{family}-certificates/{orderId}. + /// Contains lifecycle status only — serial number and validity dates are NOT present; + /// those are only in the Download Certificate response. + /// + public class V2OrderStatusResponse + { + [JsonPropertyName("orderId")] + public string OrderId { get; set; } + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } + + /// Current order status string (e.g. "pending-dcv", "issued", "revoked"). + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("productVariant")] + public string ProductVariant { get; set; } + + [JsonPropertyName("domain")] + public string Domain { get; set; } + + [JsonPropertyName("_links")] + public V2Links Links { get; set; } + + /// + /// Populated only when is "revoked". Absent entirely from the wire — + /// not present-but-null — when the order has never been revoked, which + /// System.Text.Json deserializes as a null + /// reference with no special handling required. + /// + [JsonPropertyName("revocation")] + public V2RevocationDetails Revocation { get; set; } + + /// ISO 8601 timestamp when the certificate was issued. Present when status = "issued". + [JsonPropertyName("issuedAt")] + public string IssuedAt { get; set; } + + /// ISO 8601 timestamp when the certificate expires. Present when status = "issued". + [JsonPropertyName("expiresAt")] + public string ExpiresAt { get; set; } + + /// + /// Per-domain DCV/CAA verification detail. Present on UCC orders whose + /// additional SANs each carry their own DCV state. Absent entirely on older/simpler + /// response shapes — callers must treat + /// a null / + /// the same as "no per-domain detail available" and fall back to the single top-level + /// field. + /// + [JsonPropertyName("verifications")] + public V2Verifications Verifications { get; set; } + } + + /// + /// Top-level verifications object on the V2 Track Order response. + /// Only the domain sub-block is modeled — that is the only one this plugin's DCV + /// automation drives. + /// + public class V2Verifications + { + [JsonPropertyName("domain")] + public V2DomainVerification Domain { get; set; } + } + + /// + /// verifications.domain block. is an aggregate that + /// is NOT reliable for driving DCV decisions — it can stay + /// "PENDING" even after the parent order was cancelled and every per-domain + /// had already flipped to REJECTED. Use it + /// for logging only; always decide per-domain from . + /// + public class V2DomainVerification + { + /// Aggregate status (e.g. "PENDING"). Logging only — see class remarks. + [JsonPropertyName("status")] + public string Status { get; set; } + + /// Per-domain verification entries — one per domain on the order (primary + any + /// UCC additional SANs). + [JsonPropertyName("domains")] + public List Domains { get; set; } + } + + /// + /// A single entry in verifications.domain.domains[]. Example shape: + /// {"domain":"a.pending....example.com","domainStatus":"ACTIVE","dcvStatus":"PENDING","caaStatus":"SKIPPED"} + /// for a still-pending SAN, versus + /// {"domain":"...","domainStatus":"ACTIVE","dcvMethod":"dns-txt","dcvStatus":"VERIFIED","verifiedAt":"...","caaStatus":"PASSED"} + /// once verified. and are absent entirely + /// (not present-but-null) on a pending entry — both are nullable here for exactly that + /// reason; a fix must not assume is populated before treating an + /// entry as needing DNS-01 DCV. + /// + public class V2DomainVerificationEntry + { + [JsonPropertyName("domain")] + public string Domain { get; set; } + + [JsonPropertyName("domainStatus")] + public string DomainStatus { get; set; } + + /// Absent on the wire until reaches VERIFIED — see class + /// remarks. This plugin only ever drives dns-txt DCV, so a null/absent value here is + /// treated as "use DNS-01", never as an unknown/unsupported method. + [JsonPropertyName("dcvMethod")] + public string DcvMethod { get; set; } + + /// PENDING / VERIFIED / REJECTED (see + /// DcvStatus* constants). Drive all per-domain DCV decisions from this field, never from + /// the aggregate . + [JsonPropertyName("dcvStatus")] + public string DcvStatus { get; set; } + + /// ISO 8601 timestamp. Absent until reaches VERIFIED. + [JsonPropertyName("verifiedAt")] + public string VerifiedAt { get; set; } + + [JsonPropertyName("caaStatus")] + public string CaaStatus { get; set; } + } + + /// + /// Nested revocation object on the V2 Track Order response. Example shape, from a + /// revoked SSL order: + /// {"status":"Certificate Revoked","reason":"cessation-of-operation","processedAt":"2026-09-24T20:44:41Z"} + /// + public class V2RevocationDetails + { + /// + /// Human-readable revocation engine status (e.g. "Certificate Revoked"), mirroring the + /// outer certificateState field on the same response. Not a distinct enum worth + /// modeling separately from . + /// + [JsonPropertyName("status")] + public string Status { get; set; } + + /// + /// RFC 5280 reason name, hyphenated on the wire (e.g. "cessation-of-operation", + /// "key-compromise") — NOT V1's camelCase convention. See + /// for the known values and + /// Models.StatusMapper.V2RevocationReasonToCrlCode for the reverse mapping back + /// to an RFC 5280 CRL reason code. + /// + [JsonPropertyName("reason")] + public string Reason { get; set; } + + /// Effective revocation time (CA-recorded). RFC 3339 / ISO 8601 UTC. + [JsonPropertyName("processedAt")] + public DateTime? ProcessedAt { get; set; } + } + + /// + /// Response body for GET /api/certinext/v2/{family}-certificates/{orderId}/certificate. + /// Returns the leaf certificate and, when present, intermediate chain PEM strings. + /// + public class V2CertificateDownloadResponse + { + [JsonPropertyName("orderId")] + public string OrderId { get; set; } + + [JsonPropertyName("serialNumber")] + public string SerialNumber { get; set; } + + [JsonPropertyName("subject")] + public string Subject { get; set; } + + [JsonPropertyName("issuer")] + public string Issuer { get; set; } + + [JsonPropertyName("notBefore")] + public DateTime? NotBefore { get; set; } + + [JsonPropertyName("notAfter")] + public DateTime? NotAfter { get; set; } + + /// PEM-encoded leaf certificate. + [JsonPropertyName("certificatePem")] + public string CertificatePem { get; set; } + + /// + /// Array of intermediate PEM strings returned alongside the leaf cert. + /// May be null or empty when the CA does not include chain in the response. + /// + [JsonPropertyName("chainPem")] + public List ChainPem { get; set; } + } + + /// + /// Response body for GET /api/certinext/v2/ssl-certificates/{orderId}/dcv. + /// Returns the DCV challenge token needed to publish a DNS TXT record. + /// + /// Actual wire shape: exactly two fields — + /// {"tokenExpiryDate": "...", "token": "..."}. This matches neither the spec's + /// own worked example for this endpoint (orderNumber/domainName/ + /// dcvMethod/fileNameContent) nor the + /// spec's prose for the same endpoint (method/txtToken). There is no + /// orderNumber, domainName, or method field on the wire, so none are + /// modeled here: + /// - order id and domain name are already known from the local order-placement + /// context before DCV is ever attempted (see call sites of + /// ), so they don't need + /// to be echoed back by this response. + /// - the V2 DCV path only ever performs DNS-TXT validation — the hostname + /// (_emudhra-challenge.{domain}) and validator ("dns-01") are both hardcoded + /// in , which never reads a + /// method from this response — so no method field is needed. + /// + public class V2DcvChallengeResponse + { + /// Value to publish as the DNS TXT record. + [JsonPropertyName("token")] + public string Token { get; set; } + + [JsonPropertyName("tokenExpiryDate")] + public string TokenExpiryDate { get; set; } + } + + /// + /// Request body for POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify. + /// + public class V2DcvVerifyRequest + { + [JsonPropertyName("domain")] + public string Domain { get; set; } + + /// "dns-txt" for DNS TXT record validation. + [JsonPropertyName("method")] + public string Method { get; set; } + } + + /// + /// Response body for POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify. + /// 200 OK with this body, or 204 No Content, both indicate success. + /// 422 with overallStatus="FAILED" indicates verification failure. + /// + public class V2DcvVerifyResponse + { + /// "VERIFIED" on success, "FAILED" on failure. + [JsonPropertyName("overallStatus")] + public string OverallStatus { get; set; } + + [JsonPropertyName("method")] + public string Method { get; set; } + + [JsonPropertyName("verifiedAt")] + public string VerifiedAt { get; set; } + } + + /// + /// RFC 7807 Problem Details error response from the V2 API. + /// Content-Type: application/problem+json + /// + public class V2ProblemDetails + { + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("detail")] + public string Detail { get; set; } + + [JsonPropertyName("instance")] + public string Instance { get; set; } + + /// Field-level validation errors (optional). + [JsonPropertyName("errors")] + public List Errors { get; set; } + } + + /// + /// A single field-level validation error from RFC 7807 errors array. + /// + public class V2FieldError + { + [JsonPropertyName("field")] + public string Field { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } + } + + /// + /// Response body for GET /api/certinext/v2/auth/me. + /// Used as the V2 connectivity/ping check. + /// + public class V2AuthMeResponse + { + [JsonPropertyName("accountNumber")] + public string AccountNumber { get; set; } + + [JsonPropertyName("authType")] + public string AuthType { get; set; } + } + + /// + /// Spring-style page envelope for GET /api/certinext/v2/reports/orders. The spec's + /// example body is stale; its field table is what's actually returned. + /// + public class V2OrdersReportResponse + { + [JsonPropertyName("content")] + public List Content { get; set; } + + /// 1-based page index (mirrors the request's page query param). + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("size")] + public int Size { get; set; } + + [JsonPropertyName("totalElements")] + public long TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int TotalPages { get; set; } + } + + /// + /// A single row from the V2 /reports/orders "content" array. Field names match the live + /// field table (NOT the spec's stale example body, which + /// uses different field names — state/identifier/account/group/product). + /// + /// orderStatus/certificateStatus are human-readable display strings (e.g. "Order Accepted", + /// "Certificate Downloaded") — NOT the V2 `status` enum used by TrackOrder + /// (see and + /// Keyfactor.Extensions.CAPlugin.CERTInext.Models.StatusMapper.V2StatusToRequestDisposition). + /// See CERTInextCAPlugin.MapV2ReportStatusToDisposition for how these display strings + /// are mapped; the vocabulary handled there is not guaranteed exhaustive. + /// + public class OrderReportEntryV2 + { + [JsonPropertyName("orderNumber")] + public string OrderNumber { get; set; } + + /// Most-recent request identifier on the order (reissues create new requests). + [JsonPropertyName("requestNumber")] + public string RequestNumber { get; set; } + + /// Human-readable order state, e.g. "Order Accepted", "Order Fulfilled". + [JsonPropertyName("orderStatus")] + public string OrderStatus { get; set; } + + /// Human-readable request/certificate state, e.g. "Pending for Approver", "Certificate Downloaded". + [JsonPropertyName("certificateStatus")] + public string CertificateStatus { get; set; } + + /// Hex serial assigned by the CA. Empty until issuance. + [JsonPropertyName("certificateSerialNumber")] + public string CertificateSerialNumber { get; set; } + + /// Certificate notAfter. Empty until issuance. Kept as string — format not guaranteed. + [JsonPropertyName("certificateExpiryDate")] + public string CertificateExpiryDate { get; set; } + + /// Issuing CA's CN. Empty until issuance. + [JsonPropertyName("issuerCA")] + public string IssuerCa { get; set; } + + /// + /// Catalog product code. Often empty on report rows — do not rely on this + /// for family resolution; use ResolveAndTrackOrderV2WithFamilyAsync instead. + /// + [JsonPropertyName("productCode")] + public string ProductCode { get; set; } + + /// Primary CN for SSL/TLS orders. Empty for non-SSL families. + [JsonPropertyName("domainName")] + public string DomainName { get; set; } + + [JsonPropertyName("groupNumber")] + public string GroupNumber { get; set; } + + /// Order creation time (UTC). Kept as string and parsed defensively by the caller — + /// mirrors the V1 pattern. + [JsonPropertyName("orderDate")] + public string OrderDate { get; set; } + + [JsonPropertyName("organizationName")] + public string OrganizationName { get; set; } + + [JsonPropertyName("countryName")] + public string CountryName { get; set; } + + [JsonPropertyName("originator")] + public string Originator { get; set; } + + [JsonPropertyName("tags")] + public List Tags { get; set; } + + [JsonPropertyName("customFields")] + public List CustomFields { get; set; } + } +} diff --git a/CERTInext/CERTInext.csproj b/CERTInext/CERTInext.csproj index f683ea8..50252af 100644 --- a/CERTInext/CERTInext.csproj +++ b/CERTInext/CERTInext.csproj @@ -7,26 +7,24 @@ warnings 12.0 - false + true $(DefineConstants);SUPPORTS_DCV true - + matches the gateway host: 3.3.0 (DCV / 26.x hosts) by + default, or 3.2.0 (no-DCV / 25.5.x hosts) with -p:DcvSupport=false. The 3.3-only + IDomainValidatorFactory is only referenced from #if SUPPORTS_DCV code, so the DcvSupport=false + 3.2.0 build compiles cleanly. --> + diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 231f611..ec3dbfd 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// 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, @@ -14,6 +14,7 @@ using System.Threading.Tasks; using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; using Keyfactor.Extensions.CAPlugin.CERTInext.Client; using Keyfactor.Extensions.CAPlugin.CERTInext.Models; using Keyfactor.Logging; @@ -132,13 +133,16 @@ internal CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certD /// /// Internal test-injection constructor — pass a mock /// and a specific for tests that need to override - /// configuration fields such as IgnoreExpired. + /// configuration fields such as IgnoreExpired. + /// is optional (defaults to null) and lets tests exercise the bodyless-REVOKED + /// guard, which consults . /// - internal CERTInextCAPlugin(ICERTInextClient client, CERTInextConfig config) + internal CERTInextCAPlugin(ICERTInextClient client, CERTInextConfig config, ICertificateDataReader certDataReader = null) { _client = client; _clientWasInjected = true; _config = config ?? new CERTInextConfig(); + _certificateDataReader = certDataReader; } /// @@ -229,6 +233,34 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa _config = JsonSerializer.Deserialize(rawConfig) ?? throw new InvalidOperationException("Failed to deserialize CERTInext plugin configuration."); + // Compliance gap fix: ValidateCAConnectionInfo enforces https-or-loopback on ApiUrl + // (and, in V1 OAuth mode, OAuthTokenUrl) at connection-test time, but a connector + // saved before that enforcement existed would otherwise sail straight through here + // on every gateway restart and keep sending its API key / OAuth client secret in + // cleartext. Re-check the config itself (not just the client we're about to build) + // before any client is built or used — this applies even when a test has already + // injected a mock client via `_client ??=` below, because the gap is in the saved + // config, not in which ICERTInextClient instance ends up talking to it. + void EnsureValidUrl(string fieldName, string url, string requiredSuffix) + { + string error = string.IsNullOrWhiteSpace(url) + ? $"'{fieldName}' is required{requiredSuffix}." + : ValidateHttpsOrLoopbackUrl(fieldName, url); + if (error == null) + return; + + _logger.LogError( + "CERTInext plugin initialization failed — invalid configuration. {Error}", error); + throw new InvalidOperationException(error); + } + + EnsureValidUrl(Constants.Config.ApiUrl, _config.ApiUrl, string.Empty); + + string authModeUpper = (_config.AuthMode ?? string.Empty).Trim().ToUpperInvariant(); + bool isV1OAuth = !_config.UseV2Api && (authModeUpper == "OAUTH" || authModeUpper == "OAUTH2"); + if (isV1OAuth) + EnsureValidUrl(Constants.Config.OAuth2TokenUrl, _config.OAuth2TokenUrl, " when AuthMode is 'OAuth'"); + // Only create a real client if one wasn't injected (test scenario) _client ??= new CERTInextClient(_config); @@ -240,6 +272,13 @@ 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); + + int effectivePickupRetries = _config.GetEffectivePickupRetries(); + int effectivePickupDelay = _config.GetEffectivePickupDelaySeconds(); + string preVettingMode = hasOrganizationNumber ? "1 (use pre-vetted org)" : "omitted (no org configured)"; _logger.LogInformation( "CERTInext plugin initialized. " + @@ -247,16 +286,36 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa "ApiKeyPresent={ApiKeyPresent}, UsernamePresent={UsernamePresent}, " + "PasswordPresent={PasswordPresent}, OAuth2ClientIdPresent={OAuth2ClientIdPresent}, " + "OAuth2ClientSecretPresent={OAuth2ClientSecretPresent}, OAuth2TokenUrlPresent={OAuth2TokenUrlPresent}, " + - "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}, " + + "OrganizationNumber={OrganizationNumber}, PreVetting={PreVetting}, " + + "DefaultProductCode={DefaultProductCode}, GroupNumber={GroupNumber}, " + + "AccountingModel={AccountingModel}, EmailNotifications={EmailNotifications}, " + + "AutoSecureWww={AutoSecureWww}, ValidityYears={ValidityYears}, " + + "AutoRenew={AutoRenew}, RenewCriteriaDays={RenewCriteriaDays}, " + + "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}, SubmitNonDnsSans={SubmitNonDnsSans}, " + + "PickupRetries={PickupRetries}, PickupDelay={PickupDelay}, " + "DcvEnabled={DcvEnabled}, DcvTxtRecordTemplate={DcvTxtRecordTemplate}, " + - "DomainValidatorFactoryInjected={FactoryInjected}", + "DcvPropagationDelaySeconds={DcvPropagationDelay}, DcvTimeoutMinutes={DcvTimeout}, " + + "DcvWaitForChallengeSeconds={DcvWaitChallenge}, DcvWaitForIssuanceSeconds={DcvWaitIssuance}, " + + "DomainValidatorFactoryInjected={FactoryInjected}, LogSensitiveRequestData={LogSensitiveRequestData}", _config.ApiUrl, _config.AuthMode, _config.Enabled, hasApiKey, hasUsername, hasPassword, hasClientId, hasClientSecret, hasTokenUrl, - _config.PageSize, _config.IgnoreExpired, + hasOrganizationNumber ? _config.OrganizationNumber : "(not configured)", preVettingMode, + hasDefaultProductCode ? _config.DefaultProductCode : "(not configured)", + hasGroupNumber ? _config.GroupNumber : "(not configured)", + string.IsNullOrWhiteSpace(_config.AccountingModel) ? "2 (default)" : _config.AccountingModel, + string.IsNullOrWhiteSpace(_config.EmailNotifications) ? "0 (default)" : _config.EmailNotifications, + string.IsNullOrWhiteSpace(_config.AutoSecureWww) ? "0 (default)" : _config.AutoSecureWww, + string.IsNullOrWhiteSpace(_config.SubscriptionValidityYears) ? "1 (default)" : _config.SubscriptionValidityYears, + string.IsNullOrWhiteSpace(_config.SubscriptionAutoRenew) ? "0 (default)" : _config.SubscriptionAutoRenew, + string.IsNullOrWhiteSpace(_config.SubscriptionRenewCriteriaDays) ? "30 (default)" : _config.SubscriptionRenewCriteriaDays, + _config.PageSize, _config.IgnoreExpired, _config.SubmitNonDnsSans, + effectivePickupRetries, effectivePickupDelay, _config.DcvEnabled, _config.DcvTxtRecordTemplate, - _domainValidatorFactory != null); + _config.DcvPropagationDelaySeconds, _config.DcvTimeoutMinutes, + _config.DcvWaitForChallengeSeconds, _config.DcvWaitForIssuanceSeconds, + _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 @@ -271,6 +330,19 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa "gateway image that supplies the factory, or set DcvEnabled=false to clear " + "this warning."); } + + // 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); } @@ -330,15 +402,24 @@ public async Task Ping() try { - await _client.PingAsync(); - // SOC2 CC9.2: connectivity confirmation is a security-relevant event; must be - // at Information so it survives production log filters. - _logger.LogInformation("CERTInext ping successful. ApiUrl={ApiUrl}", _config.ApiUrl); + if (_config.UseV2Api) + { + await _client.PingV2Async(); + _logger.LogInformation("CERTInext V2 ping successful. ApiUrl={ApiUrl}", _config.ApiUrl); + } + else + { + await _client.PingAsync(); + // SOC2 CC9.2: connectivity confirmation is a security-relevant event; must be + // at Information so it survives production log filters. + _logger.LogInformation("CERTInext ping successful. ApiUrl={ApiUrl}", _config.ApiUrl); + } } catch (Exception ex) { - _logger.LogError(ex, "CERTInext ping failed. ApiUrl={ApiUrl}", _config.ApiUrl); - throw new Exception($"Unable to reach CERTInext at {_config.ApiUrl}: {ex.Message}", ex); + string url = _config.ApiUrl; + _logger.LogError(ex, "CERTInext ping failed. Url={Url}, UseV2Api={UseV2Api}", url, _config.UseV2Api); + throw new Exception($"Unable to reach CERTInext at {url}: {ex.Message}", ex); } finally { @@ -373,44 +454,96 @@ public async Task ValidateCAConnectionInfo(Dictionary connection var errors = new List(); - // ApiUrl and AccountNumber are always required + // ApiUrl is always required in both modes — its meaning follows UseV2Api (V1 base + // URL incl. /emSignHub-API vs the bare V2 host). string apiUrl = GetStringValue(connectionInfo, Constants.Config.ApiUrl); if (string.IsNullOrWhiteSpace(apiUrl)) errors.Add($"'{Constants.Config.ApiUrl}' is required."); - else if (!Uri.TryCreate(apiUrl, UriKind.Absolute, out _)) - errors.Add($"'{Constants.Config.ApiUrl}' is not a valid absolute URI."); - - string accountNumber = GetStringValue(connectionInfo, Constants.Config.AccountNumber); - if (string.IsNullOrWhiteSpace(accountNumber)) - errors.Add($"'{Constants.Config.AccountNumber}' is required."); - - // Auth mode — validate the required credentials for the chosen mode - string authMode = GetStringValue(connectionInfo, Constants.Config.AuthMode, Constants.Config.AuthModeAccessKey); - switch (authMode.ToUpperInvariant()) - { - case "ACCESSKEY": - case "APIKEY": // legacy alias - string apiKey = GetStringValue(connectionInfo, Constants.Config.ApiKey); - if (string.IsNullOrWhiteSpace(apiKey)) - errors.Add($"'{Constants.Config.ApiKey}' is required when AuthMode is 'AccessKey'."); - break; + else + { + // The OAuth client secret (V2) / API key (V1) is sent to this URL on every + // request; http would transmit it in cleartext. http is allowed only for + // loopback hosts (localhost/127.0.0.1/::1) so local mock-server tests keep + // working without a real TLS endpoint. Shared with the OAuthTokenUrl check + // below and with Initialize's config-time enforcement of the same rule. + string apiUrlError = ValidateHttpsOrLoopbackUrl(Constants.Config.ApiUrl, apiUrl); + if (apiUrlError != null) + errors.Add(apiUrlError); + } - case "OAUTH": - case "OAUTH2": - string tokenUrl = GetStringValue(connectionInfo, Constants.Config.OAuth2TokenUrl); - string clientId = GetStringValue(connectionInfo, Constants.Config.OAuth2ClientId); - string clientSecret = GetStringValue(connectionInfo, Constants.Config.OAuth2ClientSecret); - if (string.IsNullOrWhiteSpace(tokenUrl)) - errors.Add($"'{Constants.Config.OAuth2TokenUrl}' is required when AuthMode is 'OAuth'."); - if (string.IsNullOrWhiteSpace(clientId)) - errors.Add($"'{Constants.Config.OAuth2ClientId}' is required when AuthMode is 'OAuth'."); - if (string.IsNullOrWhiteSpace(clientSecret)) - errors.Add($"'{Constants.Config.OAuth2ClientSecret}' is required when AuthMode is 'OAuth'."); - break; + bool useV2 = connectionInfo.TryGetValue(Constants.ConfigV2.UseV2Api, out object v2Obj) + && v2Obj is bool v2Bool && v2Bool; - default: - errors.Add($"'{Constants.Config.AuthMode}' must be one of: AccessKey, OAuth. Got: '{authMode}'."); - break; + if (useV2) + { + // V2 mode: OAuth2 client_credentials against {ApiUrl}/oauth/token, reusing the + // same OAuthClientId/OAuthClientSecret fields V1's AuthMode=OAuth uses. V1-only + // credentials (AccountNumber, AuthMode, ApiKey, ...) are NOT required here — the + // V1 AuthMode switch below is skipped entirely. + string oauthClientId = GetStringValue(connectionInfo, Constants.Config.OAuthClientId); + string oauthClientSecret = GetStringValue(connectionInfo, Constants.Config.OAuthClientSecret); + + if (string.IsNullOrWhiteSpace(oauthClientId)) + errors.Add($"'{Constants.Config.OAuthClientId}' is required when UseV2Api is true."); + + if (string.IsNullOrWhiteSpace(oauthClientSecret)) + errors.Add($"'{Constants.Config.OAuthClientSecret}' is required when UseV2Api is true."); + + // Every V2 SSL create order sends an `agreement` block, and the V2 spec marks + // agreement.signerPlace "Conditional - required if `agreement` sent". Required + // at the connector level (user decision) even though a per-template SignerPlace + // enrollment parameter can override it — EnrollV2Async also fails fast if the + // resolved value is blank. + string signerPlace = GetStringValue(connectionInfo, Constants.Config.SignerPlace); + if (string.IsNullOrWhiteSpace(signerPlace)) + errors.Add($"'{Constants.Config.SignerPlace}' is required when UseV2Api is true — the CERTInext " + + "V2 Subscriber Agreement sent with every SSL order requires the signing place " + + "(city/location, e.g. 'San Francisco, CA')."); + } + else + { + // V1 mode: AccountNumber is always required, plus whatever the selected AuthMode needs. + string accountNumber = GetStringValue(connectionInfo, Constants.Config.AccountNumber); + if (string.IsNullOrWhiteSpace(accountNumber)) + errors.Add($"'{Constants.Config.AccountNumber}' is required."); + + string authMode = GetStringValue(connectionInfo, Constants.Config.AuthMode, Constants.Config.AuthModeAccessKey); + switch (authMode.ToUpperInvariant()) + { + case "ACCESSKEY": + case "APIKEY": // legacy alias + string apiKey = GetStringValue(connectionInfo, Constants.Config.ApiKey); + if (string.IsNullOrWhiteSpace(apiKey)) + errors.Add($"'{Constants.Config.ApiKey}' is required when AuthMode is 'AccessKey'."); + break; + + case "OAUTH": + case "OAUTH2": + string tokenUrl = GetStringValue(connectionInfo, Constants.Config.OAuth2TokenUrl); + string clientId = GetStringValue(connectionInfo, Constants.Config.OAuth2ClientId); + string clientSecret = GetStringValue(connectionInfo, Constants.Config.OAuth2ClientSecret); + if (string.IsNullOrWhiteSpace(tokenUrl)) + errors.Add($"'{Constants.Config.OAuth2TokenUrl}' is required when AuthMode is 'OAuth'."); + else + { + // The OAuth client secret is POSTed to this URL on every token + // refresh (CERTInextClient.GetOrRefreshTokenAsync) — same cleartext- + // credential exposure as ApiUrl, so it gets the same https-or-loopback + // rule. + string tokenUrlError = ValidateHttpsOrLoopbackUrl(Constants.Config.OAuth2TokenUrl, tokenUrl); + if (tokenUrlError != null) + errors.Add(tokenUrlError); + } + if (string.IsNullOrWhiteSpace(clientId)) + errors.Add($"'{Constants.Config.OAuth2ClientId}' is required when AuthMode is 'OAuth'."); + if (string.IsNullOrWhiteSpace(clientSecret)) + errors.Add($"'{Constants.Config.OAuth2ClientSecret}' is required when AuthMode is 'OAuth'."); + break; + + default: + errors.Add($"'{Constants.Config.AuthMode}' must be one of: AccessKey, OAuth. Got: '{authMode}'."); + break; + } } if (errors.Any()) @@ -430,9 +563,14 @@ public async Task ValidateCAConnectionInfo(Dictionary connection // Build a transient config from the supplied connectionInfo so we don't // rely on the already-initialized _client (which may hold stale creds) string rawConfig = JsonSerializer.Serialize(connectionInfo); - tempConfig = JsonSerializer.Deserialize(rawConfig); + tempConfig = JsonSerializer.Deserialize(rawConfig) + ?? throw new InvalidOperationException("Failed to deserialize connection info."); tempClient = new CERTInextClient(tempConfig); - await tempClient.PingAsync(); + + if (tempConfig.UseV2Api) + await tempClient.PingV2Async(); + else + await tempClient.PingAsync(); } catch (Exception ex) { @@ -441,8 +579,8 @@ public async Task ValidateCAConnectionInfo(Dictionary connection _logger.LogError( ex, "CA connection validation failed — live connectivity test unsuccessful. " + - "ApiUrl={ApiUrl}, AuthMode={AuthMode}", - attemptedApiUrl, attemptedAuthMode); + "ApiUrl={ApiUrl}, UseV2Api={UseV2Api}, AuthMode={AuthMode}", + attemptedApiUrl, tempConfig?.UseV2Api ?? false, attemptedAuthMode); // The inner exception message is NOT forwarded to the AnyCAValidationException // because it may contain HTTP response bodies or header fragments from the @@ -464,6 +602,7 @@ public async Task ValidateCAConnectionInfo(Dictionary connection tempConfig.OAuthClientSecret = string.Empty; tempConfig.Password = string.Empty; } + tempClient?.Dispose(); } _logger.LogInformation( @@ -478,16 +617,13 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Diction _logger.MethodEntry(LogLevel.Debug); string rawConfig = JsonSerializer.Serialize(connectionInfo); - var tempConfig = JsonSerializer.Deserialize(rawConfig); - var tempClient = new CERTInextClient(tempConfig); + var tempConfig = JsonSerializer.Deserialize(rawConfig) + ?? throw new InvalidOperationException("Failed to deserialize connection info."); + bool useV2 = tempConfig.UseV2Api; 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( @@ -497,20 +633,228 @@ 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}, UseV2Api={UseV2Api}", + profileId, productInfo?.ProductID, useV2); + + bool isPrivatePki = useV2 + && string.Equals(params_.ProductFamilySlug, Constants.ApiV2.FamilyPrivatePki, StringComparison.Ordinal); + // Gate the SSL-only ProductVariant cross-check below on the SSL family specifically + // (not just "!isPrivatePki") so a signature (Document Signer) template — which has + // no productVariant concept — is left unaffected. + bool isSsl = useV2 + && string.Equals(params_.ProductFamilySlug, Constants.ApiV2.FamilySsl, StringComparison.Ordinal); + + var tempClient = new CERTInextClient(tempConfig); + try { - var profiles = await tempClient.GetProfilesAsync(); - bool found = profiles.Any(p => - string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase)); + // A V2 private-pki template needs a Private PKI ProductVariant and an + // explicit ProductCode — checked before any catalog call, with the same rules + // EnrollV2Async enforces, so a template that can never enroll is rejected at save + // time. Inside the try so the finally block's credential scrubbing still runs. + if (isPrivatePki) + { + string pkiConfigError = ValidatePrivatePkiEnrollmentParams(params_, out _); + if (pkiConfigError != null) + { + _logger.LogWarning( + "Product/profile validation failed — {Reason} ProductID={ProductID}", + pkiConfigError, params_.ProductId); + throw new AnyCAValidationException(pkiConfigError); + } + } + + // An SSL template's explicit ProductVariant must agree with the + // product it's paired with — checked before any catalog call, same fail-fast + // placement as the private-pki check above, so a template that would silently + // send a DV-shaped body for an OV/EV product (or vice versa) is rejected at save + // time instead of at enroll. + if (isSsl) + { + string variantError = ResolveSslProductVariant(params_, out _); + if (variantError != null) + { + _logger.LogWarning( + "Product/profile validation failed — {Reason} ProductID={ProductID}", + variantError, params_.ProductId); + throw new AnyCAValidationException(variantError); + } + } + + // V2 catalog validation mirrors ValidateCAConnectionInfo's UseV2Api branch: + // GetProfilesAsync/GetProductDetailsAsync are V1-only and 404 against a + // V2-shaped ApiUrl. There is no soft-accept difference between modes + // — an empty/unusable catalog is treated as "not found", same as V1. + List availableIds; + bool found; + + if (useV2) + { + var products = await tempClient.GetProductDetailsV2Async(); + availableIds = products.Select(p => p.ProductCode).ToList(); + + if (isPrivatePki) + { + // The SSL ProductId -> productTypeID cross-check below would + // always reject a Private PKI code (GetProductIds only advertises SSL/TLS + // product names). Check the code against the spec's Private PKI + // productTypeID instead ("39" | Private PKI | Private PKI (8)). + var matchedProduct = products.FirstOrDefault(p => + string.Equals(p.ProductCode, profileId, StringComparison.OrdinalIgnoreCase)); + + if (matchedProduct == null) + { + var available = string.Join(", ", availableIds); + _logger.LogWarning( + "Product/profile validation failed — configured private-pki ProductCode '{ProfileId}' " + + "was not found in the CERTInext V2 catalog. AvailableCount={AvailableCount}", + profileId, availableIds.Count); + throw new AnyCAValidationException( + $"ProductCode '{profileId}' was not found in the CERTInext V2 catalog. " + + $"Available codes: {available}"); + } + + if (!string.Equals(matchedProduct.ProductTypeId, Constants.ApiV2.PrivatePkiProductTypeId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Product/profile validation failed — configured ProductCode '{ProfileId}' exists in the " + + "CERTInext V2 catalog, but its productTypeID ('{ActualTypeId}') is not the Private PKI " + + "productTypeID ('{ExpectedTypeId}') while ProductFamily is 'private-pki'.", + profileId, matchedProduct.ProductTypeId, Constants.ApiV2.PrivatePkiProductTypeId); + throw new AnyCAValidationException( + $"ProductCode '{profileId}' exists in the CERTInext catalog, but it is not a Private PKI " + + "product, and the template's ProductFamily is 'private-pki'. Set ProductCode to a Private " + + "PKI product code from your account's catalog, or correct ProductFamily."); + } + + found = true; + } + else if (params_.HasExplicitProductCode) + { + // Explicit override: the code must exist in the catalog AND the matched + // catalog entry's productTypeID must actually correspond to the selected + // ProductId — not just "does this code exist as *some* product". A code + // can exist and still mean a different, wrong-assurance-level product than + // the one the administrator selected. + var matchedProduct = products.FirstOrDefault(p => + string.Equals(p.ProductCode, profileId, StringComparison.OrdinalIgnoreCase)); + + if (matchedProduct == null) + { + var available = string.Join(", ", availableIds); + _logger.LogWarning( + "Product/profile validation failed — configured ProductCode '{ProfileId}' was not " + + "found in the CERTInext V2 catalog. ProductID={ProductID}, AvailableCount={AvailableCount}", + profileId, params_.ProductId, availableIds.Count); + throw new AnyCAValidationException( + $"ProductCode '{profileId}' was not found in the CERTInext V2 catalog. " + + $"Available codes: {available}"); + } + + if (Constants.Products.ProductTypeIdsV2.TryGetValue(params_.ProductId ?? string.Empty, out string expectedTypeId) + && !string.Equals(matchedProduct.ProductTypeId, expectedTypeId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Product/profile validation failed — configured ProductCode '{ProfileId}' exists in " + + "the CERTInext V2 catalog, but its catalog productTypeID ('{ActualTypeId}') does not " + + "match the selected ProductID '{ProductID}' (expected productTypeID '{ExpectedTypeId}'). " + + "This template would order a different product than the one selected.", + profileId, matchedProduct.ProductTypeId, params_.ProductId, expectedTypeId); + throw new AnyCAValidationException( + $"ProductCode '{profileId}' exists in the CERTInext catalog, but it does not " + + $"correspond to the selected product '{params_.ProductId}'. Verify the ProductCode " + + "override is correct for this product, or remove the override to let the plugin " + + "resolve it automatically from the catalog."); + } + + found = true; + } + else + { + // No explicit override: resolve/validate by matching the live catalog's + // productTypeID for the selected ProductId — do NOT fall back to + // Constants.Products.DefaultProductCodes (V1-era numbering that does not + // match the live V2 catalog). + if (!Constants.Products.ProductTypeIdsV2.TryGetValue(params_.ProductId ?? string.Empty, out string expectedTypeId)) + { + _logger.LogWarning( + "Product/profile validation failed — no productTypeID mapping is defined for " + + "ProductID '{ProductID}'.", params_.ProductId); + throw new AnyCAValidationException( + $"No V2 productTypeID mapping is defined for ProductID '{params_.ProductId}'. " + + "Set the ProductCode template parameter explicitly, or contact support to add a " + + "mapping for this product."); + } + + // Mirrors EnrollV2Async's own ambiguity handling (so a template is + // rejected/flagged at save time, not only discovered at enroll time): + // the live catalog can carry MORE THAN ONE entry with this productTypeID + // (e.g. type 13/DV SSL on the sandbox). Resolve automatically only when + // exactly one match exists, or when the connector's DefaultProductCode + // names one of several matches; otherwise reject with the candidates + // listed. + var matchingProducts = products + .Where(p => string.Equals(p.ProductTypeId, expectedTypeId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matchingProducts.Count == 0) + { + _logger.LogWarning( + "Product/profile validation failed — no CERTInext V2 catalog entry has " + + "productTypeID '{ExpectedTypeId}' for ProductID '{ProductID}'. AvailableCount={AvailableCount}", + expectedTypeId, params_.ProductId, availableIds.Count); + throw new AnyCAValidationException( + $"Could not find a CERTInext V2 catalog entry for product '{params_.ProductId}' " + + $"(expected productTypeID '{expectedTypeId}'). Verify the account is entitled to " + + "this product."); + } + + if (matchingProducts.Count > 1) + { + bool resolvedByDefault = matchingProducts.Any(p => + !string.IsNullOrWhiteSpace(tempConfig.DefaultProductCode) && + string.Equals(p.ProductCode, tempConfig.DefaultProductCode, StringComparison.OrdinalIgnoreCase)); + + if (!resolvedByDefault) + { + string candidates = string.Join(", ", + matchingProducts.Select(p => $"{p.ProductCode} ('{p.ProductName}')")); + _logger.LogWarning( + "Product/profile validation failed — multiple CERTInext V2 catalog entries " + + "share productTypeID '{ExpectedTypeId}' for ProductID '{ProductID}', and none " + + "match the configured DefaultProductCode ('{DefaultProductCode}'). " + + "Candidates=[{Candidates}]", + expectedTypeId, params_.ProductId, + string.IsNullOrWhiteSpace(tempConfig.DefaultProductCode) ? "(not set)" : tempConfig.DefaultProductCode, + candidates); + throw new AnyCAValidationException( + $"Multiple CERTInext catalog products match ProductID '{params_.ProductId}' " + + $"(productTypeID '{expectedTypeId}'): {candidates}. Set the ProductCode " + + "template parameter explicitly, or set the CA connector's DefaultProductCode " + + "to one of these codes, to disambiguate."); + } + } + + found = true; + } + } + else + { + var profiles = await tempClient.GetProfilesAsync(); + availableIds = profiles.Select(p => p.Id).ToList(); + found = profiles.Any(p => + string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase)); + } if (!found) { - var available = string.Join(", ", profiles.Select(p => p.Id)); + var available = string.Join(", ", availableIds); // SOC2 CC7.2: log profile probe misses at Warning to support anomaly detection. _logger.LogWarning( "Product/profile validation failed — ProfileId not found in CERTInext. " + - "ProfileId={ProfileId}, AvailableCount={AvailableCount}", - profileId, profiles.Count); + "ProfileId={ProfileId}, AvailableCount={AvailableCount}, UseV2Api={UseV2Api}", + profileId, availableIds.Count, useV2); throw new AnyCAValidationException( $"Profile '{profileId}' was not found in CERTInext. " + $"Available profiles: {available}"); @@ -541,9 +885,12 @@ 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); + _logger.LogInformation( + "Product/profile validation succeeded. ProfileId={ProfileId}, UseV2Api={UseV2Api}", + profileId, useV2); _logger.MethodExit(LogLevel.Debug); } @@ -566,47 +913,72 @@ 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)"; - - _logger.LogInformation( - "Enrollment attempt started. " + - "EnrollmentType={EnrollmentType}, Subject={Subject}, " + - "ProfileId={ProfileId}, SANs={SANs}, " + - "RequesterName={RequesterName}, RequesterEmail={RequesterEmail}", - enrollmentType, subject, - ep.ProfileId, sanSummary, - ep.RequesterName, ep.RequesterEmail); + // Email-type SAN values are personal data, masked unless LogSensitiveRequestData + // is on; DNS/IP/URI values stay verbatim as audit fields. + string sanSummary = LogSanitizer.FormatSans(san, _config.LogSensitiveRequestData); + + // 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, + LogSanitizer.Strip(ep.RequesterName), LogSanitizer.Strip(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(LogSanitizer.Strip(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."); } EnrollmentResult result; - switch (enrollmentType) + if (_config.UseV2Api) { - case EnrollmentType.New: - case EnrollmentType.Reissue: - result = await EnrollNewAsync(csr, subject, san, ep); - break; - - case EnrollmentType.Renew: - case EnrollmentType.RenewOrReissue: - result = await RenewOrReissueAsync(csr, subject, san, productInfo, ep); - break; - - default: - _logger.LogError( - "Enrollment rejected — unsupported enrollment type. EnrollmentType={EnrollmentType}, Subject={Subject}", - enrollmentType, subject); - throw new NotSupportedException($"Enrollment type '{enrollmentType}' is not supported."); + // V2 path: all enrollment types go through EnrollV2Async + result = await EnrollV2Async(csr, subject, san, ep, enrollmentType); + } + else + { + switch (enrollmentType) + { + case EnrollmentType.New: + case EnrollmentType.Reissue: + result = await EnrollNewAsync(csr, subject, san, ep); + break; + + case EnrollmentType.Renew: + case EnrollmentType.RenewOrReissue: + result = await RenewOrReissueAsync(csr, subject, san, productInfo, ep); + break; + + default: + _logger.LogError( + "Enrollment rejected — unsupported enrollment type. EnrollmentType={EnrollmentType}, Subject={Subject}", + enrollmentType, LogSanitizer.Strip(subject)); + throw new NotSupportedException($"Enrollment type '{enrollmentType}' is not supported."); + } } // SOX: the completion log must include the CA-assigned identifier, serial number, @@ -617,7 +989,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; } @@ -630,7 +1002,10 @@ public async Task Enroll( public async Task GetSingleRecord(string caRequestID) { _logger.MethodEntry(LogLevel.Debug); - _logger.LogInformation("GetSingleRecord started. CARequestID={Id}", caRequestID); + _logger.LogInformation("GetSingleRecord started. CARequestID={Id}, UseV2Api={UseV2Api}", caRequestID, _config.UseV2Api); + + if (_config.UseV2Api) + return await GetSingleRecordV2Async(caRequestID); try { @@ -690,6 +1065,9 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r { _logger.MethodEntry(LogLevel.Debug); + if (_config.UseV2Api) + return await RevokeV2Async(caRequestID, hexSerialNumber, revocationReason); + string reasonString = StatusMapper.ToRevocationReason(revocationReason); // SOX: log the revocation attempt before any state change so the intent is @@ -727,7 +1105,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 +1134,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; @@ -775,11 +1153,20 @@ public async Task Synchronize( { _logger.MethodEntry(LogLevel.Debug); + if (_config.UseV2Api) + { + // V2 mode routes Synchronize through V2 /reports/orders — the V1 + // GetOrderReport path below is never used, and V1 credentials are optional. + await SynchronizeV2Async(blockingBuffer, lastSync, fullSync, cancelToken); + _logger.MethodExit(LogLevel.Debug); + return; + } + DateTime? issuedAfter = fullSync ? (DateTime?)null : lastSync; _logger.LogInformation( - "Starting CERTInext synchronization. FullSync={FullSync}, IssuedAfter={IssuedAfter}", - fullSync, issuedAfter?.ToString("O") ?? "none"); + "Starting CERTInext synchronization. FullSync={FullSync}, IssuedAfter={IssuedAfter}, UseV2Api={UseV2Api}", + fullSync, issuedAfter?.ToString("O") ?? "none", _config.UseV2Api); int synced = 0; int skipped = 0; @@ -937,7 +1324,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 +1358,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++; @@ -1047,12 +1436,12 @@ public async Task Synchronize( // Private helpers // --------------------------------------------------------------------------- - /// The DCV-during-sync gate outcome for a single pending order (issue 0002). + /// The DCV-during-sync gate outcome for a single pending order. internal enum DcvSyncDecision { Attempt, SkipByAge, SkipByCap } /// /// Decides whether to attempt DCV completion for a pending order during a sync pass, - /// bounding the work so a large pending backlog can't make sync slow (issue 0002). + /// bounding the work so a large pending backlog can't make sync slow. /// Pure/stateless so it is unit-testable without the DCV machinery. /// /// Rules (checked in order): @@ -1079,627 +1468,3740 @@ internal static DcvSyncDecision EvaluateDcvSyncEligibility( return DcvSyncDecision.Attempt; } + // --------------------------------------------------------------------------- + // V2 API private helpers — only called when _config.UseV2Api is true + // --------------------------------------------------------------------------- + /// - /// Handles New and Reissue enrollment flows by submitting a fresh certificate - /// request to CERTInext. + /// Composes a single E.164-style phone value from separate ISD-code + mobile-number + /// config fields, for V2 request shapes that carry one phone field (e.g. + /// ) rather than V1's separate + /// IsdCode/MobileNumber pair. Returns an empty string when + /// is blank (nothing to compose); returns the bare + /// mobile number when is blank (never invents a code). + /// Internal + static for direct unit testing. /// - private async Task EnrollNewAsync( - string csr, - string subject, - Dictionary san, - EnrollmentParams ep) + internal static string ComposeV2Phone(string isdCode, string mobileNumber) { - _logger.MethodEntry(LogLevel.Debug); - var enrollReq = new EnrollCertificateRequest - { - ProfileId = ep.ProfileId, - Csr = csr, - ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, - Subject = subject, - Sans = BuildSanList(san), - RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, - RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, - KeyType = string.IsNullOrWhiteSpace(ep.KeyType) ? null : ep.KeyType, - Comment = "Issued via Keyfactor Command AnyCA REST Gateway." - }; + if (string.IsNullOrWhiteSpace(mobileNumber)) + return string.Empty; + if (string.IsNullOrWhiteSpace(isdCode)) + return mobileNumber.Trim(); - var enrollResp = await _client.EnrollCertificateAsync(enrollReq); + return "+" + isdCode.Trim().TrimStart('+') + mobileNumber.Trim(); + } -#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)) + /// + /// Validates the template parameters a V2 private-pki order needs + /// beyond the SSL ones, before any CA call. Shared by (fail + /// fast with a FAILED result) and (reject at template + /// save time). Returns null when valid, else an actionable message naming the field. + /// + /// - ProductVariant must be one of the spec's create-body variant values + /// (). Its SSL-only "dv" default is + /// rejected rather than silently mapped to a Private PKI variant. + /// - ProductCode must be set explicitly. only advertises + /// SSL/TLS product names, so would + /// resolve an SSL product code for a private-pki template; and per the spec's Product + /// Codes reference, "Private PKI codes vary per customer catalog". + /// + internal static string ValidatePrivatePkiEnrollmentParams(EnrollmentParams ep, out string normalizedVariant) + { + normalizedVariant = null; + + string variant = ep.ProductVariant?.Trim(); + if (string.IsNullOrEmpty(variant) || !Constants.ApiV2.PrivatePkiVariants.Contains(variant)) { - // 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 - // CERTINEXT_DCV_TIMEOUT_MINUTES env var); defaults to 10 minutes. - // Log the resolved limit so an auditor can confirm the configured ceiling. - int dcvTimeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); - _logger.LogInformation( - "Starting DCV for order {OrderNumber}. DcvTimeoutMinutes={Timeout}", - orderNumber, dcvTimeoutMinutes); - using var dcvCts = new CancellationTokenSource(TimeSpan.FromMinutes(dcvTimeoutMinutes)); + string configured = ep.HasExplicitProductVariant ? $"'{variant}'" : "not set (defaults to the SSL-only 'dv')"; + return $"ProductFamily 'private-pki' requires the '{Constants.EnrollmentParam.ProductVariant}' " + + $"template parameter to be one of: {Constants.ApiV2.PrivatePkiVariantIntranetSsl}, " + + $"{Constants.ApiV2.PrivatePkiVariantIgtfHost}. Current value: {configured}."; + } - // Reserve the in-flight slot before running DCV so that any concurrent - // Synchronize / GetSingleRecord cycle won't try to stage TXT records for the - // same order from the sync-driven retry path. If something else already has - // the slot (the only realistic case: a duplicate Enroll for the same order - // ID), skip our own attempt and fall through to the pending result — the - // other caller will produce the same outcome and we shouldn't double-stage. - bool reserved = _dcvInFlight.TryAdd(orderNumber, 0); - if (!reserved) - { - _logger.LogInformation( - "DCV is already in flight for order {OrderNumber}; Enroll will skip its own DCV attempt " + - "and return the pending enroll response. The other caller will drive issuance.", - orderNumber); - } - else - { - try - { - bool dcvDone = await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token); - if (dcvDone) - { - // Poll GetCertificate until CERTInext finishes generating the cert OR the - // issuance budget expires. CERTInext issuance is async — DCV may verify - // but the cert PEM isn't immediately available. Without this poll, Enroll - // returns a pending result and the cert is picked up on the next sync cycle, - // which is undesirable when the whole thing completes in under a minute. - var postDcv = await WaitForIssuanceAfterDcvAsync(orderNumber, dcvCts.Token); - if (postDcv != null) - { - return BuildEnrollmentResult(new EnrollCertificateResponse - { - Id = postDcv.Id, - Status = postDcv.Status, - Certificate = postDcv.Certificate, - SerialNumber = postDcv.SerialNumber, - Message = $"Post-DCV status: {postDcv.Status}." - }, ep.AutoApprove); - } - } - } - finally - { - _dcvInFlight.TryRemove(orderNumber, out _); - } - } + if (!ep.HasExplicitProductCode) + { + return $"ProductFamily 'private-pki' requires the '{Constants.EnrollmentParam.ProductCode}' " + + "template parameter to be set explicitly to your account's Private PKI catalog product " + + "code (Private PKI codes vary per customer catalog and cannot be resolved from the " + + "selected SSL/TLS product name)."; } -#endif - _logger.MethodExit(LogLevel.Debug); - return BuildEnrollmentResult(enrollResp, ep.AutoApprove); + normalizedVariant = variant.ToLowerInvariant(); + return null; } /// - /// Handles Renew and RenewOrReissue enrollment flows. - /// Determines whether to renew (API call on existing ID) or fall back to new - /// issuance depending on the certificate's current state. + /// Resolves the SSL family's productVariant value to send, and validates an + /// explicit template override against the product it's paired with. + /// must not simply default to "dv" + /// independent of : an OV/EV product with no + /// explicit ProductVariant sending productVariant:"dv" would skip the mandatory + /// OV/EV organization block (see isOvOrEv in ) + /// and silently order a DV-shaped body for an OV/EV product. + /// + /// Derivation source: , keyed by + /// ProductId (the same productTypeID assurance-level grouping + /// documents). SSL-only — callers must + /// not invoke this for private-pki (see ) + /// or signature (not yet supported for enrollment) families. + /// + /// Returns null and sets when valid: either the + /// template's explicit value (when it agrees with the derived variant, or no mapping + /// exists for this ProductId, in which case the explicit value is kept rather than + /// guessing), or the value derived from ProductId when no explicit override is + /// configured. Returns an actionable error message + /// ( = null) when an + /// explicit override contradicts the product's derived variant. /// - private async Task RenewOrReissueAsync( - string csr, - string subject, - Dictionary san, - EnrollmentProductInfo productInfo, - EnrollmentParams ep) + internal static string ResolveSslProductVariant(EnrollmentParams ep, out string resolvedVariant) { - // Retrieve the prior certificate serial number from the product parameters. - // Command injects "PriorCertSN" for renewal flows. - string priorCertSn = null; - productInfo.ProductParameters?.TryGetValue("PriorCertSN", out priorCertSn); - - // SOC2 CC6.1: a renewal/reissue read against the gateway's certificate - // inventory is a logical-access event and must be logged at Information. - _logger.LogInformation( - "Renewal/reissue probe — read PriorCertSN from EnrollmentProductInfo. " + - "Subject={Subject}, PriorCertSN={PriorCertSN}, RenewalWindowDays={WindowDays}", - subject, string.IsNullOrWhiteSpace(priorCertSn) ? "(none)" : priorCertSn, - ep.RenewalWindowDays); + bool hasMapping = Constants.Products.ProductVariantsV2.TryGetValue( + ep.ProductId ?? string.Empty, out string derivedVariant); - if (string.IsNullOrWhiteSpace(priorCertSn)) + if (!ep.HasExplicitProductVariant) { - // SOC2 CC7.2: log policy-relevant decisions at Information so they survive - // production log filters and are available for anomaly detection. - _logger.LogInformation( - "Renewal/reissue has no PriorCertSN — treating as new enrollment. Subject={Subject}", - subject); - return await EnrollNewAsync(csr, subject, san, ep); + // No override configured — derive from the product when an authoritative + // mapping exists; otherwise fall back to the configured default rather than + // inventing a mapping for a product this table doesn't cover. + resolvedVariant = hasMapping ? derivedVariant : ep.ProductVariant; + return null; } - // Resolve the CARequestID for the prior certificate - string priorCaRequestId; - try - { - priorCaRequestId = await _certificateDataReader.GetRequestIDBySerialNumber(priorCertSn); - } - catch (Exception ex) + string explicitVariant = ep.ProductVariant.Trim(); + if (hasMapping && !string.Equals(explicitVariant, derivedVariant, StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning(ex, - "Could not resolve CARequestID for serial '{SN}'. Falling back to new enrollment.", priorCertSn); - return await EnrollNewAsync(csr, subject, san, ep); + resolvedVariant = null; + return $"Template parameter '{Constants.EnrollmentParam.ProductVariant}' is set to " + + $"'{explicitVariant}', but the selected product '{ep.ProductId}' is " + + $"'{derivedVariant}'. Set '{Constants.EnrollmentParam.ProductVariant}' to " + + $"'{derivedVariant}' (or remove the override to let the plugin derive it " + + $"automatically from the product), or select a product whose assurance " + + $"level matches '{explicitVariant}'."; } - if (string.IsNullOrWhiteSpace(priorCaRequestId)) - { + resolvedVariant = explicitVariant.ToLowerInvariant(); + return null; + } + + /// + /// Dispatches all enrollment types through the V2 REST API. + /// + private async Task EnrollV2Async( + string csr, + string subject, + Dictionary san, + EnrollmentParams ep, + EnrollmentType enrollmentType) + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogInformation( + "EnrollV2Async started. EnrollmentType={EnrollmentType}, ProductFamily={Family}, ProductVariant={Variant}, " + + "ProductId={ProductId}, HasExplicitProductCode={HasExplicit}, ConfiguredProductCode={Code}", + enrollmentType, ep.ProductFamilySlug, ep.ProductVariant, ep.ProductId, ep.HasExplicitProductCode, + ep.HasExplicitProductCode ? ep.ProductCode : "(resolved from catalog)"); + + // The create-order body is family-specific: Private PKI and Document Signer each need + // their own shape, not the SSL body (productVariant/certificate/agreement...). ssl + // (and any unrecognized ProductFamily, which ProductFamilySlug already maps to ssl) + // uses the SSL flow below. + bool isPrivatePki = string.Equals(ep.ProductFamilySlug, Constants.ApiV2.FamilyPrivatePki, StringComparison.Ordinal); + + // Document Signer (signature): the body DTO exists (V2CreateSignatureOrderRequest), + // but its mandatory subjectType / subject.email and the per-subject-type subject + // name/organization fields have no settled source in the Command enrollment inputs. + // Fail fast with a clear message, before any CA call, rather than guessing those + // values or sending the wrong-family SSL body (which the CA rejects anyway: + // subjectType and subject.email are "400 if missing"). + if (string.Equals(ep.ProductFamilySlug, Constants.ApiV2.FamilySignature, StringComparison.Ordinal)) + { + _logger.LogWarning( + "EnrollV2Async rejected a ProductFamily=signature (Document Signer) order — V2 Document " + + "Signer enrollment is not yet supported by this plugin version. EnrollmentType={EnrollmentType}, " + + "ProductId={ProductId}", + enrollmentType, ep.ProductId); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "V2 enrollment rejected: ProductFamily 'signature' (Document Signer) is not yet " + + "supported for enrollment by this plugin version. A Document Signer order needs " + + "signer-subject details (subjectType, subject email, and per-subject-type name or " + + "organization fields) that the plugin does not yet take from the enrollment request. " + + "No order was placed." + }; + } + + string privatePkiVariant = null; + if (isPrivatePki) + { + string pkiConfigError = ValidatePrivatePkiEnrollmentParams(ep, out privatePkiVariant); + if (pkiConfigError != null) + { + _logger.LogWarning( + "EnrollV2Async rejected a private-pki order before any CA call: {Reason}", pkiConfigError); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "V2 enrollment rejected: " + pkiConfigError + }; + } + } + + // The SSL family's productVariant must reflect the actual product ordered + // — not the template's independent (and possibly wrong/stale) ProductVariant value. + // Resolve/validate before any CA call, same fail-fast placement as the private-pki + // check above. Private PKI (checked above) doesn't use this — its variant enum is + // unrelated (intranet-ssl/igtf-host). + string sslProductVariant = ep.ProductVariant; + if (!isPrivatePki) + { + string variantError = ResolveSslProductVariant(ep, out sslProductVariant); + if (variantError != null) + { + _logger.LogWarning( + "EnrollV2Async rejected an SSL order before any CA call: {Reason}", variantError); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "V2 enrollment rejected: " + variantError + }; + } + } + + // The SSL create body always carries an `agreement` block, and the V2 spec + // marks agreement.signerPlace "Conditional - required if `agreement` sent". Resolved + // per-template SignerPlace -> connector SignerPlace (which ValidateCAConnectionInfo + // already requires); fail fast here too, before any CA call, in case the connector was + // saved before that check existed. Private PKI sends no agreement, so it is exempt. + string signerPlace = string.IsNullOrWhiteSpace(ep.SignerPlace) ? _config.SignerPlace : ep.SignerPlace; + if (!isPrivatePki && string.IsNullOrWhiteSpace(signerPlace)) + { + _logger.LogWarning( + "EnrollV2Async rejected an SSL order before any CA call — no SignerPlace is configured " + + "(neither the template's SignerPlace enrollment parameter nor the CA connector's SignerPlace), " + + "but the V2 Subscriber Agreement requires it. EnrollmentType={EnrollmentType}, ProductId={ProductId}", + enrollmentType, ep.ProductId); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "V2 enrollment rejected: the CERTInext V2 Subscriber Agreement requires a signer " + + "place, but SignerPlace is blank on both the CA connector and the template. Set " + + "SignerPlace on the CA connector (or the template's SignerPlace enrollment " + + "parameter) and retry. No order was placed." + }; + } + + // Derive the primary domain from subject CN (for private-pki this is the order's + // `hostname` — spec: "hostname - primary CN" — sourced the same way). + string domain = ep.DomainName; + if (string.IsNullOrWhiteSpace(domain)) + domain = ExtractCnFromSubject(subject); + if (string.IsNullOrWhiteSpace(domain)) + throw new Exception(isPrivatePki + ? "Cannot determine the primary hostname for V2 private-pki order — set the DomainName enrollment parameter or ensure the CSR subject has a CN." + : "Cannot determine primary domain for V2 order — set the DomainName enrollment parameter or ensure the CSR subject has a CN."); + + // organization is "Conditional — Mandatory for OV / EV" per the V2 spec's SSL field + // table; every OV/EV create example in the spec sends it, and it is omitted entirely + // for DV. CERTInext hard-rejects an OV/EV order with no organization block (HTTP 422 + // EMS-1180 "Organization Name cannot be empty"), so fail fast with a + // clear message here — before any catalog/order-placement call — rather than + // sending an incomplete block and letting the CA surface that opaque error. + // SSL-only: Private PKI "has no DCV, no organization block, and no Subscriber + // Agreement" per the spec, and its ProductVariant is intranet-ssl/igtf-host. + bool isOvOrEv = !isPrivatePki + && (string.Equals(sslProductVariant, Constants.ApiV2.ProductVariantOv, StringComparison.OrdinalIgnoreCase) + || string.Equals(sslProductVariant, Constants.ApiV2.ProductVariantEv, StringComparison.OrdinalIgnoreCase)); + + V2OrganizationParams organization = null; + if (isOvOrEv) + { + if (string.IsNullOrWhiteSpace(_config.OrganizationNumber)) + { + _logger.LogWarning( + "EnrollV2Async rejected a '{Variant}' order for domain '{Domain}' — the CA " + + "connector's OrganizationNumber is not configured, but an organization block is " + + "mandatory for OV/EV orders under the V2 API.", + sslProductVariant, LogSanitizer.Strip(domain)); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: product variant '{sslProductVariant}' requires " + + "a pre-vetted organization, but the CA connector's OrganizationNumber " + + "setting is empty. Set OrganizationNumber on the CA connector before " + + "enrolling OV/EV certificates via the V2 API." + }; + } + + organization = new V2OrganizationParams + { + OrganizationNumber = _config.OrganizationNumber, + PreVetted = true + }; + } + + // SubscriptionAutoRenew / SubscriptionRenewCriteriaDays — CA connector config that + // feeds the Subscription block below. AutoRenew uses the same bare "1"-means-true + // comparison AutoSecureWww already uses elsewhere in this method. RenewBeforeDays is + // validated up front — before any CA call — so a bad value fails the enrollment with + // a clear message rather than surfacing as an opaque CA-side error later, matching the + // fail-fast convention the OrganizationNumber check above already established. Blank/ + // unset stays null (omitted on the wire; the client's global WhenWritingNull option + // means the CA falls back to its documented default of 30). + bool subscriptionAutoRenew = _config.SubscriptionAutoRenew == "1"; + int? subscriptionRenewBeforeDays = null; + if (!string.IsNullOrWhiteSpace(_config.SubscriptionRenewCriteriaDays)) + { + if (!int.TryParse(_config.SubscriptionRenewCriteriaDays, out int parsedRenewDays) || parsedRenewDays < 0) + { + _logger.LogError( + "EnrollV2Async rejected an order — the CA connector's SubscriptionRenewCriteriaDays " + + "value ('{Value}') is not a valid non-negative integer.", + _config.SubscriptionRenewCriteriaDays); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: the CA connector's SubscriptionRenewCriteriaDays " + + $"setting ('{_config.SubscriptionRenewCriteriaDays}') must be a non-negative " + + "integer (or blank to use the CA's default of 30). Fix the CA connector " + + "configuration and retry." + }; + } + + subscriptionRenewBeforeDays = parsedRenewDays; + } + + // EmailNotifications: the connector field uses V1's "0"/"1" vocabulary and defaults + // to "0". V2 honors "0" by suppressing the order-creation emails — it does not + // silently coerce back to "all" — so the mapping below is user-decided, not a guess: + // "1" -> "all" (full notification set), "0" -> + // "0" (silent, matching V1's own wire vocabulary), blank/unset -> null (omitted; the + // client's global WhenWritingNull option drops the key and the CA falls back to its + // documented default of "all", NOT silent — this is the one place V1 and V2 defaults + // diverge for a blank config value, since V1's own fallback always sends "0"). Any + // other value fails fast, before any CA call, mirroring the + // SubscriptionRenewCriteriaDays check above. + string emailNotifications; + string configEmailNotifications = _config.EmailNotifications?.Trim(); + if (string.IsNullOrEmpty(configEmailNotifications)) + { + emailNotifications = null; + } + else if (configEmailNotifications == "1") + { + emailNotifications = "all"; + } + else if (configEmailNotifications == "0") + { + emailNotifications = "0"; + } + else + { + _logger.LogError( + "EnrollV2Async rejected an order — the CA connector's EmailNotifications value " + + "('{Value}') is not one of the supported values.", + _config.EmailNotifications); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: the CA connector's EmailNotifications " + + $"setting ('{_config.EmailNotifications}') must be \"0\", \"1\", or " + + "blank. Fix the CA connector configuration and retry." + }; + } + + // Resolve the real V2 product code (and UCC-ness) from the live Catalog response. + // Fetched once here and reused for both purposes — no second catalog call. + // + // Explicit override (ProductCode/ProfileId set on the template): trust the + // administrator's value as-is; the catalog is only consulted for UCC detection, and a + // catalog lookup failure there must not block enrollment — fall back to non-UCC + // (single-domain) behavior, the strictly-safer failure mode: the CSR-SAN-count guard + // below still runs against that non-UCC assumption, so a surplus-SAN CSR is rejected + // rather than silently accepted. + // + // No explicit override: the code must NOT fall back to + // Constants.Products.DefaultProductCodes (V1-era numbering that does not match the + // live V2 catalog, e.g. its "842" is OV SSL but the live V2 catalog's "842" is DV + // SSL). Instead resolve the live product code by matching the catalog + // entry whose productTypeID equals the stable numeric type ID for ep.ProductId + // (Constants.Products.ProductTypeIdsV2) — productTypeID is a small CERTInext-documented + // enum, unlike productCode (wrong table) or productName (spelling varies by + // account/catalog version). Here, a catalog failure or an + // unresolvable mapping MUST fail the enrollment loudly: there is no safe fallback code + // to send on the wire. + // + // Private PKI: the explicit ProductCode is required (validated above) and + // trusted as-is, exactly like the SSL explicit-override case; the catalog is not + // fetched at all because its only use on that path — SSL UCC detection — does not + // apply (ValidateProductInfo checks the code's productTypeID at template save time). + string productCode; + bool isUccProduct = false; + bool isWildcardProduct = false; + List catalog = null; + if (!isPrivatePki) + { + try + { + catalog = await _client.GetProductDetailsV2Async(); + } + catch (Exception catalogEx) + { + _logger.LogWarning(catalogEx, + "EnrollV2Async: could not fetch the live V2 product catalog. ProductId={ProductId}, " + + "HasExplicitProductCode={HasExplicit}", ep.ProductId, ep.HasExplicitProductCode); + } + } + + if (isPrivatePki) + { + productCode = ep.ProductCode; + } + else if (ep.HasExplicitProductCode) + { + productCode = ep.ProductCode; + + var matchedProduct = catalog?.FirstOrDefault(p => + string.Equals(p.ProductCode, productCode, StringComparison.OrdinalIgnoreCase)); + isUccProduct = matchedProduct != null + && !string.IsNullOrWhiteSpace(matchedProduct.ProductTypeId) + && Constants.ApiV2.UccProductTypeIds.Contains(matchedProduct.ProductTypeId); + isWildcardProduct = matchedProduct != null + && !string.IsNullOrWhiteSpace(matchedProduct.ProductTypeId) + && Constants.ApiV2.WildcardProductTypeIds.Contains(matchedProduct.ProductTypeId); + } + else + { + if (!Constants.Products.ProductTypeIdsV2.TryGetValue(ep.ProductId ?? string.Empty, out string expectedTypeId)) + { + _logger.LogError( + "EnrollV2Async rejected an order for ProductId={ProductId} — no productTypeID mapping " + + "is defined for this product name, and no explicit ProductCode override is configured.", + ep.ProductId); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: no productTypeID mapping is defined for " + + $"ProductID '{ep.ProductId}'. Set the ProductCode template parameter " + + "explicitly, or contact support to add a mapping for this product." + }; + } + + // The live catalog can carry more than one entry with the same productTypeID + // (e.g. two type-13 DV SSL entries, "917 SSL DV 1 month" and "842 DV SSL + // Certificate"). Picking the catalog's first-listed match is not safe: CERTInext + // rejects some of these entries at PUT /csr with 422 "PFX based certificate + // orders are not allowed", which would fail every ProductId-only DV SSL + // enrollment that resolved to one. When more than one catalog entry shares the + // expected productTypeID, only resolve automatically if the connector's + // DefaultProductCode names one of them; otherwise reject with a clear message + // listing the candidates rather than guessing. + var matchingProducts = (catalog ?? new List()) + .Where(p => string.Equals(p.ProductTypeId, expectedTypeId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + ProductDetail matchedProduct = null; + if (matchingProducts.Count == 1) + { + matchedProduct = matchingProducts[0]; + } + else if (matchingProducts.Count > 1) + { + matchedProduct = matchingProducts.FirstOrDefault(p => + !string.IsNullOrWhiteSpace(_config.DefaultProductCode) && + string.Equals(p.ProductCode, _config.DefaultProductCode, StringComparison.OrdinalIgnoreCase)); + + if (matchedProduct == null) + { + string candidates = string.Join(", ", + matchingProducts.Select(p => $"{p.ProductCode} ('{p.ProductName}')")); + _logger.LogWarning( + "EnrollV2Async rejected an order — multiple CERTInext V2 catalog entries share " + + "productTypeID '{ExpectedTypeId}' for ProductId={ProductId}, and none match the " + + "configured DefaultProductCode ('{DefaultProductCode}'). Candidates=[{Candidates}]", + expectedTypeId, ep.ProductId, + string.IsNullOrWhiteSpace(_config.DefaultProductCode) ? "(not set)" : _config.DefaultProductCode, + candidates); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: multiple CERTInext catalog products match " + + $"ProductID '{ep.ProductId}' (productTypeID '{expectedTypeId}'): " + + $"{candidates}. Set the ProductCode template parameter explicitly, " + + "or set the CA connector's DefaultProductCode to one of these codes, " + + "to disambiguate." + }; + } + } + + if (matchedProduct == null || string.IsNullOrWhiteSpace(matchedProduct.ProductCode)) + { + _logger.LogError( + "EnrollV2Async: could not resolve a live V2 product code for ProductId={ProductId} " + + "(expected catalog productTypeID='{ExpectedTypeId}'). CatalogFetchSucceeded={CatalogOk}", + ep.ProductId, expectedTypeId, catalog != null); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: could not resolve a live product code for " + + $"ProductID '{ep.ProductId}' from the CERTInext catalog (expected " + + $"productTypeID '{expectedTypeId}'). Verify the account is entitled to " + + "this product, or set the ProductCode template parameter explicitly." + }; + } + + productCode = matchedProduct.ProductCode; + isUccProduct = Constants.ApiV2.UccProductTypeIds.Contains(expectedTypeId); + isWildcardProduct = Constants.ApiV2.WildcardProductTypeIds.Contains(expectedTypeId); + + _logger.LogInformation( + "EnrollV2Async: resolved V2 product code from live catalog. ProductId={ProductId}, " + + "ProductTypeId={ProductTypeId}, ResolvedProductCode={ProductCode}", + ep.ProductId, expectedTypeId, productCode); + } + + // Non-UCC V2 products support only a single domain plus autoSecureWww's www. + // variant; any other DNS SAN — from the CSR OR Command's SAN dictionary — would be + // silently dropped or cause a CA-side rejection, so fail fast with a + // clear message rather than letting the CA return an opaque error, or the extra + // names vanish with no order-side signal at all. UCC (multi-domain) products are + // exempt — their extra SANs are the expected input and are carried into + // additionalDomains below instead. This check necessarily runs after UCC detection + // above (which needs the live catalog lookup to know isUccProduct), not before it, + // but it still runs before PlaceOrderV2Async, so a rejected non-UCC request never + // places a CA order. + // + // Looking at the CSR alone misses the case where Command's SAN + // dictionary carries the extras and the CSR itself carries only the primary domain — + // a non-UCC order never sends additionalDomains at all, so those dictionary-only + // extras would have nowhere to go and would be dropped with no warning. + // + // This guard deliberately takes the UNION of the CSR's own SANs and the SAN + // dictionary — NOT CollectRequestedSanEntries'/BuildSanList's "fallback, not union" + // rule (dictionary wins when non-null; CSR consulted only when the dictionary is + // null). That rule exists to decide what additionalDomains/additionalHosts actually + // send to the CA, where the CSR's own subjectAltName extension is otherwise ignored. + // Here it is wrong: SubmitCsrV2Async submits the CSR to CERTInext verbatim regardless + // of what the SAN dictionary contains, so a CSR-embedded extra domain still reaches + // the CA even when Command also supplied a (single-domain) SAN dictionary. Deferring + // to the dictionary alone whenever it is non-null would let that CSR-embedded extra + // slip past this guard unrejected for any enrollment that also happens to pass a SAN + // dictionary. + // + // SSL-only: a private-pki order carries its SANs in additionalHosts, + // which the spec defines as a multi-entry "SAN list (DNS names or IPv4 / IPv6)" for + // every Private PKI variant, so the single-domain restriction does not apply. + // + // Wildcard apex exemption: a non-UCC wildcard product's `domain` is the literal + // wildcard value (e.g. "*.example.com"), and a wildcard CSR/SAN dictionary + // routinely also carries the bare apex ("example.com") alongside it — rejecting + // that apex as an "extra SAN" would make every ordinary wildcard CSR unenrollable + // via this guard. Exempt exactly the apex (the wildcard domain with its leading + // "*." stripped) for wildcard products (productTypeID 14/17, + // Constants.ApiV2.WildcardProductTypeIds) — any other extra SAN is still rejected. + // NOTE: this only widens what the guard *accepts*; it does not change + // what is sent to the CA for the apex — additionalDomains is still not populated + // for non-UCC products below. Whether CERTInext's V2 order create needs the apex + // added to additionalDomains (or handles it automatically for a wildcard product) + // is unverified and should be confirmed against a live order before relying on it. + if (!isPrivatePki && !isUccProduct) + { + string wildcardApexDomain = isWildcardProduct && domain != null + && domain.StartsWith("*.", StringComparison.Ordinal) + ? StripWildcardPrefix(domain) + : null; + + var csrSanEntries = ExtractSanEntriesFromCsr(csr, out _); + // CollectRequestedSanEntries(san, csr: null, ...) yields exactly the SAN + // dictionary's own (MapSanType-normalized) entries: when san is non-null the + // CSR-fallback branch never runs, and when san is null there is nothing to + // collect (csr: null makes the fallback branch's own CSR read a no-op) — the + // real CSR is already covered by csrSanEntries above, so no double-count. + var dictSanEntries = CollectRequestedSanEntries(san, csr: null, out _, out _); + var extraSans = csrSanEntries + .Concat(dictSanEntries) + .Where(s => string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)) + .Select(s => s.Value?.ToLowerInvariant()) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Where(v => !string.Equals(v, domain, StringComparison.OrdinalIgnoreCase)) + .Where(v => !string.Equals(v, "www." + domain, StringComparison.OrdinalIgnoreCase)) + .Where(v => wildcardApexDomain == null || !string.Equals(v, wildcardApexDomain, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (extraSans.Count > 0) + { + _logger.LogWarning( + "EnrollV2Async rejected multi-SAN order on domain '{Domain}'. " + + "This product does not support additional domains via the V2 API " + + "(autoSecureWww covers www.); only UCC (multi-domain) products may carry " + + "extra SANs, whether requested via the CSR or Command's SAN dictionary. " + + "ExtraSans=[{ExtraSans}]", + LogSanitizer.Strip(domain), + LogSanitizer.Strip(string.Join(", ", extraSans))); + _logger.MethodExit(LogLevel.Debug); + // Wildcard products get their own wording: unlike a plain single-domain + // product, a wildcard product cannot be made to accept extra SANs by + // switching to a UCC product on this same domain, so the rejection must not + // suggest that. + string statusMessage = isWildcardProduct + ? $"V2 enrollment rejected: {extraSans.Count} SAN(s) beyond the primary wildcard " + + $"domain ('{domain}') and its www. variant were requested (via the CSR and/or " + + "Command's SAN dictionary). Only the primary wildcard domain and its bare apex " + + "are supported via the V2 API for this product. Resubmit with a CSR/SAN set " + + "carrying only those, and no other SANs." + : $"V2 enrollment rejected: {extraSans.Count} SAN(s) beyond the primary domain " + + $"('{domain}') and its www. variant were requested (via the CSR and/or Command's " + + "SAN dictionary). This product only supports a single domain via the V2 API " + + "(UCC/multi-domain products are the exception). Resubmit with a single-domain " + + "CSR and no additional SANs, or enroll against a UCC product if additional " + + "domains are required."; + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = statusMessage + }; + } + } + + // For UCC products, additionalDomains is populated from the same SAN source the V1 + // path uses for BuildAdditionalDomains (CERTInextClient.cs) — the gateway-supplied SAN + // dictionary, falling back to CSR SANs only when Command supplied no SAN dictionary at + // all (BuildSanList's own fallback rule). additionalDomains is a domain-name-only field + // per the V2 spec, so non-DNS SAN types are excluded (and logged) rather than submitted. + // BuildSanList runs in DNS-only mode here: V1's SubmitNonDnsSans switch + // and its "submitted rather than dropped" wording do not apply to this field, so the + // exclusion is logged below instead — SAN types only, since a value (e.g. an email + // address) may be personal data. + List additionalDomains = null; + if (isUccProduct) + { + var resolvedSans = BuildSanList(san, csr, subject, dnsOnly: true, out var nonDnsSans); + if (nonDnsSans.Count > 0) + { + _logger.LogWarning( + "EnrollV2Async: {Count} non-DNS SAN(s) excluded from V2 additionalDomains " + + "(FQDNs only) — they will NOT appear on the issued certificate. " + + "Types=[{Types}], Subject={Subject}", + nonDnsSans.Count, + string.Join(", ", nonDnsSans.Select(s => s.Type).Distinct(StringComparer.OrdinalIgnoreCase)), + LogSanitizer.Strip(subject)); + } + + additionalDomains = resolvedSans? + .Where(s => string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)) + .Select(s => s.Value?.Trim()) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Where(v => !string.Equals(v, domain, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (additionalDomains != null && additionalDomains.Count == 0) + additionalDomains = null; + + _logger.LogInformation( + "EnrollV2Async: resolved UCC product. ProductCode={ProductCode}, AdditionalDomainCount={Count}", + productCode, additionalDomains?.Count ?? 0); + } + + // Private PKI additionalHosts: same SAN source rule as the UCC path + // above, but DNS names AND IP addresses are both native here (spec: "SAN list (DNS + // names or IPv4 / IPv6)"; the Intranet SSL example sends "10.0.0.50"). + List additionalHosts = null; + if (isPrivatePki) + { + additionalHosts = BuildPrivatePkiAdditionalHosts(san, csr, subject, domain); + _logger.LogInformation( + "EnrollV2Async: private-pki order. Variant={Variant}, ProductCode={ProductCode}, " + + "Hostname={Hostname}, AdditionalHostCount={Count}", + privatePkiVariant, productCode, LogSanitizer.Strip(domain), additionalHosts?.Count ?? 0); + } + + string requestorName = string.IsNullOrWhiteSpace(ep.RequesterName) ? _config.RequestorName : ep.RequesterName; + string requestorEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? _config.RequestorEmail : ep.RequesterEmail; + string signerName = string.IsNullOrWhiteSpace(ep.SignerName) ? requestorName : ep.SignerName; + string signerIp = string.IsNullOrWhiteSpace(ep.SignerIp) ? _config.SignerIp : ep.SignerIp; + // signerPlace is resolved (and required for SSL) near the top of this method. + int validityYears = ep.ValidityYears > 0 ? ep.ValidityYears + : (int.TryParse(_config.SubscriptionValidityYears, out int cfgYears) && cfgYears > 0 ? cfgYears : 1); + + // requestorIsd/requestorMobile are the same Requestor* defaults V1 falls back to for + // its own TechnicalPointOfContact (CERTInextClient.cs BuildOrderRequestFromLegacyEnrollRequest). + // Also used directly for this order's own Requestor.Phone (below) via ComposeV2Phone, + // which combines RequestorIsdCode with the mobile number rather than sending the raw + // mobile number alone — the same helper TechnicalPointOfContact.Phone uses. + string requestorIsd = string.IsNullOrWhiteSpace(_config.RequestorIsdCode) ? "1" : _config.RequestorIsdCode; + string requestorMobile = _config.RequestorMobileNumber ?? string.Empty; + + // Requestor.Designation is sourced from the RequestorDesignation config field + // rather than a hardcoded value (the V2 spec's own example for this Optional + // free-text field is "IT Administrator"); blank/unset leaves this null so the property is + // omitted from the wire JSON entirely (relies on the client's global + // DefaultIgnoreCondition = WhenWritingNull, CERTInextClient.GetJsonOptions()), rather than + // sending any default designation value. + string requestorDesignation = string.IsNullOrWhiteSpace(_config.RequestorDesignation) + ? null + : _config.RequestorDesignation.Trim(); + + // technicalPointOfContact — each field falls back to the requestor default when its + // TechnicalContact* counterpart is blank, mirroring V1's BuildOrderRequestFromLegacyEnrollRequest + // (CERTInextClient.cs:2335-2341). + string technicalContactName = string.IsNullOrWhiteSpace(_config.TechnicalContactName) ? requestorName : _config.TechnicalContactName; + string technicalContactEmail = string.IsNullOrWhiteSpace(_config.TechnicalContactEmail) ? requestorEmail : _config.TechnicalContactEmail; + string technicalContactIsd = string.IsNullOrWhiteSpace(_config.TechnicalContactIsdCode) ? requestorIsd : _config.TechnicalContactIsdCode; + string technicalContactMobile = string.IsNullOrWhiteSpace(_config.TechnicalContactMobileNumber) ? requestorMobile : _config.TechnicalContactMobileNumber; + + // Blocks shared verbatim by every family's create body — the spec's requestor, + // subscription and technicalPointOfContact field tables are identical for the SSL/TLS + // and Private PKI folders. + var requestor = new V2Requestor + { + Name = requestorName, + Email = requestorEmail, + Phone = ComposeV2Phone(requestorIsd, requestorMobile), + Designation = requestorDesignation + }; + var subscription = new V2SubscriptionParams + { + ValidityYears = validityYears, + AutoRenew = subscriptionAutoRenew, + RenewBeforeDays = subscriptionRenewBeforeDays + }; + // Always populated (never omitted), even though the spec marks every subfield + // Optional — mirrors V1's fallback-to-Requestor* TechnicalPointOfContact + // defaulting rather than leaving the block blank. + var technicalPointOfContact = new V2TechnicalPointOfContact + { + Name = technicalContactName, + Email = technicalContactEmail, + Phone = ComposeV2Phone(technicalContactIsd, technicalContactMobile), + Designation = Constants.ApiV2.DefaultTechnicalContactDesignation + }; + const string remarks = "Issued via Keyfactor Command AnyCA REST Gateway."; + // Mirrors V1's DelegationInformation.GroupNumber — omit when unconfigured so the + // order falls back to the account's default billing group. + string groupNumber = string.IsNullOrWhiteSpace(_config.GroupNumber) ? null : _config.GroupNumber; + + V2CreateOrderResponse createResp; + if (isPrivatePki) + { + // Spec "Private PKI Certificates" body: no productVariant / organization / + // certificate / agreement blocks — "Private PKI has no DCV, no organization + // block, and no Subscriber Agreement". + var privatePkiReq = new V2CreatePrivatePkiOrderRequest + { + Variant = privatePkiVariant, + EmailNotifications = emailNotifications, + GroupNumber = groupNumber, + Requestor = requestor, + Hostname = domain, + AdditionalHosts = additionalHosts, + Subscription = subscription, + Remarks = remarks, + TechnicalPointOfContact = technicalPointOfContact + }; + createResp = await _client.PlaceOrderV2Async(productCode, privatePkiReq); + } + else + { + var orderReq = new V2CreateSslOrderRequest + { + ProductVariant = sslProductVariant, + EmailNotifications = emailNotifications, + Requestor = requestor, + Organization = organization, + Certificate = new V2CertificateParams + { + Domain = domain, + AutoSecureWww = _config.AutoSecureWww == "1", + AdditionalDomains = additionalDomains + }, + Subscription = subscription, + Agreement = new V2AgreementParams + { + SignerName = signerName, + SignerIp = string.IsNullOrWhiteSpace(signerIp) ? null : signerIp, + SignerPlace = string.IsNullOrWhiteSpace(signerPlace) ? null : signerPlace, + Accepted = true + }, + TechnicalPointOfContact = technicalPointOfContact, + Remarks = remarks, + GroupNumber = groupNumber + }; + createResp = await _client.PlaceOrderV2Async(ep.ProductFamilySlug, productCode, orderReq); + } + string orderId = createResp.OrderId; + + _logger.LogInformation( + "V2 order placed. OrderId={OrderId}, Status={Status}, EnrollmentType={EnrollmentType}", + orderId, createResp.Status, enrollmentType); + + // The V2 API creates the order in 'pending-csr' and requires a separate PUT to submit + // the CSR before the order can progress to validation or issuance. + // If Submit CSR throws, the order already exists at + // pending-csr and Command would otherwise never learn its ID. A *definitive* CA + // rejection (a real HTTP 4xx response body) keeps the original single-best-effort- + // cancel-and-FAILED behavior. But a transport-level failure or timeout tells us + // nothing about whether CERTInext actually received and recorded the CSR — cancelling + // unconditionally on any exception here can orphan a CSR the CA genuinely accepted. + // For that ambiguous case, track the order first and only cancel if it is still + // pending-csr; if tracking itself fails, don't cancel — return pending so sync resolves + // it later. + V2OrderStatusResponse postCsrStatus = null; + int disposition = 0; + // Raw CA status string behind the current `disposition` value — kept in step with it + // (reassigned everywhere `disposition` is recomputed from a fresh TrackOrderV2Async + // call) purely so a terminal REVOKED result can be logged/reported with the CA's own + // status text, not just Command's mapped disposition. + string lastKnownCaStatus = null; + bool csrStatusResolvedAfterFailure = false; + try + { + await _client.SubmitCsrV2Async(ep.ProductFamilySlug, orderId, csr); + } + catch (Exception csrEx) + { + if (!IsTransportLevelCsrFailure(csrEx)) + { + // Definitive CA rejection (a real 4xx response) — unchanged behavior. + var orphanResult = await CancelOrphanedV2OrderAfterCsrFailureAsync( + ep.ProductFamilySlug, orderId, csrEx); + _logger.MethodExit(LogLevel.Debug); + return orphanResult; + } + + _logger.LogWarning(csrEx, + "V2 SubmitCsrV2Async failed with a transport-level or timeout error for order " + + "{OrderId}; tracking the order before deciding whether to cancel it (CERTInext " + + "may have already accepted the CSR).", orderId); + + V2OrderStatusResponse trackedAfterCsrFailure; + try + { + trackedAfterCsrFailure = await _client.TrackOrderV2Async(ep.ProductFamilySlug, orderId); + } + catch (Exception trackEx) + { + // Tracking itself failed — we still don't know whether the CSR landed. Do not + // cancel (that risks orphaning an order CERTInext may have already advanced); + // return pending with the known orderId so the next sync resolves it. + _logger.LogWarning(trackEx, + "V2 TrackOrderV2Async also failed while resolving an ambiguous CSR-submit " + + "failure for order {OrderId}; returning pending without cancelling.", orderId); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = orderId, + Certificate = null, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + StatusMessage = $"V2 order {orderId} placed; CSR submission result is unknown " + + "after a transport error and the follow-up status check also " + + "failed — sync will resolve." + }; + } + + if (string.Equals(trackedAfterCsrFailure.Status, Constants.ApiV2.StatusPendingCsr, + StringComparison.OrdinalIgnoreCase)) + { + // Confirmed: the CSR never landed. Cancel the orphaned order. + var orphanResult = await CancelOrphanedV2OrderAfterCsrFailureAsync( + ep.ProductFamilySlug, orderId, csrEx); + _logger.MethodExit(LogLevel.Debug); + return orphanResult; + } + + // The order progressed past pending-csr — CERTInext did receive the CSR despite the + // transport error on our side. Continue the normal post-CSR flow below instead of + // cancelling a valid order. + _logger.LogInformation( + "V2 CSR submission actually succeeded despite a transport-level error — order " + + "{OrderId} progressed to status {Status}. Continuing normal post-CSR flow.", + orderId, trackedAfterCsrFailure.Status); + postCsrStatus = trackedAfterCsrFailure; + disposition = StatusMapper.V2StatusToRequestDisposition(trackedAfterCsrFailure.Status); + lastKnownCaStatus = trackedAfterCsrFailure.Status; + csrStatusResolvedAfterFailure = true; + } + + if (!csrStatusResolvedAfterFailure) + { + _logger.LogInformation("V2 CSR submitted. OrderId={OrderId}", orderId); + + // Re-read status after CSR submission — the order advances past pending-csr. + // Guard: if TrackOrderV2Async fails transiently here the order is already + // placed and the CSR submitted; return pending with the known orderId so Command + // has a CARequestID and the next sync can resolve the status. + try + { + postCsrStatus = await _client.TrackOrderV2Async(ep.ProductFamilySlug, orderId); + disposition = StatusMapper.V2StatusToRequestDisposition(postCsrStatus.Status); + lastKnownCaStatus = postCsrStatus.Status; + } + catch (Exception trackEx) + { + _logger.LogWarning(trackEx, + "V2 TrackOrderV2Async failed after CSR submission for order {OrderId}; " + + "returning pending so sync can pick it up.", orderId); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = orderId, + Certificate = null, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + StatusMessage = "V2 order placed and CSR submitted; status check failed transiently — sync will resolve." + }; + } + } + + // Whether the inline 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 call below the same way (it simply stays false on the no-DCV build). + // Mirrors dcvIssuanceWaitRan in EnrollNewAsync, but the condition is derived + // differently: V2's outer gate here is only "order landed in pending-dcv" — whether + // DCV is actually configured/enabled is checked *inside* PerformDcvV2IfNeededAsync, not + // at this call site — so the flag cannot be pre-set before calling it (that would also + // catch the DCV-disabled case and wrongly skip pickup for every V2 order). Instead this + // is set from PerformDcvV2IfNeededAsync's own return contract ("true when DCV steps were + // executed, false when cleanly skipped"), which is the one source of truth for whether + // an in-call wait was actually spent. + bool dcvV2Ran = false; + +#if SUPPORTS_DCV + // Attempt DCV inline when the order lands in pending-dcv and DCV is configured + if (disposition == (int)EndEntityStatus.EXTERNALVALIDATION) + { + int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + using var dcvCts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + dcvCts.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes)); + try + { + bool dcvDone = await PerformDcvV2IfNeededAsync( + orderId, domain, ep.ProductFamilySlug, dcvCts.Token, + postCsrStatus?.Verifications?.Domain?.Domains); + dcvV2Ran = dcvDone; + if (dcvDone) + { + // Re-check status after DCV completes + var tracked = await _client.TrackOrderV2Async(ep.ProductFamilySlug, orderId); + disposition = StatusMapper.V2StatusToRequestDisposition(tracked.Status); + lastKnownCaStatus = tracked.Status; + _logger.LogInformation( + "V2 DCV completed inline for order {OrderId}. Post-DCV status={Status}", + orderId, tracked.Status); + } + } + catch (Exception dcvEx) + { + // Every early-exit path inside PerformDcvV2IfNeededAsync (not configured, no + // domain, in-flight elsewhere, GetDcv/staging/verify failure) is caught + // internally and returns false rather than throwing — the only way an + // exception reaches here is VerifyDcvV2Async failing *after* the TXT record + // was already staged and the propagation delay already spent. Real in-call + // wait time was consumed, so still treat this as "DCV ran" and defer to the + // next sync rather than stacking a pickup poll on top. + dcvV2Ran = true; + _logger.LogWarning(dcvEx, + "V2 inline DCV attempt failed for order {OrderId}; order will remain pending for sync.", + orderId); + } + } +#endif + + // If the order issued immediately, download the certificate + if (disposition == (int)EndEntityStatus.GENERATED) + { + try + { + var certResp = await _client.DownloadCertificateV2Async(ep.ProductFamilySlug, orderId); + string fullChain = AssembleV2CertChain(certResp); + _logger.LogInformation( + "V2 certificate downloaded immediately. OrderId={OrderId}, SerialNumber={Serial}, ChainPemCount={ChainCount}", + orderId, certResp.SerialNumber, certResp.ChainPem?.Count ?? 0); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = orderId, + Certificate = fullChain, + Status = (int)EndEntityStatus.GENERATED, + StatusMessage = "Certificate issued via V2 API." + }; + } + catch (Exception dlEx) + { + _logger.LogWarning(dlEx, + "V2 order is 'issued' but certificate download failed — returning pending. OrderId={OrderId}", + orderId); + } + } + + // Synchronous certificate pickup (V2 parity with the V1/Sectigo pickup poll): + // poll for the issued certificate so a fast-issuing DV order returns GENERATED + // + PEM in this same call instead of waiting for the next synchronization. No-op when + // the inline DCV block above already ran an in-call wait for this order (dcvV2Ran). + var pendingResult = new EnrollmentResult + { + CARequestID = orderId, + Certificate = null, + Status = disposition == (int)EndEntityStatus.GENERATED + ? (int)EndEntityStatus.EXTERNALVALIDATION + : disposition, + StatusMessage = $"V2 order placed. Status={createResp.Status}" + }; + var (pickedUpResult, observedCaStatus) = await PickUpEnrolledCertificateV2Async( + pendingResult, orderId, ep.ProductFamilySlug, dcvV2Ran, lastKnownCaStatus); + + // Normalize a body-less REVOKED disposition to FAILED once here, right + // before returning — every path above that can surface REVOKED (the post-CSR-submit + // status check, the post-DCV status re-check, and PickUpEnrolledCertificateV2Async's + // own poll) funnels through pickedUpResult, so a single check here covers all of them + // rather than patching each branch individually. See NormalizeV2RevokedEnrollResult. + var finalResult = NormalizeV2RevokedEnrollResult(pickedUpResult, orderId, observedCaStatus); + + _logger.MethodExit(LogLevel.Debug); + return finalResult; + } + + /// + /// True when (thrown by SubmitCsrV2Async) + /// represents a transport-level failure, timeout, or cancellation — i.e. whether CERTInext + /// actually received and recorded the CSR is unknown — rather than a definitive CA-side + /// rejection (a real HTTP 4xx response CERTInext returned after processing the request). + /// CERTInextClient is built with ThrowOnAnyError=false, so a connection + /// failure/timeout never received a response and instead renders through + /// ThrowOnV2Failure's generic fallback as "... HTTP 0. ..." — 0 is the + /// RestSharp default when no response arrived. + /// A 5xx or any message shape this cannot parse is treated the same conservative way (not + /// a confirmed rejection): a CA server error or an unrecognized failure does not confirm + /// the CSR was rejected, so the safer default is to track the order rather than assume. + /// Only a parsed 4xx status is treated as definitive. + /// + internal static bool IsTransportLevelCsrFailure(Exception ex) + { + if (ex == null) return true; + if (ex is OperationCanceledException) return true; + if (ex is System.Net.Http.HttpRequestException) return true; + if (ex is TimeoutException) return true; + + var m = System.Text.RegularExpressions.Regex.Match(ex.Message ?? string.Empty, @"HTTP (\d+)\."); + if (!m.Success) return true; + int status = int.Parse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); + return status < 400 || status >= 500; + } + + /// + /// Cancel reason sent to CERTInext when Submit CSR fails after the order was created. + /// Deliberately fixed text: the CSR exception may carry CA response detail, + /// and the reason is persisted in the CA's audit log. + /// + internal const string OrphanedOrderCancelReason = + "Keyfactor gateway: CSR submission failed; cancelling orphaned order."; + + /// + /// calls this when SubmitCsrV2Async throws + /// after the order was placed. Makes exactly one best-effort CancelOrderV2Async + /// call (never retried, never rethrown) and returns a FAILED result that carries the + /// orderId and says whether the orphaned order was cancelled. + /// + private async Task CancelOrphanedV2OrderAfterCsrFailureAsync( + string familySlug, string orderId, Exception csrEx) + { + _logger.MethodEntry(LogLevel.Trace); + // The CSR exception message can carry CA response detail (problem+json "detail"), + // so it is redacted like any other CA body and capped at 500 characters. + string csrFailure = RedactAndCapForLog(csrEx.Message); + + string cancelOutcome; + string cancelMessage; + try + { + var outcome = await _client.CancelOrderV2Async(familySlug, orderId, OrphanedOrderCancelReason); + if (outcome == V2CancelOrderOutcome.Cancelled) + { + cancelOutcome = "cancelled"; + cancelMessage = "The orphaned order was cancelled."; + } + else + { + cancelOutcome = "not cancelled (HTTP 422: order already in a terminal state)"; + cancelMessage = "The orphaned order was NOT cancelled: the CA reported it is already in a " + + "terminal state (HTTP 422). Check the order in the CERTInext portal."; + } + } + catch (Exception cancelEx) + { + cancelOutcome = $"not cancelled (cancel failed: {cancelEx.GetType().Name}: " + + $"{RedactAndCapForLog(cancelEx.Message)})"; + cancelMessage = "The orphaned order was NOT cancelled: the cancel request failed. " + + "Cancel it manually in the CERTInext portal. See gateway logs for details."; + } + + _logger.LogWarning( + "V2 CSR submission failed after the order was placed. OrderId={OrderId}, Family={Family}, " + + "CsrFailure={CsrFailure}, CancelOutcome={CancelOutcome}", + orderId, familySlug, csrFailure, cancelOutcome); + + _logger.MethodExit(LogLevel.Trace); + return new EnrollmentResult + { + CARequestID = orderId, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 CSR submission failed for order {orderId}: {csrFailure} {cancelMessage}" + }; + } + + private string RedactAndCapForLog(string message) + { + string redacted = CERTInextClient.ApplyLoggingRedaction(message ?? string.Empty, _config?.LogSensitiveRequestData ?? false); + return redacted.Length <= 500 ? redacted : redacted.Substring(0, 500) + "…"; + } + + /// + /// At enroll time the gateway never holds a stored certificate body for a + /// brand-new order — unlike the Synchronize/GetSingleRecord guard (), there is no "gateway already holds a body" case + /// for to fall back to. A REVOKED disposition surfaced from V2 + /// enrollment — whether observed on the post-CSR-submit status check, the post-DCV status + /// re-check, or 's own poll — is therefore + /// always body-less. Persisting that as a REVOKED row with Certificate = null would + /// poison the gateway (RevocationDate never clears, and every + /// later revoked-certificate search calls FromDER(null) and 500s). Mapped to FAILED here, + /// once, on the final result is about to return, rather than in + /// each branch that can produce a REVOKED disposition. + /// + private EnrollmentResult NormalizeV2RevokedEnrollResult(EnrollmentResult result, string orderId, string observedCaStatus) + { + if (result == null || result.Status != (int)EndEntityStatus.REVOKED) + return result; + + // SOC2 audit trail: the log must show the CA reported revoked and the plugin reported + // FAILED, with enough context (order id + raw CA status) to reconstruct why. + _logger.LogWarning( + "V2 enroll observed order '{OrderId}' as revoked at the CA (raw status='{CaStatus}') " + + "before a certificate was ever delivered. Mapping the enrollment result to FAILED " + + "instead of a body-less REVOKED record — Enroll has no stored certificate body to " + + "fall back on, unlike the Synchronize/GetSingleRecord guard.", + orderId, string.IsNullOrWhiteSpace(observedCaStatus) ? "(unknown)" : observedCaStatus); + + string statusSuffix = string.IsNullOrWhiteSpace(observedCaStatus) + ? string.Empty + : $" (CA status '{observedCaStatus}')"; + + return new EnrollmentResult + { + CARequestID = result.CARequestID, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"Order '{orderId}' was revoked at the CA{statusSuffix} before a " + + "certificate could be delivered; reporting enrollment failure rather " + + "than a certificate revocation with no certificate body." + }; + } + + /// + /// Outcome of — what a V2 caller should do + /// with a REVOKED disposition that has no downloadable certificate body. + /// + private enum BodylessRevokedDecision + { + /// Gateway already holds a body for this order — emit REVOKED with no + /// body (this is how out-of-band CA revokes propagate). + EmitRevoked, + /// Gateway has a row but no body — downgrade to FAILED so the gateway + /// never persists a REVOKED row with Certificate = null. + EmitFailed, + /// Gateway has no row at all (or the reader can't be consulted) — + /// the caller should not create a brand-new poisoned row. + Skip + } + + /// + /// The CERTInext V2 CA refuses to serve a revoked certificate's body + /// (422 EMS-1165), so a V2 order whose first gateway sighting is already revoked has + /// no body to attach. Emitting that as a body-less REVOKED record is what poisons the + /// gateway: it persists the row with RevocationDate set and Certificate = + /// null, never clears the date afterward, and its own revoked-certificate search + /// then calls FromDER(null) on every future scan of this CA — a 500 that blocks + /// *all* Command sync for the CA, not just this record. + /// + /// A body-less REVOKED record is safe — and required, to propagate out-of-band CA + /// revokes — only when the gateway already holds a certificate body for this + /// CARequestID: the gateway keeps the stored body and applies status/date/reason on top + /// of it. is the per-order probe + /// for that: it returns the stored cert's NotAfter when a body is held, null when + /// the row exists but has no body, and throws when there + /// is no row at all. + /// + private BodylessRevokedDecision DecideBodylessRevokedRecord(string caRequestId) + { + if (_certificateDataReader == null) + { + _logger.LogWarning( + "V2: no ICertificateDataReader available to check whether the gateway holds a " + + "certificate body for revoked order '{Id}' — skipping this cycle rather than risk " + + "poisoning a fresh gateway row with a body-less REVOKED record.", caRequestId); + return BodylessRevokedDecision.Skip; + } + + try + { + DateTime? expiry = _certificateDataReader.GetExpirationDateByRequestId(caRequestId); + if (expiry.HasValue) + { + _logger.LogDebug( + "V2: gateway already holds a certificate body for revoked order '{Id}' " + + "(expires {Expiry:O}) — emitting body-less REVOKED to propagate the revocation.", + caRequestId, expiry.Value); + return BodylessRevokedDecision.EmitRevoked; + } + + _logger.LogInformation( + "V2: gateway has a row for revoked order '{Id}' with no certificate body — " + + "emitting FAILED instead of a body-less REVOKED record to avoid poisoning the " + + "gateway's revoked-certificate search.", caRequestId); + return BodylessRevokedDecision.EmitFailed; + } + catch (ArgumentException) + { + _logger.LogInformation( + "V2: gateway has no row at all for revoked order '{Id}' — skipping rather than " + + "create a body-less REVOKED row the gateway can never un-poison.", caRequestId); + return BodylessRevokedDecision.Skip; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "V2: failed to determine whether the gateway holds a certificate body for revoked " + + "order '{Id}' — skipping this cycle rather than risk emitting a body-less REVOKED " + + "record. The revocation will be retried on a later sync/single-record call.", caRequestId); + return BodylessRevokedDecision.Skip; + } + } + + /// + /// Retrieves a single certificate record via the V2 REST API. + /// + private async Task GetSingleRecordV2Async(string caRequestID) + { + _logger.MethodEntry(LogLevel.Debug); + + try + { + // Use the family-aware resolve so DCV (if needed) hits the correct endpoint for + // non-SSL families (private-pki, signature). ResolveAndTrackOrderV2Async discards + // the family; here we keep it to thread through PerformDcvV2IfNeededAsync. + var (resolvedFamily, statusResp) = await _client.ResolveAndTrackOrderV2WithFamilyAsync(caRequestID); + int disposition = StatusMapper.V2StatusToRequestDisposition(statusResp.Status); + +#if SUPPORTS_DCV + // Mirror V1 GetSingleRecord: attempt DCV on pending-dcv orders so a manual + // single-record refresh can unstick an order whose DCV wasn't completed at enroll + // time. A UCC order's own domainEntries[] can carry pending SANs even + // when the top-level Domain field is populated (the common case) or, defensively, + // if it were ever blank — either is enough to attempt DCV. + var pendingDomainEntries = statusResp.Verifications?.Domain?.Domains; + if (disposition == (int)EndEntityStatus.EXTERNALVALIDATION + && (!string.IsNullOrWhiteSpace(statusResp.Domain) || (pendingDomainEntries?.Count ?? 0) > 0)) + { + int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + using var dcvCts = new CancellationTokenSource(TimeSpan.FromMinutes(timeoutMinutes)); + try + { + bool dcvDone = await PerformDcvV2IfNeededAsync( + caRequestID, statusResp.Domain, resolvedFamily, dcvCts.Token, pendingDomainEntries); + if (dcvDone) + { + statusResp = await _client.ResolveAndTrackOrderV2Async(caRequestID); + disposition = StatusMapper.V2StatusToRequestDisposition(statusResp.Status); + } + } + catch (Exception dcvEx) + { + _logger.LogWarning(dcvEx, + "V2 GetSingleRecord: DCV attempt failed for order {Id}.", caRequestID); + } + } +#endif + + string certPem = null; + if (disposition == (int)EndEntityStatus.GENERATED) + { + if (!string.IsNullOrWhiteSpace(statusResp.ExpiresAt)) + _logger.LogDebug( + "V2 order expiry from status response. CARequestID={Id}, ExpiresAt={ExpiresAt}", + caRequestID, statusResp.ExpiresAt); + + try + { + var certResp = await _client.ResolveAndDownloadCertificateV2Async(caRequestID); + certPem = AssembleV2CertChain(certResp); + } + catch (Exception dlEx) + { + _logger.LogWarning(dlEx, + "V2 GetSingleRecord: order is issued but certificate download failed. CARequestID={Id}", + caRequestID); + } + } + + // A REVOKED disposition with no certificate body must never be + // returned as-is unless the gateway already holds a body for this order — see + // DecideBodylessRevokedRecord. GetSingleRecord can't skip (it must return + // something), so both the no-row and can't-tell cases fall through to FAILED. + int effectiveDisposition = disposition; + DateTime? effectiveRevocationDate = statusResp.Revocation?.ProcessedAt; + int effectiveRevocationReason = statusResp.Revocation != null + ? StatusMapper.V2RevocationReasonToCrlCode(statusResp.Revocation.Reason) + : 0; + + if (disposition == (int)EndEntityStatus.REVOKED && string.IsNullOrWhiteSpace(certPem)) + { + var decision = DecideBodylessRevokedRecord(caRequestID); + if (decision != BodylessRevokedDecision.EmitRevoked) + { + effectiveDisposition = (int)EndEntityStatus.FAILED; + effectiveRevocationDate = null; + effectiveRevocationReason = 0; + } + } + + _logger.LogInformation( + "GetSingleRecordV2 complete. CARequestID={Id}, V2Status={Status}, Disposition={Disposition}", + caRequestID, statusResp.Status, effectiveDisposition); + _logger.MethodExit(LogLevel.Debug); + + return new AnyCAPluginCertificate + { + CARequestID = caRequestID, + Certificate = certPem, + Status = effectiveDisposition, + ProductID = statusResp.ProductVariant ?? string.Empty, + RevocationDate = effectiveRevocationDate, + RevocationReason = effectiveRevocationReason + }; + } + catch (KeyNotFoundException) + { + _logger.LogWarning("V2: Certificate not found. CARequestID={Id}", caRequestID); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "V2: Error retrieving certificate. CARequestID={Id}", caRequestID); + throw; + } + } + + /// + /// Synchronizes certificates via the V2 /reports/orders endpoint. + /// Called from when _config.UseV2Api is true; V1 + /// credentials are not required on this path. + /// + /// Lookback window (incremental sync): whether the from/to filter brackets + /// order-placement date or issuance date is not documented. Rather than depend on that, + /// an incremental pass requests + /// from = lastSync - V2SyncLookbackHours (default 72h) so an order created before + /// lastSync but issued afterward (e.g. a slow-DCV order) still surfaces. + /// + private async Task SynchronizeV2Async( + BlockingCollection blockingBuffer, + DateTime? lastSync, + bool fullSync, + CancellationToken cancelToken) + { + _logger.MethodEntry(LogLevel.Debug); + + string from = null; + if (!fullSync && lastSync.HasValue) + { + int lookbackHours = _config.V2SyncLookbackHours > 0 + ? _config.V2SyncLookbackHours + : Constants.ApiV2.DefaultSyncLookbackHours; + DateTime effectiveFrom = lastSync.Value.ToUniversalTime().AddHours(-lookbackHours); + from = effectiveFrom.ToString("yyyy-MM-dd"); + } + + _logger.LogInformation( + "Starting CERTInext V2 synchronization. FullSync={FullSync}, LastSync={LastSync}, " + + "LookbackFrom={From}, UseV2Api=true", + fullSync, lastSync?.ToString("O") ?? "none", from ?? "(none — full history)"); + + int synced = 0; + int skipped = 0; + int errors = 0; + int unresolvedStatusFallbacks = 0; // report rows whose display strings needed a live track call + + // Emit-side accounting (mirrors the V1 path). + int emittedGeneratedWithBody = 0, emittedGeneratedNoBody = 0, emittedRevoked = 0, emittedPending = 0; + // Bodyless-REVOKED guard decisions — see DecideBodylessRevokedRecord. + int emittedFailedFromBodylessRevoked = 0, skippedBodylessRevoked = 0; + +#if SUPPORTS_DCV + bool dcvOperational = _config.DcvEnabled && _domainValidatorFactory != null; + int ageWindowHours = _config.DcvSyncMaxOrderAgeHours; + int perPassCap = _config.DcvSyncMaxPerPass; + int dcvAttempted = 0, dcvSkippedAge = 0, dcvSkippedCap = 0; +#endif + + try + { + await foreach (var row in _client.ListOrdersV2Async(from, null, _config.PageSize, cancelToken)) + { + cancelToken.ThrowIfCancellationRequested(); + + try + { + DateTime? orderDateUtc = null; + if (DateTime.TryParse( + row.OrderDate, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out DateTime parsedOrderDate)) + orderDateUtc = parsedOrderDate; + + // Skip expired certificates when IgnoreExpired is configured — mirrors the + // V1 check above. CertificateExpiryDate is a string + // whose format is not guaranteed (see the OrderReportEntryV2 doc comment), so an + // unparseable or missing value must NOT be skipped — only a value that + // parses cleanly and is actually in the past is treated as expired. + DateTime? certExpiryUtc = null; + if (!string.IsNullOrWhiteSpace(row.CertificateExpiryDate) + && DateTime.TryParse( + row.CertificateExpiryDate, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out DateTime parsedExpiry)) + certExpiryUtc = parsedExpiry; + + if (_config.IgnoreExpired + && certExpiryUtc.HasValue + && certExpiryUtc.Value < DateTime.UtcNow) + { + _logger.LogTrace( + "V2 sync: skipping expired certificate '{Id}' (expires {ExpiresAt:u}).", + row.OrderNumber, certExpiryUtc.Value); + skipped++; + continue; + } + + int? disposition = MapV2ReportStatusToDisposition(row.OrderStatus, row.CertificateStatus); + string resolvedFamily = null; + V2OrderStatusResponse trackedStatus = null; + + if (disposition == null) + { + // Unrecognised orderStatus/certificateStatus combination — the display- + // string vocabulary mapped below is NOT guaranteed + // exhaustive. Don't guess: fall back to a live TrackOrder call, which + // returns the authoritative V2 `status` enum via StatusMapper. + unresolvedStatusFallbacks++; + _logger.LogDebug( + "V2 sync: unrecognised report status — falling back to live track. " + + "Id={Id}, OrderStatus={OrderStatus}, CertificateStatus={CertificateStatus}", + row.OrderNumber, row.OrderStatus, row.CertificateStatus); + (resolvedFamily, trackedStatus) = + await _client.ResolveAndTrackOrderV2WithFamilyAsync(row.OrderNumber, cancelToken); + disposition = StatusMapper.V2StatusToRequestDisposition(trackedStatus.Status); + } + + if (disposition == (int)EndEntityStatus.FAILED) + { + _logger.LogTrace( + "V2 sync: skipping order '{Id}' with terminal status. OrderStatus={OrderStatus}, " + + "CertificateStatus={CertificateStatus}", + row.OrderNumber, row.OrderStatus, row.CertificateStatus); + skipped++; + continue; + } + +#if SUPPORTS_DCV + if (dcvOperational && disposition == (int)EndEntityStatus.EXTERNALVALIDATION) + { + var decision = EvaluateDcvSyncEligibility( + orderDateUtc, DateTime.UtcNow, ageWindowHours, dcvAttempted, perPassCap); + + _logger.LogTrace( + "V2 sync DCV gate: Id={Id}, decision={Decision}, orderDate={OrderDate}, " + + "ageWindowHours={Age}, attemptedSoFar={Attempted}, perPassCap={Cap}", + row.OrderNumber, decision, orderDateUtc?.ToString("o") ?? "(none)", + ageWindowHours, dcvAttempted, perPassCap); + + if (decision == DcvSyncDecision.SkipByAge) + { + _logger.LogInformation( + "V2 sync: pending DV order aged out of the DCV-during-sync window and will " + + "not be advanced. CARequestID={Id}, OrderDate={OrderDate}, AgeWindowHours={Age}.", + row.OrderNumber, orderDateUtc?.ToString("o") ?? "(none)", ageWindowHours); + dcvSkippedAge++; + } + else if (decision == DcvSyncDecision.SkipByCap) + { + dcvSkippedCap++; + } + else + { + dcvAttempted++; + + // Resolve family lazily — only for rows actually attempting DCV. + // Report rows carry no family, and productCode is often empty, + // so this costs one extra call per attempted row, not per row in + // the page. + if (resolvedFamily == null) + { + (resolvedFamily, trackedStatus) = await _client.ResolveAndTrackOrderV2WithFamilyAsync( + row.OrderNumber, cancelToken); + } + + string domain = !string.IsNullOrWhiteSpace(trackedStatus?.Domain) + ? trackedStatus.Domain + : row.DomainName; + + if (!string.IsNullOrWhiteSpace(domain)) + { + int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + using var dcvCts = CancellationTokenSource.CreateLinkedTokenSource(cancelToken); + dcvCts.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes)); + try + { + bool dcvDone = await PerformDcvV2IfNeededAsync( + row.OrderNumber, domain, resolvedFamily, dcvCts.Token, + trackedStatus?.Verifications?.Domain?.Domains); + if (dcvDone) + { + trackedStatus = await _client.ResolveAndTrackOrderV2Async(row.OrderNumber, cancelToken); + disposition = StatusMapper.V2StatusToRequestDisposition(trackedStatus.Status); + } + } + catch (Exception dcvEx) + { + _logger.LogWarning(dcvEx, + "V2 sync: DCV attempt failed for order {Id}.", row.OrderNumber); + } + } + else + { + _logger.LogWarning( + "V2 sync: no domain available for pending-dcv order {Id} — cannot drive DCV.", + row.OrderNumber); + } + } + } +#endif + + // Report rows carry no revocation reason/date (OrderReportEntryV2 has no + // such fields) — only a live TrackOrder response's nested + // `revocation` object does. Resolve lazily, mirroring the DCV branch's + // "only for rows that actually need it" pattern above: this extra call is + // scoped to revoked rows whose disposition came from the report's display + // strings alone. trackedStatus is already populated (with .Revocation, if + // any) when disposition instead came from the unresolved-status fallback. + if (disposition == (int)EndEntityStatus.REVOKED && trackedStatus == null) + { + try + { + (resolvedFamily, trackedStatus) = await _client.ResolveAndTrackOrderV2WithFamilyAsync( + row.OrderNumber, cancelToken); + } + catch (Exception revEx) + { + _logger.LogWarning(revEx, + "V2 sync: failed to fetch revocation detail for revoked order '{Id}' — " + + "emitting without RevocationDate/RevocationReason.", row.OrderNumber); + } + } + + string certPem = null; + if (disposition == (int)EndEntityStatus.GENERATED) + { + try + { + var certResp = await _client.ResolveAndDownloadCertificateV2Async(row.OrderNumber, cancelToken); + certPem = AssembleV2CertChain(certResp); + } + catch (Exception dlEx) + { + _logger.LogWarning(dlEx, + "V2 sync: order '{Id}' is issued but certificate download failed — emitting " + + "metadata-only record.", row.OrderNumber); + } + } + + // Prefer the report row's own ProductCode (matches V1's "trust the + // listing" precedent, MapToAnyCAPluginCertificate). Only when that's + // empty, fall back to whatever trackedStatus already exists + // in local scope from one of the lazy TrackOrder fetches above + // (unresolved-status fallback, DCV attempt, or revoked-row lookup) — never + // fetch just to backfill this field. + string productId = !string.IsNullOrWhiteSpace(row.ProductCode) + ? row.ProductCode + : (trackedStatus?.ProductVariant ?? string.Empty); + + var record = new AnyCAPluginCertificate + { + CARequestID = row.OrderNumber, + Certificate = certPem, + Status = disposition.Value, + ProductID = productId, + RevocationDate = trackedStatus?.Revocation?.ProcessedAt, + RevocationReason = trackedStatus?.Revocation != null + ? StatusMapper.V2RevocationReasonToCrlCode(trackedStatus.Revocation.Reason) + : 0 + }; + + bool recordHasBody = !string.IsNullOrWhiteSpace(record.Certificate); + + // Never hand the gateway buffer a REVOKED record with no + // certificate body unless the gateway already holds one for this order — + // see DecideBodylessRevokedRecord's doc comment. "Skip" means this row is + // dropped entirely (not added to blockingBuffer) rather than risk creating + // a row the gateway can never un-poison. + if (record.Status == (int)EndEntityStatus.REVOKED && !recordHasBody) + { + var decision = DecideBodylessRevokedRecord(record.CARequestID); + if (decision == BodylessRevokedDecision.Skip) + { + skipped++; + skippedBodylessRevoked++; + continue; + } + if (decision == BodylessRevokedDecision.EmitFailed) + { + record.Status = (int)EndEntityStatus.FAILED; + record.RevocationDate = null; + record.RevocationReason = 0; + emittedFailedFromBodylessRevoked++; + } + // EmitRevoked falls through — record stays REVOKED with no body, + // (propagates an out-of-band CA revoke). + } + + if (record.Status == (int)EndEntityStatus.GENERATED) + { + if (recordHasBody) emittedGeneratedWithBody++; else emittedGeneratedNoBody++; + } + else if (record.Status == (int)EndEntityStatus.REVOKED) + { + emittedRevoked++; + } + else if (record.Status == (int)EndEntityStatus.EXTERNALVALIDATION) + { + emittedPending++; + } + + _logger.LogDebug( + "V2 sync emit: CARequestID={Id}, Status={Status}, CertBytes={CertBytes}, " + + "OrderStatus={OrderStatus}, CertificateStatus={CertificateStatus}", + record.CARequestID, record.Status, record.Certificate?.Length ?? 0, + row.OrderStatus, row.CertificateStatus); + + blockingBuffer.Add(record, cancelToken); + synced++; + } + catch (OperationCanceledException) + { + _logger.LogWarning( + "CERTInext V2 synchronization cancelled by caller. FullSync={FullSync}, Synced={Synced}, " + + "Skipped={Skipped}, Errors={Errors}", + fullSync, synced, skipped, errors); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing V2 order '{Id}' during synchronization.", row.OrderNumber); + errors++; + + // SOC1 completeness/accuracy: abort on an error-rate cliff rather than + // silently 'completing' with mostly-failed records (mirrors the V1 gate). + int totalSeen = synced + skipped + errors; + if (totalSeen >= 50 && errors > totalSeen / 4) + { + _logger.LogError( + "CERTInext V2 synchronization aborted — error rate ({Errors}/{Total}) exceeded " + + "25% threshold. Likely CA-side outage; will retry on next sync cycle.", + errors, totalSeen); + throw new Exception( + $"CERTInext V2 synchronization aborted after {errors}/{totalSeen} records failed " + + "(>25% error rate). See gateway logs for the underlying CA errors."); + } + } + } + + string dcvClause; +#if SUPPORTS_DCV + if (dcvOperational) + dcvClause = $"DCV-during-sync: Attempted={dcvAttempted}, SkippedByAge={dcvSkippedAge} (>{ageWindowHours}h), SkippedByCap={dcvSkippedCap} (cap={perPassCap})."; + else + dcvClause = $"DCV-during-sync: not active (DcvEnabled={_config.DcvEnabled}, DnsProviderInjected={_domainValidatorFactory != null}) — pending orders left as EXTERNALVALIDATION."; +#else + dcvClause = "DCV-during-sync: not supported on this build (IAnyCAPlugin 3.2.0)."; +#endif + _logger.LogInformation( + "CERTInext V2 synchronization complete. Synced={Synced}, Skipped={Skipped}, Errors={Errors}, " + + "UnresolvedStatusFallbacks={Fallbacks}. Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " + + "GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}, " + + "FailedFromBodylessRevoked={FailedFromBodylessRevoked}. " + + "BodylessRevokedSkipped={BodylessRevokedSkipped} (bodyless-revoked guard). {DcvClause}", + synced, skipped, errors, unresolvedStatusFallbacks, + emittedGeneratedWithBody, emittedGeneratedNoBody, emittedRevoked, emittedPending, + emittedFailedFromBodylessRevoked, skippedBodylessRevoked, + dcvClause); + } + catch (OperationCanceledException) + { + _logger.LogWarning("CERTInext V2 synchronization was cancelled."); + throw; + } + finally + { + blockingBuffer.CompleteAdding(); + } + + _logger.MethodExit(LogLevel.Debug); + } + + /// + /// Maps a V2 /reports/orders row's human-readable orderStatus/ + /// certificateStatus display strings to an + /// disposition. These are display strings (e.g. "Order Accepted", + /// "Certificate Downloaded") — NOT the V2 status enum used by TrackOrder (see + /// for that). The vocabulary + /// below combines values seen on live orders (orderStatus "Order + /// Accepted"/"Order Fulfilled"; certificateStatus "Pending for Approver"/"Certificate + /// Downloaded") with additional values the spec's field-table prose names ("Approved by + /// System", "Issued", "Certificate Generated"). + /// + /// Returns null for anything not confidently recognised so the caller falls back + /// to a live TrackOrder call rather than guessing — the vocabulary below is NOT + /// guaranteed exhaustive, and silently misclassifying a row (e.g. treating a still-pending + /// order as issued, or dropping a row that is actually revoked) would be worse than the + /// cost of an extra API call. + /// + internal static int? MapV2ReportStatusToDisposition(string orderStatus, string certificateStatus) + { + // certificateStatus is the more specific signal when present — prefer it. + if (TryMapV2ReportDisplayStatus(certificateStatus, out int certDisposition)) + return certDisposition; + + if (TryMapV2ReportDisplayStatus(orderStatus, out int orderDisposition)) + return orderDisposition; + + return null; + } + + private static bool TryMapV2ReportDisplayStatus(string status, out int disposition) + { + disposition = default; + if (string.IsNullOrWhiteSpace(status)) + return false; + + switch (status.Trim().ToLowerInvariant()) + { + // Issued — certificate exists and is downloadable. + case "certificate downloaded": + case "certificate generated": + case "order fulfilled": + case "issued": + disposition = (int)EndEntityStatus.GENERATED; + return true; + + // Pending — somewhere in the approval/DCV/issuance workflow. + case "order accepted": + case "pending for approver": + case "approved by system": + disposition = (int)EndEntityStatus.EXTERNALVALIDATION; + return true; + + // Revoked. + case "revoked": + case "certificate revoked": + disposition = (int)EndEntityStatus.REVOKED; + return true; + + // Expired-but-not-revoked certs remain visible in inventory as GENERATED — + // mirrors StatusMapper.ToRequestDisposition's V1 convention. + case "expired": + disposition = (int)EndEntityStatus.GENERATED; + return true; + + // Terminal failure — never issued, or explicitly cancelled/rejected. + case "rejected": + case "cancelled": + case "certificate rejected": + case "order rejected": + case "order cancelled": + disposition = (int)EndEntityStatus.FAILED; + return true; + + default: + return false; + } + } + + /// + /// Assembles a full PEM chain from a V2 certificate download response. + /// Concatenates the leaf certificatePem and any intermediate PEM strings + /// in chainPem (when present) in leaf-first order, matching the V1 chain format. + /// + private static string AssembleV2CertChain(V2CertificateDownloadResponse certResp) + { + if (certResp?.ChainPem == null || certResp.ChainPem.Count == 0) + return certResp?.CertificatePem; + + var sb = new System.Text.StringBuilder(); + if (string.IsNullOrWhiteSpace(certResp.CertificatePem)) + throw new InvalidOperationException( + $"V2 certificate download for order '{certResp?.OrderId}' returned a null or empty leaf " + + "certificate PEM while a chain PEM is present; cannot assemble a valid chain without the leaf."); + sb.Append(certResp.CertificatePem.TrimEnd()); + foreach (var intermediate in certResp.ChainPem) + { + if (string.IsNullOrWhiteSpace(intermediate)) continue; + sb.AppendLine(); + sb.Append(intermediate.TrimEnd()); + } + return sb.ToString(); + } + + /// + /// Maps a V2 revoke reason that CERTInext rejects with "Invalid Revoke Reason + /// ID" to an accepted fallback, or null if + /// is not one of the known-rejected values. Of the 9 spec-documented reason strings, + /// only key-compromise, affiliation-changed, superseded, + /// cessation-of-operation, and privilege-withdrawn are actually accepted + /// (the same restriction V1's has + /// always documented) — unspecified, ca-compromise, + /// certificate-hold, and aa-compromise are all rejected. + /// key-compromise is the fallback for the two *-compromise reasons (closest + /// semantic match); cessation-of-operation is the fallback for + /// unspecified and certificate-hold, neither of which implies an actual + /// key compromise — key-compromise there would misrepresent the revoke and + /// trigger the spec's own BR 4.9.1.1 24-hour CRL-turnaround obligation for no reason. + /// + private static string ResolveRejectedRevokeReasonFallback(string rejectedV2Reason) => + rejectedV2Reason switch + { + Constants.RevocationReasonV2.Unspecified => Constants.RevocationReasonV2.CessationOfOperation, + Constants.RevocationReasonV2.CertificateHold => Constants.RevocationReasonV2.CessationOfOperation, + Constants.RevocationReasonV2.CACompromise => Constants.RevocationReasonV2.KeyCompromise, + Constants.RevocationReasonV2.AACompromise => Constants.RevocationReasonV2.KeyCompromise, + _ => null + }; + + /// + /// Revokes a certificate via the V2 REST API. If CERTInext rejects the resolved + /// reason with its "Invalid Revoke Reason ID" 422 and the reason is one of the four + /// known-rejected values (), retries + /// exactly once with an accepted fallback. Any other revoke failure (including a 422 for a + /// reason not in that set) is surfaced as-is, with no retry. + /// + private async Task RevokeV2Async(string caRequestID, string hexSerialNumber, uint revocationReason) + { + _logger.MethodEntry(LogLevel.Debug); + + string v2Reason = StatusMapper.ToV2RevocationReason(revocationReason); + + _logger.LogInformation( + "Revocation V2 attempt started. CARequestID={Id}, HexSerialNumber={Serial}, " + + "ReasonCode={ReasonCode}, V2Reason={V2Reason}", + caRequestID, hexSerialNumber, revocationReason, v2Reason); + + // Pre-flight: resolve which product family owns this order (via TrackOrder, + // which probes families and 404s cleanly per-family) and verify it is revocable. + // This resolves the family definitively before revoke is ever called, so a 404 + // from RevokeOrderV2Async below is unambiguous: revoke's own 404 + // means "not found or not revokable" (per spec). Probing multiple families + // on a revoke 404 instead would produce a misleading "not found in any product + // family" for orders that legitimately exist but simply aren't revokable yet. + V2OrderStatusResponse currentStatus; + string resolvedFamily; + try + { + (resolvedFamily, currentStatus) = await _client.ResolveAndTrackOrderV2WithFamilyAsync(caRequestID); + } + catch (Exception ex) + { + _logger.LogError(ex, + "V2 revocation pre-flight failed. CARequestID={Id}", + caRequestID); + throw; + } + + int disposition = StatusMapper.V2StatusToRequestDisposition(currentStatus.Status); + if (disposition == (int)EndEntityStatus.REVOKED) + { + _logger.LogWarning( + "V2 revocation skipped — already revoked. CARequestID={Id}", + caRequestID); + _logger.MethodExit(LogLevel.Debug); + return (int)EndEntityStatus.REVOKED; + } + + if (disposition != (int)EndEntityStatus.GENERATED) + { + // Compliance finding: a revoke denial must leave an audit record (SOX/SOC2 + // who/what/when/outcome) — mirrors the V1 "Revocation rejected" LogError above. + _logger.LogError( + "V2 revocation rejected — certificate is not in a revocable state. " + + "CARequestID={Id}, Family={Family}, CurrentStatus={Status}", + caRequestID, resolvedFamily, currentStatus.Status); + throw new Exception( + $"V2 certificate '{caRequestID}' cannot be revoked: current status is '{currentStatus.Status}'. " + + "Only issued certificates may be revoked."); + } + + var revokeReq = new V2RevokeRequest + { + Reason = v2Reason, + Note = $"Revoked via Keyfactor Command. CRL reason code: {revocationReason} ({v2Reason})." + }; + + string retriedFromReason = null; + try + { + await _client.RevokeOrderV2Async(resolvedFamily, caRequestID, revokeReq); + } + catch (KeyNotFoundException knf) + { + // Compliance finding: audit the denial (CARequestID, family, HTTP status, EMS + // code) before converting/rethrowing — the client already logged the raw + // HTTP/body; this adds the plugin-level who/what/outcome context. + _logger.LogWarning( + "V2 revocation denied — order not found or not in a revokable state. " + + "CARequestID={Id}, Family={Family}, HttpStatus={HttpStatus}, EmsCode={EmsCode}", + caRequestID, resolvedFamily, 404, ExtractEmsCode(knf.Message) ?? "(none)"); + // The order was already confirmed to live in `resolvedFamily` via TrackOrder + // above, so a 404 here is the spec's other documented meaning — "not in a + // revokable state" — not a genuine family miss. Surface that plainly + // instead of retrying other families. + throw new InvalidOperationException( + $"V2 order '{caRequestID}' (family '{resolvedFamily}') could not be revoked: " + + $"CERTInext reports it as not found or not in a revokable state. {knf.Message}"); + } + catch (InvalidOperationException ioe) when ( + ResolveRejectedRevokeReasonFallback(v2Reason) != null && + ioe.Message.IndexOf("Invalid Revoke Reason ID", StringComparison.OrdinalIgnoreCase) >= 0) + { + // CERTInext's V2 API rejects several of the spec-documented reason values + // outright (422 "Invalid Revoke Reason ID"), independent of + // this plugin. Only 5 of the 9 spec-documented reason strings + // are actually accepted: key-compromise, affiliation-changed, superseded, + // cessation-of-operation, privilege-withdrawn — the same restriction V1's + // ToRevocationReasonId has always been documented against. Retry exactly once + // with the accepted fallback ResolveRejectedRevokeReasonFallback resolved for + // this reason, rather than failing outright on what may be Command's default + // revoke call. + string originalReason = v2Reason; + string fallbackReason = ResolveRejectedRevokeReasonFallback(v2Reason); + _logger.LogWarning( + "V2 revoke rejected reason '{OriginalReason}' as invalid (CARequestID={Id}, Family={Family}, " + + "HttpStatus={HttpStatus}, EmsCode={EmsCode}); retrying once with '{FallbackReason}'.", + originalReason, caRequestID, resolvedFamily, 422, ExtractEmsCode(ioe.Message) ?? "(none)", + fallbackReason); + v2Reason = fallbackReason; + revokeReq = new V2RevokeRequest + { + // CERTInext's "note" field silently rejects a semicolon with the same + // "Invalid Revoke Remarks" 422 — comma/period/slash/parens are all fine; + // only ';' triggers it. Avoid semicolons in this string. + Reason = v2Reason, + Note = $"Revoked via Command, reason {originalReason} rejected, retried as {fallbackReason}." + }; + retriedFromReason = originalReason; + try + { + await _client.RevokeOrderV2Async(resolvedFamily, caRequestID, revokeReq); + } + catch (Exception retryEx) + { + // Compliance finding: the retry attempt is itself a revoke call against the + // CA and must leave an audit record on failure, not just succeed silently or + // vanish into the caller's exception. + _logger.LogError(retryEx, + "V2 revocation retry ({FallbackReason}) failed. CARequestID={Id}, Family={Family}", + fallbackReason, caRequestID, resolvedFamily); + throw; + } + } + catch (InvalidOperationException ioe) + { + // Any 422 not matched by the retry-eligible case above (e.g. a distinct EMS + // code/detail, or a reason not in the known-rejected set) — audit the denial + // and rethrow unchanged. Never more than the one retry above. + _logger.LogWarning( + "V2 revocation denied. CARequestID={Id}, Family={Family}, HttpStatus={HttpStatus}, " + + "EmsCode={EmsCode}, Detail={Detail}", + caRequestID, resolvedFamily, 422, ExtractEmsCode(ioe.Message) ?? "(none)", ioe.Message); + throw; + } + + _logger.LogInformation( + "V2 revocation complete. CARequestID={Id}, HexSerialNumber={Serial}, V2Reason={V2Reason}, " + + "Family={Family}, RetriedFromReason={RetriedFromReason}", + caRequestID, hexSerialNumber, v2Reason, resolvedFamily, retriedFromReason ?? "(none)"); + _logger.MethodExit(LogLevel.Debug); + return (int)EndEntityStatus.REVOKED; + } + + // --------------------------------------------------------------------------- + // V2 private utility + // --------------------------------------------------------------------------- + + /// + /// Pulls the first EMS-NNN code out of an exception message, for audit log + /// lines that want the CA's own error code as a discrete field rather than only the + /// free-text detail. Returns null when no code is present (e.g. a CERTInext + /// detail with no EMS code, such as "Certificate Request still being processed"). + /// + private static string ExtractEmsCode(string message) + { + if (string.IsNullOrEmpty(message)) return null; + var m = System.Text.RegularExpressions.Regex.Match(message, @"\bEMS-\d+\b"); + return m.Success ? m.Value : null; + } + + private static string ExtractCnFromSubject(string subject) + { + if (string.IsNullOrWhiteSpace(subject)) return null; + // subject format: "CN=example.com, O=Org, ..." + foreach (var part in subject.Split(',')) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) + return trimmed.Substring(3).Trim(); + } + return null; + } + + // --------------------------------------------------------------------------- + // V1 private helpers + // --------------------------------------------------------------------------- + + /// + /// Handles New and Reissue enrollment flows by submitting a fresh certificate + /// request to CERTInext. + /// + private async Task EnrollNewAsync( + string csr, + string subject, + Dictionary san, + EnrollmentParams ep) + { + _logger.MethodEntry(LogLevel.Debug); + var enrollReq = new EnrollCertificateRequest + { + 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), + RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, + RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, + KeyType = string.IsNullOrWhiteSpace(ep.KeyType) ? null : ep.KeyType, + Comment = "Issued via Keyfactor Command AnyCA REST Gateway." + }; + + 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 (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 polling a terminal order. + 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 + // CERTINEXT_DCV_TIMEOUT_MINUTES env var); defaults to 10 minutes. + // Log the resolved limit so an auditor can confirm the configured ceiling. + int dcvTimeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + _logger.LogInformation( + "Starting DCV for order {OrderNumber}. DcvTimeoutMinutes={Timeout}", + orderNumber, dcvTimeoutMinutes); + using var dcvCts = new CancellationTokenSource(TimeSpan.FromMinutes(dcvTimeoutMinutes)); + + // Reserve the in-flight slot before running DCV so that any concurrent + // Synchronize / GetSingleRecord cycle won't try to stage TXT records for the + // same order from the sync-driven retry path. If something else already has + // the slot (the only realistic case: a duplicate Enroll for the same order + // ID), skip our own attempt and fall through to the pending result — the + // other caller will produce the same outcome and we shouldn't double-stage. + bool reserved = _dcvInFlight.TryAdd(orderNumber, 0); + if (!reserved) + { + _logger.LogInformation( + "DCV is already in flight for order {OrderNumber}; Enroll will skip its own DCV attempt " + + "and return the pending enroll response. The other caller will drive issuance.", + orderNumber); + } + else + { + try + { + bool dcvDone = await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token); + if (dcvDone) + { + // Poll GetCertificate until CERTInext finishes generating the cert OR the + // issuance budget expires. CERTInext issuance is async — DCV may verify + // but the cert PEM isn't immediately available. Without this poll, Enroll + // returns a pending result and the cert is picked up on the next sync cycle, + // which is undesirable when the whole thing completes in under a minute. + var postDcv = await WaitForIssuanceAfterDcvAsync(orderNumber, dcvCts.Token); + if (postDcv != null) + { + return BuildEnrollmentResult(new EnrollCertificateResponse + { + Id = postDcv.Id, + Status = postDcv.Status, + Certificate = postDcv.Certificate, + SerialNumber = postDcv.SerialNumber, + Message = $"Post-DCV status: {postDcv.Status}." + }, ep.AutoApprove); + } + } + } + finally + { + _dcvInFlight.TryRemove(orderNumber, out _); + } + } + } +#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, dcvIssuanceWaitRan); + + _logger.MethodExit(LogLevel.Debug); + return newResult; + } + + /// + /// Handles Renew and RenewOrReissue enrollment flows. + /// Determines whether to renew (API call on existing ID) or fall back to new + /// issuance depending on the certificate's current state. + /// + private async Task RenewOrReissueAsync( + string csr, + string subject, + Dictionary san, + EnrollmentProductInfo productInfo, + EnrollmentParams ep) + { + // Retrieve the prior certificate serial number from the product parameters. + // Command injects "PriorCertSN" for renewal flows. + string priorCertSn = null; + productInfo.ProductParameters?.TryGetValue("PriorCertSN", out priorCertSn); + + // SOC2 CC6.1: a renewal/reissue read against the gateway's certificate + // inventory is a logical-access event and must be logged at Information. + _logger.LogInformation( + "Renewal/reissue probe — read PriorCertSN from EnrollmentProductInfo. " + + "Subject={Subject}, PriorCertSN={PriorCertSN}, RenewalWindowDays={WindowDays}", + LogSanitizer.Strip(subject), string.IsNullOrWhiteSpace(priorCertSn) ? "(none)" : priorCertSn, + ep.RenewalWindowDays); + + if (string.IsNullOrWhiteSpace(priorCertSn)) + { + // SOC2 CC7.2: log policy-relevant decisions at Information so they survive + // production log filters and are available for anomaly detection. + _logger.LogInformation( + "Renewal/reissue has no PriorCertSN — treating as new enrollment. Subject={Subject}", + LogSanitizer.Strip(subject)); + return await EnrollNewAsync(csr, subject, san, ep); + } + + // Resolve the CARequestID for the prior certificate + string priorCaRequestId; + try + { + priorCaRequestId = await _certificateDataReader.GetRequestIDBySerialNumber(priorCertSn); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not resolve CARequestID for serial '{SN}'. Falling back to new enrollment.", priorCertSn); + return await EnrollNewAsync(csr, subject, san, ep); + } + + if (string.IsNullOrWhiteSpace(priorCaRequestId)) + { + _logger.LogInformation( + "CARequestID for serial '{SN}' is empty — falling back to new enrollment. Subject={Subject}", + priorCertSn, LogSanitizer.Strip(subject)); + return await EnrollNewAsync(csr, subject, san, ep); + } + + // Determine whether this is within the renewal window. + // + // Semantics (Option A — "window before expiry"): + // useRenewalApi = true when the cert expires within the next RenewalWindowDays. + // useRenewalApi = false when the cert expires further away than that (too early → reissue). + // useRenewalApi = false when the cert is already expired (graceful degradation → new order). + // + // This matches operator expectation: "renew when within N days of expiry". + // Certs expiring far in the future should be reissued, not renewed via the CA's + // renew endpoint (which may assume near-expiry context on its side). + bool useRenewalApi = false; + try + { + DateTime? expiry = _certificateDataReader.GetExpirationDateByRequestId(priorCaRequestId); + if (expiry.HasValue) + { + DateTime now = DateTime.UtcNow; + DateTime renewalWindowEnd = now.AddDays(ep.RenewalWindowDays); + // Renew only if the cert is not yet expired AND expires within the window. + useRenewalApi = expiry.Value > now && expiry.Value <= renewalWindowEnd; + + // SOX CC6.2 / SOC2 CC7.2: the renewal window evaluation is a security-relevant + // policy decision (determines whether an existing CA record is reused). Logged + // at Information so it survives production log filters and is not suppressible + // by log-level configuration. + _logger.LogInformation( + "Renewal window evaluation complete. " + + "PriorCARequestID={PriorId}, CertExpiry={Expiry:O}, " + + "RenewalWindowEnd={WindowEnd:O}, RenewalWindowDays={Window}, UseRenewalApi={Use}", + priorCaRequestId, expiry.Value, renewalWindowEnd, ep.RenewalWindowDays, useRenewalApi); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not determine expiry for '{Id}'. Defaulting to new enrollment.", priorCaRequestId); + } + + if (useRenewalApi) + { + // SOX / SOC2 CC7.3: log the renewal attempt at Information so the intent is + // captured before the API call, enabling reconstruction if the call fails. + _logger.LogInformation( + "Renewal via CERTInext renew API started. " + + "PriorCARequestID={PriorId}, Subject={Subject}, ProfileId={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), + 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, + Comment = $"Renewed via Keyfactor Command. Prior ID: {priorCaRequestId}." + }; + + var renewResp = await _client.RenewCertificateAsync(priorCaRequestId, renewReq); + var renewResult = BuildEnrollmentResult(renewResp, ep.AutoApprove); + + // SOX: log the renewal outcome so the new certificate ID and status are + // independently recorded (the outer Enroll method also logs, but this + // ensures the renew path is auditable if the result is further transformed). + _logger.LogInformation( + "Renewal via CERTInext renew API complete. " + + "PriorCARequestID={PriorId}, NewCARequestID={NewId}, Status={Status}", + 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); + + return renewResult; + } + else + { + _logger.LogInformation( + "Certificate '{Id}' is outside the renewal window ({Window} days) — issuing new certificate. Subject={Subject}", + priorCaRequestId, ep.RenewalWindowDays, LogSanitizer.Strip(subject)); + return await EnrollNewAsync(csr, subject, san, ep); + } + } + + // --------------------------------------------------------------------------- + // DCV helpers + // --------------------------------------------------------------------------- + + /// + /// True when a GetDcv failure is the CERTInext-side "DCV slot is exposed in + /// TrackOrder but the endpoint won't accept calls yet" condition. Surfaces as the + /// API error EMS-956 "Invalid Request for this API" for several hours after + /// enrollment. + /// + /// Detection is intentionally narrow: + /// * If the message contains the literal code EMS-956, treat it as the + /// known not-ready condition. + /// * Otherwise, only fall back to the human-readable phrase match when *no other* + /// EMS-NNN code is present. Without that guard, an upstream proxy or WAF + /// returning a 4xx whose body happens to contain "Invalid Request for this API …" + /// plus a different CERTInext code (e.g. EMS-401) would be silently deferred, + /// masking a real authentication or input-validation failure. + /// + private static bool IsDcvNotYetReady(Exception ex) + { + if (ex == null) return false; + string msg = ex.Message ?? string.Empty; + if (msg.IndexOf("EMS-956", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + bool hasPhrase = msg.IndexOf("Invalid Request for this API", StringComparison.OrdinalIgnoreCase) >= 0; + bool hasOtherEmsCode = System.Text.RegularExpressions.Regex.IsMatch(msg, @"\bEMS-\d+\b"); + return hasPhrase && !hasOtherEmsCode; + } + + /// + /// Strips a leading wildcard label ("*.") so a domain can be used to pick a DNS + /// zone / and to build the + /// DCV TXT record hostname. A literal "*" is not a queryable DNS label, so staging + /// a record at e.g. _emsign-validation.*.example.com for a wildcard domain can never + /// be seen by the CA (a wildcard order with a literal-asterisk TXT host staged stays + /// pending indefinitely). Callers must keep using the + /// ORIGINAL domain string (including "*.") for every CERTInext API call + /// (GetDcv/VerifyDcv/TrackOrder) — that is what the CA itself tracks and reports back + /// per-domain; only the DNS-side hostname/zone-resolution inputs use the base domain. + /// + /// NOTE (unverified): whether CERTInext's own DCV actually accepts a base-domain TXT + /// record as proof for a wildcard domain entry has not been confirmed against the live + /// API — verify before relying on it for a wildcard enrollment. + /// + private static string StripWildcardPrefix(string domain) + { + if (string.IsNullOrEmpty(domain)) + return domain; + return domain.StartsWith("*.", StringComparison.Ordinal) + ? domain.Substring(2) + : domain; + } + + /// + /// Best-effort DCV retry for an order that may still be pending validation. + /// + /// Called from Synchronize and GetSingleRecord so that orders which CERTInext placed + /// into "Pending for Approver"/"Pending System RA" between enrollment and the next + /// gateway cycle (when domainVerification was still null at enroll time) can be + /// driven forward through DCV. Wraps with: + /// * a per-order in-flight guard so overlapping sync cycles or a sync+single + /// refresh do not double-stage TXT records, + /// * a bounded DCV timeout linked to the caller's cancellation token, + /// * swallowing of non-cancellation exceptions so a single bad order does not + /// halt a 12-hour sync — the order will be retried on the next cycle. + /// + /// Uses a single-shot challenge check (waitForChallengeSeconds=0) by default + /// because sync runs periodically: if CERTInext hasn't yet exposed the DCV slot for + /// this order, the next sync cycle will pick it up. Waiting per-order during sync + /// scales poorly — a single pending order's 60s budget becomes minutes of wasted + /// gateway thread time across an account with many orders. See PR #2 discussion. + /// + /// Returns true when DCV actually executed (or DCV is already complete), + /// false when skipped. + /// + private async Task TryRunDcvDuringSyncAsync(string orderNumber, CancellationToken ct, bool fastSync = false) + { + _logger.MethodEntry(LogLevel.Debug); +#if SUPPORTS_DCV + if (_domainValidatorFactory == null || !_config.DcvEnabled || string.IsNullOrEmpty(orderNumber)) + return false; + + if (!_dcvInFlight.TryAdd(orderNumber, 0)) + { + // SOC2 CC7.2: concurrent DCV-attempt collisions are security-relevant + // (they indicate either a normal overlap of two sync cycles OR an attempt + // to interleave operations on the same order). Log at Information so the + // event appears in production logs without verbose-debug being enabled. + _logger.LogInformation( + "DCV already in flight for order {OrderNumber}; skipping concurrent attempt.", + orderNumber); + return false; + } + + try + { + int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + using var dcvCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + dcvCts.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes)); + _logger.LogInformation( - "CARequestID for serial '{SN}' is empty — falling back to new enrollment. Subject={Subject}", - priorCertSn, subject); - return await EnrollNewAsync(csr, subject, san, ep); + "Attempting deferred DCV during sync/refresh (single-shot challenge check). " + + "OrderNumber={OrderNumber}, DcvTimeoutMinutes={Timeout}", + orderNumber, timeoutMinutes); + + return await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token, + waitForChallengeSecondsOverride: 0, + propagationDelaySecondsOverride: fastSync ? Constants.Dcv.SyncPropagationDelaySeconds : (int?)null); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Deferred DCV attempt failed for order {OrderNumber}. Order will be retried on the next sync cycle.", + orderNumber); + return false; + } + finally + { + _dcvInFlight.TryRemove(orderNumber, out _); } +#else + // DCV is not supported on this build (IAnyCAPlugin 3.2.0). No-op: pending orders + // are reported as EXTERNALVALIDATION and not advanced during sync. + await Task.CompletedTask; + return false; +#endif + } - // Determine whether this is within the renewal window. + /// + /// 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). + /// + /// 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. + /// + /// lets the sync path force a + /// single-shot challenge check (pass 0) so a sync cycle doesn't spend up to + /// DcvWaitForChallengeSeconds per pending order waiting for CERTInext to + /// expose the DCV slot — sync runs periodically, so unexposed orders are picked up + /// on the next cycle instead. Enroll passes null to keep the full configured + /// budget (user-visible latency benefits from a one-shot end-to-end finish). + /// +#if SUPPORTS_DCV + private async Task PerformDcvIfNeededAsync( + string orderNumber, + CancellationToken ct, + int? waitForChallengeSecondsOverride = null, + int? propagationDelaySecondsOverride = null) + { + // Poll TrackOrder until CERTInext exposes the DCV challenge (domainVerification + // populated) OR the cert reaches a terminal state OR the wait budget expires. + // Under concurrent enrollment load CERTInext sometimes takes a few seconds to + // materialize the slot after GenerateOrderSSL returns — without this wait a + // race-condition order skips DCV entirely and waits for the next sync cycle. + int waitBudgetSeconds = waitForChallengeSecondsOverride + ?? _config.GetEffectiveDcvWaitForChallengeSeconds(); + // Challenge-wait poll interval is clamped to [1s, 5s] so it's responsive even + // when an admin has set DcvPropagationDelaySeconds high for slow zones (that + // setting governs how long we wait *after* publishing a TXT record, which is a + // different, slower concern than how often we re-check TrackOrder here). + int challengePollSeconds = Math.Max(1, Math.Min(5, _config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 5)); + var waitDeadline = DateTime.UtcNow.AddSeconds(Math.Max(0, waitBudgetSeconds)); + + TrackOrderResponse track = null; + API.TrackOrderDomainVerification domainVerification = null; + int pollAttempts = 0; + + while (true) + { + pollAttempts++; + ct.ThrowIfCancellationRequested(); + track = await _client.TrackOrderAsync(orderNumber, ct); + + // Skip DCV entirely if the certificate is already issued or revoked + if (track.OrderDetails != null + && int.TryParse(track.OrderDetails.CertificateStatusId, out int certStatusId)) + { + int disposition = StatusMapper.CertificateStatusIdToRequestDisposition(certStatusId); + if (disposition == (int)EndEntityStatus.GENERATED || disposition == (int)EndEntityStatus.REVOKED) + { + _logger.LogDebug( + "DCV skipped — order {OrderNumber} is already in terminal state (certificateStatusId={Status}).", + orderNumber, certStatusId); + return false; + } + } + + // Skip if the order itself reached a terminal failure state. Without this + // the cached-DCV path below could still return true on a cancelled order + // (domainVerification.Status = "1" survives the cancellation), sending the + // caller into a wasted DcvWaitForIssuanceSeconds-long GetCertificate poll + // that can never resolve. OrderStatusId 4 = cancelled, 5 = rejected. + if (track.OrderDetails?.OrderStatusId is "4" or "5") + { + _logger.LogDebug( + "DCV skipped — order {OrderNumber} is cancelled/rejected " + + "(orderStatusId={OrderStatus}).", + orderNumber, track.OrderDetails.OrderStatusId); + return false; + } + + domainVerification = track.OrderDetails?.DomainVerification; + if (domainVerification != null) + break; + + // domainVerification still null — sleep and retry if we have budget left. + if (waitBudgetSeconds <= 0 || DateTime.UtcNow >= waitDeadline) + { + _logger.LogInformation( + "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; + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(challengePollSeconds), ct); + } + catch (OperationCanceledException) + { + // Rethrow if the gateway-level token is cancelled so shutdown is not blocked; + // only swallow an internal timeout (e.g. a per-poll deadline CTS). + ct.ThrowIfCancellationRequested(); + return false; + } + } + + // If DCV is already validated CERTInext-side, the plugin has no DCV work to + // do — but CERTInext's certificate generation may still be in flight (this + // happens when CERTInext has cached a prior DCV validation for the parent + // domain). Return true so the caller can run the issuance poll and pick up + // the cert directly from Enroll() instead of leaving it for the next sync. // - // Semantics (Option A — "window before expiry"): - // useRenewalApi = true when the cert expires within the next RenewalWindowDays. - // useRenewalApi = false when the cert expires further away than that (too early → reissue). - // useRenewalApi = false when the cert is already expired (graceful degradation → new order). + // Treat "DCV done" as EITHER the overall aggregate Status flipping to "1" + // OR every individual per-domain dcvStatus being "1": the per-domain field can + // flip before the parent aggregate. + var allDomainEntries = domainVerification.GetDomainEntries(); + bool aggregateValidated = string.Equals( + domainVerification.Status, Constants.Dcv.StatusValidated, StringComparison.Ordinal); + bool everyDomainValidated = allDomainEntries.Count > 0 + && allDomainEntries.All(kvp => string.Equals( + kvp.Value?.DcvStatus, Constants.Dcv.StatusValidated, StringComparison.Ordinal)); + if (aggregateValidated || everyDomainValidated) + { + _logger.LogInformation( + "DCV is already validated for order {OrderNumber} " + + "(aggregateStatus={Aggregate}, perDomainAllValidated={PerDomain}). " + + "Skipping DNS-TXT staging; caller may run the issuance poll.", + orderNumber, aggregateValidated, everyDomainValidated); + return true; + } + + // Include domains that are pending DCV and either have no method set yet, + // 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); + }) + .ToList(); + + // 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 — LogError, so the + // condition is visible in the audit trail — but they are EXCLUDED rather than + // thrown on. // - // This matches operator expectation: "renew when within N days of expiry". - // Certs expiring far in the future should be reissued, not renewed via the CA's - // renew endpoint (which may assume near-expiry context on its side). - bool useRenewalApi = false; + // 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) + { + string domain = entry.Key; + + // 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.", + // An email SAN submitted to V1 comes back verbatim as an order domain; mask it + // unless LogSensitiveRequestData is on. + invalidDomains.Count, orderNumber, + LogSanitizer.FormatUntypedSans(invalidDomains, _config.LogSensitiveRequestData, ", "), + validPendingDomains.Count); + } + + pendingDomains = validPendingDomains; + + if (pendingDomains.Count == 0) + return false; + + _logger.LogInformation( + "DCV required for order {OrderNumber}. Pending DNS TXT domains: [{Domains}]", + orderNumber, string.Join(", ", pendingDomains.Select(x => x.Key))); + + var stagedValidations = new List<(string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator)>(); + + // Every domain that got through staging this pass — whether it freshly published a + // TXT record or shared an already-staged hostname with a sibling (see + // stagedHostnames below). Drives the per-domain CERTInext Verify calls; kept separate + // from stagedValidations (which holds only ONE entry per unique hostname) so a + // wildcard/apex pair sharing a base-domain hostname still each get their own CA-side + // VerifyDcv, without staging — or cleaning up — the shared TXT record twice. + var verifyDomains = new List(); + + // TXT hostname -> the first domain that staged it this pass. A UCC order can list + // both "example.com" and "*.example.com"; StripWildcardPrefix collapses both to the + // same base-domain hostname, so the second domain to reach it must reuse the + // already-staged record instead of publishing (and later cleaning up) a duplicate. + var stagedHostnames = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // 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 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) + { + var (domain, hostname, validator) = entry; + try + { + // 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( + "DNS TXT record cleaned up{Context}. Domain={Domain}, Hostname={Hostname}", + context, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); + } + catch (Exception ex) + { + _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)); + } + } + try { - DateTime? expiry = _certificateDataReader.GetExpirationDateByRequestId(priorCaRequestId); - if (expiry.HasValue) + // 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. Without this guard: a GetDcv error or an empty token for a + // non-DNS SAN (submitted on purpose — see BuildSanList) would abort co-tenant DNS + // domains on the same order; a StageValidation failure on domain N+1 would orphan + // domain N's TXT record; and a misconfiguration-detection throw on an ordinary + // non-DNS Subject CN (which no setting can prevent, since SubmitNonDnsSans only + // filters the SAN list, not the subject) would abort the whole order. There is no + // "this must still throw" case in this loop. + foreach (var (domain, _) in pendingDomains) { - DateTime now = DateTime.UtcNow; - DateTime renewalWindowEnd = now.AddDays(ep.RenewalWindowDays); - // Renew only if the cert is not yet expired AND expires within the window. - useRenewalApi = expiry.Value > now && expiry.Value <= renewalWindowEnd; + 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 — surfaces 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 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; + } + + string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) + ? Constants.Dcv.DefaultTxtRecordTemplate + : _config.DcvTxtRecordTemplate; + // DCV publishes/looks up the TXT record under the BASE domain — a wildcard's + // "*." label is not a queryable DNS name. CERTInext's own GetDcv/VerifyDcv + // calls above/below still use the original `domain` (including "*."), since + // that is what Track Order reports back per-domain. + string baseDomain = StripWildcardPrefix(domain); + string hostname = string.Format(template, baseDomain); + + if (stagedHostnames.TryGetValue(hostname + "|" + token, out string sharedWithDomain)) + { + // Sibling domain (e.g. the wildcard/apex pair of the same base domain) + // already staged this exact TXT hostname this pass — reuse it instead of + // publishing a second record at the same name. This domain still gets its + // own CA-side GetDcv/VerifyDcv (CERTInext tracks DCV per domain entry); it + // just doesn't need its own TXT record. + _logger.LogInformation( + "DCV hostname {Hostname} for domain {Domain} on order {OrderNumber} is already " + + "staged (shared with {SharedWith}); reusing it instead of publishing a second " + + "TXT record.", + LogSanitizer.Strip(hostname), LogSanitizer.Strip(domain), orderNumber, + LogSanitizer.Strip(sharedWithDomain)); + verifyDomains.Add(domain); + continue; + } + + var validator = DomainValidatorFactory.ResolveDomainValidator(baseDomain, "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; + } - // SOX CC6.2 / SOC2 CC7.2: the renewal window evaluation is a security-relevant - // policy decision (determines whether an existing CA record is reused). Logged - // at Information so it survives production log filters and is not suppressible - // by log-level configuration. _logger.LogInformation( - "Renewal window evaluation complete. " + - "PriorCARequestID={PriorId}, CertExpiry={Expiry:O}, " + - "RenewalWindowEnd={WindowEnd:O}, RenewalWindowDays={Window}, UseRenewalApi={Use}", - priorCaRequestId, expiry.Value, renewalWindowEnd, ep.RenewalWindowDays, useRenewalApi); + "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; + } + + stagedHostnames[hostname + "|" + token] = domain; + stagedValidations.Add((domain, hostname, validator)); + verifyDomains.Add(domain); } } catch (Exception ex) { - _logger.LogWarning(ex, - "Could not determine expiry for '{Id}'. Defaulting to new enrollment.", priorCaRequestId); + // Nothing in the loop above throws for a per-domain reason — 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; } - if (useRenewalApi) + if (deferToNextSyncCycle) { - // SOX / SOC2 CC7.3: log the renewal attempt at Information so the intent is - // captured before the API call, enabling reconstruction if the call fails. - _logger.LogInformation( - "Renewal via CERTInext renew API started. " + - "PriorCARequestID={PriorId}, Subject={Subject}, ProfileId={ProfileId}", - priorCaRequestId, subject, ep.ProfileId); + await CleanupPartialStagingAsync(); + return false; + } - var renewReq = new RenewCertificateRequest - { - Csr = csr, - ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, - RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, - RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, - Comment = $"Renewed via Keyfactor Command. Prior ID: {priorCaRequestId}." - }; + 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})")))); + } - var renewResp = await _client.RenewCertificateAsync(priorCaRequestId, renewReq); - var renewResult = BuildEnrollmentResult(renewResp, ep.AutoApprove); + if (stagedValidations.Count == 0) + return false; - // SOX: log the renewal outcome so the new certificate ID and status are - // independently recorded (the outer Enroll method also logs, but this - // ensures the renew path is auditable if the result is further transformed). + try + { + // Allow DNS propagation before asking CERTInext to verify. The sync path passes + // a short override so a bounded set of recent pending orders doesn't + // each burn the full configured delay; Enroll uses the full configured value. + int delaySeconds = propagationDelaySecondsOverride + ?? (_config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 30); _logger.LogInformation( - "Renewal via CERTInext renew API complete. " + - "PriorCARequestID={PriorId}, NewCARequestID={NewId}, Status={Status}", - priorCaRequestId, renewResult.CARequestID, renewResult.Status); + "Waiting {Delay}s for DNS propagation before verifying DCV. OrderNumber={OrderNumber}", + delaySeconds, orderNumber); + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); - return renewResult; + // Every domain that got through staging — including hostname-sharing siblings — + // still gets its own CA-side verify call; CERTInext tracks DCV per domain entry + // even when two domains share one TXT record. + foreach (var domain in verifyDomains) + { + _logger.LogInformation( + "Triggering CERTInext DCV verification. OrderNumber={OrderNumber}, Domain={Domain}", + orderNumber, LogSanitizer.Strip(domain)); + await _client.VerifyDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct); + } + + // Poll TrackOrder until CERTInext confirms all staged domains are verified + // before removing TXT records — VerifyDcv triggers an async DNS lookup on + // their side, so cleanup must wait for dcvStatus=1 on every domain. + await WaitForDcvVerificationAsync(orderNumber, verifyDomains, ct); } - else + finally { - _logger.LogInformation( - "Certificate '{Id}' is outside the renewal window ({Window} days) — issuing new certificate. Subject={Subject}", - priorCaRequestId, ep.RenewalWindowDays, subject); - return await EnrollNewAsync(csr, subject, san, ep); + // 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, ""))); } - } - // --------------------------------------------------------------------------- - // DCV helpers - // --------------------------------------------------------------------------- + return true; + } /// - /// 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 - /// API error EMS-956 "Invalid Request for this API" for several hours after - /// enrollment — see analysis/certinext-support-ticket-2026-05-12.md. - /// - /// Detection is intentionally narrow: - /// * If the message contains the literal code EMS-956, treat it as the - /// known not-ready condition. - /// * Otherwise, only fall back to the human-readable phrase match when *no other* - /// EMS-NNN code is present. Without that guard, an upstream proxy or WAF - /// returning a 4xx whose body happens to contain "Invalid Request for this API …" - /// plus a different CERTInext code (e.g. EMS-401) would be silently deferred, - /// masking a real authentication or input-validation failure. + /// True when the exception's message contains the CERTInext V2 EMS-1080 code + /// ("Domain is already verified"), the spec's documented no-op for both + /// GetDcv and VerifyDcv on a domain that is still within its DCV reuse window. + /// Message-based rather than a typed field because the API's + /// RFC 7807 body carries the EMS code as text embedded in `detail`/`title`, + /// not as a separate structured field (see ). /// - private static bool IsDcvNotYetReady(Exception ex) - { - if (ex == null) return false; - string msg = ex.Message ?? string.Empty; - if (msg.IndexOf("EMS-956", StringComparison.OrdinalIgnoreCase) >= 0) - return true; - bool hasPhrase = msg.IndexOf("Invalid Request for this API", StringComparison.OrdinalIgnoreCase) >= 0; - bool hasOtherEmsCode = System.Text.RegularExpressions.Regex.IsMatch(msg, @"\bEMS-\d+\b"); - return hasPhrase && !hasOtherEmsCode; - } - - // (`DomainValidatorConfigProvider` nested helper removed — it declared an - // implementation of `Keyfactor.AnyGateway.Extensions.IDomainValidatorConfigProvider`, - // a v3.3-only interface, but the type was never instantiated anywhere in the - // plugin. Keeping a nested type whose base list references a missing assembly - // type is a hazard for CLR class-load on v3.2 hosts (see issue #7). Dead code - // that costs nothing to remove.) + private static bool IsEms1080DomainAlreadyVerified(Exception ex) => + ex?.Message?.IndexOf("EMS-1080", StringComparison.OrdinalIgnoreCase) >= 0; /// - /// Best-effort DCV retry for an order that may still be pending validation. - /// - /// Called from Synchronize and GetSingleRecord so that orders which CERTInext placed - /// into "Pending for Approver"/"Pending System RA" between enrollment and the next - /// gateway cycle (when domainVerification was still null at enroll time) can be - /// driven forward through DCV. Wraps with: - /// * a per-order in-flight guard so overlapping sync cycles or a sync+single - /// refresh do not double-stage TXT records, - /// * a bounded DCV timeout linked to the caller's cancellation token, - /// * swallowing of non-cancellation exceptions so a single bad order does not - /// halt a 12-hour sync — the order will be retried on the next cycle. + /// Performs DNS-01 DCV for a V2 SSL order using the V2 DCV endpoints. /// - /// Uses a single-shot challenge check (waitForChallengeSeconds=0) by default - /// because sync runs periodically: if CERTInext hasn't yet exposed the DCV slot for - /// this order, the next sync cycle will pick it up. Waiting per-order during sync - /// scales poorly — a single pending order's 60s budget becomes minutes of wasted - /// gateway thread time across an account with many orders. See PR #2 discussion. + /// A UCC order's additional SAN domains each carry their own DCV state in + /// Track Order's verifications.domain.domains[] block. This entry point owns the + /// single per-order guard (enrollment + sync overlap + /// protection — one guard entry regardless of how many domains the order has), then + /// dispatches to whichever flow applies: + /// - non-empty → , + /// which loops every domain whose own dcvStatus isn't VERIFIED. + /// - null/empty (single-domain orders, or an older/ + /// simpler response shape that never populated the block) → the + /// single-domain flow in + /// . /// - /// Returns true when DCV actually executed (or DCV is already complete), - /// false when skipped. + /// Returns true when DCV steps were executed for at least one domain, false + /// when skipped entirely (not configured, no domain(s) to act on, or already in flight). /// - private async Task TryRunDcvDuringSyncAsync(string orderNumber, CancellationToken ct, bool fastSync = false) + private async Task PerformDcvV2IfNeededAsync( + string orderId, + string domain, + string productFamilySlug, + CancellationToken ct, + IReadOnlyList domainEntries = null) { - _logger.MethodEntry(LogLevel.Debug); -#if SUPPORTS_DCV - if (_domainValidatorFactory == null || !_config.DcvEnabled || string.IsNullOrEmpty(orderNumber)) - return false; - - if (!_dcvInFlight.TryAdd(orderNumber, 0)) + if (_domainValidatorFactory == null || !_config.DcvEnabled) { - // SOC2 CC7.2: concurrent DCV-attempt collisions are security-relevant - // (they indicate either a normal overlap of two sync cycles OR an attempt - // to interleave operations on the same order). Log at Information so the - // event appears in production logs without verbose-debug being enabled. - _logger.LogInformation( - "DCV already in flight for order {OrderNumber}; skipping concurrent attempt.", - orderNumber); + _logger.LogDebug( + "V2 DCV skipped: DCV factory not configured or DcvEnabled=false. OrderId={OrderId}", orderId); return false; } - try + // Domain control validation exists only for the SSL/TLS family — the + // spec's DCV endpoints live under /ssl-certificates only, the Private PKI folder says + // "No DCV - your CA trusts you", and the Document Signer folder has no DCV step. This + // single gate covers every caller (EnrollV2Async, GetSingleRecordV2Async and V2 + // Synchronize), so a non-SSL order that is merely pending approval/documents is never + // sent to a nonexistent /{family}/{orderId}/dcv endpoint. + if (!string.Equals(productFamilySlug, Constants.ApiV2.FamilySsl, StringComparison.OrdinalIgnoreCase)) { - int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); - using var dcvCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - dcvCts.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes)); + _logger.LogDebug( + "V2 DCV skipped: product family '{Family}' has no domain control validation step. OrderId={OrderId}", + productFamilySlug, orderId); + return false; + } - _logger.LogInformation( - "Attempting deferred DCV during sync/refresh (single-shot challenge check). " + - "OrderNumber={OrderNumber}, DcvTimeoutMinutes={Timeout}", - orderNumber, timeoutMinutes); + bool multiDomainMode = domainEntries != null && domainEntries.Count > 0; - return await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token, - waitForChallengeSecondsOverride: 0, - propagationDelaySecondsOverride: fastSync ? Constants.Dcv.SyncPropagationDelaySeconds : (int?)null); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) + if (!multiDomainMode && string.IsNullOrWhiteSpace(domain)) { - throw; + _logger.LogWarning( + "V2 DCV skipped: no domain name available for order {OrderId}.", orderId); + return false; } - catch (Exception ex) + + // Prevent concurrent DCV staging for the same order (enrollment + sync overlap). + // Mirrors the _dcvInFlight guard in TryRunDcvDuringSyncAsync (V1 path). One guard + // entry per ORDER, not per domain — a UCC order with several pending SANs is still + // a single in-flight unit of work. + if (!_dcvInFlight.TryAdd(orderId, 0)) { - _logger.LogWarning(ex, - "Deferred DCV attempt failed for order {OrderNumber}. Order will be retried on the next sync cycle.", - orderNumber); + _logger.LogInformation( + "DCV already in flight for V2 order {OrderId}; skipping concurrent attempt.", orderId); return false; } + + try + { + return multiDomainMode + ? await PerformDcvV2MultiDomainAsync(orderId, domainEntries, productFamilySlug, ct) + : await PerformDcvV2SingleDomainAsync(orderId, domain, productFamilySlug, ct); + } finally { - _dcvInFlight.TryRemove(orderNumber, out _); + _dcvInFlight.TryRemove(orderId, out _); } -#else - // DCV is not supported on this build (IAnyCAPlugin 3.2.0). No-op: pending orders - // are reported as EXTERNALVALIDATION and not advanced during sync. See issue 0003. - await Task.CompletedTask; - return false; -#endif } /// - /// 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). + /// Single-domain V2 DCV flow, used whenever the order has no per-domain + /// verifications.domain.domains[] block to drive from (single-domain orders, or an + /// older/simpler response shape). The _dcvInFlight guard is owned by its caller + /// for the whole call. /// - /// 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. + /// Flow: + /// 1. GET /ssl-certificates/{orderId}/dcv → retrieve token (token) + /// 2. Publish TXT record at the configured DcvTxtRecordTemplate hostname + /// (default Constants.Dcv.DefaultTxtRecordTemplate) via + /// 3. POST /ssl-certificates/{orderId}/dcv/verify → trigger CA-side verification + /// 4. Poll until status != "pending-dcv" + /// 5. Clean up TXT record /// - /// lets the sync path force a - /// single-shot challenge check (pass 0) so a sync cycle doesn't spend up to - /// DcvWaitForChallengeSeconds per pending order waiting for CERTInext to - /// expose the DCV slot — sync runs periodically, so unexposed orders are picked up - /// on the next cycle instead. Enroll passes null to keep the full configured - /// budget (user-visible latency benefits from a one-shot end-to-end finish). + /// EMS-1080 ("Domain is already verified") from either GetDcv or VerifyDcv is + /// treated as DCV already satisfied: publishing is skipped and + /// the flow proceeds straight to step 4. + /// + /// Returns true when DCV steps were executed, false when skipped. /// -#if SUPPORTS_DCV - private async Task PerformDcvIfNeededAsync( - string orderNumber, - CancellationToken ct, - int? waitForChallengeSecondsOverride = null, - int? propagationDelaySecondsOverride = null) + private async Task PerformDcvV2SingleDomainAsync( + string orderId, + string domain, + string productFamilySlug, + CancellationToken ct) { - // Poll TrackOrder until CERTInext exposes the DCV challenge (domainVerification - // populated) OR the cert reaches a terminal state OR the wait budget expires. - // Under concurrent enrollment load CERTInext sometimes takes a few seconds to - // materialize the slot after GenerateOrderSSL returns — without this wait a - // race-condition order skips DCV entirely and waits for the next sync cycle. - int waitBudgetSeconds = waitForChallengeSecondsOverride - ?? _config.GetEffectiveDcvWaitForChallengeSeconds(); - // Challenge-wait poll interval is clamped to [1s, 5s] so it's responsive even - // when an admin has set DcvPropagationDelaySeconds high for slow zones (that - // setting governs how long we wait *after* publishing a TXT record, which is a - // different, slower concern than how often we re-check TrackOrder here). - int challengePollSeconds = Math.Max(1, Math.Min(5, _config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 5)); - var waitDeadline = DateTime.UtcNow.AddSeconds(Math.Max(0, waitBudgetSeconds)); - - TrackOrderResponse track = null; - API.TrackOrderDomainVerification domainVerification = null; - int pollAttempts = 0; + _logger.LogInformation( + "V2 DCV starting for order {OrderId}, domain {Domain}.", orderId, LogSanitizer.Strip(domain)); - while (true) + // 1. Fetch challenge + V2DcvChallengeResponse challenge = null; + bool dcvAlreadySatisfied = false; + try { - pollAttempts++; - ct.ThrowIfCancellationRequested(); - track = await _client.TrackOrderAsync(orderNumber, ct); + challenge = await _client.GetDcvV2Async(orderId, productFamilySlug, ct); + } + catch (Exception ex) when (IsEms1080DomainAlreadyVerified(ex)) + { + // EMS-1080 "Domain is already verified" is a documented no-op, + // not a failure: the domain is account-scoped and reusable, so there is no + // fresh challenge to fetch. Treat DCV as already satisfied and skip straight + // to tracking/issuance instead of deferring to the next sync cycle. + _logger.LogInformation( + "V2 DCV already satisfied (EMS-1080 domain already verified) for order {OrderId}; " + + "skipping TXT publish and proceeding to tracking.", orderId); + dcvAlreadySatisfied = true; + } + catch (Exception ex) + { + _dcvInFlight.TryRemove(orderId, out _); + _logger.LogWarning(ex, + "V2 GetDcv failed for order {OrderId}; deferring DCV to next sync cycle.", orderId); + return false; + } - // Skip DCV entirely if the certificate is already issued or revoked - if (track.OrderDetails != null - && int.TryParse(track.OrderDetails.CertificateStatusId, out int certStatusId)) + string token = null; + string hostname = null; + Keyfactor.AnyGateway.Extensions.IDomainValidator validator = null; + + if (!dcvAlreadySatisfied) + { + token = challenge?.Token; + if (string.IsNullOrWhiteSpace(token)) { - int disposition = StatusMapper.CertificateStatusIdToRequestDisposition(certStatusId); - if (disposition == (int)EndEntityStatus.GENERATED || disposition == (int)EndEntityStatus.REVOKED) - { - _logger.LogDebug( - "DCV skipped — order {OrderNumber} is already in terminal state (certificateStatusId={Status}).", - orderNumber, certStatusId); - return false; - } + _dcvInFlight.TryRemove(orderId, out _); + _logger.LogWarning( + "V2 GetDcv returned no token for order {OrderId}; deferring DCV.", orderId); + return false; } - // Skip if the order itself reached a terminal failure state. Without this - // the cached-DCV path below could still return true on a cancelled order - // (domainVerification.Status = "1" survives the cancellation), sending the - // caller into a wasted DcvWaitForIssuanceSeconds-long GetCertificate poll - // that can never resolve. OrderStatusId 4 = cancelled, 5 = rejected. - if (track.OrderDetails?.OrderStatusId is "4" or "5") + // TXT record hostname template — config-driven, mirroring V1's + // PerformDcvIfNeededAsync. Falls back to the same + // Constants.Dcv.DefaultTxtRecordTemplate default V1 uses when unconfigured; + // {0} is substituted with the BASE domain name via string.Format, same as V1 — + // a wildcard's "*." label is not a queryable DNS name, so the DNS-side hostname + // and zone resolution use StripWildcardPrefix(domain); the CERTInext calls above + // and below keep using the original `domain` string, since that is what Track + // Order reports back. + string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) + ? Constants.Dcv.DefaultTxtRecordTemplate + : _config.DcvTxtRecordTemplate; + string baseDomain = StripWildcardPrefix(domain); + hostname = string.Format(template, baseDomain); + + validator = DomainValidatorFactory.ResolveDomainValidator(baseDomain, "dns-01"); + if (validator == null) { - _logger.LogDebug( - "DCV skipped — order {OrderNumber} is cancelled/rejected " + - "(orderStatusId={OrderStatus}).", - orderNumber, track.OrderDetails.OrderStatusId); + _dcvInFlight.TryRemove(orderId, out _); + _logger.LogError( + "No DNS provider plugin resolved for domain '{Domain}' on V2 order {OrderId}. " + + "Ensure the appropriate DNS provider plugin is deployed and configured.", + LogSanitizer.Strip(domain), orderId); return false; } + } + + // staged=true only after a successful StageValidation so the finally only attempts + // cleanup when there is a record to remove (Finding C — cleanup skipped on !Success). + bool staged = false; + try + { + if (!dcvAlreadySatisfied) + { + // 2. Publish TXT record + _logger.LogInformation( + "Staging V2 DNS TXT record. OrderId={OrderId}, Hostname={Hostname}", orderId, LogSanitizer.Strip(hostname)); + + DomainValidationResult stageResult; + try + { + // Non-null here: only reached when !dcvAlreadySatisfied, and + // validator/hostname are always assigned together in that branch above. + stageResult = await validator!.StageValidation(hostname!, token, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, + "V2 DCV: DNS provider threw while staging '{Domain}' for order {OrderId}.", + LogSanitizer.Strip(domain), orderId); + return false; + } + + if (!stageResult.Success) + { + _logger.LogError( + "V2 DCV: Failed to stage DNS TXT for '{Domain}' on order {OrderId}: {Error}.", + LogSanitizer.Strip(domain), orderId, LogSanitizer.Strip(stageResult.ErrorMessage)); + return false; + } + staged = true; - domainVerification = track.OrderDetails?.DomainVerification; - if (domainVerification != null) - break; + // Wait for DNS propagation + int delaySeconds = _config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 30; + _logger.LogInformation( + "Waiting {Delay}s for DNS propagation before V2 DCV verify. OrderId={OrderId}", delaySeconds, orderId); + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); - // domainVerification still null — sleep and retry if we have budget left. - if (waitBudgetSeconds <= 0 || DateTime.UtcNow >= waitDeadline) - { + // 3. Trigger CA-side verification _logger.LogInformation( - "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; - } + "Triggering V2 DCV verification. OrderId={OrderId}, Domain={Domain}", orderId, LogSanitizer.Strip(domain)); + try + { + var verifyResp = await _client.VerifyDcvV2Async(orderId, domain, productFamilySlug, ct); + _logger.LogInformation( + "V2 DCV verify response. OrderId={OrderId}, OverallStatus={Status}", + orderId, verifyResp?.OverallStatus ?? "(null)"); - try - { - await Task.Delay(TimeSpan.FromSeconds(challengePollSeconds), ct); + if (!string.Equals(verifyResp?.OverallStatus, "VERIFIED", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "V2 DCV verify did not return VERIFIED for order {OrderId}. Status={Status}", + orderId, verifyResp?.OverallStatus); + return false; + } + } + catch (Exception ex) when (IsEms1080DomainAlreadyVerified(ex)) + { + // Same no-op as the GetDcv branch above, but surfaced at Verify time + // instead — the domain became/was already verified between the two + // calls. Treat as verified and continue to tracking rather than + // deferring. + _logger.LogInformation( + "V2 DCV already satisfied (EMS-1080 domain already verified) for order {OrderId} " + + "during VerifyDcv; treating as verified and proceeding to tracking.", orderId); + } } - catch (OperationCanceledException) + + // 4. Poll TrackOrderV2 until status leaves pending-dcv + int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + var deadline = DateTime.UtcNow.AddMinutes(timeoutMinutes); + // Fixed short cadence — decoupled from DcvPropagationDelaySeconds (one-shot + // DNS wait), not a poll interval. Reusing it here would yield only ~2 polls + // before the 5-minute timeout. + int pollSeconds = Constants.Dcv.SyncPropagationDelaySeconds; + + while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested) { - return false; + await Task.Delay(TimeSpan.FromSeconds(pollSeconds), ct); + try + { + var trackResp = await _client.TrackOrderV2Async(productFamilySlug, orderId, ct); + _logger.LogDebug( + "V2 DCV poll. OrderId={OrderId}, Status={Status}", orderId, trackResp.Status); + if (!string.Equals(trackResp.Status, "pending-dcv", StringComparison.OrdinalIgnoreCase)) + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "V2 DCV: TrackOrderV2 poll failed for order {OrderId}.", orderId); + break; + } } - } - // If DCV is already validated CERTInext-side, the plugin has no DCV work to - // do — but CERTInext's certificate generation may still be in flight (this - // happens when CERTInext has cached a prior DCV validation for the parent - // domain). Return true so the caller can run the issuance poll and pick up - // the cert directly from Enroll() instead of leaving it for the next sync. - // - // Treat "DCV done" as EITHER the overall aggregate Status flipping to "1" - // OR every individual per-domain dcvStatus being "1" — observed in the wild - // that the per-domain field can flip before the parent aggregate. - var allDomainEntries = domainVerification.GetDomainEntries(); - bool aggregateValidated = string.Equals( - domainVerification.Status, Constants.Dcv.StatusValidated, StringComparison.Ordinal); - bool everyDomainValidated = allDomainEntries.Count > 0 - && allDomainEntries.All(kvp => string.Equals( - kvp.Value?.DcvStatus, Constants.Dcv.StatusValidated, StringComparison.Ordinal)); - if (aggregateValidated || everyDomainValidated) - { - _logger.LogInformation( - "DCV is already validated for order {OrderNumber} " + - "(aggregateStatus={Aggregate}, perDomainAllValidated={PerDomain}). " + - "Skipping DNS-TXT staging; caller may run the issuance poll.", - orderNumber, aggregateValidated, everyDomainValidated); return true; } - - // Include domains that are pending DCV and either have no method set yet, - // 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); - }) - .ToList(); - - // 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) + finally { - if (string.IsNullOrWhiteSpace(domain)) - throw new InvalidOperationException( - $"TrackOrder returned a blank domain key in domainVerification for order '{orderNumber}'. " + - "Cannot proceed with DCV."); + // Release the in-flight guard regardless of how the staged block exits. + _dcvInFlight.TryRemove(orderId, out _); - // 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])?$")) + // 5. Clean up TXT record — only when staging succeeded (staged=true). + if (staged) { - _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."); + try + { + using var cleanupCts = new CancellationTokenSource( + TimeSpan.FromSeconds(Constants.Dcv.CleanupValidationTimeoutSeconds)); + // Non-null here: staged is only true when !dcvAlreadySatisfied, in + // which case validator/hostname were assigned before staging began. + await validator!.CleanupValidation(hostname!, cleanupCts.Token); + _logger.LogInformation( + "V2 DCV: DNS TXT record cleaned up. OrderId={OrderId}, Hostname={Hostname}", + orderId, LogSanitizer.Strip(hostname)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "V2 DCV: Failed to clean up DNS TXT record. OrderId={OrderId}, Hostname={Hostname}. " + + "May require manual removal.", orderId, LogSanitizer.Strip(hostname)); + } } } + } + + /// + /// Generalized V2 DCV for orders whose Track Order response surfaced a per-domain + /// verifications.domain.domains[] block — chiefly UCC orders with + /// additional SAN domains. Every entry whose own dcvStatus isn't VERIFIED is + /// processed: stage a TXT record for each pending domain, wait once for DNS propagation + /// (not once per domain), verify each domain individually, poll Track Order until every + /// domain just verified is confirmed (or the shared DCV timeout elapses), then always + /// clean up every staged record regardless of outcome. + /// + /// Partial failure: a domain that fails GetDcv/staging/verification is logged and + /// skipped — the others keep going. The order is left pending for any domain not + /// resolved this pass; because the caller always re-derives + /// from its own most recent Track Order response, the next sync/GetSingleRecord call + /// naturally retries only whichever domains are still not VERIFIED. + /// + /// The per-order _dcvInFlight guard is already held by the caller + /// () for the whole call. + /// + /// Returns true when DCV steps were executed for at least one domain (staged, or + /// found already verified via EMS-1080); false only when every domain in + /// was already VERIFIED (nothing to do). + /// + private async Task PerformDcvV2MultiDomainAsync( + string orderId, + IReadOnlyList domainEntries, + string productFamilySlug, + CancellationToken ct) + { + var pendingDomains = domainEntries + .Where(e => !string.IsNullOrWhiteSpace(e?.Domain) + && !string.Equals(e.DcvStatus, Constants.ApiV2.DcvStatusVerified, StringComparison.OrdinalIgnoreCase)) + .Select(e => e.Domain) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); if (pendingDomains.Count == 0) + { + _logger.LogDebug( + "V2 DCV (multi-domain) skipped: every domain on order {OrderId} is already VERIFIED.", + orderId); return false; + } _logger.LogInformation( - "DCV required for order {OrderNumber}. Pending DNS TXT domains: [{Domains}]", - orderNumber, string.Join(", ", pendingDomains.Select(x => x.Key))); - - var stagedValidations = new List<(string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator)>(); + "V2 DCV (multi-domain) starting for order {OrderId}. PendingDomains=[{Domains}]", + orderId, LogSanitizer.Strip(string.Join(", ", pendingDomains))); + + var staged = new List<(string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator)>(); + var verifiedDomains = new List(); + var failedDomains = new List<(string domain, string reason)>(); + + // Every domain that got through staging this pass — whether it freshly published a + // TXT record or shared an already-staged hostname with a sibling (see + // stagedHostnames below). Drives the per-domain CA Verify calls in Phase 2; kept + // separate from `staged` (which holds only ONE entry per unique hostname, for + // cleanup) so a wildcard/apex pair sharing a base-domain hostname still each get + // their own VerifyDcv call without staging — or cleaning up — the shared TXT record + // twice. + var verifyCandidates = new List(); + + // TXT hostname -> the first domain that staged it this pass. A UCC order can list + // both "example.com" and "*.example.com"; StripWildcardPrefix collapses both to the + // same base-domain hostname, so the second domain to reach it must reuse the + // already-staged record instead of publishing (and later cleaning up) a duplicate. + var stagedHostnames = new Dictionary(StringComparer.OrdinalIgnoreCase); + + async Task CleanupStagedAsync() + { + // Concurrent, not sequential — mirrors the V1 multi-SAN cleanup rationale + // (CleanupPartialStagingAsync above): a UCC order with N staged domains must not + // let the aggregate cleanup time scale with N. + await Task.WhenAll(staged.Select(entry => CleanupOneV2StagedRecordAsync(orderId, entry))); + } - // Stage DNS TXT records for all pending domains - foreach (var (domain, _) in pendingDomains) + try { - GetDcvResponse dcvResp; - try + // Phase 1: stage a TXT record for every pending domain. Every failure here is + // scoped to the one domain that hit it (logged loudly, then skipped) — never + // thrown — so one bad SAN cannot abort DCV for the co-tenant domains on the same + // order; a partial failure keeps the rest going. + foreach (var d in pendingDomains) { - dcvResp = await _client.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct); + ct.ThrowIfCancellationRequested(); + + V2DcvChallengeResponse challenge = null; + bool alreadySatisfied = false; + try + { + challenge = await _client.GetDcvV2Async(orderId, d, productFamilySlug, ct); + } + catch (Exception ex) when (IsEms1080DomainAlreadyVerified(ex)) + { + _logger.LogInformation( + "V2 DCV already satisfied (EMS-1080) for domain {Domain} on order {OrderId}; " + + "skipping TXT publish for this domain.", LogSanitizer.Strip(d), orderId); + alreadySatisfied = true; + } + catch (OperationCanceledException) + { + // The shared, DcvTimeoutMinutes-bound cancellation — not a per-domain + // failure. Propagate to the outer catch, which logs and cleans up. + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, + "V2 GetDcv failed for domain {Domain} on order {OrderId}; skipping this domain " + + "so the rest of the order can still be validated.", LogSanitizer.Strip(d), orderId); + failedDomains.Add((d, "GetDcv failed")); + continue; + } + + if (alreadySatisfied) + { + verifiedDomains.Add(d); + continue; + } + + string token = challenge?.Token; + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogError( + "V2 GetDcv returned no token for domain {Domain} on order {OrderId}; skipping " + + "this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(d), orderId); + failedDomains.Add((d, "no DCV token returned")); + continue; + } + + string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) + ? Constants.Dcv.DefaultTxtRecordTemplate + : _config.DcvTxtRecordTemplate; + // DCV publishes/looks up the TXT record under the BASE domain — a wildcard's + // "*." label is not a queryable DNS name. CERTInext's own GetDcv/VerifyDcv + // calls keep using the original `d` string, since that is what Track Order + // reports back per-domain. + string baseDomain = StripWildcardPrefix(d); + string hostname = string.Format(template, baseDomain); + + if (stagedHostnames.TryGetValue(hostname + "|" + token, out string sharedWithDomain)) + { + // Sibling domain (e.g. the wildcard/apex pair of the same base domain) + // already staged this exact TXT hostname this pass — reuse it instead of + // publishing a second record at the same name. This domain still gets its + // own CA-side VerifyDcv in Phase 2 below. + _logger.LogInformation( + "V2 DCV hostname {Hostname} for domain {Domain} on order {OrderId} is already " + + "staged (shared with {SharedWith}); reusing it instead of publishing a second " + + "TXT record.", + LogSanitizer.Strip(hostname), LogSanitizer.Strip(d), orderId, + LogSanitizer.Strip(sharedWithDomain)); + verifyCandidates.Add(d); + continue; + } + + var validator = DomainValidatorFactory.ResolveDomainValidator(baseDomain, "dns-01"); + if (validator == null) + { + _logger.LogError( + "No DNS provider plugin resolved for domain '{Domain}' on V2 order {OrderId}; " + + "skipping this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(d), orderId); + failedDomains.Add((d, "no DNS provider resolved")); + continue; + } + + _logger.LogInformation( + "Staging V2 DNS TXT record for DCV. OrderId={OrderId}, Domain={Domain}, Hostname={Hostname}", + orderId, LogSanitizer.Strip(d), LogSanitizer.Strip(hostname)); + + DomainValidationResult stageResult; + try + { + stageResult = await validator.StageValidation(hostname, token, ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, + "V2 DCV: DNS provider threw while staging '{Domain}' for order {OrderId}; " + + "skipping this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(d), orderId); + failedDomains.Add((d, "DNS provider plugin threw")); + continue; + } + + if (!stageResult.Success) + { + _logger.LogError( + "V2 DCV: Failed to stage DNS TXT for '{Domain}' on order {OrderId}: {Error}. " + + "Skipping this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(d), orderId, LogSanitizer.Strip(stageResult.ErrorMessage)); + failedDomains.Add((d, $"stage failed: {stageResult.ErrorMessage}")); + continue; + } + + stagedHostnames[hostname + "|" + token] = d; + staged.Add((d, hostname, validator)); + verifyCandidates.Add(d); } - catch (Exception ex) when (IsDcvNotYetReady(ex)) + } + catch (Exception ex) + { + // 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) or a bug. Clean up whatever was already staged before + // this propagates — none of the callers add their own cleanup. + _logger.LogError(ex, + "Unexpected failure during V2 DCV staging for order {OrderId}; cleaning up any " + + "already-staged TXT records before this propagates.", orderId); + await CleanupStagedAsync(); + throw; + } + + if (verifyCandidates.Count > 0) + { + try { - // 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. + // One propagation wait for the whole batch, not one per domain. + int delaySeconds = _config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 30; _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; + "Waiting {Delay}s for DNS propagation before V2 DCV verify. OrderId={OrderId}, DomainCount={Count}", + delaySeconds, orderId, verifyCandidates.Count); + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); + + // Phase 2: verify each domain that got through staging individually — the + // spec's Verify DCV body takes a single `domain`, mirroring Get DCV + // Challenges' per-domain shape. + // NOTE (unverified): per-SAN semantics have not been confirmed against the + // live API end-to-end. This includes hostname-sharing siblings + // (verifyCandidates), not just the domains that staged a fresh TXT record + // (staged) — CERTInext tracks DCV per domain entry even when two domains + // share one TXT record. + foreach (var d in verifyCandidates) + { + try + { + _logger.LogInformation( + "Triggering V2 DCV verification. OrderId={OrderId}, Domain={Domain}", + orderId, LogSanitizer.Strip(d)); + var verifyResp = await _client.VerifyDcvV2Async(orderId, d, productFamilySlug, ct); + _logger.LogInformation( + "V2 DCV verify response. OrderId={OrderId}, Domain={Domain}, OverallStatus={Status}", + orderId, LogSanitizer.Strip(d), verifyResp?.OverallStatus ?? "(null)"); + + if (string.Equals(verifyResp?.OverallStatus, "VERIFIED", StringComparison.OrdinalIgnoreCase)) + { + verifiedDomains.Add(d); + } + else + { + _logger.LogWarning( + "V2 DCV verify did not return VERIFIED for domain {Domain} on order {OrderId}. " + + "Status={Status}", LogSanitizer.Strip(d), orderId, verifyResp?.OverallStatus); + failedDomains.Add((d, $"verify returned {verifyResp?.OverallStatus ?? "(null)"}")); + } + } + catch (Exception ex) when (IsEms1080DomainAlreadyVerified(ex)) + { + // Same no-op as the GetDcv branch above, but surfaced at Verify time + // instead — the domain became/was already verified between the two + // calls. Treat as verified rather than deferring. + _logger.LogInformation( + "V2 DCV already satisfied (EMS-1080) for domain {Domain} on order {OrderId} " + + "during VerifyDcv; treating as verified.", LogSanitizer.Strip(d), orderId); + verifiedDomains.Add(d); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, + "V2 DCV verify failed for domain {Domain} on order {OrderId}; skipping this " + + "domain so the rest of the order can still be validated.", + LogSanitizer.Strip(d), orderId); + failedDomains.Add((d, "verify failed")); + } + } + + // Phase 3: poll Track Order until every domain just verified this pass is + // confirmed there too, before cleanup — mirrors the V1 rationale + // (WaitForDcvVerificationAsync): VerifyDcv's synchronous response may not yet + // be reflected by the CA's own async DNS lookup. + var stagedDomainNames = new HashSet( + verifyCandidates, StringComparer.OrdinalIgnoreCase); + var justVerifiedStaged = verifiedDomains + .Where(d => stagedDomainNames.Contains(d)) + .ToList(); + if (justVerifiedStaged.Count > 0) + { + await WaitForDomainsVerifiedV2Async(orderId, productFamilySlug, justVerifiedStaged, ct); + } } - catch (Exception ex) + finally { - _logger.LogError(ex, "GetDcv failed for order {OrderNumber} domain {Domain}", orderNumber, domain); - throw; + await CleanupStagedAsync(); } + } - string token = dcvResp.DcvDetails?.Token; - if (string.IsNullOrWhiteSpace(token)) - throw new InvalidOperationException( - $"GetDcv returned no token for order '{orderNumber}' domain '{domain}'."); + if (failedDomains.Count > 0) + { + _logger.LogError( + "{FailedCount} domain(s) on V2 order {OrderId} could not be validated this pass and " + + "were skipped: [{Domains}]. The order remains pending; a later sync/GetSingleRecord " + + "retries only the still-unverified domains.", + failedDomains.Count, orderId, + LogSanitizer.Strip(string.Join(", ", failedDomains.Select(f => $"{f.domain} ({f.reason})")))); + } - string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) - ? Constants.Dcv.DefaultTxtRecordTemplate - : _config.DcvTxtRecordTemplate; - string hostname = string.Format(template, domain); + _logger.LogInformation( + "V2 DCV (multi-domain) summary. OrderId={OrderId}, PendingCount={Pending}, VerifiedCount={Verified}, FailedCount={Failed}", + orderId, pendingDomains.Count, verifiedDomains.Count, failedDomains.Count); - 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."); + return true; + } + /// + /// Removes one already-published V2 DCV TXT record. Shared cleanup logic for + /// 's staged-domain list — mirrors the + /// single-domain V2 path's own inline cleanup (and V1's + /// CleanupOneStagedValidationAsync) in both bound and best-effort behavior: a + /// fresh, independently-bounded token (neither the ambient ct nor + /// ) so a stuck DNS provider cannot hang this + /// best-effort compensating action indefinitely, regardless of why cleanup was + /// triggered. + /// + private async Task CleanupOneV2StagedRecordAsync( + string orderId, + (string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator) entry) + { + var (domain, hostname, validator) = entry; + try + { + using var cleanupCts = new CancellationTokenSource( + TimeSpan.FromSeconds(Constants.Dcv.CleanupValidationTimeoutSeconds)); + await validator.CleanupValidation(hostname, cleanupCts.Token); _logger.LogInformation( - "Staging DNS TXT record for DCV. OrderNumber={OrderNumber}, Domain={Domain}, Hostname={Hostname}", - orderNumber, domain, hostname); + "V2 DCV: DNS TXT record cleaned up. OrderId={OrderId}, Domain={Domain}, Hostname={Hostname}", + orderId, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "V2 DCV: Failed to clean up DNS TXT record. OrderId={OrderId}, Domain={Domain}, " + + "Hostname={Hostname}. May require manual removal.", + orderId, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); + } + } - var stageResult = await validator.StageValidation(hostname, token, ct); - if (!stageResult.Success) - throw new InvalidOperationException( - $"Failed to stage DNS validation for '{domain}': {stageResult.ErrorMessage}"); + /// + /// Polls until every domain in + /// reaches a VERIFIED dcvStatus in + /// verifications.domain.domains[], reaches REJECTED (terminal — seen + /// after an order cancellation), or is cancelled / + /// the internal deadline elapses. V2 analogue of . + /// + private async Task WaitForDomainsVerifiedV2Async( + string orderId, string productFamilySlug, IReadOnlyList domains, CancellationToken ct) + { + if (domains.Count == 0) return; - stagedValidations.Add((domain, hostname, validator)); - } + var pending = new HashSet(domains, StringComparer.OrdinalIgnoreCase); + // Fixed short cadence — decoupled from DcvPropagationDelaySeconds (a one-shot DNS + // wait), not a poll interval. + int pollSeconds = Constants.Dcv.SyncPropagationDelaySeconds; - if (stagedValidations.Count == 0) - return false; + // Defense-in-depth deadline, same rationale as WaitForDcvVerificationAsync: bounded + // even if a future refactor breaks the cancellation chain. + var deadline = DateTime.UtcNow.AddMinutes(_config.GetEffectiveDcvTimeoutMinutes()); - try + while (pending.Count > 0 && !ct.IsCancellationRequested) { - // Allow DNS propagation before asking CERTInext to verify. The sync path passes - // a short override (issue 0002) so a bounded set of recent pending orders doesn't - // each burn the full configured delay; Enroll uses the full configured value. - int delaySeconds = propagationDelaySecondsOverride - ?? (_config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 30); - _logger.LogInformation( - "Waiting {Delay}s for DNS propagation before verifying DCV. OrderNumber={OrderNumber}", - delaySeconds, orderNumber); - await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); + if (DateTime.UtcNow >= deadline) + { + _logger.LogWarning( + "V2 DCV verification poll exceeded its internal deadline ({Minutes}min). " + + "OrderId={OrderId}, StillPendingDomains=[{Pending}]. Exiting and leaving TXT " + + "records for the caller's cleanup.", + _config.GetEffectiveDcvTimeoutMinutes(), orderId, + LogSanitizer.Strip(string.Join(",", pending))); + return; + } - foreach (var (domain, hostname, _) in stagedValidations) + try { - _logger.LogInformation( - "Triggering CERTInext DCV verification. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, domain); - await _client.VerifyDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct); + await Task.Delay(TimeSpan.FromSeconds(pollSeconds), ct); + } + catch (OperationCanceledException) + { + return; } - // Poll TrackOrder until CERTInext confirms all staged domains are verified - // before removing TXT records — VerifyDcv triggers an async DNS lookup on - // their side, so cleanup must wait for dcvStatus=1 on every domain. - await WaitForDcvVerificationAsync(orderNumber, stagedValidations.Select(s => s.domain).ToList(), ct); - } - finally - { - // Always clean up staged DNS records — even on failure - foreach (var (domain, hostname, validator) in stagedValidations) + V2OrderStatusResponse poll; + try { - try + poll = await _client.TrackOrderV2Async(productFamilySlug, orderId, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "V2 TrackOrder polling failed during DCV wait. OrderId={OrderId}", orderId); + return; + } + + var entries = poll.Verifications?.Domain?.Domains; + if (entries == null) continue; + + foreach (var entry in entries) + { + if (entry?.Domain == null || !pending.Contains(entry.Domain)) continue; + + if (string.Equals(entry.DcvStatus, Constants.ApiV2.DcvStatusVerified, StringComparison.OrdinalIgnoreCase)) { - await validator.CleanupValidation(hostname, ct); _logger.LogInformation( - "DNS TXT record cleaned up. Domain={Domain}, Hostname={Hostname}", domain, hostname); + "V2 DCV verified by CERTInext. OrderId={OrderId}, Domain={Domain}", + orderId, LogSanitizer.Strip(entry.Domain)); + pending.Remove(entry.Domain); } - catch (Exception ex) + else if (string.Equals(entry.DcvStatus, Constants.ApiV2.DcvStatusRejected, StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning(ex, - "Failed to clean up DNS TXT record. Domain={Domain}, Hostname={Hostname}", domain, hostname); + _logger.LogWarning( + "V2 DCV rejected by CERTInext. OrderId={OrderId}, Domain={Domain}", + orderId, LogSanitizer.Strip(entry.Domain)); + pending.Remove(entry.Domain); } } } - - return true; } #endif @@ -1805,7 +5307,10 @@ private async Task WaitForDcvVerificationAsync(string orderNumber, IReadOnlyList if (domains.Count == 0) return; var pending = new HashSet(domains, StringComparer.OrdinalIgnoreCase); - int pollSeconds = Math.Max(1, _config.DcvPropagationDelaySeconds); + // Fixed short cadence — decoupled from DcvPropagationDelaySeconds, which is a + // one-shot DNS propagation wait, not a polling interval. Reusing it here would + // reduce the number of polls to ~2 before the 5-minute timeout. + int pollSeconds = Constants.Dcv.SyncPropagationDelaySeconds; // Defense-in-depth deadline: SOX CC7.3 requires every wait to be bounded. // The caller passes a `ct` derived from a CancellationTokenSource that already @@ -1823,7 +5328,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; } @@ -1856,18 +5362,432 @@ 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); } } } } + /// + /// 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. + /// + /// Applies to ALL products. CERTInext issues OV/EV asynchronously (organization + /// verification, minutes to hours; per CERTInext support), 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, bool dcvIssuanceWaitRan, + CancellationToken ct = default) + { + // 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 is returned as-is. + if (pendingResult == null + || 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) + { + _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(); + + // 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 + // attempt to let a fast order finish issuing before we start polling at all. + await Task.Delay(TimeSpan.FromSeconds(Constants.Pickup.InitialDelaySeconds), ct); + + for (int attempt = 1; attempt <= retries; attempt++) + { + try + { + var cert = await _client.GetCertificateAsync(orderNumber, ct); + 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. + 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) ? "(not provided by CA)" : 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) + { + // 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, + Certificate = cert.Certificate, + Status = disposition, + StatusMessage = $"Order {orderNumber} reached status '{cert.Status}' during enrollment pickup." + }; + } + } + 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++; + _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), ct); + } + + // 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.", + 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."; + } + 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; + } + + /// + /// Synchronous certificate pickup for the V2 API — parity with + /// above: without this poll, a DV order + /// that CERTInext issues within seconds of CSR submission would only ever reach Command + /// via a gateway sync plus a Command full scan. After a V2 + /// order is created and its CSR submitted, polls + /// up to PickupRetries times, PickupDelay seconds apart (after the same + /// fixed initial delay), so a fast-issuing order is returned GENERATED + PEM in this same + /// enrollment call instead of waiting for the next synchronization. Reuses the identical + /// config knobs and wait-budget ceiling as the V1 method (Constants.Pickup, + /// GetEffectivePickupRetries, GetEffectivePickupDelaySeconds) so the two + /// behave identically from an operator's perspective. Never throws — any polling error + /// degrades to the pending result. + /// + /// Also returns the freshest raw CA status string observed while producing the result + /// (falling back to when no fresher poll ran), so + /// 's terminal REVOKED-to-FAILED + /// normalization can log/report the CA's own status text, not just Command's mapped + /// disposition. Note: a REVOKED result reaching that normalization is never "surfaced + /// immediately" as REVOKED — see NormalizeV2RevokedEnrollResult. + /// + private async Task<(EnrollmentResult Result, string RawCaStatus)> PickUpEnrolledCertificateV2Async( + EnrollmentResult pendingResult, string orderId, string productFamilySlug, + bool dcvV2Ran, string lastKnownCaStatus, CancellationToken ct = default) + { + // The inline V2 DCV path already owns the in-call issuance wait for this order — + // running a second stacked poll here would double the wait budget (when + // PerformDcvV2IfNeededAsync ran its own tracking poll) or waste it re-polling an + // order DCV already left mid-flight. Defer to the pending result; a later sync + // completes it. Mirrors dcvIssuanceWaitRan's role in the V1 method above. + if (dcvV2Ran) + return (pendingResult, lastKnownCaStatus); + + // Only a still-pending (external-validation) result can benefit from a pickup poll. + // An already issued/failed/revoked result is returned unpolled — the caller's own + // terminal normalization (NormalizeV2RevokedEnrollResult) decides what a REVOKED + // disposition here ultimately becomes; this method does not surface it as-is. + if (pendingResult == null + || pendingResult.Status != (int)EndEntityStatus.EXTERNALVALIDATION) + return (pendingResult, lastKnownCaStatus); + + // A pending result with no order id cannot be polled — surface the anomaly rather + // than silently returning, so an un-pollable pending state leaves an audit trace. + if (string.IsNullOrWhiteSpace(orderId)) + { + _logger.LogWarning( + "V2 synchronous pickup skipped: a pending enrollment was returned with no order " + + "id to poll. The certificate can only be reconciled by a later synchronization."); + return (pendingResult, lastKnownCaStatus); + } + + int retries = _config.GetEffectivePickupRetries(); + if (retries <= 0) + { + _logger.LogInformation( + "V2 synchronous certificate pickup disabled (PickupRetries<=0). Order {OrderId} " + + "will be picked up on the next synchronization.", orderId); + return (pendingResult, lastKnownCaStatus); + } + + int delaySeconds = _config.GetEffectivePickupDelaySeconds(); + + // Hard ceiling on total in-call occupancy — identical rationale to the V1 method: + // PickupRetries and PickupDelay are each clamped independently, but their product can + // still reach ~30 min at the extremes. 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 V2 synchronous certificate pickup. OrderId={OrderId}, PickupRetries={Retries}, " + + "PickupDelaySeconds={Delay} (max ~{Max}s including a {Initial}s initial delay).", + orderId, retries, delaySeconds, + Constants.Pickup.InitialDelaySeconds + retries * delaySeconds, Constants.Pickup.InitialDelaySeconds); + + int pollErrors = 0; + try + { + // Small static delay before the first poll — mirrors the V1 method's attempt to + // let a fast order finish issuing before we start polling at all. + await Task.Delay(TimeSpan.FromSeconds(Constants.Pickup.InitialDelaySeconds), ct); + + for (int attempt = 1; attempt <= retries; attempt++) + { + try + { + var tracked = await _client.TrackOrderV2Async(productFamilySlug, orderId, ct); + int disposition = StatusMapper.V2StatusToRequestDisposition(tracked.Status); + lastKnownCaStatus = tracked.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( + "V2 pickup poll observed status. OrderId={OrderId}, Attempt={Attempt}/{Retries}, " + + "MappedDisposition={Disposition}, Status='{Status}'.", + orderId, attempt, retries, disposition, tracked.Status); + + if (disposition == (int)EndEntityStatus.GENERATED) + { + // Issued: only surface GENERATED when the certificate body actually + // downloads — never hand Command a body-less "issued" record. A failed + // or empty download keeps polling until the body appears or the budget + // runs out, same invariant the V1 method already enforces. + try + { + var certResp = await _client.DownloadCertificateV2Async(productFamilySlug, orderId, ct); + string fullChain = AssembleV2CertChain(certResp); + + if (!string.IsNullOrWhiteSpace(fullChain)) + { + _logger.LogInformation( + "V2 synchronous pickup complete. OrderId={OrderId}, SerialNumber={Serial}, " + + "Attempt={Attempt}/{Retries}.", + orderId, + string.IsNullOrWhiteSpace(certResp.SerialNumber) ? "(not provided by CA)" : certResp.SerialNumber, + attempt, retries); + return (new EnrollmentResult + { + CARequestID = orderId, + Certificate = fullChain, + Status = (int)EndEntityStatus.GENERATED, + StatusMessage = "Certificate issued via V2 API." + }, lastKnownCaStatus); + } + + _logger.LogDebug( + "V2 pickup poll: order {OrderId} reports issued but returned no certificate " + + "body yet (attempt {Attempt}/{Retries}); continuing to poll.", + orderId, attempt, retries); + } + catch (Exception dlEx) + { + // Issued but the download itself failed — consume the attempt and + // keep polling rather than aborting; a later attempt (or the next + // sync) may succeed. + pollErrors++; + _logger.LogWarning(dlEx, + "V2 pickup: order {OrderId} is issued but certificate download failed " + + "(attempt {Attempt}/{Retries}).", orderId, attempt, retries); + } + } + else if (disposition == (int)EndEntityStatus.REVOKED + || disposition == (int)EndEntityStatus.FAILED) + { + // Terminal non-issued outcomes carry no body and stop the poll + // immediately. The REVOKED case still comes back from this method with + // Status=REVOKED, but EnrollV2Async's terminal + // NormalizeV2RevokedEnrollResult call maps it to FAILED before it ever + // reaches the gateway, since a REVOKED order observed here never has a + // downloadable certificate body. + if (disposition == (int)EndEntityStatus.FAILED) + _logger.LogError( + "V2 order {OrderId} reached terminal FAILED status '{Status}' during " + + "synchronous pickup (attempt {Attempt}/{Retries}).", + orderId, tracked.Status, attempt, retries); + else + _logger.LogWarning( + "V2 order {OrderId} was REVOKED ('{Status}') during synchronous pickup " + + "(attempt {Attempt}/{Retries}).", + orderId, tracked.Status, attempt, retries); + return (new EnrollmentResult + { + CARequestID = orderId, + Certificate = null, + Status = disposition, + StatusMessage = $"Order {orderId} reached status '{tracked.Status}' during enrollment pickup." + }, lastKnownCaStatus); + } + } + catch (Exception ex) + { + if (ex is OperationCanceledException) throw; + // A transient status-fetch failure consumes an attempt rather than aborting + // the wait; if it never recovers the pending result is returned below. + pollErrors++; + _logger.LogWarning(ex, + "V2 pickup TrackOrderV2Async failed for order {OrderId} (attempt {Attempt}/{Retries}).", + orderId, attempt, retries); + } + + // Delay after every attempt (including the last), matching the V1 method's + // pickup cadence so the max-occupancy ceiling is identical. + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); + } + + // SOC1 accuracy: don't attribute non-completion to "still validating" 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( + "V2 synchronous pickup exhausted {Retries} attempts for order {OrderId} — ALL polls " + + "errored (see preceding warnings). Returning pending result; the next synchronization " + + "will re-attempt retrieval.", + retries, orderId); + else + _logger.LogInformation( + "V2 synchronous pickup did not complete within {Retries} attempts for order {OrderId} " + + "({Errors} poll error(s); remainder still pending). Returning pending result; the " + + "certificate will be imported by the next synchronization.", + retries, orderId, pollErrors); + 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, + "V2 synchronous pickup failed for order {OrderId}. Returning pending result; " + + "sync will pick up the certificate later.", orderId); + } + + return (pendingResult, lastKnownCaStatus); + } + /// /// Converts a CERTInext API enrollment/renewal response into the /// expected by the AnyCA gateway. @@ -1878,6 +5798,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) @@ -1958,46 +5888,479 @@ 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: a CSR carrying two DNS names, submitted with additionalDomains + /// omitted, produces 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 List BuildSanList(Dictionary san, string csr, string subject) + => BuildSanList(san, csr, subject, dnsOnly: false, out _); + + /// + /// with an explicit + /// DNS-only mode for callers whose wire field cannot carry non-DNS SANs at all — the V2 + /// SSL UCC additionalDomains path. With set, + /// non-DNS entries are removed before any logging, regardless of + /// (a V1-only switch), and handed back via + /// so the caller can log its own accurate message. The + /// V1-worded "DROPPED because SubmitNonDnsSans is false" / "submitted rather than dropped" + /// warnings are only emitted when is false. /// - private static List BuildSanList(Dictionary san) + private List BuildSanList( + Dictionary san, string csr, string subject, + bool dnsOnly, out List excludedNonDns) { - if (san == null || san.Count == 0) + excludedNonDns = new List(); + + // "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 result = CollectRequestedSanEntries(san, csr, out var fromCsrKeys, out var skippedCsrTags); + + // Email SAN values masked unless LogSensitiveRequestData is on. + string FormatSans(IEnumerable sans) => + LogSanitizer.FormatSans(sans, _config.LogSensitiveRequestData); + + 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 (product 844): 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. + // + // NOTE (unverified): production behavior for this case has not been confirmed 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. Logging + // must reflect what is actually submitted, not what was initially collected: with + // SubmitNonDnsSans=false, logging against the pre-filter set would claim a SAN was + // added to the order when it was in fact dropped — a self-contradicting audit record + // for the same enrollment. + var nonDns = result.Where(s => !string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (dnsOnly) + { + // DNS-only caller (V2 SSL UCC additionalDomains): non-DNS SANs can + // never reach the wire there, whatever SubmitNonDnsSans says, so exclude them + // here — before the logging below — and leave the wording to the caller, which + // knows which field they were excluded from. No V1-worded warning is raised. + excludedNonDns = nonDns; + result = result + .Where(s => string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)) + .ToList(); + nonDns = new List(); + + if (result.Count == 0) + return null; + } + else 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; + } + + // 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 dnsOnly is false and SubmitNonDnsSans is true (both + // other cases emptied nonDns 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; + } + /// + /// The SAN source rule shared by and + /// (extracted here so both honour it + /// identically): the gateway-supplied SAN + /// dictionary (types normalized by ), falling back to the CSR's own + /// subjectAltName extension only when the dictionary is itself null — see + /// for why this is a fallback, not a union. Entries are trimmed + /// and de-duplicated by type+value. No filtering and no logging happens here. + /// + /// "type|value" keys of the entries that came from the CSR fallback. + /// GeneralName tags present in the CSR that have no string rendering. + private static List CollectRequestedSanEntries( + Dictionary san, string csr, + out HashSet fromCsrKeys, out List skippedCsrTags) + { 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); + var csrKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - // AnyCA passes SANs keyed by type name (e.g. "Dns", "Ip", "Email", "Uri") - foreach (var kvp in san) + void Add(string type, string value, bool fromCsr = false) { - string sanType = MapSanType(kvp.Key); - if (kvp.Value == null) continue; + 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) csrKeys.Add(key); + } - 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. + skippedCsrTags = new List(); + if (san == null) + { + var csrSans = ExtractSanEntriesFromCsr(csr, out skippedCsrTags); + foreach (var csrSan in csrSans) + Add(csrSan.Type, csrSan.Value, fromCsr: true); + } + + fromCsrKeys = csrKeys; + return result; + } + + /// + /// Resolves the additionalHosts list for a V2 private-pki order. Spec + /// ("Private PKI Certificates" -> Create - Intranet SSL): "additionalHosts[] - SAN + /// list (DNS names or IPv4 / IPv6)"; the example body sends + /// ["portal.acme.local", "reports.acme.local", "10.0.0.50"] with the primary host in + /// hostname, not repeated here. + /// + /// - Source: — the same gateway-dictionary-with- + /// CSR-fallback rule the SSL UCC path uses via . + /// - DNS and IP SANs are both submitted. Unlike SSL's FQDN-only additionalDomains, + /// IP literals are native to this field, so the V1-era SubmitNonDnsSans switch + /// (whose purpose is keeping SANs CERTInext cannot validate out of a domain-name field) + /// is deliberately not consulted — dropping a requested IP SAN here would silently issue + /// a certificate without it. + /// - Email/URI SANs (and CSR GeneralName types with no string form) have no place in a + /// host list: excluded with a warning that names only their types (an email SAN value is + /// personal data). + /// - The primary is excluded; duplicates collapse + /// case-insensitively. Returns null (field omitted) when nothing remains. + /// + private List BuildPrivatePkiAdditionalHosts( + Dictionary san, string csr, string subject, string hostname) + { + var requested = CollectRequestedSanEntries(san, csr, out var fromCsrKeys, out var skippedCsrTags); + + if (skippedCsrTags.Count > 0) + { + _logger.LogWarning( + "{Count} SAN(s) in the CSR use a type that cannot be represented as a host name or IP " + + "address and were not submitted (ASN.1 GeneralName tag(s): {Tags}). V2 private-pki " + + "additionalHosts carries DNS names and IPv4/IPv6 addresses only, so these cannot appear " + + "on the issued certificate. Subject={Subject}", + skippedCsrTags.Count, string.Join(", ", skippedCsrTags), LogSanitizer.Strip(subject)); + } + + bool IsHostType(SanEntry s) => + string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase) + || string.Equals(s.Type, "ip", StringComparison.OrdinalIgnoreCase); + + var unsupported = requested.Where(s => !IsHostType(s)).ToList(); + if (unsupported.Count > 0) + { + _logger.LogWarning( + "EnrollV2Async: {Count} requested SAN(s) are neither DNS names nor IP addresses and cannot " + + "be submitted via V2 private-pki additionalHosts (DNS names or IPv4/IPv6 only) — they will " + + "NOT appear on the issued certificate. Types=[{Types}]", + unsupported.Count, string.Join(", ", unsupported.Select(s => s.Type))); + } + + var hosts = requested + .Where(IsHostType) + .Select(s => s.Value?.Trim()) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Where(v => !string.Equals(v, hostname, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + // Gateway- and CSR-sourced entries are mutually exclusive by construction (the CSR is + // only read when the gateway dictionary is null), so when any entry came from the CSR, + // every surviving host did. Same wiring-smell warning BuildSanList raises for SSL. + if (fromCsrKeys.Count > 0 && hosts.Count > 0) + { + _logger.LogWarning( + "Command supplied no SAN data for this enrollment; {Count} SAN(s) present in the CSR " + + "have been added to the private-pki order's additionalHosts instead. Review the " + + "enrollment pattern / template SAN configuration. Subject={Subject}", + hosts.Count, LogSanitizer.Strip(subject)); + } + + return hosts.Count == 0 ? null : hosts; } 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 would + /// assign a type string ("directoryname", "registeredid", ...) to tags that always return a + /// null value here anyway — the type never reaches 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 = "") { @@ -2007,9 +6370,47 @@ private static string GetStringValue( } /// - /// Extracts the X.509 serial number from a PEM-encoded certificate for inclusion - /// in audit log entries. Returns "(parse-error)" rather than throwing, so that a - /// logging failure never suppresses an audit record. + /// Validates that is an absolute URI using https, or http only + /// when the host is loopback (localhost/127.0.0.1/::1). Shared by every config field + /// that receives a credential on every outbound request — currently ApiUrl + /// (, ) and, in V1 OAuth + /// mode, OAuthTokenUrl (same two call sites) — so the http-cleartext rule can + /// never drift between connection-test time and actual startup. Does NOT check for + /// null/empty; callers that need a distinct "is required" message should check that + /// first and only call this helper once the value is known to be non-blank. + /// + /// null when passes; otherwise an actionable + /// error message naming . + private static string ValidateHttpsOrLoopbackUrl(string fieldName, string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri parsed)) + return $"'{fieldName}' is not a valid absolute URI."; + + if (!string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && !parsed.IsLoopback) + { + return $"'{fieldName}' must use https — credentials (the OAuth client secret or API " + + "key) are sent to this URL on every request, and http would transmit them in " + + "cleartext. http is only allowed for a loopback host (localhost/127.0.0.1/::1)."; + } + + return null; + } + + /// + /// Extracts the X.509 serial number of the *leaf* (first) certificate from a + /// PEM-encoded certificate — or from a PEM chain — for inclusion in audit log + /// entries. Returns "(parse-error)" rather than throwing, so that a logging + /// failure never suppresses an audit record. + /// + /// V2 enrollment/sync pass this a full chain PEM (: + /// leaf followed by intermediate blocks). Decoding that as a single base64 blob is + /// wrong on two counts: each PEM block carries its own base64 padding, so a '=' + /// from the leaf block lands mid-string once concatenated (Convert.FromBase64String + /// throws FormatException), and even if it didn't, the leaf and intermediate DER + /// would be mashed into one invalid ASN.1 structure. Reading block-by-block via + /// BouncyCastle's PemReader and stopping after the first block sidesteps both: + /// it returns exactly the leaf block's own decoded DER bytes, ignoring the rest. /// /// Implemented with BouncyCastle (per the project's crypto policy: all certificate /// and key handling goes through BouncyCastle, never BCL System.Security.Cryptography). @@ -2018,20 +6419,19 @@ private static string ExtractSerialFromPem(string pem) { try { - // Strip PEM headers and decode the DER bytes - string b64 = pem - .Replace("-----BEGIN CERTIFICATE-----", string.Empty) - .Replace("-----END CERTIFICATE-----", string.Empty) - .Replace("\r", string.Empty) - .Replace("\n", string.Empty) - .Trim(); - - if (string.IsNullOrWhiteSpace(b64)) + if (string.IsNullOrWhiteSpace(pem)) return "(empty-pem)"; - byte[] der = Convert.FromBase64String(b64); + using var stringReader = new System.IO.StringReader(pem); + var pemReader = new Org.BouncyCastle.Utilities.IO.Pem.PemReader(stringReader); + var pemObject = pemReader.ReadPemObject(); + if (pemObject == null) + return "(parse-error)"; // no "-----BEGIN"/"-----END" block found at all + if (pemObject.Content == null || pemObject.Content.Length == 0) + return "(empty-pem)"; // block markers present but body is empty + var parser = new Org.BouncyCastle.X509.X509CertificateParser(); - var cert = parser.ReadCertificate(der); + var cert = parser.ReadCertificate(pemObject.Content); if (cert == null) return "(parse-error)"; // Match X509Certificate2.SerialNumber's format precisely: uppercase hex, diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 43d0537..4e4f6cb 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// 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, @@ -175,9 +175,22 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = string.Empty, Type = "String" }, + [Constants.Config.RequestorDesignation] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Job title / role of the requestor (e.g. 'IT Administrator'). " + + "Sent in V2 orders' `requestor.designation` field. Free text with no CA-side " + + "enum. Left blank by default, in which case the field is omitted entirely " + + "from the order rather than sent with a default value.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, [Constants.Config.SignerPlace] = new PropertyConfigInfo { - Comments = "City or location of the subscriber agreement signer. Required by CERTInext for all orders.", + Comments = "City or location of the subscriber agreement signer (e.g. 'San Francisco, CA'). " + + "REQUIRED when UseV2Api is on: the V2 Subscriber Agreement sent with every SSL order " + + "requires it, so the connector cannot be saved with it blank. A per-template " + + "SignerPlace enrollment parameter overrides it.", Hidden = false, DefaultValue = string.Empty, Type = "String" @@ -209,8 +222,12 @@ public static Dictionary GetCAConnectorAnnotations() [Constants.Config.EmailNotifications] = new PropertyConfigInfo { Comments = "OPTIONAL: Whether CERTInext sends lifecycle-event emails to the requestor. " + - "\"1\" = enabled, \"0\" = silent (recommended for gateway-driven orders so end users " + - "aren't surprised by CA emails). Default: \"0\".", + "\"1\" = full notification set (V1 sends it as-is; V2 maps it to \"all\"). " + + "\"0\" = silent on both V1 and V2. Blank/unset " + + "stays silent on V1 (sent as \"0\") but is omitted on V2, so the CA's own " + + "default (\"all\", not silent) applies instead. Any other value fails V2 " + + "enrollment before any CA call. Default: \"0\" — V2 orders are silent by " + + "default, matching V1.", Hidden = false, DefaultValue = "0", Type = "String" @@ -256,6 +273,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. " + @@ -272,14 +302,58 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = true, Type = "Boolean" }, + [Constants.Config.LogSensitiveRequestData] = new PropertyConfigInfo + { + Comments = "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: the Trace-level " + + "request/response bodies logged for every CA call 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') and the enrollment log line omits the " + + "requester name entirely. Credentials (API keys, 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 " + + "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. 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, + Type = "Number" + }, [Constants.Config.DcvEnabled] = new PropertyConfigInfo { Comments = "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.", + "Default: true.", Hidden = false, - DefaultValue = false, + DefaultValue = true, Type = "Boolean" }, [Constants.Config.DcvTxtRecordTemplate] = new PropertyConfigInfo @@ -358,6 +432,34 @@ public static Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = Constants.Dcv.DefaultSyncMaxPerPass, Type = "Number" + }, + + // ----------------------------------------------------------------------- + // V2 API settings — only required when UseV2Api = true + // ----------------------------------------------------------------------- + + [Constants.ConfigV2.UseV2Api] = new PropertyConfigInfo + { + Comments = "OPTIONAL: When true, the plugin routes Enroll / GetSingleRecord / Revoke / Synchronize " + + "through the CERTInext V2 REST API (/api/certinext/v2/), including V2 " + + "/reports/orders for Synchronize. Requires ApiUrl (the V2 base URL in this mode) " + + "plus OAuthClientId and OAuthClientSecret. V1 credentials (ApiKey/AccountNumber/AuthMode) " + + "are not required when this is true. Default: false (V1 API).", + Hidden = false, + DefaultValue = false, + Type = "Boolean" + }, + [Constants.Config.V2SyncLookbackHours] = new PropertyConfigInfo + { + Comments = "OPTIONAL (V2 mode only): during an incremental Synchronize, the plugin queries V2 " + + "/reports/orders with a 'from' date of (lastSync minus this many hours) rather than " + + "exactly lastSync. Whether the API's from/to filter " + + "brackets order-placement date or issuance date is not documented; a lookback window " + + "ensures an order created before lastSync but issued afterward (e.g. a slow DCV order) " + + $"still surfaces on the next incremental pass. Ignored when UseV2Api is false. Default: {Constants.ApiV2.DefaultSyncLookbackHours}.", + Hidden = false, + DefaultValue = Constants.ApiV2.DefaultSyncLookbackHours, + Type = "Number" } }; } @@ -373,8 +475,11 @@ public static Dictionary GetTemplateParameterAnnotat [Constants.EnrollmentParam.ProductCode] = new PropertyConfigInfo { Comments = "OPTIONAL: Override the numeric CERTInext product code for this template. " + - "When omitted, the default production code for the selected product is used automatically " + - "(e.g. DV SSL → 838). Set this explicitly when targeting sandbox or a non-standard code.", + "When omitted: on the V1 API, the default production code for the selected product " + + "is used automatically; on the V2 API, the code is instead resolved live from the " + + "CERTInext product catalog by matching the selected product, so it stays correct " + + "even though V2 catalog numbering varies by account. Set this explicitly when " + + "targeting sandbox or a non-standard code.", Hidden = false, DefaultValue = string.Empty, Type = "String" @@ -406,8 +511,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" @@ -447,7 +552,7 @@ public static Dictionary GetTemplateParameterAnnotat }, [Constants.EnrollmentParam.DomainName] = new PropertyConfigInfo { - Comments = "OPTIONAL: Primary domain for SSL/TLS orders. " + + Comments = "OPTIONAL: Primary domain for SSL/TLS orders (for V2 private-pki orders, the primary hostname). " + "Derived from the CSR CN if omitted.", Hidden = false, DefaultValue = string.Empty, @@ -476,6 +581,32 @@ public static Dictionary GetTemplateParameterAnnotat Hidden = false, DefaultValue = string.Empty, Type = "String" + }, + + // ----------------------------------------------------------------------- + // V2 API enrollment parameters (only used when UseV2Api = true) + // ----------------------------------------------------------------------- + + [Constants.EnrollmentParam.ProductFamily] = new PropertyConfigInfo + { + Comments = "V2 API ONLY: Product family for this template. " + + "Accepted values: 'ssl' (default), 'private-pki', 'signature'. " + + "Maps to the corresponding V2 resource path (/api/certinext/v2/{family}-certificates/). " + + "'private-pki' requires an explicit ProductCode and a Private PKI ProductVariant. " + + "'signature' (Document Signer) enrollment is not yet supported.", + Hidden = false, + DefaultValue = "ssl", + Type = "String" + }, + [Constants.EnrollmentParam.ProductVariant] = new PropertyConfigInfo + { + Comments = "V2 API ONLY: Product variant sent in the V2 order body. " + + "ProductFamily 'ssl': 'dv' (default), 'ov', 'ev'. " + + "ProductFamily 'private-pki': 'intranet-ssl' or 'igtf-host' (required; no default). " + + "Must match the variant associated with the configured product code.", + Hidden = false, + DefaultValue = "dv", + Type = "String" } }; } @@ -594,6 +725,13 @@ public class CERTInextConfig [JsonPropertyName("RequestorMobileNumber")] public string RequestorMobileNumber { get; set; } = string.Empty; + /// + /// Default requestor job title / role. Blank by default; when blank, the V2 order's + /// requestor.designation field is omitted rather than sent with any default value. + /// + [JsonPropertyName("RequestorDesignation")] + public string RequestorDesignation { get; set; } = string.Empty; + /// Subscriber agreement signer place (city/location). Required by CERTInext. [JsonPropertyName("SignerPlace")] public string SignerPlace { get; set; } = string.Empty; @@ -639,7 +777,12 @@ public class CERTInextConfig [JsonPropertyName("AccountingModel")] public string AccountingModel { get; set; } = "2"; - /// "1" = enable lifecycle emails to requestor, "0" = silent (default). + /// + /// "1" = full notification set (V1 sends it as-is; V2 maps to "all"). "0" = silent on + /// both V1 and V2 (default). Blank stays silent on V1 (sent + /// as "0") but is omitted on V2, letting the CA's own default ("all") apply instead. Any + /// other value fails V2 enrollment before any CA call. + /// [JsonPropertyName("EmailNotifications")] public string EmailNotifications { get; set; } = "0"; @@ -666,12 +809,46 @@ 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; [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 — plus complete CA request/response + /// bodies into the gateway's log files. Intended only 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 replaced with "***REDACTED***" (email values are masked but + /// keep their domain, e.g. "j***@example.com") and the enrollment log line omits the + /// requester name entirely. Credentials (API keys, 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 // ----------------------------------------------------------------------- @@ -679,10 +856,10 @@ public class CERTInextConfig /// /// When true, the plugin will run DNS DCV for orders that require it during enrollment. /// Requires IDomainValidatorFactory to be injected by the gateway (available from - /// IAnyCAPlugin 3.3.0-prerelease). Default: false. + /// IAnyCAPlugin 3.3.0). Default: true. /// [JsonPropertyName("DcvEnabled")] - public bool DcvEnabled { get; set; } = false; + public bool DcvEnabled { get; set; } = true; /// /// Format string for the TXT record hostname. {0} is replaced with the domain. @@ -695,6 +872,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; @@ -746,6 +940,31 @@ public class CERTInextConfig [JsonPropertyName("DcvSyncMaxPerPass")] public int DcvSyncMaxPerPass { get; set; } = Constants.Dcv.DefaultSyncMaxPerPass; + // ----------------------------------------------------------------------- + // V2 API settings + // ----------------------------------------------------------------------- + + /// + /// When true, Enroll / GetSingleRecord / Revoke / Synchronize use the CERTInext V2 REST + /// API. In this mode is the V2 base URL (e.g. + /// https://sandbox-us-api.certinext.io, no trailing path suffix) and V2 OAuth2 auth reuses + /// / . V1-only credentials + /// (, , ) are not + /// required when this is true. Default: false. + /// + [JsonPropertyName("UseV2Api")] + public bool UseV2Api { get; set; } = false; + + /// + /// V2 mode only: during an incremental Synchronize, query V2 /reports/orders with a + /// 'from' date of (lastSync minus this many hours) rather than exactly lastSync — see + /// (the + /// from/to filter's order-date-vs-issue-date semantics are not documented). + /// Ignored when is false. Default: 72. + /// + [JsonPropertyName("V2SyncLookbackHours")] + public int V2SyncLookbackHours { get; set; } = Constants.ApiV2.DefaultSyncLookbackHours; + /// /// Returns the effective DCV timeout, preferring the environment variable over the /// config field so operators can adjust the ceiling without a connector reconfiguration. @@ -782,5 +1001,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/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 255b65a..3a5c15a 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// 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, @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Runtime.CompilerServices; using System.Text; @@ -14,6 +15,7 @@ using System.Threading; using System.Threading.Tasks; using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; using Keyfactor.Extensions.CAPlugin.CERTInext.Models; using Keyfactor.Logging; using Microsoft.Extensions.Logging; @@ -41,11 +43,17 @@ public class CERTInextClient : ICERTInextClient, IDisposable private readonly CERTInextConfig _config; private readonly RestClient _http; - // OAuth2 token cache — refreshed when expired + // OAuth2 token cache — refreshed when expired (V1) private string _cachedToken; private DateTime _tokenExpiry = DateTime.MinValue; private readonly SemaphoreSlim _tokenLock = new SemaphoreSlim(1, 1); + // V2 API HTTP client and token cache + private readonly RestClient _httpV2; + private string _v2Token; + private DateTime _v2TokenExpiry = DateTime.MinValue; + private readonly SemaphoreSlim _v2TokenLock = new SemaphoreSlim(1, 1); + // --------------------------------------------------------------------------- // Construction // --------------------------------------------------------------------------- @@ -67,6 +75,20 @@ public CERTInextClient(CERTInextConfig config) }; _http = new RestClient(options); + + // V2 client — only constructed when V2 is enabled and ApiUrl is set. A single ApiUrl + // serves both modes (its meaning follows UseV2Api); + // in V2 mode it is the V2 base URL (no trailing path suffix). + // No authenticator: tokens are injected per-request via BuildV2RequestAsync. + if (config.UseV2Api && !string.IsNullOrWhiteSpace(config.ApiUrl)) + { + var v2Options = new RestClientOptions(config.ApiUrl.TrimEnd('/')) + { + ThrowOnAnyError = false, + Timeout = TimeSpan.FromSeconds(120) + }; + _httpV2 = new RestClient(v2Options); + } } // --------------------------------------------------------------------------- @@ -77,6 +99,8 @@ public void Dispose() { _http?.Dispose(); _tokenLock?.Dispose(); + _httpV2?.Dispose(); + _v2TokenLock?.Dispose(); } // --------------------------------------------------------------------------- @@ -186,9 +210,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, + // Untyped by now: an email SAN submitted here is masked unless + // LogSensitiveRequestData is on. + LogSanitizer.FormatUntypedSans(certInfo?.AdditionalDomains, _config.LogSensitiveRequestData)); GenerateOrderResponse result = null; RestResponse resp = null; @@ -213,10 +248,17 @@ 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}", + ApplyLoggingRedaction(jsonBody, _config.LogSensitiveRequestData)); + req.AddJsonBody(jsonBody); 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 +274,26 @@ 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 (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.", + 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. " + + "See gateway logs for details."); + } + result = DeserializeOrThrow(resp, "place order"); if (result.Meta != null && !result.Meta.IsSuccess) @@ -259,6 +321,31 @@ public async Task PlaceOrderAsync( continue; // retry } + // EMS-947 "Duplicate requestTxn": CERTInext already received an order for this + // transaction. The non-idempotent-retry handling above means this should not 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). " + + "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, + 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 " + + "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 +387,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 +399,23 @@ 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 (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( + "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."); } @@ -354,6 +460,8 @@ 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)); // A meta status of "0" with errorCode EMS-913 or similar means the order was not found if (result.Meta != null && !result.Meta.IsSuccess) @@ -710,26 +818,75 @@ public async Task RenewCertificateAsync( throw new KeyNotFoundException($"Cannot renew: prior order '{certificateId}' was not found in CERTInext."); } - // We don't have the product code from TrackOrder — build an order using - // the config defaults and the CSR from the renewal request. + // 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 still gets a domain. + // 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)); + } + + // 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", + // Mirrors BuildOrderRequestFromLegacyEnrollRequest — omit when blank so the + // order falls back to the unvetted/ungroup path, same as new enrollments. + DelegationInformation = !string.IsNullOrWhiteSpace(_config.GroupNumber) + ? new DelegationInformation { GroupNumber = _config.GroupNumber } + : null, RequestorInformation = new RequestorInformation { RequestorName = request.RequesterName ?? _config.RequestorName, RequestorEmail = request.RequesterEmail ?? _config.RequestorEmail, RequestorIsdCode = _config.RequestorIsdCode ?? "1", - RequestorMobileNumber = _config.RequestorMobileNumber ?? string.Empty + RequestorMobileNumber = _config.RequestorMobileNumber ?? string.Empty, + RequestorDesignation = string.IsNullOrWhiteSpace(_config.RequestorDesignation) ? null : _config.RequestorDesignation.Trim() + }, + TechnicalPointOfContact = new TechnicalPointOfContact + { + TpcName = string.IsNullOrWhiteSpace(_config.TechnicalContactName) + ? (request.RequesterName ?? _config.RequestorName) + : _config.TechnicalContactName, + TpcEmail = string.IsNullOrWhiteSpace(_config.TechnicalContactEmail) + ? (request.RequesterEmail ?? _config.RequestorEmail) + : _config.TechnicalContactEmail, + TpcIsdCode = string.IsNullOrWhiteSpace(_config.TechnicalContactIsdCode) + ? (string.IsNullOrWhiteSpace(_config.RequestorIsdCode) ? "1" : _config.RequestorIsdCode) + : _config.TechnicalContactIsdCode, + TpcMobileNumber = string.IsNullOrWhiteSpace(_config.TechnicalContactMobileNumber) + ? (_config.RequestorMobileNumber ?? string.Empty) + : _config.TechnicalContactMobileNumber }, 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() @@ -1143,6 +1300,868 @@ private static string GenerateTxnId() /// /// Returns a valid OAuth2 access token, refreshing it if expired. Thread-safe. /// + // --------------------------------------------------------------------------- + // ICERTInextClient — V2 REST API methods + // --------------------------------------------------------------------------- + + /// + public async Task GetAuthMeV2Async(CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + var req = await BuildV2RequestAsync(Constants.ApiV2.AuthMePath, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + ThrowOnV2Failure(resp, "auth/me"); + var result = DeserializeV2OrThrow(resp, "auth/me"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task PingV2Async(CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + var me = await GetAuthMeV2Async(ct); + Logger.LogInformation( + "CERTInext V2 ping successful. AccountNumber={AccountNumber}, AuthType={AuthType}", + me.AccountNumber, me.AuthType); + Logger.MethodExit(LogLevel.Trace); + } + + /// + public async Task PlaceOrderV2Async( + string productFamilySlug, + string productCode, + V2CreateSslOrderRequest request, + CancellationToken ct = default) + { + // The SSL-shaped body must only ever go to the SSL endpoint: substituting the slug + // into the URL unchecked would let a private-pki/signature template silently send + // this body to the wrong family's create endpoint. + if (!string.Equals(productFamilySlug, Constants.ApiV2.FamilySsl, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException( + $"A V2 SSL/TLS order body can only be sent to the '{Constants.ApiV2.FamilySsl}' family, " + + $"not '{productFamilySlug}'. Use the Private PKI or Document Signer PlaceOrderV2Async overload.", + nameof(productFamilySlug)); + + return await PlaceOrderV2CoreAsync(Constants.ApiV2.SslCertificatesPath, productCode, request, ct); + } + + /// + public Task PlaceOrderV2Async( + string productCode, + V2CreatePrivatePkiOrderRequest request, + CancellationToken ct = default) + => PlaceOrderV2CoreAsync(Constants.ApiV2.PrivatePkiCertificatesPath, productCode, request, ct); + + /// + public Task PlaceOrderV2Async( + string productCode, + V2CreateSignatureOrderRequest request, + CancellationToken ct = default) + => PlaceOrderV2CoreAsync(Constants.ApiV2.SignatureCertificatesPath, productCode, request, ct); + + /// + /// Shared V2 create-order transport for every product family: POSTs the + /// family-specific body to with the X-Product-Code and + /// Idempotency-Key headers. Request and response bodies are only ever logged (Trace) + /// through , so credentials are always scrubbed and + /// requestor/subject PII is scrubbed unless LogSensitiveRequestData is on. + /// X-Product-Code is the spec's "Optional override" on SSL create (and is sent the same + /// way for Private PKI / Document Signer): a null/blank + /// omits the header entirely rather than sending it empty, + /// which is not itself a valid override value. + /// + private async Task PlaceOrderV2CoreAsync( + string path, + string productCode, + TRequest request, + CancellationToken ct) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string idempotencyKey = Guid.NewGuid().ToString(); + var req = await BuildV2RequestAsync(path, Method.Post, ct, idempotencyKey); + if (!string.IsNullOrWhiteSpace(productCode)) + req.AddHeader("X-Product-Code", productCode); + string json = JsonSerializer.Serialize(request, GetJsonOptions()); + Logger.LogTrace("PlaceOrderV2Async request payload: {Payload}", + ApplyLoggingRedaction(json, _config.LogSensitiveRequestData)); + req.AddJsonBody(json); + var sw = System.Diagnostics.Stopwatch.StartNew(); + var resp = await _httpV2.ExecuteAsync(req, ct); + sw.Stop(); + Logger.LogInformation( + "CERTInext V2 API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}", + path, (int)resp.StatusCode, sw.ElapsedMilliseconds); + Logger.LogTrace("PlaceOrderV2Async response: {Body}", + ApplyLoggingRedaction(resp.Content, _config.LogSensitiveRequestData)); + ThrowOnV2Failure(resp, "V2 place order"); + var result = DeserializeV2OrThrow(resp, "V2 place order"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task SubmitCsrV2Async( + string productFamilySlug, + string orderId, + string csrPem, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string path = BuildV2OrderPath(productFamilySlug, orderId) + "/csr"; + var req = await BuildV2RequestAsync(path, Method.Put, ct); + var body = new V2SubmitCsrRequest { Csr = csrPem }; + req.AddJsonBody(JsonSerializer.Serialize(body, GetJsonOptions())); + var resp = await _httpV2.ExecuteAsync(req, ct); + ThrowOnV2Failure(resp, "V2 submit CSR"); + Logger.MethodExit(LogLevel.Trace); + } + + /// + public async Task TrackOrderV2Async( + string productFamilySlug, + string orderId, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string path = BuildV2OrderPath(productFamilySlug, orderId); + var req = await BuildV2RequestAsync(path, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + if (resp.StatusCode == HttpStatusCode.NotFound) + { + Logger.MethodExit(LogLevel.Trace); + throw new KeyNotFoundException($"V2 order '{orderId}' not found in family '{productFamilySlug}'."); + } + ThrowOnV2Failure(resp, "V2 track order"); + var result = DeserializeV2OrThrow(resp, "V2 track order"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task DownloadCertificateV2Async( + string productFamilySlug, + string orderId, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string path = BuildV2OrderPath(productFamilySlug, orderId) + "/certificate"; + var req = await BuildV2RequestAsync(path, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + if (resp.StatusCode == HttpStatusCode.NotFound) + { + Logger.MethodExit(LogLevel.Trace); + throw new KeyNotFoundException($"V2 certificate for order '{orderId}' not found in family '{productFamilySlug}'."); + } + ThrowOnV2Failure(resp, "V2 download certificate"); + var result = DeserializeV2OrThrow(resp, "V2 download certificate"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task RevokeOrderV2Async( + string productFamilySlug, + string orderId, + V2RevokeRequest request, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string idempotencyKey = Guid.NewGuid().ToString(); + string path = BuildV2OrderPath(productFamilySlug, orderId) + "/revoke"; + var req = await BuildV2RequestAsync(path, Method.Post, ct, idempotencyKey); + req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); + var resp = await _httpV2.ExecuteAsync(req, ct); + if (resp.StatusCode == HttpStatusCode.NotFound) + { + // Compliance finding: a revoke denial must leave an audit trail (SOX/SOC2 + // who/what/when/outcome) even though this branch returns before + // ThrowOnV2Failure/LogV2ApiFailure would otherwise run it. Log explicitly with + // the order/family identity plus the usual HTTP-status+redacted-body line. + Logger.LogWarning( + "V2 revoke denied — order not found or not in a revokable state. " + + "OrderId={OrderId}, ProductFamily={Family}, HttpStatus={HttpStatus}", + orderId, productFamilySlug, (int)resp.StatusCode); + LogV2ApiFailure("V2 revoke order", resp, LogLevel.Warning); + Logger.MethodExit(LogLevel.Trace); + // Per the V2 spec ("Revoke Certificate", 404 response): "Order not found + // or not in a revokable state." This is deliberately ambiguous on the + // wire — callers that have already confirmed the order lives in + // `productFamilySlug` (e.g. via TrackOrderV2Async) should treat a 404 + // here as "not revokable", not as a family miss. + throw new KeyNotFoundException( + $"V2 order '{orderId}' in family '{productFamilySlug}' not found or not in a revokable state."); + } + if (resp.StatusCode == (HttpStatusCode)422) + { + // Label by whatever EMS code/detail CERTInext actually returned rather + // than presuming "not in issued state" — 422s here cover multiple + // distinct conditions (EMS-969 revoke reason ID missing, sandbox-timing + // "Certificate Request still being processed", etc.). + string detail = ExtractV2ErrorMessage(resp.Content, "V2 revoke"); + // Compliance finding: same audit-trail requirement as the 404 branch above — + // this also returns before ThrowOnV2Failure would otherwise log it. + Logger.LogWarning( + "V2 revoke rejected. OrderId={OrderId}, ProductFamily={Family}, HttpStatus={HttpStatus}, " + + "Detail={Detail}", + orderId, productFamilySlug, (int)resp.StatusCode, detail); + LogV2ApiFailure("V2 revoke order", resp, LogLevel.Warning); + throw new InvalidOperationException($"V2 revoke rejected. {detail}"); + } + ThrowOnV2Failure(resp, "V2 revoke order"); + Logger.MethodExit(LogLevel.Trace); + } + + /// + public async Task CancelOrderV2Async( + string productFamilySlug, + string orderId, + string reason, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException( + "A cancel reason is required (CERTInext rejects an empty one with EMS-984).", nameof(reason)); + EnsureV2Client(); + string idempotencyKey = Guid.NewGuid().ToString(); + string path = BuildV2OrderPath(productFamilySlug, orderId) + "/cancel"; + var req = await BuildV2RequestAsync(path, Method.Post, ct, idempotencyKey); + req.AddJsonBody(JsonSerializer.Serialize(new V2CancelOrderRequest { Reason = reason }, GetJsonOptions())); + var sw = System.Diagnostics.Stopwatch.StartNew(); + var resp = await _httpV2.ExecuteAsync(req, ct); + sw.Stop(); + Logger.LogInformation( + "CERTInext V2 API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}", + path, (int)resp.StatusCode, sw.ElapsedMilliseconds); + if (resp.StatusCode == (HttpStatusCode)422) + { + // Spec "Cancel Order" 422: "order already in a terminal state" (e.g. already + // issued — use /revoke). Not an exception: the caller reports it as "not cancelled". + LogV2ApiFailure("V2 cancel order", resp, LogLevel.Warning); + Logger.MethodExit(LogLevel.Trace); + return V2CancelOrderOutcome.AlreadyTerminal; + } + ThrowOnV2Failure(resp, "V2 cancel order"); + Logger.MethodExit(LogLevel.Trace); + return V2CancelOrderOutcome.Cancelled; + } + + /// + public async Task ResolveAndTrackOrderV2Async( + string orderId, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + var (_, status) = await ResolveV2OrderFamilyAsync(orderId, ct); + Logger.MethodExit(LogLevel.Trace); + return status; + } + + /// + public async Task<(string family, V2OrderStatusResponse status)> ResolveAndTrackOrderV2WithFamilyAsync( + string orderId, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + var result = await ResolveV2OrderFamilyAsync(orderId, ct); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task ResolveAndDownloadCertificateV2Async( + string orderId, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + var (family, _) = await ResolveV2OrderFamilyAsync(orderId, ct); + var cert = await DownloadCertificateV2Async(family, orderId, ct); + Logger.MethodExit(LogLevel.Trace); + return cert; + } + + /// + public async Task GetDcvV2Async(string orderId, string familySlug, CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string path = $"/api/certinext/v2/{familySlug}/{orderId}/dcv"; + var req = await BuildV2RequestAsync(path, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + Logger.LogInformation( + "CERTInext V2 API call: Method=GET, Path={Path}, HttpStatus={Status}", + path, (int)resp.StatusCode); + ThrowOnV2Failure(resp, "V2 get DCV challenge"); + var result = DeserializeV2OrThrow(resp, "V2 get DCV challenge"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task GetDcvV2Async( + string orderId, string domain, string familySlug, CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + // Per-domain scope: returns a distinct token per SAN on a UCC order. + string path = $"/api/certinext/v2/{familySlug}/{orderId}/dcv?domain=" + Uri.EscapeDataString(domain); + var req = await BuildV2RequestAsync(path, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + Logger.LogInformation( + "CERTInext V2 API call: Method=GET, Path={Path}, HttpStatus={Status}", + path, (int)resp.StatusCode); + ThrowOnV2Failure(resp, "V2 get DCV challenge (per-domain)"); + var result = DeserializeV2OrThrow(resp, "V2 get DCV challenge (per-domain)"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task VerifyDcvV2Async(string orderId, string domain, string familySlug, CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string path = $"/api/certinext/v2/{familySlug}/{orderId}/dcv/verify"; + var req = await BuildV2RequestAsync(path, Method.Post, ct); + var body = new V2DcvVerifyRequest { Domain = domain, Method = "dns-txt" }; + req.AddJsonBody(JsonSerializer.Serialize(body, GetJsonOptions())); + var resp = await _httpV2.ExecuteAsync(req, ct); + Logger.LogInformation( + "CERTInext V2 API call: Method=POST, Path={Path}, HttpStatus={Status}", + path, (int)resp.StatusCode); + + if (resp.StatusCode == (HttpStatusCode)422) + { + string detail = ExtractV2ErrorMessage(resp.Content, "V2 verify DCV"); + throw new InvalidOperationException( + $"V2 DCV verification failed for order '{orderId}', domain '{domain}'. {detail}"); + } + + // 204 No Content is a valid success — return an empty verified response + if (resp.StatusCode == System.Net.HttpStatusCode.NoContent || string.IsNullOrWhiteSpace(resp.Content)) + { + Logger.MethodExit(LogLevel.Trace); + return new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }; + } + + ThrowOnV2Failure(resp, "V2 verify DCV"); + var result = DeserializeV2OrThrow(resp, "V2 verify DCV"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task> GetProductDetailsV2Async(CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + + // Scope the catalog to the connector's configured billing group, mirroring V1's + // GetProductDetailsAsync (ProductDetailsFilter.GroupNumber). Omitted entirely when + // unconfigured so the account's default group is used, same as V1. + string path = Constants.ApiV2.CatalogProductsPath; + if (!string.IsNullOrWhiteSpace(_config.GroupNumber)) + path += "?groupNumber=" + Uri.EscapeDataString(_config.GroupNumber); + + var req = await BuildV2RequestAsync(path, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + Logger.LogInformation( + "CERTInext V2 API call: Method=GET, Path={Path}, HttpStatus={Status}", + Constants.ApiV2.CatalogProductsPath, (int)resp.StatusCode); + ThrowOnV2Failure(resp, "V2 get product details"); + var result = ParseProductDetailsV2Response(resp.Content); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async IAsyncEnumerable ListOrdersV2Async( + string from = null, + string to = null, + int pageSize = Constants.Api.DefaultPageSize, + [EnumeratorCancellation] CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + + // Server clamps size to 100 and treats page=0 as page 1; the client always sends + // 1-based pages itself so that quirk never surfaces here. + int size = pageSize <= 0 + ? Constants.Api.DefaultPageSize + : Math.Min(pageSize, Constants.ApiV2.OrdersReportMaxPageSize); + + int page = 1; + int totalPages = int.MaxValue; // sentinel until the first response tells us + + while (true) + { + ct.ThrowIfCancellationRequested(); + + var query = new StringBuilder(); + query.Append(Constants.ApiV2.OrdersReportPath) + .Append("?page=").Append(page) + .Append("&size=").Append(size); + if (!string.IsNullOrWhiteSpace(from)) + query.Append("&from=").Append(Uri.EscapeDataString(from)); + if (!string.IsNullOrWhiteSpace(to)) + query.Append("&to=").Append(Uri.EscapeDataString(to)); + // Scope the orders report to the connector's configured billing group, mirroring + // V1's DelegationInformation.GroupNumber. Omitted entirely when unconfigured so + // the account's default group is used, same as V1. + if (!string.IsNullOrWhiteSpace(_config.GroupNumber)) + query.Append("&groupNumber=").Append(Uri.EscapeDataString(_config.GroupNumber)); + + var req = await BuildV2RequestAsync(query.ToString(), Method.Get, ct); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var resp = await _httpV2.ExecuteAsync(req, ct); + sw.Stop(); + + Logger.LogInformation( + "CERTInext V2 API call: Method=GET, Path={Path}, Page={Page}, HttpStatus={Status}, LatencyMs={Latency}", + Constants.ApiV2.OrdersReportPath, page, (int)resp.StatusCode, sw.ElapsedMilliseconds); + + ThrowOnV2Failure(resp, "V2 list orders"); + var listResp = DeserializeV2OrThrow(resp, $"V2 list orders page {page}"); + + var rows = listResp.Content; + if (rows == null || rows.Count == 0) + break; + + if (page == 1) + totalPages = listResp.TotalPages > 0 ? listResp.TotalPages : 1; + + Logger.LogDebug( + "V2 orders report: fetched page {Page}/{TotalPages} with {Count} rows (totalElements={Total}).", + page, totalPages, rows.Count, listResp.TotalElements); + + foreach (var row in rows) + yield return row; + + if (page >= totalPages) + break; + + page++; + } + + Logger.MethodExit(LogLevel.Trace); + } + + /// + /// Minimal, read-only escape hatch for probing V2 endpoints that don't yet have a + /// typed client method (e.g. /reports/orders, /domains during discovery). + /// Issues a GET against the V2 base URL using the same token/header machinery as the + /// typed V2 methods, and returns the raw status/content instead of throwing on + /// non-success so callers can inspect 4xx/5xx bodies directly. Intended for + /// integration-test spikes — prefer a typed method once the response shape is known. + /// + public async Task<(int StatusCode, string ContentType, string Content)> ProbeV2GetAsync( + string pathAndQuery, CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + var req = await BuildV2RequestAsync(pathAndQuery, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + Logger.LogInformation( + "CERTInext V2 probe call: Method=GET, Path={Path}, HttpStatus={Status}", + pathAndQuery, (int)resp.StatusCode); + Logger.MethodExit(LogLevel.Trace); + return ((int)resp.StatusCode, resp.ContentType, resp.Content); + } + + // --------------------------------------------------------------------------- + // V2 private helpers + // --------------------------------------------------------------------------- + + /// + /// Parses the GET /api/certinext/v2/catalog/products response into a flat + /// list. The endpoint may return a bare JSON array + /// or a JSON object that wraps the list under a known property name + /// ("products", "data", "items", or "catalog"). The sandbox account returns the SAME + /// nested category-envelope shape as V1's GetProductDetails — each top-level array element + /// is a category ("categoryName"/"categoryID"/"currencyType") containing its own + /// nested "products" array of {productCode, productName, productTypeID, ...}. The + /// spec's flat "productId" example shape is also handled as a fallback in case a + /// different account/API version returns it. Per-element, not per-response, so a + /// mixed response (unlikely but not contractually excluded) is still flattened. + /// + private List ParseProductDetailsV2Response(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return new List(); + + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + + JsonElement arr; + if (root.ValueKind == JsonValueKind.Array) + { + arr = root; + } + else if (root.ValueKind == JsonValueKind.Object) + { + // Log the top-level property names so the actual schema is visible in test output. + var keys = string.Join(", ", root.EnumerateObject().Select(p => p.Name)); + Logger.LogInformation( + "GetProductDetailsV2Async: response is a JSON object with top-level keys: [{Keys}]", keys); + + JsonElement? found = null; + foreach (string candidate in new[] { "products", "data", "items", "catalog" }) + { + if (root.TryGetProperty(candidate, out JsonElement candidateArr) && candidateArr.ValueKind == JsonValueKind.Array) + { + found = candidateArr; + break; + } + } + + if (found == null) + { + // No recognised array property found — surface the object keys in the exception + // so the caller/test can see the actual schema and create a proper DTO. + throw new InvalidOperationException( + $"V2 catalog/products returned an unexpected JSON object. Top-level keys: [{keys}]. " + + "Update ParseProductDetailsV2Response with the correct property name."); + } + + arr = found.Value; + } + else + { + throw new InvalidOperationException( + $"V2 catalog/products returned unexpected JSON kind: {root.ValueKind}."); + } + + var result = new List(); + foreach (var element in arr.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Object) + continue; + + if (element.TryGetProperty("products", out JsonElement nestedProducts) + && nestedProducts.ValueKind == JsonValueKind.Array) + { + // Nested category envelope — mirror V1's GetProductDetailsResponse.FlattenProducts(). + string categoryName = element.TryGetProperty("categoryName", out var cn) && cn.ValueKind == JsonValueKind.String + ? cn.GetString() + : null; + + foreach (var product in nestedProducts.EnumerateArray()) + { + if (product.ValueKind != JsonValueKind.Object) + continue; + + result.Add(new ProductDetail + { + ProductCode = product.TryGetProperty("productCode", out var pc) && pc.ValueKind == JsonValueKind.String ? pc.GetString() : null, + ProductName = product.TryGetProperty("productName", out var pn) && pn.ValueKind == JsonValueKind.String ? pn.GetString() : null, + ProductType = categoryName, + ProductTypeId = product.TryGetProperty("productTypeID", out var pt) + ? (pt.ValueKind == JsonValueKind.String ? pt.GetString() : pt.ToString()) + : null, + Active = true // the API only returns products available on the account + }); + } + } + else if (element.TryGetProperty("productCode", out _)) + { + // Flat row already shaped like ProductDetail. + result.Add(JsonSerializer.Deserialize(element.GetRawText(), GetJsonOptions())); + } + else if (element.TryGetProperty("productId", out var pid)) + { + // Flat row using the spec example's "productId" key instead of "productCode". + result.Add(new ProductDetail + { + ProductCode = pid.ValueKind == JsonValueKind.String ? pid.GetString() : pid.ToString(), + ProductName = element.TryGetProperty("productName", out var pn2) && pn2.ValueKind == JsonValueKind.String ? pn2.GetString() : null, + ProductType = element.TryGetProperty("masterProductName", out var mpn) && mpn.ValueKind == JsonValueKind.String ? mpn.GetString() : null, + ProductTypeId = element.TryGetProperty("productTypeID", out var pt2) + ? (pt2.ValueKind == JsonValueKind.String ? pt2.GetString() : pt2.ToString()) + : null, + Active = true + }); + } + else + { + Logger.LogWarning( + "GetProductDetailsV2Async: skipping catalog element with unrecognised shape. Keys: [{Keys}]", + string.Join(", ", element.EnumerateObject().Select(p => p.Name))); + } + } + + return result; + } + + private void EnsureV2Client() + { + if (_httpV2 == null) + throw new InvalidOperationException( + "V2 API client is not initialised. Ensure UseV2Api=true and ApiUrl is set in the connector configuration."); + } + + private static string BuildV2OrderPath(string productFamilySlug, string orderId) + => $"/api/certinext/v2/{productFamilySlug}/{orderId}"; + + /// + /// Probes all three V2 product families (SSL → Private PKI → Signature) to find + /// which one owns the given order ID. Returns the matching family slug and the + /// TrackOrder response. Throws if not found. + /// + private async Task<(string family, V2OrderStatusResponse status)> ResolveV2OrderFamilyAsync( + string orderId, + CancellationToken ct) + { + foreach (var family in new[] + { + Constants.ApiV2.FamilySsl, + Constants.ApiV2.FamilyPrivatePki, + Constants.ApiV2.FamilySignature + }) + { + try + { + var status = await TrackOrderV2Async(family, orderId, ct); + return (family, status); + } + catch (KeyNotFoundException) + { + // Not in this family — try the next one + } + } + throw new KeyNotFoundException($"V2 order '{orderId}' not found in any product family."); + } + + /// + /// Fetches or returns the cached V2 OAuth2 bearer token. + /// Uses standard client_credentials grant with form-encoded body. + /// Token is cached until 60 seconds before its expiry. + /// + private async Task GetOrRefreshV2TokenAsync(CancellationToken ct) + { + if (!string.IsNullOrEmpty(_v2Token) && DateTime.UtcNow < _v2TokenExpiry) + return _v2Token; + + await _v2TokenLock.WaitAsync(ct); + try + { + if (!string.IsNullOrEmpty(_v2Token) && DateTime.UtcNow < _v2TokenExpiry) + return _v2Token; + + Logger.LogInformation( + "V2 OAuth2 token acquisition started. ApiUrl={ApiUrl}, ClientId={ClientId}", + _config.ApiUrl, _config.OAuthClientId); + + string tokenUrl = _config.ApiUrl.TrimEnd('/') + Constants.ApiV2.TokenPath; + using var tokenClient = new RestClient(tokenUrl); + var tokenReq = new RestRequest(string.Empty, Method.Post); + tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded"); + tokenReq.AddParameter("grant_type", "client_credentials"); + tokenReq.AddParameter("client_id", _config.OAuthClientId); + tokenReq.AddParameter("client_secret", _config.OAuthClientSecret); + + var tokenResp = await tokenClient.ExecuteAsync(tokenReq, ct); + if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) + { + // SOX CC6.1: never log tokenResp.Content — may contain client_secret. + // Per the V2 spec's OAuth2 error table: 401 invalid_client = wrong client_id / + // client_secret (or a revoked key); 403 unauthorized_client = the key exists but + // was never generated in OAuth mode in the portal. These are distinct failure + // modes with distinct fixes, so each gets its own hint rather than sharing one. + if ((int)tokenResp.StatusCode == 401) + { + Logger.LogError( + "V2 OAuth2 token acquisition failed with 401 Unauthorized (invalid_client). " + + "ApiUrl={ApiUrl}, ClientId={ClientId}. " + + "Hint: the OAuthClientId or OAuthClientSecret is wrong, or the key was revoked in the portal.", + _config.ApiUrl, _config.OAuthClientId); + throw new Exception( + "V2 OAuth2 token request denied (401 Unauthorized, invalid_client). " + + "The OAuthClientId or OAuthClientSecret is incorrect, or the key was revoked. " + + "Regenerate the client secret in the CERTInext portal (Integration → REST APIs → OAuth2) " + + "and update the connector config. See gateway logs for details."); + } + if ((int)tokenResp.StatusCode == 403) + { + Logger.LogError( + "V2 OAuth2 token acquisition failed with 403 Forbidden (unauthorized_client). " + + "ApiUrl={ApiUrl}, ClientId={ClientId}. " + + "Hint: the access key exists but was not generated in OAuth mode in the portal.", + _config.ApiUrl, _config.OAuthClientId); + throw new Exception( + "V2 OAuth2 token request denied (403 Forbidden, unauthorized_client). " + + "The access key was not generated in OAuth mode. Recreate the key in the CERTInext " + + "portal (Integration → REST APIs → OAuth2) with the OAuth radio button selected. " + + "See gateway logs for details."); + } + Logger.LogError( + "V2 OAuth2 token acquisition failed. ApiUrl={ApiUrl}, ClientId={ClientId}, HttpStatus={Status}", + _config.ApiUrl, _config.OAuthClientId, (int)tokenResp.StatusCode); + throw new Exception( + $"Failed to obtain V2 OAuth2 token. HTTP {(int)tokenResp.StatusCode}. See gateway logs for details."); + } + + var tokenPayload = JsonSerializer.Deserialize(tokenResp.Content, GetJsonOptions()); + if (tokenPayload == null || string.IsNullOrEmpty(tokenPayload.AccessToken)) + { + Logger.LogError( + "V2 OAuth2 token response did not contain access_token. ApiUrl={ApiUrl}", + _config.ApiUrl); + throw new Exception("V2 OAuth2 token response did not contain an access_token."); + } + + _v2Token = tokenPayload.AccessToken; + _v2TokenExpiry = DateTime.UtcNow.AddSeconds(Math.Max(tokenPayload.ExpiresIn - 60, 30)); + + Logger.LogInformation( + "V2 OAuth2 token acquired. ApiUrl={ApiUrl}, ClientId={ClientId}, ExpiresAt={Expiry:u}", + _config.ApiUrl, _config.OAuthClientId, _v2TokenExpiry); + return _v2Token; + } + finally + { + _v2TokenLock.Release(); + } + } + + /// + /// Builds a V2 REST request with the Authorization: Bearer header populated from + /// the cached/refreshed V2 token. Optionally adds an Idempotency-Key header. + /// + private async Task BuildV2RequestAsync( + string path, + Method method, + CancellationToken ct, + string idempotencyKey = null) + { + string token = await GetOrRefreshV2TokenAsync(ct); + var req = new RestRequest(path, method); + req.AddHeader("Authorization", $"Bearer {token}"); + req.AddHeader("Accept", "application/json"); + if (!string.IsNullOrEmpty(idempotencyKey)) + req.AddHeader("Idempotency-Key", idempotencyKey); + return req; + } + + /// + /// Throws an appropriate exception for V2 API non-success responses. + /// Handles RFC 7807 problem+json and plain HTTP errors. + /// + // Instance (not static) — calls LogV2ApiFailure, which needs _config.LogSensitiveRequestData. + private void ThrowOnV2Failure(RestResponse resp, string operation) + { + if (resp.IsSuccessful) return; + + if (resp.StatusCode == HttpStatusCode.Unauthorized) + { + LogV2ApiFailure(operation, resp, LogLevel.Error); + throw new Exception($"V2 authentication failure during '{operation}'. HTTP 401. See gateway logs for details."); + } + + if (resp.StatusCode == HttpStatusCode.Forbidden) + { + LogV2ApiFailure(operation, resp, LogLevel.Error); + string hint = ExtractV2ErrorMessage(resp.Content, operation); + throw new Exception( + $"V2 access denied during '{operation}'. HTTP 403. {hint} " + + "If error code is EMS-2022, ensure OAuth2 is enabled in the CERTInext portal."); + } + + LogV2ApiFailure(operation, resp, LogLevel.Warning); + string msg = ExtractV2ErrorMessage(resp.Content, operation); + throw new Exception($"CERTInext V2 API error during '{operation}'. HTTP {(int)resp.StatusCode}. {msg}"); + } + + /// + /// Writes a structured log for a V2 API non-success response — matching the V1 + /// pattern but adapted for V2's RFC 7807 error shape. + /// Call immediately before throwing so the exception's "See gateway logs for details" + /// message has a corresponding structured entry in the gateway log. + /// + // Instance (not static) so it can read _config.LogSensitiveRequestData. + private void LogV2ApiFailure(string operation, RestResponse resp, LogLevel level = LogLevel.Warning) + { + string sanitizedBody = Truncate( + ApplyLoggingRedaction(resp?.Content, _config.LogSensitiveRequestData) ?? "(empty)", + LoggedResponseBodyCapBytes); + Logger.Log( + level, + "CERTInext V2 API non-success. Operation={Operation}, Method={Method}, Path={Path}, " + + "HttpStatus={HttpStatus}, ResponseBody={ResponseBody}", + operation, + resp?.Request?.Method.ToString() ?? "(unknown)", + resp?.Request?.Resource ?? "(unknown)", + (int?)resp?.StatusCode ?? 0, + sanitizedBody); + } + + /// + /// Parses an RFC 7807 problem+json body and returns a human-readable message. + /// Falls back to a generic message on parse failure. + /// + private static string ExtractV2ErrorMessage(string content, string operation) + { + if (string.IsNullOrWhiteSpace(content)) + return $"CERTInext V2 returned no body for '{operation}'."; + + string capped = content.Length > MaxErrorBodyBytes + ? content.Substring(0, MaxErrorBodyBytes) + : content; + + try + { + var problem = JsonSerializer.Deserialize(capped, GetJsonOptions()); + if (problem != null && (!string.IsNullOrWhiteSpace(problem.Detail) || !string.IsNullOrWhiteSpace(problem.Title) + || (problem.Errors != null && problem.Errors.Count > 0))) + { + string msg = $"{problem.Title}: {problem.Detail}".Trim(':').Trim(); + + // RFC 7807 per-field validation errors (spec's "errors[]", e.g. a 400 on + // order create naming exactly which field failed) — fold them into the + // message so the operator doesn't have to go dig the raw response out of + // the gateway log to find out which field CERTInext rejected. + if (problem.Errors != null && problem.Errors.Count > 0) + { + string fieldErrors = string.Join("; ", problem.Errors + .Where(e => !string.IsNullOrWhiteSpace(e?.Field) || !string.IsNullOrWhiteSpace(e?.Message)) + .Select(e => $"{e.Field}: {e.Message}".Trim(':').Trim())); + if (!string.IsNullOrWhiteSpace(fieldErrors)) + msg = string.IsNullOrWhiteSpace(msg) ? fieldErrors : $"{msg} [{fieldErrors}]"; + } + + if (!string.IsNullOrWhiteSpace(msg)) + return msg; + } + } + catch + { + // Not a problem+json body — fall through + } + + return $"See gateway logs for raw response. Operation='{operation}'."; + } + + private static T DeserializeV2OrThrow(RestResponse resp, string operation) where T : class + { + if (string.IsNullOrWhiteSpace(resp.Content)) + throw new Exception($"CERTInext V2 returned an empty body for '{operation}'."); + var result = JsonSerializer.Deserialize(resp.Content, GetJsonOptions()); + if (result == null) + throw new Exception($"CERTInext V2 returned a null/unrecognised body for '{operation}'."); + return result; + } + + // --------------------------------------------------------------------------- + // V1 token helper (unchanged) + // --------------------------------------------------------------------------- + private async Task GetOrRefreshTokenAsync(CancellationToken ct) { if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _tokenExpiry) @@ -1213,27 +2232,78 @@ 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++) + 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; - if (attempt < maxAttempts) + // 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( "CERTInext API returned {Status} on attempt {Attempt}/{Max} — retrying...", - (int)resp.StatusCode, attempt, maxAttempts); + (int)resp.StatusCode, attempt, attempts); } } @@ -1312,18 +2382,24 @@ 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; 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 @@ -1359,7 +2435,8 @@ private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollC RequestorName = requestorName, RequestorEmail = requestorEmail, RequestorIsdCode = requestorIsd, - RequestorMobileNumber = requestorMobile + RequestorMobileNumber = requestorMobile, + RequestorDesignation = string.IsNullOrWhiteSpace(_config.RequestorDesignation) ? null : _config.RequestorDesignation.Trim() }, SubscriptionDetails = new SubscriptionDetails { @@ -1369,8 +2446,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 }, @@ -1415,6 +2492,7 @@ private AgreementDetails BuildDefaultAgreementDetails() { AcceptAgreement = "1", SignerName = _config.RequestorName ?? "Keyfactor Gateway", + // Effectively dead fallback: SignerPlace defaults to "", not null, so a blank setting sends "" (only an explicit JSON null reaches "Gateway"). V1 wire behaviour intentionally unchanged. SignerPlace = _config.SignerPlace ?? "Gateway", SignerIp = signerIp }; @@ -1432,16 +2510,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 collapses that repetition itself (a CN submitted twice is + /// registered once), but that is undocumented and not verified 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; } @@ -1449,14 +2568,17 @@ private static List BuildAdditionalDomains(System.Collections.Generic.Li // 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); + // 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); } @@ -1617,6 +2739,245 @@ internal static string RedactCredentials(string body) return body; } + // Exact JSON key names that carry a person's email address across the V1 and V2 wire + // shapes (see CERTInext/API/CertificateRequest.cs and CERTInext/API/V2/CertificateRequestV2.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. + 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). "name" is bare only inside the V2 requestor / + // technicalPointOfContact blocks in every currently-logged body — it is never used as an + // exact top-level key anywhere else on the CERTInext wire shapes this plugin logs raw. + // + // The V2 Document Signer (signature) body's `subject` block and its create + // response add a natural person's name, identity-document and street-address fields + // (firstName, lastName, identityDocumentType, identificationNumber, streetAddress1/2, + // locality, postalCode) and a `subjectDisplayName` (full name for natural/legal person). + // subject.email / subject.phone / subject.designation are already covered by the bare + // "email"/"phone"/"designation" keys. Deliberately NOT added: organizationName, + // organizationUnit, organizationIdentificationNumber, businessCategory, state, + // countryCode — organization or coarse-location data, and "organizationName" is also a + // V1 order/report key whose value is an OV organization, not a person. The V2 + // private-pki body adds no new personal keys: its requestor / + // technicalPointOfContact blocks reuse the bare keys above, and hostname / + // additionalHosts are host names / IP literals, not personal data. + private static readonly string[] PersonalOtherFieldNames = + { + "requestorName", "requesterName", "tpcName", "signerName", "name", + "requestorIsdCode", "requestorMobileNumber", "requestorDesignation", + "tpcIsdCode", "tpcMobileNumber", "signerPlace", "signedPlace", "signerip", "phone", "designation", + "firstName", "lastName", "subjectDisplayName", "identityDocumentType", "identificationNumber", + "streetAddress1", "streetAddress2", "locality", "postalCode" + }; + + /// + /// Scrubs known person/contact-bearing keys out of a JSON-ish body before it goes into a + /// log line, when LogSensitiveRequestData is off. 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) and the + /// V2 nested requestor / technicalPointOfContact shapes (bare name/ + /// email/phone/designation), plus the V2 Document Signer + /// subject block's person fields and subjectDisplayName (see + /// PersonalOtherFieldNames for the exact list and what is deliberately excluded). + /// + /// 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 SAN arrays + /// (additionalDomains/additionalHosts) and domainVerification keys are + /// masked afterwards 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); + } + + // Exact JSON keys whose value is an array of SAN strings. V1 additionalDomains carries every + // requested SAN regardless of type, emails included. + // V2 SSL additionalDomains and private-pki additionalHosts are filtered to DNS / IP before + // submission, so they are covered only as defence in depth: a DNS name or IP literal never + // contains '@', so masking there can only ever touch a mis-typed 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 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; + } + } + + /// + /// 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); + } + + /// + /// Applies the standard logging redaction pipeline to a request/response body before it + /// is written to a log line: credentials are always scrubbed via + /// , and personal-data fields are additionally scrubbed via + /// unless is + /// true. Centralizing this here keeps all six raw-body log sites in this + /// class (and LogApiFailure/LogV2ApiFailure) consistent and gives the on/off + /// behavior 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 @@ -1644,14 +3005,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. + 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}, " + @@ -1663,10 +3025,12 @@ private static 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) { @@ -1688,19 +3052,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."; } diff --git a/CERTInext/Client/ICERTInextClient.cs b/CERTInext/Client/ICERTInextClient.cs index cbba099..1b2bfa3 100644 --- a/CERTInext/Client/ICERTInextClient.cs +++ b/CERTInext/Client/ICERTInextClient.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// 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, @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; namespace Keyfactor.Extensions.CAPlugin.CERTInext.Client { @@ -172,5 +173,190 @@ Task VerifyDcvAsync( string domainName, string dcvMethod, CancellationToken ct = default); + + // ----------------------------------------------------------------------- + // V2 REST API methods — only active when UseV2Api = true + // ----------------------------------------------------------------------- + + /// + /// V2 connectivity check via GET /api/certinext/v2/auth/me. + /// Throws if the V2 endpoint is unreachable or credentials are invalid. + /// + Task PingV2Async(CancellationToken ct = default); + + /// + /// Places a new SSL/TLS order via POST /api/certinext/v2/{productFamilySlug}. + /// The product code is sent as the X-Product-Code header. + /// An Idempotency-Key is generated automatically. + /// must be ssl-certificates: this body is the + /// SSL/TLS shape, and sending it to another family's endpoint would send the wrong body + /// shape to that family's create endpoint — any other slug throws + /// before a request is made. Use + /// the / + /// overloads for the other families. + /// + Task PlaceOrderV2Async( + string productFamilySlug, + string productCode, + V2CreateSslOrderRequest request, + CancellationToken ct = default); + + /// + /// Places a new Private PKI order via POST /api/certinext/v2/private-pki-certificates. + /// Same X-Product-Code / Idempotency-Key handling as the SSL overload. + /// + /// A distinct overload rather than a shared base type on the SSL overload's request + /// parameter, deliberately: every existing Moq Setup/Callback is typed to + /// , and widening that parameter would break the + /// typed callbacks at runtime. The family is implied by the request type, so there is no + /// slug parameter that could be mismatched with the body. + /// + Task PlaceOrderV2Async( + string productCode, + V2CreatePrivatePkiOrderRequest request, + CancellationToken ct = default); + + /// + /// Places a new Document Signer order via POST /api/certinext/v2/signature-certificates. + /// Same X-Product-Code / Idempotency-Key handling as the SSL overload. Not + /// yet called by EnrollV2Async — see . + /// + Task PlaceOrderV2Async( + string productCode, + V2CreateSignatureOrderRequest request, + CancellationToken ct = default); + + /// + /// Submits a CSR to an existing V2 order via PUT /api/certinext/v2/{family}/{orderId}/csr. + /// + Task SubmitCsrV2Async( + string productFamilySlug, + string orderId, + string csrPem, + CancellationToken ct = default); + + /// + /// Returns the current status of a V2 order via GET /api/certinext/v2/{family}/{orderId}. + /// Throws when the order does not exist in that family. + /// + Task TrackOrderV2Async( + string productFamilySlug, + string orderId, + CancellationToken ct = default); + + /// + /// Downloads the issued certificate for a V2 order. + /// GET /api/certinext/v2/{family}/{orderId}/certificate + /// + Task DownloadCertificateV2Async( + string productFamilySlug, + string orderId, + CancellationToken ct = default); + + /// + /// Revokes a V2 certificate via POST /api/certinext/v2/{family}/{orderId}/revoke. + /// An Idempotency-Key is generated automatically. + /// + Task RevokeOrderV2Async( + string productFamilySlug, + string orderId, + V2RevokeRequest request, + CancellationToken ct = default); + + /// + /// Cancels a not-yet-issued V2 order via POST /api/certinext/v2/{family}/{orderId}/cancel + /// with body { "reason": ... }. An Idempotency-Key is generated + /// automatically. Returns on 2xx (spec: 204) + /// and on 422 (spec: "order already in + /// a terminal state"); throws on any other failure. Never retries. + /// + /// One of the Constants.ApiV2.Family* slugs. + /// The V2 order ID. + /// Required free-text reason (the CA rejects an empty one with EMS-984). + Task CancelOrderV2Async( + string productFamilySlug, + string orderId, + string reason, + CancellationToken ct = default); + + /// + /// Returns V2 auth/me response (accountNumber, authType). + /// + Task GetAuthMeV2Async(CancellationToken ct = default); + + /// + /// Resolves the product-family slug for the given V2 order ID by probing all three + /// families (ssl → private-pki → signature), then returns the track response. + /// Throws if the order is not found in any family. + /// + Task ResolveAndTrackOrderV2Async( + string orderId, + CancellationToken ct = default); + + /// + /// Resolves the product-family slug for the given V2 order ID and downloads the certificate. + /// Throws if the order is not found in any family. + /// + Task ResolveAndDownloadCertificateV2Async( + string orderId, + CancellationToken ct = default); + + /// + /// Returns the DCV challenge details for a V2 order. + /// GET /api/certinext/v2/{familySlug}/{orderId}/dcv + /// + Task GetDcvV2Async(string orderId, string familySlug, CancellationToken ct = default); + + /// + /// Returns the DCV challenge details for one specific domain on a V2 order. + /// GET /api/certinext/v2/{familySlug}/{orderId}/dcv?domain={domain} + /// + /// A distinct overload rather than an optional parameter on + /// deliberately: Moq (and any other expression-tree-based mocking) cannot omit an + /// argument on a mocked call — every existing 3-argument Setup/Verify for the no-domain + /// overload would otherwise fail to compile. Parameter order mirrors + /// 's established (orderId, domain, familySlug, ct) + /// convention. Returns a distinct token per SAN on a UCC order. + /// + Task GetDcvV2Async(string orderId, string domain, string familySlug, CancellationToken ct = default); + + /// + /// Asks CERTInext to verify the DNS TXT record for the given domain on a V2 order. + /// POST /api/certinext/v2/{familySlug}/{orderId}/dcv/verify + /// Both 200 OK and 204 No Content are treated as success. + /// Throws on 422 (verification failed). + /// + Task VerifyDcvV2Async(string orderId, string domain, string familySlug, CancellationToken ct = default); + + /// + /// Resolves the product-family slug for the given V2 order ID by probing all three + /// families (ssl → private-pki → signature), then returns both the resolved slug and the + /// track response. Use this when the caller needs to pass the family slug to downstream + /// operations such as DCV. + /// Throws if the order is not found in any family. + /// + Task<(string family, V2OrderStatusResponse status)> ResolveAndTrackOrderV2WithFamilyAsync( + string orderId, + CancellationToken ct = default); + + /// + /// Returns the list of products available in the V2 catalog. + /// GET /api/certinext/v2/catalog/products + /// + Task> GetProductDetailsV2Async(CancellationToken ct = default); + + /// + /// Pages through all orders via GET /api/certinext/v2/reports/orders. Used for V2-mode + /// Synchronize. Paging is 1-based; is clamped + /// to (100) server-side. + /// + /// Optional inclusive start date filter (YYYY-MM-DD). + /// Optional inclusive end date filter (YYYY-MM-DD). + /// Page size requested; server clamps to 100. + IAsyncEnumerable ListOrdersV2Async( + string from = null, + string to = null, + int pageSize = Constants.Api.DefaultPageSize, + CancellationToken ct = default); } } diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 83e6929..27ed5f4 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// 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, @@ -20,11 +20,27 @@ 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"; + + // 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 + // 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. + 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"; public const string RequestorMobileNumber = "RequestorMobileNumber"; + public const string RequestorDesignation = "RequestorDesignation"; public const string SignerPlace = "SignerPlace"; public const string SignerIp = "SignerIp"; @@ -63,13 +79,16 @@ public static class Config public const string DcvWaitForIssuanceSeconds = "DcvWaitForIssuanceSeconds"; // Bounds on DCV-during-sync so a large pending backlog can't make a sync pass - // slow (issue 0002). Only pending orders younger than DcvSyncMaxOrderAgeHours + // slow. Only pending orders younger than DcvSyncMaxOrderAgeHours // are eligible for DCV completion during sync, and at most DcvSyncMaxPerPass // orders are attempted per pass; the rest are emitted as pending and revisited // on a later pass (the per-minute incremental cadence keeps recent orders moving). public const string DcvSyncMaxOrderAgeHours = "DcvSyncMaxOrderAgeHours"; public const string DcvSyncMaxPerPass = "DcvSyncMaxPerPass"; + // V2 mode only: incremental-sync lookback window for /reports/orders. + public const string V2SyncLookbackHours = "V2SyncLookbackHours"; + // Environment variable that overrides DcvTimeoutMinutes when set. public const string DcvTimeoutMinutesEnvVar = "CERTINEXT_DCV_TIMEOUT_MINUTES"; public const string DcvWaitForChallengeSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS"; @@ -111,6 +130,10 @@ public static class EnrollmentParam public const string SignerIp = "SignerIp"; public const string DomainName = "DomainName"; // primary domain for SSL/TLS orders public const string KeyType = "KeyType"; + + // V2 API enrollment parameters + public const string ProductFamily = "ProductFamily"; // V2: "ssl", "private-pki", or "signature" + public const string ProductVariant = "ProductVariant"; // V2: ssl "dv"/"ov"/"ev"; private-pki "intranet-ssl"/"igtf-host" } public static class Products @@ -126,9 +149,16 @@ public static class Products public const string EvSsl = "EV SSL"; public const string EvSslUcc = "EV SSL Multi-Domain (UCC)"; - // Default production numeric codes. These are the standard codes for the - // CERTInext production environment. Sandbox codes differ — set ProductCode - // explicitly on the template to override when targeting sandbox. + // V1-ONLY. Default production numeric codes for the CERTInext V1 (legacy) API. + // These are the standard codes for the CERTInext production environment under V1. + // Sandbox codes differ — set ProductCode explicitly on the template to override + // when targeting sandbox. + // + // Do NOT reuse this table for V2 dispatch: its numbering does not match the live + // V2 catalog — e.g. this table's "842" is OV SSL, but the live V2 + // catalog's "842" is DV SSL, a flat +4 offset across all 10 codes. V2 resolves the + // product code live from the Catalog response instead — see ProductTypeIdsV2 below + // and EnrollV2Async/ValidateProductInfo in CERTInextCAPlugin.cs. public static readonly System.Collections.Generic.Dictionary DefaultProductCodes = new System.Collections.Generic.Dictionary(System.StringComparer.OrdinalIgnoreCase) { @@ -143,6 +173,69 @@ public static class Products [EvSsl] = "846", [EvSslUcc] = "847", }; + + // V2-ONLY. Maps each product name (ProductId, as advertised by GetProductIds()) to + // the CERTInext V2 catalog's stable numeric productTypeID value (spec: + // "Get Product Details" field vocabulary). productTypeID is the CA's own documented mechanism + // for "programmatic routing" (its docs explicitly say productName is "for display" + // only) — unlike productCode (V1-era table above, wrong numbering for V2) or + // productName (varies by account/catalog version: the live catalog, the V1 spec + // table, and the V2 spec table each use different spellings/suffixes for the same + // product), productTypeID is a small, stable, CERTInext-documented + // enum. + // NOTE: 15/18/20/21/22 match the real V2 sandbox catalog; the other five + // (13/14/16/17/19) are spec-documented but have not been checked against it. + // + // Used by EnrollV2Async/ValidateProductInfo to resolve/validate the real V2 product + // code from the live catalog when no explicit ProductCode override is configured. + // Never used for V1. + public static readonly System.Collections.Generic.Dictionary ProductTypeIdsV2 = + new System.Collections.Generic.Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + [DvSsl] = "13", + [DvSslWildcard] = "14", + [DvSslUcc] = "15", + [DvSslWildcardUcc] = "21", + [OvSsl] = "16", + [OvSslWildcard] = "17", + [OvSslUcc] = "18", + [OvSslWildcardUcc] = "22", + [EvSsl] = "19", + [EvSslUcc] = "20", + }; + + // V2-ONLY. Maps each SSL product name (ProductId) to the V2 create body's + // productVariant value ("dv"/"ov"/"ev"). Grouped by the same + // productTypeID assurance level ProductTypeIdsV2 above already documents (13-15 and + // 21 -> dv, 16-18 and 22 -> ov, 19-20 -> ev); kept as its own ProductId-keyed table + // (rather than a second indirection through ProductTypeIdsV2) so it reads the same way + // as DefaultProductCodes/ProductTypeIdsV2 above. + // + // Used by CERTInextCAPlugin.ResolveSslProductVariant (EnrollV2Async/ValidateProductInfo) + // to derive productVariant when the template's ProductVariant enrollment parameter is + // not set explicitly, and to reject an explicit ProductVariant that contradicts the + // selected product (e.g. "dv" configured for "OV SSL" — the bug this table fixes: the + // plugin was sending productVariant:"dv" for every product regardless of ProductId, + // which skips the OV/EV organization block CERTInext requires). + // + // All 10 SSL ProductIds are covered; there is no "unmapped" case today. private-pki + // and signature families are unrelated (private-pki's variant enum is intranet-ssl / + // igtf-host — Constants.ApiV2.PrivatePkiVariants — and signature enrollment is not yet + // supported) and must not consult this table. + public static readonly System.Collections.Generic.Dictionary ProductVariantsV2 = + new System.Collections.Generic.Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + [DvSsl] = ApiV2.ProductVariantDv, + [DvSslWildcard] = ApiV2.ProductVariantDv, + [DvSslUcc] = ApiV2.ProductVariantDv, + [DvSslWildcardUcc] = ApiV2.ProductVariantDv, + [OvSsl] = ApiV2.ProductVariantOv, + [OvSslWildcard] = ApiV2.ProductVariantOv, + [OvSslUcc] = ApiV2.ProductVariantOv, + [OvSslWildcardUcc] = ApiV2.ProductVariantOv, + [EvSsl] = ApiV2.ProductVariantEv, + [EvSslUcc] = ApiV2.ProductVariantEv, + }; } public static class CertificateStatusId @@ -268,6 +361,34 @@ 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; + + // 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 { // CERTInext dcvMethod values (dcvDetails.dcvMethod in GetDcv / VerifyDcv) @@ -285,6 +406,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; @@ -297,7 +429,151 @@ public static class Dcv public const int SyncPropagationDelaySeconds = 3; } + /// + /// V2 REST API constants — all paths, status strings, and family slugs for the + /// /api/certinext/v2/ surface. Auth is OAuth2 client_credentials. The plugin sends + /// an Idempotency-Key header on order-create/revoke, but the spec only documents + /// this header (as "parsed today, enforced in a future release") on Verify DCV and Domains + /// endpoints, not order-create/revoke. + /// + public static class ApiV2 + { + // Auth / connectivity + public const string TokenPath = "/oauth/token"; + public const string AuthMePath = "/api/certinext/v2/auth/me"; + + // Product-family resource paths (appended to base URL) + public const string SslCertificatesPath = "/api/certinext/v2/ssl-certificates"; + public const string PrivatePkiCertificatesPath = "/api/certinext/v2/private-pki-certificates"; + public const string SignatureCertificatesPath = "/api/certinext/v2/signature-certificates"; + public const string CatalogProductsPath = "/api/certinext/v2/catalog/products"; + + // Order status strings (V2 REST — NOT numeric IDs) + public const string StatusPendingDcv = "pending-dcv"; + public const string StatusPendingCsr = "pending-csr"; + public const string StatusPendingAgreement = "pending-agreement"; + public const string StatusPendingOrganizationVerification = "pending-organization-verification"; + public const string StatusPendingDocuments = "pending-documents"; + public const string StatusPendingApproval = "pending-approval"; + public const string StatusIssued = "issued"; + public const string StatusCancelled = "cancelled"; + public const string StatusRevoked = "revoked"; + public const string StatusRejected = "rejected"; + public const string StatusExpired = "expired"; + // Spec-documented V2 status — CERTInext can't say where the order is; not terminal. + public const string StatusUnknown = "unknown"; + + // Per-domain dcvStatus values on Track Order's verifications.domain.domains[] + // block: PENDING while a SAN's DCV is outstanding, VERIFIED once confirmed, + // REJECTED after the parent + // order is cancelled. Uppercase — distinct from the V1 Dcv class's numeric "0"/"1" + // dcvStatus values, which belong to a different API generation entirely. + public const string DcvStatusPending = "PENDING"; + public const string DcvStatusVerified = "VERIFIED"; + public const string DcvStatusRejected = "REJECTED"; + + // Product-family slugs (used as URL path segments) + public const string FamilySsl = "ssl-certificates"; + public const string FamilyPrivatePki = "private-pki-certificates"; + public const string FamilySignature = "signature-certificates"; + + // productVariant values that require an organization block — every + // other value (dv and its wildcard/UCC combinations) omits it entirely. + public const string ProductVariantOv = "ov"; + public const string ProductVariantEv = "ev"; + + // The SSL family's own "no assurance vetting" variant. Given its own named constant + // so Constants.Products.ProductVariantsV2 below doesn't repeat the "dv" + // literal that EnrollmentParams.ProductVariant/V2CreateSslOrderRequest.ProductVariant + // also default to. + public const string ProductVariantDv = "dv"; + + // Private PKI create-body `variant` enum. Spec, "Private PKI + // Certificates" field table: "`variant` | **Mandatory** (`intranet-ssl` / + // `igtf-host`)". Sourced from the ProductVariant template parameter (the same + // "variant within the family" parameter the SSL body's productVariant uses). + // Note: the spec's create-*response* table echoes a wider enum (`intranet-ssl` / + // `igtf-host` / `igtf-personal` / `device` / `vpn`) — only the two documented + // create values are accepted here. + public const string PrivatePkiVariantIntranetSsl = "intranet-ssl"; + public const string PrivatePkiVariantIgtfHost = "igtf-host"; + public static readonly System.Collections.Generic.HashSet PrivatePkiVariants = + new System.Collections.Generic.HashSet(System.StringComparer.OrdinalIgnoreCase) + { + PrivatePkiVariantIntranetSsl, + PrivatePkiVariantIgtfHost + }; + + // Catalog productTypeID for Private PKI products. Spec, Catalog -> List Products + // "productTypeID values": `"39"` | Private PKI | Private PKI (`8`). Also observed live + // on the sandbox catalog (product 149, "Sandbox emSign Intranet SSL 1 Year"). + public const string PrivatePkiProductTypeId = "39"; + + // Document Signer create-body `subjectType` enum. Spec, "Document + // Signer Certificates" field table: "`subjectType` | **Mandatory** + // (`natural-person` / `legal-person` / `legal-entity`)". Not yet sourced by + // EnrollV2Async — signature enrollment is still an open design decision. + public static readonly System.Collections.Generic.HashSet SignatureSubjectTypes = + new System.Collections.Generic.HashSet(System.StringComparer.OrdinalIgnoreCase) + { + "natural-person", + "legal-person", + "legal-entity" + }; + + // Fixed designation sent on technicalPointOfContact.designation. The + // spec documents this as free text with no enum (examples: "Technical Contact", + // "IT Administrator", "PKI Manager", "Authorized Signer") and there is no connector + // config field for it — deliberately out of scope for + // RequestorDesignation (Config.RequestorDesignation), which only covers + // requestor.designation. No existing generic designation/title config field was + // found to reuse for this one, so this remains a fixed default. + public const string DefaultTechnicalContactDesignation = "Technical Contact"; + + // UCC (multi-SAN) product family detection — from the live Catalog response's + // productTypeID field: 15=DV SSL UCC, 18=OV SSL UCC, 20=EV SSL UCC, + // 21=DV SSL Wildcard UCC, 22=OV SSL Wildcard UCC. + // Deliberately NOT derived from Constants.Products.DefaultProductCodes — that table's + // numbering disagrees with the live/spec numbering. + public static readonly System.Collections.Generic.HashSet UccProductTypeIds = + new System.Collections.Generic.HashSet { "15", "18", "20", "21", "22" }; + + // Non-UCC wildcard product family detection — from the live Catalog response's + // productTypeID field: 14=DV SSL Wildcard, 17=OV SSL Wildcard + // (Constants.Products.ProductTypeIdsV2). Deliberately excludes the UCC wildcard + // type IDs (21/22, in UccProductTypeIds above) — those are already exempt from + // the single-domain SAN guard by virtue of being UCC, and their additionalDomains + // handling is unrelated to the apex-SAN exemption this set is used for (the V2 + // single-domain enrollment guard in EnrollV2Async). + public static readonly System.Collections.Generic.HashSet WildcardProductTypeIds = + new System.Collections.Generic.HashSet { "14", "17" }; + + // Orders report (Synchronize, V2 mode) — GET /api/certinext/v2/reports/orders. + // Spring-style page envelope: content/page/size/totalPages/totalElements. + // Paging is 1-based; size is clamped to 100 server-side; page=0 is treated as + // page 1. + public const string OrdersReportPath = "/api/certinext/v2/reports/orders"; + public const int OrdersReportMaxPageSize = 100; + + // Default lookback window: whether /reports/orders' from/to filter brackets order + // date or issue date is not documented. + // An incremental sync re-requests from (lastSync - this window) rather than + // exactly lastSync, so an order created before lastSync but issued after it + // (e.g. a slow-DCV order) still surfaces. See CERTInextConfig.V2SyncLookbackHours. + public const int DefaultSyncLookbackHours = 72; + } + + // V2 config key constants (added here alongside existing Config constants) + public static class ConfigV2 + { + public const string UseV2Api = "UseV2Api"; + } + // Legacy string revocation reasons — retained so StatusMapper still compiles. + // V1 never puts these on the wire (RevokeOrderRequest sends a numeric + // revokeReasonId — see CERTInextClient.RevokeCertificateAsync / + // MapLegacyReasonStringToCrlCode), so this class is intentionally left + // untouched by the V2 kebab-case fix; see RevocationReasonV2 below. public static class RevocationReason { public const string Unspecified = "unspecified"; @@ -311,5 +587,27 @@ public static class RevocationReason public const string PrivilegeWithdrawn = "privilegeWithdrawn"; public const string AACompromise = "aACompromise"; } + + // V2 API revocation reason strings. These must match the CERTInext V2 spec's + // kebab-case `reason` enum exactly ("Revoke Certificate" in the + // V2 spec). Sending camelCase (the + // values shared with the legacy RevocationReason class above) gets + // HTTP 400. `AACompromise` is accepted on the + // signature-certificates / private-pki-certificates revoke endpoints per spec, + // but is not documented on ssl-certificates; kept here as the RFC 5280 code-10 + // mapping for those other families. There is no V2 equivalent of the RFC 5280 + // CRL-only "removeFromCRL" (code 8) reason, so it is intentionally absent here. + public static class RevocationReasonV2 + { + public const string Unspecified = "unspecified"; + public const string KeyCompromise = "key-compromise"; + public const string CACompromise = "ca-compromise"; + public const string AffiliationChanged = "affiliation-changed"; + public const string Superseded = "superseded"; + public const string CessationOfOperation = "cessation-of-operation"; + public const string CertificateHold = "certificate-hold"; + public const string PrivilegeWithdrawn = "privilege-withdrawn"; + public const string AACompromise = "aa-compromise"; + } } } diff --git a/CERTInext/Models/EnrollmentParams.cs b/CERTInext/Models/EnrollmentParams.cs index 69b662f..0a58ce4 100644 --- a/CERTInext/Models/EnrollmentParams.cs +++ b/CERTInext/Models/EnrollmentParams.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// 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, @@ -33,14 +33,18 @@ public EnrollmentParams(EnrollmentProductInfo productInfo) /// Resolution order: /// 1. ProductCode template parameter (explicit override — use for sandbox or non-standard codes) /// 2. ProfileId template parameter (deprecated alias for ProductCode) - /// 3. Default production code looked up from the selected product name (ProductId) + /// 3. V1-ONLY fallback: default production code looked up from the selected product + /// name (ProductId) via Constants.Products.DefaultProductCodes. + /// V2 callers must check before using this value: + /// when false, this getter's fallback (step 3) is the V1-era table, whose numbering does + /// not match the live V2 catalog — resolve the V2 code from the live catalog + /// by ProductTypeId instead (see EnrollV2Async / ValidateProductInfo). /// public string ProductCode { get { - var explicit_ = GetString(Constants.EnrollmentParam.ProductCode, - GetString(Constants.EnrollmentParam.ProfileId, string.Empty)); + var explicit_ = GetExplicitProductCode(); if (!string.IsNullOrEmpty(explicit_)) return explicit_; @@ -52,6 +56,23 @@ public string ProductCode /// Alias for ProductCode — kept for backward compat. public string ProfileId => ProductCode; + /// + /// True when an explicit ProductCode or ProfileId override was configured on the + /// template. False means 's getter falls back to the V1-only + /// Constants.Products.DefaultProductCodes table — V2 callers must not use that fallback + /// value; resolve the code from the live catalog by ProductTypeId instead. + /// + public bool HasExplicitProductCode => !string.IsNullOrEmpty(GetExplicitProductCode()); + + private string GetExplicitProductCode() + { + return GetString(Constants.EnrollmentParam.ProductCode, + GetString(Constants.EnrollmentParam.ProfileId, string.Empty)); + } + + /// 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); @@ -73,8 +94,8 @@ public string ProductCode public string KeyType => GetString(Constants.EnrollmentParam.KeyType, string.Empty); /// - /// Primary domain name for SSL/TLS orders. - /// Derived from the CSR CN by the client if omitted here. + /// Primary domain name for SSL/TLS orders (and the hostname of a V2 private-pki + /// order). Derived from the CSR CN by the client if omitted here. /// public string DomainName => GetString(Constants.EnrollmentParam.DomainName, string.Empty); @@ -96,6 +117,46 @@ public string ProductCode /// public string SignerIp => GetString(Constants.EnrollmentParam.SignerIp, string.Empty); + // ------------------------------------------------------------------ + // V2 API parameters + // ------------------------------------------------------------------ + + /// + /// V2 product family. Accepted values: "ssl" (default), "private-pki", "signature". + /// Used to select the correct V2 resource path. + /// + public string ProductFamily => GetString(Constants.EnrollmentParam.ProductFamily, "ssl"); + + /// + /// V2 product family as the REST path slug used in V2 URL construction. + /// Maps "ssl" → "ssl-certificates", "private-pki" → "private-pki-certificates", + /// "signature" → "signature-certificates". + /// + public string ProductFamilySlug => ProductFamily.ToLowerInvariant() switch + { + "ssl" => Constants.ApiV2.FamilySsl, + "private-pki" => Constants.ApiV2.FamilyPrivatePki, + "signature" => Constants.ApiV2.FamilySignature, + _ => Constants.ApiV2.FamilySsl + }; + + /// + /// V2 product variant within the family, sent in the order body. For the SSL family this + /// is the productVariant field ("dv", "ov", "ev"); for the private-pki family it + /// is the variant field ("intranet-ssl", "igtf-host"). + /// Default: "dv" (SSL-only; private-pki enrollment rejects it — see + /// ). + /// + public string ProductVariant => GetString(Constants.EnrollmentParam.ProductVariant, "dv"); + + /// + /// True when the ProductVariant template parameter is actually set (non-blank), as + /// opposed to falling back to its SSL-only "dv" default. + /// Used only to word the private-pki validation error accurately. + /// + public bool HasExplicitProductVariant => + !string.IsNullOrEmpty(GetString(Constants.EnrollmentParam.ProductVariant, string.Empty)); + // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ diff --git a/CERTInext/Models/LogSanitizer.cs b/CERTInext/Models/LogSanitizer.cs new file mode 100644 index 0000000..8da27df --- /dev/null +++ b/CERTInext/Models/LogSanitizer.cs @@ -0,0 +1,135 @@ +// 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; + +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 a single shared definition keeps + /// them identical. + /// + 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"); + } + + /// + /// 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. 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. + 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. + /// 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/CERTInext/Models/StatusMapper.cs b/CERTInext/Models/StatusMapper.cs index 59795f8..1594ab2 100644 --- a/CERTInext/Models/StatusMapper.cs +++ b/CERTInext/Models/StatusMapper.cs @@ -1,11 +1,13 @@ -// Copyright 2024 Keyfactor +// 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 Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; namespace Keyfactor.Extensions.CAPlugin.CERTInext.Models { @@ -19,6 +21,8 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Models /// internal static class StatusMapper { + private static readonly ILogger Logger = LogHandler.GetClassLogger(typeof(StatusMapper)); + // ----------------------------------------------------------------------- // Certificate status ID mapping (TrackOrder.orderDetails.certificateStatusId) // ----------------------------------------------------------------------- @@ -191,6 +195,136 @@ public static string ToRevocationReason(uint crlReason) } } + // ----------------------------------------------------------------------- + // V2 API status mapping + // ----------------------------------------------------------------------- + + /// + /// Maps a V2 REST API order status string to the Keyfactor + /// integer code expected by the gateway. + /// + /// Covers the status values documented by the V2 spec's /reports/orders + /// status filter. pending-organization-verification, + /// pending-documents, and pending-approval join the existing + /// pending-* values as EXTERNALVALIDATION — they are OV/EV/DV orders still + /// actively progressing toward issuance, not failures. rejected is a + /// terminal negative outcome mapped to FAILED deliberately, same as the + /// pre-existing cancelled. expired maps to GENERATED instead — + /// an expired-but-not-revoked certificate remains issued inventory, mirroring + /// 's V1 convention and the sync/report path's + /// own "expired" case (CERTInextCAPlugin.TryMapV2ReportDisplayStatus). The + /// spec-documented unknown maps to + /// EXTERNALVALIDATION: the order may still be live. Any value not in this list falls + /// through to the default arm, which also returns FAILED but logs a warning, + /// so "deliberately FAILED" and "unmapped, degrading to + /// FAILED" stay distinguishable in the logs even though the return value + /// is the same today. + /// + /// Status string from the V2 order response. + public static int V2StatusToRequestDisposition(string v2Status) + { + switch (v2Status?.ToLowerInvariant()) + { + case Constants.ApiV2.StatusIssued: + // Expired-but-not-revoked certs remain in inventory as GENERATED — + // mirrors StatusMapper.ToRequestDisposition's V1 convention and the + // sync/report path's own "expired" case. + case Constants.ApiV2.StatusExpired: + return (int)EndEntityStatus.GENERATED; + + case Constants.ApiV2.StatusPendingDcv: + case Constants.ApiV2.StatusPendingCsr: + case Constants.ApiV2.StatusPendingAgreement: + case Constants.ApiV2.StatusPendingOrganizationVerification: + case Constants.ApiV2.StatusPendingDocuments: + case Constants.ApiV2.StatusPendingApproval: + return (int)EndEntityStatus.EXTERNALVALIDATION; + + case Constants.ApiV2.StatusRevoked: + return (int)EndEntityStatus.REVOKED; + + case Constants.ApiV2.StatusCancelled: + case Constants.ApiV2.StatusRejected: + return (int)EndEntityStatus.FAILED; + + case Constants.ApiV2.StatusUnknown: + // `unknown` is in the spec's documented status list, so it is NOT + // the "status we've never heard of" case below — CERTInext is saying it can't + // currently report where the order is, not that the order is dead. Treat it as + // pending so Command keeps the order and sync/pickup keep re-checking it, rather + // than dropping a possibly-live order as FAILED. Warn so an order stuck here is + // visible to operators. + Logger.LogWarning( + "V2StatusToRequestDisposition: CERTInext reported V2 order status 'unknown' — " + + "treating the order as pending (EXTERNALVALIDATION) rather than FAILED; it will be " + + "re-checked on the next status poll or sync. If an order stays 'unknown', raise it with CERTInext."); + return (int)EndEntityStatus.EXTERNALVALIDATION; + + default: + // Distinct from the deliberate cancelled/rejected/expired -> FAILED + // mappings above: this status string isn't recognized at all. Log so + // an operator can tell "legitimately + // failed" apart from "gateway doesn't know this status yet" — degrade + // to FAILED rather than guessing EXTERNALVALIDATION, since an + // unrecognized value could just as easily be a new terminal state. + Logger.LogWarning( + "V2StatusToRequestDisposition: unmapped V2 order status '{V2Status}' — " + + "defaulting to FAILED. This is not one of the V2 spec's documented status " + + "values; if CERTInext has added a new status, StatusMapper needs updating.", + v2Status); + return (int)EndEntityStatus.FAILED; + } + } + + /// + /// Converts an RFC 5280 CRL reason code to the V2 API revocation reason string. + /// Values are the CERTInext V2 spec's kebab-case `reason` enum (see + /// — sending the + /// legacy camelCase strings gets HTTP 400). Codes without a direct V2 + /// equivalent (e.g. RFC 5280 code 8, "removeFromCRL", which is CRL-only and + /// not a valid revocation request reason) are mapped to "unspecified". + /// + /// RFC 5280 CRL reason code from the gateway. + public static string ToV2RevocationReason(uint crlReason) => + crlReason switch + { + 1 => Constants.RevocationReasonV2.KeyCompromise, // RFC: keyCompromise + 2 => Constants.RevocationReasonV2.CACompromise, // RFC: cACompromise + 3 => Constants.RevocationReasonV2.AffiliationChanged, // RFC: affiliationChanged + 4 => Constants.RevocationReasonV2.Superseded, // RFC: superseded + 5 => Constants.RevocationReasonV2.CessationOfOperation,// RFC: cessationOfOperation + 6 => Constants.RevocationReasonV2.CertificateHold, // RFC: certificateHold + 9 => Constants.RevocationReasonV2.PrivilegeWithdrawn, // RFC: privilegeWithdrawn + 10 => Constants.RevocationReasonV2.AACompromise, // RFC: aACompromise + _ => Constants.RevocationReasonV2.Unspecified + }; + + /// + /// Converts a V2 API revocation reason string (the CERTInext V2 spec's kebab-case + /// reason enum on the Track Order response's nested revocation object, + /// e.g. "cessation-of-operation") back to the RFC 5280 CRL reason code for storage in + /// the Keyfactor Command database. Inverse of + /// . Unrecognized or null input (including the + /// not-revoked case, where the caller should not invoke this at all) falls back to 0 + /// (unspecified), mirroring 's V1 default. + /// + /// Raw revocation.reason string from the V2 Track Order response. + public static int V2RevocationReasonToCrlCode(string v2Reason) + { + switch (v2Reason?.ToLowerInvariant()) + { + case Constants.RevocationReasonV2.KeyCompromise: return 1; + case Constants.RevocationReasonV2.CACompromise: return 2; + case Constants.RevocationReasonV2.AffiliationChanged: return 3; + case Constants.RevocationReasonV2.Superseded: return 4; + case Constants.RevocationReasonV2.CessationOfOperation: return 5; + case Constants.RevocationReasonV2.CertificateHold: return 6; + case Constants.RevocationReasonV2.PrivilegeWithdrawn: return 9; + case Constants.RevocationReasonV2.AACompromise: return 10; + default: return 0; + } + } + /// /// Converts a CERTInext revokeReasonId integer back to the RFC 5280 CRL /// reason code for storage in the Keyfactor Command database. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6065cb2..e86c164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +# 2.0.0 + +## Features +- feat(v2): Opt-in CERTInext V2 REST API via `UseV2Api` (default `false`; V1 is unchanged), authenticating with OAuth2 `client_credentials` through the existing `ApiUrl`/`OAuthClientId`/`OAuthClientSecret` settings. V1 credentials are not needed in V2 mode. +- feat(v2): Enroll, renew, reissue, revoke, and synchronize SSL/TLS and Private PKI products under V2, selected with the `ProductFamily` and `ProductVariant` template parameters; a template's `ProductCode` is validated against the V2 catalog when saved. +- feat(v2): Multi-domain (UCC) and wildcard SSL/TLS enrollment under V2, with additional SANs sent as `additionalDomains`. +- feat(dcv): DNS-01 domain validation for V2 SSL/TLS orders, including every SAN on a UCC order. +- feat(dcv): DCV is on by default (`DcvEnabled` defaults to `true`); it needs a DNS provider plugin deployed on the gateway. +- feat(v2): Synchronize reads V2 `/reports/orders`; incremental syncs look back `V2SyncLookbackHours` (default 72) and honor `IgnoreExpired`. +- feat(enroll): Quickly-issued certificates are returned in the same `Enroll` call. Tune with `PickupRetries` (default 5, `0` disables) and `PickupDelay` (default 10s); slower orders stay pending and are picked up by the next sync. +- feat(config): New settings `RequestorDesignation`, `SubmitNonDnsSans`, `V2SyncLookbackHours`, and `LogSensitiveRequestData`. +- feat(config): V2 orders honor `GroupNumber`, `OrganizationNumber` (required for OV/EV), the `TechnicalContact*` fields, `SubscriptionAutoRenew`/`SubscriptionRenewCriteriaDays`, and `EmailNotifications`. + +## Bug Fixes +- fix(enroll): UCC certificates no longer come back with only the common name; SANs are read from every key the gateway sends (including `dnsname`), and from the CSR when the gateway sends none. +- fix(enroll): Renewals keep their SANs and primary domain, and use the template's product code (falling back to `DefaultProductCode`). +- fix(enroll): An order CERTInext auto-approves before it finishes issuing now returns pending instead of an issued result with no certificate. +- fix(dcv): Wildcard domains publish their TXT record at the base domain instead of a literal `*.` label, and a wildcard and its apex share one record. +- fix(config): `ApiUrl` and, for V1 OAuth, `OAuthTokenUrl` must use https (http is allowed only for loopback hosts), enforced on save and at startup, so existing http connectors fail to start. +- fix(logging): Requestor personal data, email SANs, and full request/response payloads are redacted from gateway logs unless `LogSensitiveRequestData` is enabled. +- fix(enroll): Order and CSR submissions are no longer auto-retried after a timeout, which could create duplicate orders; if the CA did create the order, the next sync imports it. + +## Chores +- chore(compat): Requires AnyCA Gateway REST framework 26.2.0 or later; the plugin is built against `IAnyCAPlugin` 3.3.0 with DNS-01 DCV included. + +## Upgrade Notes +- AnyCA Gateway REST 25.5.x is not supported; upgrade the gateway to 26.2.0 or later before installing 2.0.0. +- `DcvEnabled` now defaults to `true` for connectors that don't already store a value. It needs a DNS provider plugin on the gateway; without one, orders that need domain validation stay pending and the plugin logs why. Set `DcvEnabled` to `false` if you validate domains another way. +- V2 templates with only `ProductId` need `ProductCode` or the connector's `DefaultProductCode` when the catalog has several products of that type; otherwise enrollment fails and lists the candidates. +- V2 connectors require `SignerPlace`, and V2 OV/EV orders require `OrganizationNumber`; `ProductVariant` is derived from the selected product when unset. +- V2 revoke reasons CERTInext rejects are substituted: CA/AA compromise becomes key-compromise, and unspecified/certificate-hold become cessation-of-operation. +- V2 `expired` orders are reported as issued, and revoking one is sent to the CA. +- V2 renew and reissue place a new order; the original order is not revoked. +- V1 connectors now submit non-DNS SANs (IP, email, URI) instead of dropping them, so such an order won't issue until the SAN is removed; set `SubmitNonDnsSans` to `false` to drop them as before. + +## Known Limitations +- OV and OV UCC order placement under V2 can exceed the plugin's fixed 120s request timeout; the order may still be created on the CA and is imported by the next sync. EV orders under V2 are not validated end to end. +- Document Signer (`ProductFamily=signature`) enrollment is not supported under V2. +- Per-SAN DCV on UCC orders, DCV for a domain that has never been validated, and wildcard DCV are not validated end to end against the CA. +- Rolling a connector back to V1 after it has issued V2 certificates is not validated. + # 1.0.0 Initial release of the CERTInext (emSign Hub) AnyCA REST Gateway plugin. diff --git a/DCV_BUILD_SUPPORT.md b/DCV_BUILD_SUPPORT.md new file mode 100644 index 0000000..977bc9a --- /dev/null +++ b/DCV_BUILD_SUPPORT.md @@ -0,0 +1,35 @@ +# DNS-01 DCV: build and code layout + +DNS-01 domain control validation (DCV) is part of the standard build of the plugin. This page describes how the build is configured and where the DCV code lives. For how the DCV flow behaves, see [docsource/architecture.md](docsource/architecture.md#domain-control-validation); for the connector settings, see the `Dcv*` rows in [docsource/configuration.md](docsource/configuration.md#ca-configuration). + +## Build + +- The plugin compiles against `Keyfactor.AnyGateway.IAnyCAPlugin` **3.3.0**, which provides `IDomainValidatorFactory`, and targets AnyCA Gateway REST **26.2.0** and later. +- `CERTInext/CERTInext.csproj` sets the `DcvSupport` MSBuild property to `true` by default. That one property selects the IAnyCAPlugin package version, defines the `SUPPORTS_DCV` compile constant, and includes the DCV test files in the two test projects. Plain `dotnet build` and `make build` therefore produce the DCV build; no flag is needed. +- The project targets both **`net8.0`** and **`net10.0`**. +- The test projects mirror the property so the DCV test files compile with it: `CERTInext.Tests/CERTInext.Tests.csproj` and `CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj`. + +``` +dotnet build +``` + +## Where the DCV code lives + +The DCV code is in `CERTInext/CERTInextCAPlugin.cs`, compiled under `#if SUPPORTS_DCV`: + +| Member | What it does | +|---|---| +| `using` alias and `DomainValidatorFactory` property | Name the `IDomainValidatorFactory` type and cast the stored factory to it | +| Internal test constructor taking an `IDomainValidatorFactory` | Lets unit and integration tests inject a fake or real DNS validator | +| `SetDomainValidatorFactory(object)` | Receives the gateway's DNS provider factory and logs which type was offered | +| `EnrollNewAsync` (V1) | Runs DCV right after a new order is placed, then waits for issuance | +| `EnrollV2Async` (V2) | Runs DCV when the new order is pending, then re-checks its status | +| `Synchronize` / `SynchronizeV2Async` | Drive recently placed pending orders through DCV, bounded by `DcvSyncMaxOrderAgeHours` and `DcvSyncMaxPerPass` | +| `GetSingleRecord` / `GetSingleRecordV2Async` | Drive a pending order through DCV on a manual refresh | +| `TryRunDcvDuringSyncAsync` | The sync and single-record retry wrapper for V1: in-flight guard, bounded timeout, swallows non-cancellation errors | +| `PerformDcvIfNeededAsync` | The V1 DCV flow: wait for the challenge, `GetDcv`, publish TXT, `VerifyDcv`, poll, clean up | +| `PerformDcvV2IfNeededAsync` | The V2 entry point; dispatches to the single-domain or multi-domain V2 flow | + +The stored factory is held as `object` and cast inside method bodies, so the plugin class loads even when the host doesn't supply `IDomainValidatorFactory`. In that case DCV is simply inactive: when `DcvEnabled` is `true`, the plugin logs a warning at startup, and orders that need validation stay pending until a DNS provider is available or `DcvEnabled` is set to `false`. + +The DNS provider side is a separate gateway plugin that implements `IDomainValidator` (for example `azure-azuredns-dnsplugin`). The integration tests include a Cloudflare-backed validator (`CloudflareDomainValidator`) and a recording wrapper for exercising the flow against the live sandbox. diff --git a/Makefile b/Makefile index c2a726f..1be4305 100644 --- a/Makefile +++ b/Makefile @@ -3,10 +3,12 @@ COVERAGE_DIR := /tmp/certinext-coverage REPORT_DIR := /tmp/certinext-coverage-report # --------------------------------------------------------------------------- -# V2 API credentials — set CERTINEXT_V2_API_URL in ~/.env_certinext. -# For the sandbox environment this is the same host as V1 but without the +# V2 API credentials — CERTINEXT_API_URL / CERTINEXT_CLIENT_ID / +# CERTINEXT_CLIENT_SECRET in ~/.env_certinext_v2 (override the path with +# CERTINEXT_V2_ENV_FILE). CERTINEXT_API_URL is the V2 base URL, without the # /emSignHub-API/ suffix, e.g.: -# CERTINEXT_V2_API_URL=https://sandbox-us.certinext.io +# CERTINEXT_API_URL=https://sandbox-us.certinext.io +# See scripts/v2/README.md. # --------------------------------------------------------------------------- .PHONY: build test integration-test coverage coverage-report open-coverage clean \ @@ -138,11 +140,11 @@ generate-test-csr: # --------------------------------------------------------------------------- # probe-products — places saveAndHold=1 draft orders for every SSL/TLS -# product code known to be provisioned on this sandbox account and reports -# which codes are accepted by GenerateOrderSSL. +# product code in the list below and reports which codes are accepted by +# GenerateOrderSSL for the configured account. # -# Product codes exercised (all SSL/TLS from GetProductDetails for this -# sandbox account with groupNumber=2171775848): +# Product codes exercised (sandbox SSL/TLS codes; production codes differ — +# see docsource/configuration.md): # 842 DV SSL Certificate # 843 DV SSL Certificate Wildcard # 844 DV SSL Certificate UCC @@ -167,7 +169,7 @@ probe-products: generate-test-csr # Aliases: orders # Optional overrides: PAGE (default 1), PAGE_SIZE (default 10) # -# Response shape (live API, verified 2026-04): +# Response shape: # { "orderDetails": { "ordersArray": [...], "noOfPages": N, # "totalNoOfResults": N, "pageSize": N, "currentPage": "1" }, # "meta": { "status": "1", ... } } @@ -285,17 +287,11 @@ submit-csr: # --------------------------------------------------------------------------- # list-cas — Sub-CA listing via API # -# The CERTInext REST API does NOT expose a Sub-CA listing endpoint. -# All 18 candidate endpoint names return HTTP 404. +# The CERTInext REST API does not expose a Sub-CA listing endpoint. +# Sub-CA information is available in the CERTInext portal UI +# (https://sandbox-us.certinext.io for the sandbox environment). # -# Sub-CA information must be obtained via the sandbox portal UI at -# https://sandbox-us.certinext.io. Active Sub-CAs for this account: -# Name : emSign Issuing Sand box CA IGTF - C6 -# Type : Subordinate CA -# Status : Active -# (Backed by emSign Trusted Sandbox Root CA - C6) -# -# See analysis/certinext-caplugin/postman-api-findings.md for full details. +# See analysis/certinext-caplugin/postman-api-findings.md for details. # --------------------------------------------------------------------------- list-cas: @@ -319,8 +315,8 @@ list-cas: # make register-import # 05 import templates into Command [CHECK=1] # make register-enrollment # 06 enrollment patterns + template KeyRetention # -# Stages 01 and 06 are VERIFIED live; 02-05 are built from docs/reference -# captures — validate against a live gateway/Command before relying on them. +# Stages 02-05 are modeled on the captured JSON in docs/reference — validate +# them against your gateway/Command before relying on them. # Auth (cookie/token/OAuth), env vars, and gotchas: scripts/register/README.md. # NOTE: stage 04 (and stage 02's CA-connection PUT) touch the CA config, which # is fragile — leave it alone unless explicitly required. @@ -349,14 +345,11 @@ register-enrollment: # --------------------------------------------------------------------------- # create-product — Create a custom product via API # -# The CERTInext REST API does NOT expose a product creation or configuration -# endpoint. All 8 candidate endpoint names return HTTP 404. -# -# Products must be created via the sandbox portal UI at -# https://sandbox-us.certinext.io under: +# The CERTInext REST API does not expose a product creation or configuration +# endpoint. Products are created in the CERTInext portal UI under: # Account → Products → Configure Product # -# See analysis/certinext-caplugin/postman-api-findings.md for full details. +# See analysis/certinext-caplugin/postman-api-findings.md for details. # --------------------------------------------------------------------------- create-product: @@ -365,9 +358,10 @@ create-product: # --------------------------------------------------------------------------- # generate-order-igtf — Place a Private PKI order using product 149 # -# Product 149 (Sandbox emSign Intranet SSL 1 Year) is the only Private PKI -# product provisioned on this sandbox account. Product 108 (IGTF Host -# Certificate) is NOT provisioned here — GetFieldDetails returns EMS-1269. +# Product 149 (Sandbox emSign Intranet SSL 1 Year) is a Private PKI product +# available on sandbox accounts with the Private PKI entitlement. Product 108 +# (IGTF Host Certificate) requires separate provisioning — GetFieldDetails +# returns EMS-1269 when it is not provisioned. # # Uses GenerateOrderPrivatePKI. # Required: CSR at /tmp/certinext-igtf-test.csr (run generate-test-csr first) @@ -413,7 +407,7 @@ generate-order-private-pki: generate-test-csr # reports whether they exist (non-404) or not (404). Wraps # scripts/probe_endpoints.py. # -# Result (confirmed 2026-04): ALL 18 candidates return HTTP 404. +# None of the candidate endpoints exist (all return HTTP 404). # --------------------------------------------------------------------------- probe-endpoints: @@ -453,7 +447,7 @@ get-field-details: FILTER ?= show-postman-bodies: - @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/extract_postman_bodies.py \ + @python3 scripts/extract_postman_bodies.py \ --filter "$(FILTER)" # --------------------------------------------------------------------------- @@ -465,7 +459,7 @@ show-postman-bodies: # --------------------------------------------------------------------------- show-postman-variables: - @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/extract_postman_variables.py + @python3 scripts/extract_postman_variables.py # --------------------------------------------------------------------------- # probe-private-pki-payloads — Try three payload variants for @@ -479,24 +473,28 @@ show-postman-variables: # --------------------------------------------------------------------------- probe-private-pki-payloads: generate-test-csr - @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/order_private_pki_minimal.py \ + @python3 scripts/order_private_pki_minimal.py \ --csr /tmp/certinext-test.csr \ --domain "$(IGTF_DOMAIN)" \ --product "$(PRIVATE_PKI_CODE)" \ --save-and-hold "$(SAVE_AND_HOLD)" # --------------------------------------------------------------------------- -# V2 API targets (credentials + CERTINEXT_V2_API_URL from ~/.env_certinext) +# V2 API targets (credentials from ~/.env_certinext_v2) # -# Auth: scripts/lib/certinext-v2-auth.sh exchanges SHA256(accessKey+ts+txn) -# for a short-lived Bearer JWT at POST {v2BaseURL}/oauth/token. All V2 -# scripts source that lib automatically — no manual token step needed. +# Auth: scripts/lib/certinext-v2-auth.sh fetches an OAuth2 client_credentials +# token at POST {CERTINEXT_API_URL}/oauth/token (same as the plugin's V2 mode). +# The env file is parsed, not sourced; the secret/token never hit argv, disk, +# or output. # -# Scripts live in scripts/v2/. Each script sources ~/.env_certinext and -# scripts/lib/certinext-v2-auth.sh; jq is used for JSON construction and -# pretty-printing. +# Mutating targets (create/verify-dcv/submit-csr/accept/cancel/revoke) only +# PREVIEW the request unless you pass V2_ARGS=--yes-mutate, e.g.: +# make v2-revoke-ssl ORDER_ID=123 V2_ARGS=--yes-mutate +# Full details: scripts/v2/README.md. # --------------------------------------------------------------------------- +V2_ARGS ?= + # --------------------------------------------------------------------------- # v2-ping — GET /api/certinext/v2/auth/me # Connectivity + auth check; returns the account context the token resolves to. @@ -575,7 +573,7 @@ V2_VARIANT ?= dv v2-create-ssl-order: @echo "V2 create SSL order — POST /api/certinext/v2/ssl-certificates" - @PRODUCT_CODE=$(V2_PRODUCT_CODE) DOMAIN=$(V2_DOMAIN) VARIANT=$(V2_VARIANT) scripts/v2/create-ssl-order.sh + @PRODUCT_CODE=$(V2_PRODUCT_CODE) DOMAIN=$(V2_DOMAIN) VARIANT=$(V2_VARIANT) scripts/v2/create-ssl-order.sh $(V2_ARGS) # --------------------------------------------------------------------------- # v2-track-order — GET /api/certinext/v2/ssl-certificates/{orderId} @@ -613,7 +611,7 @@ V2_DCV_METHOD ?= http-url v2-verify-dcv: @echo "V2 verify DCV — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/dcv/verify" - @ORDER_ID=$(ORDER_ID) DOMAIN=$(V2_DOMAIN) METHOD=$(V2_DCV_METHOD) scripts/v2/verify-dcv.sh + @ORDER_ID=$(ORDER_ID) DOMAIN=$(V2_DOMAIN) METHOD=$(V2_DCV_METHOD) scripts/v2/verify-dcv.sh $(V2_ARGS) # --------------------------------------------------------------------------- # v2-submit-csr — PUT /api/certinext/v2/ssl-certificates/{orderId}/csr @@ -625,7 +623,7 @@ V2_CSR_FILE ?= v2-submit-csr: @echo "V2 submit CSR (SSL) — PUT /api/certinext/v2/ssl-certificates/$(ORDER_ID)/csr" - @ORDER_ID=$(ORDER_ID) CSR_FILE=$(V2_CSR_FILE) scripts/v2/submit-csr.sh + @ORDER_ID=$(ORDER_ID) CSR_FILE=$(V2_CSR_FILE) scripts/v2/submit-csr.sh $(V2_ARGS) # --------------------------------------------------------------------------- # v2-accept-agreement — POST /api/certinext/v2/ssl-certificates/{orderId}/agreement @@ -635,7 +633,7 @@ v2-submit-csr: v2-accept-agreement: @echo "V2 accept agreement — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/agreement" - @ORDER_ID=$(ORDER_ID) scripts/v2/accept-agreement.sh + @ORDER_ID=$(ORDER_ID) scripts/v2/accept-agreement.sh $(V2_ARGS) # --------------------------------------------------------------------------- # v2-download-certificate — GET /api/certinext/v2/ssl-certificates/{orderId}/certificate @@ -661,7 +659,7 @@ V2_REASON ?= superseded v2-revoke-ssl: @echo "V2 revoke SSL — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/revoke" - @ORDER_ID=$(ORDER_ID) REASON=$(V2_REASON) scripts/v2/revoke-ssl.sh + @ORDER_ID=$(ORDER_ID) REASON=$(V2_REASON) scripts/v2/revoke-ssl.sh $(V2_ARGS) # --------------------------------------------------------------------------- # v2-cancel-ssl-order — POST /api/certinext/v2/ssl-certificates/{orderId}/cancel @@ -671,12 +669,14 @@ v2-revoke-ssl: v2-cancel-ssl-order: @echo "V2 cancel SSL order — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/cancel" - @ORDER_ID=$(ORDER_ID) scripts/v2/cancel-ssl-order.sh + @ORDER_ID=$(ORDER_ID) scripts/v2/cancel-ssl-order.sh $(V2_ARGS) # --------------------------------------------------------------------------- # v2-create-private-pki-order — POST /api/certinext/v2/private-pki-certificates # Creates a Private PKI certificate order against a customer-owned CA. -# Required: PRODUCT_CODE= HOSTNAME= CA_PROFILE_ID= MASTER_PRODUCT_ID= +# Required: V2_HOSTNAME= V2_CA_PROFILE_ID= V2_MASTER_PRODUCT_ID= +# Optional: V2_PRODUCT_CODE= +# (the script input is CERT_HOSTNAME; bash always sets HOSTNAME to the local machine name) # # Prints orderId on success. Use orderId with v2-track-private-pki, # v2-submit-csr-private-pki, v2-download-certificate-private-pki, and @@ -689,7 +689,7 @@ V2_MASTER_PRODUCT_ID ?= v2-create-private-pki-order: @echo "V2 create Private PKI order — POST /api/certinext/v2/private-pki-certificates" - @PRODUCT_CODE=$(V2_PRODUCT_CODE) HOSTNAME=$(V2_HOSTNAME) CA_PROFILE_ID=$(V2_CA_PROFILE_ID) MASTER_PRODUCT_ID=$(V2_MASTER_PRODUCT_ID) scripts/v2/create-private-pki-order.sh + @PRODUCT_CODE=$(V2_PRODUCT_CODE) CERT_HOSTNAME=$(V2_HOSTNAME) CA_PROFILE_ID=$(V2_CA_PROFILE_ID) MASTER_PRODUCT_ID=$(V2_MASTER_PRODUCT_ID) scripts/v2/create-private-pki-order.sh $(V2_ARGS) # --------------------------------------------------------------------------- # v2-track-private-pki — GET /api/certinext/v2/private-pki-certificates/{orderId} @@ -711,7 +711,7 @@ v2-track-private-pki: v2-submit-csr-private-pki: @echo "V2 submit CSR (Private PKI) — PUT /api/certinext/v2/private-pki-certificates/$(ORDER_ID)/csr" - @ORDER_ID=$(ORDER_ID) CSR_FILE=$(V2_CSR_FILE) scripts/v2/submit-csr-private-pki.sh + @ORDER_ID=$(ORDER_ID) CSR_FILE=$(V2_CSR_FILE) scripts/v2/submit-csr-private-pki.sh $(V2_ARGS) # --------------------------------------------------------------------------- # v2-download-certificate-private-pki — GET /api/certinext/v2/private-pki-certificates/{orderId}/certificate @@ -735,18 +735,20 @@ v2-download-certificate-private-pki: v2-revoke-private-pki: @echo "V2 revoke Private PKI — POST /api/certinext/v2/private-pki-certificates/$(ORDER_ID)/revoke" - @ORDER_ID=$(ORDER_ID) REASON=$(V2_REASON) scripts/v2/revoke-private-pki.sh + @ORDER_ID=$(ORDER_ID) REASON=$(V2_REASON) scripts/v2/revoke-private-pki.sh $(V2_ARGS) # --------------------------------------------------------------------------- -# v2-orders-report — GET /api/certinext/v2/reports/orders?page=0&size=50 -# Paginated order history across all product types. -# NOTE: currently returns 501 Not Implemented. -# Use v1 make get-order-report (POST /emSignHub-API/GetOrderReport) meanwhile. +# v2-orders-report — GET /api/certinext/v2/reports/orders?page=1&size=100 +# One page of order history (the endpoint V2 Synchronize pages through). +# Optional: V2_PAGE=1 (1-based) V2_SIZE=100 (server clamps to 100) # --------------------------------------------------------------------------- +V2_PAGE ?= 1 +V2_SIZE ?= 100 + v2-orders-report: - @echo "V2 orders report — GET /api/certinext/v2/reports/orders (NOTE: currently 501)" - @scripts/v2/orders-report.sh + @echo "V2 orders report — GET /api/certinext/v2/reports/orders?page=$(V2_PAGE)&size=$(V2_SIZE)" + @PAGE=$(V2_PAGE) SIZE=$(V2_SIZE) scripts/v2/orders-report.sh # --------------------------------------------------------------------------- # Help @@ -775,7 +777,7 @@ api-help: @echo "" @echo " make probe-products [PROBE_DOMAIN=test-integration.example.com]" @echo " Place saveAndHold=1 draft orders for all SSL/TLS product codes" - @echo " provisioned on the sandbox account (842–851, 149) and report which" + @echo " in the sandbox product list (842–851, 149) and report which" @echo " codes are accepted. A code returning a requestNumber is valid." @echo " Depends on generate-test-csr (called automatically)." @echo "" @@ -813,9 +815,8 @@ api-help: @echo "" @echo " make generate-order-igtf [IGTF_CSR_FILE=/tmp/certinext-igtf-test.csr]" @echo " GenerateOrderPrivatePKI — place a Private PKI order using product 149" - @echo " (Sandbox emSign Intranet SSL, the only active Private PKI product on this" - @echo " sandbox account). Uses saveAndHold=1 by default." - @echo " NOTE: product 108 (IGTF Host) is not provisioned on this account." + @echo " (Sandbox emSign Intranet SSL). Uses saveAndHold=1 by default." + @echo " NOTE: product 108 (IGTF Host) requires separate provisioning." @echo "" @echo " make generate-order-private-pki [PRIVATE_PKI_CSR=...] [PRIVATE_PKI_DOMAIN=...] [PRIVATE_PKI_CODE=149]" @echo " GenerateOrderPrivatePKI — place a Private PKI order for any product code." diff --git a/QUICKSTART.md b/QUICKSTART.md index 40b8292..054559e 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -803,4 +803,4 @@ sync pulls down the actual certificate. | Step 6 returns `0xA0110004` "Key type 'RSA' disallowed by policy" | Gateway `key_algs` are empty or wrong, or Command hasn't re-imported templates after a profile change | Update `key_algs` (Step 2), re-run `/Templates/Import` (Step 5). | | Step 6 returns `0xA0010023` "external validation" with HTTP 400 | The gateway returned a pending response and Command's exception filter translated it — Command 25.x bug | The plugin DID accept the order. Confirm via `GET ${GATEWAY_URL}/AnyGatewayREST/.../v1/certificate/`. Fixed in newer Command builds; rewrite as 200 with disposition `EXTERNAL_VALIDATION`. | | Step 6 returns `"Inactive Account User."` from the gateway log | CERTInext sandbox rate limit | Wait 5-25 minutes; retry a single order to confirm the account is alive. See [#8](https://github.com/Keyfactor/certinext-caplugin/issues/8). | -| Step 6 returns `TypeLoadException IDomainValidatorFactory` in the gateway pod log | DCV build deployed on a gateway running IAnyCAPlugin 3.2.x (25.5.x) | Deploy the no-DCV build (the default release artifact); do not deploy the DCV build (`-p:DcvSupport=true`) on a gateway running IAnyCAPlugin 3.2.x (25.5.x). Use the DCV build only on 26.x. | +| Step 6 returns `TypeLoadException IDomainValidatorFactory` in the gateway pod log | The plugin is deployed on a gateway older than AnyCA Gateway REST 26.2.0 (IAnyCAPlugin before 3.3) | Upgrade the gateway to 26.2.0 or later. The plugin requires it. | diff --git a/README.md b/README.md index ff91ab1..7a4d676 100644 --- a/README.md +++ b/README.md @@ -40,17 +40,20 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi * Expired certificates can optionally be excluded from synchronization using the `IgnoreExpired` configuration flag. * Certificate Enrollment for profiles configured in CERTInext: * 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 renewal — on the V1 API, 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). On the V2 API every renewal places a new order. * 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`. + * DNS-01 domain control validation (DCV) — the plugin publishes the validation TXT record through a DNS provider plugin deployed on the gateway and asks CERTInext to verify it, during enrollment and during synchronization (`DcvEnabled`, on by default). * 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: - * AccessKey (HMAC-based request signing) — the primary and recommended mode - * OAuth (bearer token via client credentials flow) + * AccessKey (HMAC-based request signing) — the primary and recommended mode for the V1 API + * OAuth (bearer token via client credentials flow) — optional for V1, and the only mode for the V2 API +* Two CERTInext API generations, selected per connector with `UseV2Api`: the V1 API (default) and the order-centric V2 REST API, which adds Private PKI products. See [V2 API](#v2-api) and [Migrating from V1 to V2](#migrating-from-v1-to-v2). ## Compatibility -The CERTInext AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.5.0 and later. +The CERTInext AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2.0 and later. ## Support The CERTInext AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. @@ -59,8 +62,9 @@ The CERTInext AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor ## Requirements -* Keyfactor Command 25.5.x or later -* AnyCA Gateway REST framework version 25.5.0 or later +* Keyfactor Command 26.2 or later +* AnyCA Gateway REST framework version 26.2.0 or later +* A DNS provider plugin deployed on the AnyCA Gateway if the connector validates domains with DNS-01 DCV (the default; see `DcvEnabled`) * A CERTInext account with API access enabled and at least one certificate product configured * Network connectivity from the AnyCA Gateway host to the CERTInext API endpoint for your region (see table below) * The AnyCA Gateway host must trust the TLS certificate presented by the CERTInext API endpoint @@ -75,6 +79,8 @@ CERTInext operates three separate environments. Use the sandbox environment for | Production — India (Global) | https://in.certinext.io/ | `https://api.certinext.io/emSignHub-API/` | | Production — US | https://us.certinext.io/ | `https://us-api.certinext.io/emSignHub-API/` | +The V2 API (`UseV2Api` = `true`) is served from the bare host with no path segment, for example `https://sandbox-us-api.certinext.io` or `https://us-api.certinext.io`. See [V2 API](#v2-api). + > Note: Product codes differ between sandbox and production. Always confirm product codes from the GetProductDetails API call against the environment you are targeting before going live. ## Installation @@ -109,87 +115,100 @@ CERTInext operates three separate environments. Use the sandbox environment for 1. Log in to the CERTInext portal and download the root CA certificate and any intermediate CA certificates in the chain as PEM or DER files. 2. On the Keyfactor Command server, import those certificates into the appropriate Windows certificate store — **Trusted Root Certification Authorities** for the root CA and **Intermediate Certification Authorities** for any subordinate CAs. 3. In the Keyfactor Command Management Portal, navigate to **CA Connectors** and add a new CA using the **CERTInext AnyCA REST Gateway Plugin**. - 4. Complete the CA connector configuration fields described in the next section, then save and test the connection. The gateway performs a live connectivity test against the CERTInext `ValidateCredentials` endpoint during validation. + 4. Complete the CA connector configuration fields described in the next section, then save and test the connection. The gateway performs a live connectivity test during validation: the V1 `ValidateCredentials` endpoint, or `GET /api/certinext/v2/auth/me` when `UseV2Api` is `true`. * **CA Connection** Populate using the configuration fields collected in the [requirements](#requirements) section. - * **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. - * **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'. - * **OAuthClientId** - OAuth client ID. Required when AuthMode is 'OAuth'. - * **OAuthClientSecret** - OAuth client secret. Required when AuthMode is 'OAuth'. + * **ApiUrl** - REQUIRED: CERTInext API base URL. Its meaning follows UseV2Api. V1 (default): 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/. V2 (UseV2Api=true): the bare V2 host, e.g. https://sandbox-us-api.certinext.io, no trailing slash or path suffix — V1 and V2 are hosted differently, so this value changes when UseV2Api is toggled. + * **AccountNumber** - REQUIRED when UseV2Api is false: your CERTInext account number (numeric string). Included in the `meta` block of every V1 request. Not used when UseV2Api is true. Available in the CERTInext portal. + * **GroupNumber** - OPTIONAL: CERTInext group (delegation) number. When set, it is included in product-catalog requests and in the order (`delegationInformation.groupNumber` on V1, `groupNumber` on V2) so the order is routed to the correct account group, and V2 synchronization is scoped to the 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, and REQUIRED for OV/EV orders under V2: numeric CERTInext organization number for a pre-vetted organization (e.g. your company's pre-vetted entry). When set, V1 orders are submitted with `organizationDetails.preVetting="1"` and the configured `organizationNumber`, telling CERTInext to skip the manual organization-vetting queue; V2 OV/EV orders send it as `organization.organizationNumber` with `preVetted=true` (V2 DV orders never send an organization block). Without this value, V1 orders are placed without any organizationDetails block and CERTInext may park them in `Pending System RA` for extended manual review (potentially tens of hours), and V2 OV/EV enrollment fails before any CA call. Available in the CERTInext portal under Organizations → Pre-vetted Organizations. + * **TechnicalContactName** - OPTIONAL: Name sent as the order's technical point of contact (V1 `technicalPointOfContact.tpcName`, V2 `technicalPointOfContact.name`). 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 as the order's technical point of contact (V1 `tpcEmail`, V2 `email`). Defaults to the configured RequestorEmail when blank. + * **TechnicalContactIsdCode** - OPTIONAL: International dialing code for the technical contact phone number. Defaults to the configured RequestorIsdCode when blank. + * **TechnicalContactMobileNumber** - OPTIONAL: Mobile number for the technical contact (digits only). Defaults to the configured RequestorMobileNumber when blank. + * **AuthMode** - REQUIRED when UseV2Api is false: authentication mode. 'AccessKey' (default) — uses authKey = SHA256(accessKey + ts + txn) in every request body. 'OAuth' — uses an OAuth2 bearer token (requires OAuthTokenUrl, OAuthClientId, OAuthClientSecret). Ignored when UseV2Api is true; V2 always uses OAuth2 client credentials. + * **ApiKey** - REQUIRED when UseV2Api is false and 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. Not used when UseV2Api is true. + * **OAuthTokenUrl** - OAuth token endpoint URL. Required when UseV2Api is false and AuthMode is 'OAuth'; must use https. Not used when UseV2Api is true — V2 requests its token from {ApiUrl}/oauth/token. + * **OAuthClientId** - OAuth client ID. Required when AuthMode is 'OAuth' (V1). Also required, and reused, when UseV2Api is true — V2 authenticates with these same OAuthClientId/OAuthClientSecret fields via client_credentials against {ApiUrl}/oauth/token, rather than separate V2-only credentials. The key must be generated in OAuth mode in the CERTInext portal. + * **OAuthClientSecret** - OAuth client secret. Required when AuthMode is 'OAuth' (V1). Also required, and reused, when UseV2Api is true (see OAuthClientId). * **RequestorName** - REQUIRED: Default requestor name submitted with all certificate orders. This is the name of the person/service responsible for the certificates. * **RequestorEmail** - REQUIRED: Default requestor email submitted with all certificate orders. Must be a valid email address registered in your CERTInext account. * **RequestorIsdCode** - International dialing code for the requestor phone number (e.g. '1' for US). Default: '1'. * **RequestorMobileNumber** - Requestor mobile number (digits only, no country code). - * **SignerPlace** - City or location of the subscriber agreement signer. Required by CERTInext for all orders. - * **SignerIp** - IP address of the subscriber agreement signer. Required by CERTInext for all orders. - * **DefaultProductCode** - OPTIONAL: Default numeric product code used when not specified at template level. Product codes are provided by eMudhra (e.g. the SSL DV 1-year code for your account). Retrieve available codes from Integrations → APIs → GetProductDetails. - * **AccountingModel** - OPTIONAL: CERTInext billing model sent in `orderDetails.accountingModel`. "2" = credit-based (most accounts, default). "1" = cash model. - * **EmailNotifications** - OPTIONAL: Whether CERTInext sends lifecycle-event emails to the requestor. "1" = enabled, "0" = silent (recommended for gateway-driven orders so end users aren't surprised by CA emails). Default: "0". + * **RequestorDesignation** - OPTIONAL: Job title / role of the requestor (e.g. 'IT Administrator'). Sent in V2 orders' `requestor.designation` field. Free text with no CA-side enum. Left blank by default, in which case the field is omitted entirely from the order rather than sent with a default value. + * **SignerPlace** - City or location of the subscriber agreement signer (e.g. 'San Francisco, CA'). REQUIRED when UseV2Api is on: the V2 Subscriber Agreement sent with every SSL order requires it, so the connector cannot be saved with it blank. A per-template SignerPlace enrollment parameter overrides it. + * **SignerIp** - IP address of the subscriber agreement signer. Sent in the order's agreement block; a template-level SignerIp enrollment parameter overrides it on V2. + * **DefaultProductCode** - OPTIONAL: Default numeric product code. V1: used for renewals when the template doesn't supply a product code (CERTInext's TrackOrder doesn't return the prior order's code). V2: disambiguates a ProductId-only template when the live catalog has more than one product at the same assurance level. Product codes are provided by eMudhra (e.g. the SSL DV 1-year code for your account). Retrieve available codes from Integrations → APIs → GetProductDetails. + * **AccountingModel** - OPTIONAL: CERTInext billing model sent in `orderDetails.accountingModel` on V1 orders. "2" = credit-based (most accounts, default). "1" = cash model. Not used by V2. + * **EmailNotifications** - OPTIONAL: Whether CERTInext sends lifecycle-event emails to the requestor. "1" = full notification set (V1 sends it as-is; V2 maps it to "all"). "0" = silent on both V1 and V2. Blank/unset stays silent on V1 (sent as "0") but is omitted on V2, so the CA's own default ("all", not silent) applies instead. Any other value fails V2 enrollment before any CA call. Default: "0" — V2 orders are silent by default, matching V1. * **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". * **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. + * **SubmitNonDnsSans** - OPTIONAL: If true (default), V1 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: the order issues, but the certificate will not contain the non-DNS names. Not consulted on V2 (UCC `additionalDomains` takes DNS names only; Private PKI `additionalHosts` takes DNS names and IP addresses). Default: true. + * **PageSize** - Number of orders to fetch per page during synchronization. Default: 100, max: 500 (V2 `/reports/orders` pages are capped at 100). * **Enabled** - Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true. - * **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. + * **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 (API keys, OAuth secrets, tokens) are always redacted regardless of this setting. Default: false. + * **PickupRetries** - OPTIONAL: Number of times Enroll() polls CERTInext for 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. OV/EV orders are issued asynchronously (organization verification, minutes to hours), so they typically exhaust the wait and are returned pending regardless of this value. + * **PickupDelay** - OPTIONAL: Number of seconds between certificate-pickup retries. A fixed 5-second initial delay plus PickupRetries times this value is the maximum time an enrollment call occupies a Command worker thread; the plugin caps the effective total at 180 seconds regardless of how PickupRetries/PickupDelay are set, reducing the retry count to fit. Target a total well under ~90s so the request does not time out. Default: 10 (a ~55s ceiling with the default retries). + * **DcvEnabled** - OPTIONAL: When true, the plugin performs DNS-based Domain Control Validation (DCV) during enrollment and synchronization for orders that require it, using the DNS provider plugin deployed on the gateway (e.g. azure-azuredns-dnsplugin). Without a DNS provider plugin, orders that need validation stay pending and the plugin logs why. Set to false to skip DCV. Default: true. * **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. - * **DcvTimeoutMinutes** - OPTIONAL: Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) before timing out the enrollment. Can also be set via the CERTINEXT_DCV_TIMEOUT_MINUTES environment variable; the env var takes precedence when both are set. Default: 10. - * **DcvWaitForChallengeSeconds** - OPTIONAL: How long (seconds) the plugin will wait inside Enroll() for CERTInext to expose the DCV challenge (i.e. populate `domainVerification` in TrackOrder). Under concurrent load CERTInext sometimes takes a few seconds after GenerateOrderSSL before the slot appears. Without this wait, the plugin's initial TrackOrder check sees null and skips DCV — the order then has to wait for the next gateway sync cycle to be picked up. Setting to 0 disables the wait (single-check behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60. - * **DcvWaitForIssuanceSeconds** - OPTIONAL: How long (seconds) the plugin will wait inside Enroll() after DCV verifies for CERTInext to finish generating the certificate. CERTInext issuance is async — DCV may be verified but the cert PEM isn't yet available for download. Without this wait, Enroll() returns a pending result and the issued cert is picked up by the next sync cycle. Setting to 0 disables the wait (single-fetch behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60. + * **DcvTimeoutMinutes** - OPTIONAL: Maximum minutes for the entire DCV flow (DNS publish + propagation + verify) for one order before it is abandoned and left pending. Can also be set via the CERTINEXT_DCV_TIMEOUT_MINUTES environment variable; the env var takes precedence when both are set. Default: 10. + * **DcvWaitForChallengeSeconds** - OPTIONAL (V1 only): How long (seconds) the plugin will wait inside Enroll() for CERTInext to expose the DCV challenge (i.e. populate `domainVerification` in TrackOrder). Under concurrent load CERTInext sometimes takes a few seconds after GenerateOrderSSL before the slot appears. Without this wait, the plugin's initial TrackOrder check sees null and skips DCV — the order then has to wait for the next gateway sync cycle to be picked up. Setting to 0 disables the wait (single-check behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60. + * **DcvWaitForIssuanceSeconds** - OPTIONAL (V1 only): How long (seconds) the plugin will wait inside Enroll() after DCV verifies for CERTInext to finish generating the certificate. CERTInext issuance is async — DCV may be verified but the cert PEM isn't yet available for download. Without this wait, Enroll() returns a pending result and the issued cert is picked up by the next sync cycle. Setting to 0 disables the wait (single-fetch behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60. * **DcvSyncMaxOrderAgeHours** - OPTIONAL: During synchronization, only pending DV orders younger than this many hours are eligible to be driven through DCV. This keeps a sync pass fast when there is a large backlog of old, never-completing pending orders (e.g. abandoned orders or domains outside the configured DNS provider's zone): they age out and are simply reported as pending rather than retried every pass. Recently-placed orders (the ones that legitimately deferred DCV) are always within the window and complete via the normal scan cadence. Set to 0 to disable the age filter (attempt DCV for all pending). Default: 24. * **DcvSyncMaxPerPass** - OPTIONAL: Maximum number of pending DV orders the plugin will attempt to drive through DCV in a single synchronization pass. Bounds the per-pass cost regardless of backlog size; remaining pending orders are reported as-is and picked up on a later pass (the per-minute incremental scan keeps recent orders moving). Set to 0 to disable the cap. Default: 50. + * **UseV2Api** - OPTIONAL: When true, the plugin routes Ping / Enroll / GetSingleRecord / Revoke / Synchronize through the CERTInext V2 REST API (/api/certinext/v2/), including V2 /reports/orders for Synchronize. Requires ApiUrl (the V2 base URL in this mode), OAuthClientId, OAuthClientSecret, and SignerPlace. V1 credentials (ApiKey/AccountNumber/AuthMode) are not required when this is true. Default: false (V1 API). + * **V2SyncLookbackHours** - OPTIONAL (V2 mode only): during an incremental Synchronize, the plugin queries V2 /reports/orders with a 'from' date of (lastSync minus this many hours) rather than exactly lastSync, since the API's from/to filter may bracket either the order-placement date or the issuance date. A lookback window ensures an order created before lastSync but issued afterward (e.g. a slow DCV order) still surfaces on the next incremental pass. Ignored when UseV2Api is false. Default: 72. 2. A Keyfactor Command certificate template maps an enrollment request to a specific CERTInext product. Create one template per CERTInext product that you want to make available to requesters. +The AnyCA Gateway REST portal also needs one certificate profile per product before Command templates can be created against it. Create the profiles by hand in the gateway portal, or with the helper script this repository ships (`make register-profiles`; set `DRY_RUN=1` to preview). See [scripts/register/README.md](https://github.com/Keyfactor/certinext-caplugin/blob/main/scripts/register/README.md) for authentication and options. + In the Keyfactor Command Management Portal, navigate to **Certificate Templates** and create a new template associated with the CERTInext CA connector. The following enrollment parameters are available: | 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: on V1, the built-in default code for the selected product name is used (see [Product Codes](#product-codes)); on V2, the code is resolved from the live product catalog (see [V2 Product Code Resolution](#v2-product-code-resolution)). Set this explicitly when targeting the sandbox environment or a non-standard code. Required for `ProductFamily=private-pki`. | 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` | +| `ValidityDays` | Deprecated | Number | Legacy validity field, V1 only. 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 | **Currently has no effect** — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. | `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` | -| `KeyType` | Optional | String | Key algorithm to request at enrollment time. The key type is carried by the submitted CSR. CERTInext accepts **RSA 2048 / 3072 / 4096 and ECC P-256 / P-384** only — larger RSA, ECC P-521, and the Ed25519/Ed448 curves are rejected by the CA (`Invalid key size`). If omitted, the product default is used. | `RSA2048`, `RSA3072`, `RSA4096`, `EC256`, `EC384` | -| `DomainName` | Optional | String | Primary domain name for SSL/TLS orders. If omitted, the gateway derives the domain from the CSR `CN` field. | `example.com` | -| `SignerName` | Optional | String | Per-template override for the subscriber agreement signer name. When omitted, defaults to the connector-level `RequestorName`. | `Jane Smith` | -| `SignerPlace` | Optional | String | Per-template override for the subscriber agreement signer location. When omitted, defaults to the connector-level `SignerPlace`. | `Austin` | -| `SignerIp` | Optional | String | Per-template override for the subscriber agreement signer IP address. When omitted, defaults to the connector-level `SignerIp`. | `203.0.113.10` | +| `RenewalWindowDays` | Optional | Number | V1 only. Number of days before certificate expiration within which a renewal is attempted instead of a reissue. V2 places a new order for every renewal and reissue. Default: `90`. | `90` | +| `KeyType` | Optional | String | Informational. The key algorithm is determined by the submitted CSR, not by this parameter. CERTInext accepts **RSA 2048 / 3072 / 4096 and ECC P-256 / P-384** only — larger RSA, ECC P-521, and the Ed25519/Ed448 curves are rejected by the CA (`Invalid key size`). | `RSA2048`, `RSA3072`, `RSA4096`, `EC256`, `EC384` | +| `DomainName` | Optional | String | V2 only: primary domain name for SSL/TLS orders (for Private PKI, the primary hostname). If omitted, the plugin uses the CSR subject `CN`. V1 always uses the CSR subject `CN`. | `example.com` | +| `SignerName` | Optional | String | V2 only: per-template override for the subscriber agreement signer name. When omitted, defaults to the requestor name. V1 uses the connector-level `RequestorName`. | `Jane Smith` | +| `SignerPlace` | Optional | String | V2 only: per-template override for the subscriber agreement signer location. When omitted, defaults to the connector-level `SignerPlace`. V1 uses the connector-level value. | `Austin` | +| `SignerIp` | Optional | String | V2 only: per-template override for the subscriber agreement signer IP address. When omitted, defaults to the connector-level `SignerIp`. V1 uses the connector-level value. | `203.0.113.10` | + +The V2-only template parameters `ProductFamily` and `ProductVariant` are described under [V2 Certificate Template Fields](#v2-certificate-template-fields). 3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. 4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters: - * **ProductCode** - OPTIONAL: Override the numeric CERTInext product code for this template. When omitted, the default production code for the selected product is used automatically (e.g. DV SSL → 838). Set this explicitly when targeting sandbox or a non-standard code. + * **ProductCode** - OPTIONAL: Override the numeric CERTInext product code for this template. When omitted: on V1, the default production code for the selected product is used (e.g. DV SSL → 838); on V2, the code is resolved from the live CERTInext product catalog by matching the selected product, so it stays correct even though V2 catalog numbering varies by account. Required for ProductFamily 'private-pki'. Set this explicitly when targeting sandbox or a non-standard code. * **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. + * **ValidityDays** - DEPRECATED: Use ValidityYears instead. V1 only: if set, value is divided by 365 and rounded up to get the subscription year count. + * **AutoApprove** - Currently has no effect — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. Default: false. * **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. - * **KeyType** - OPTIONAL: Key algorithm to request (e.g. 'RSA2048', 'RSA4096', 'EC256', 'EC384'). If omitted, the profile default is used. - * **DomainName** - OPTIONAL: Primary domain for SSL/TLS orders. Derived from the CSR CN if omitted. - * **SignerName** - OPTIONAL: Per-template subscriber agreement signer name. Falls back to the connector-level RequestorName if omitted. - * **SignerPlace** - OPTIONAL: Per-template signer city/location. Falls back to the connector-level SignerPlace if omitted. - * **SignerIp** - OPTIONAL: Per-template signer IP address. Falls back to the connector-level SignerIp if omitted. + * **RenewalWindowDays** - OPTIONAL: V1 only. 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. V2 places a new order for every renewal and reissue. Default: 90. + * **KeyType** - OPTIONAL: Informational. The key algorithm is determined by the submitted CSR; CERTInext accepts RSA 2048/3072/4096 and ECC P-256/P-384 and rejects larger RSA, ECC P-521, and Ed25519/Ed448. + * **DomainName** - OPTIONAL: Primary domain for SSL/TLS orders (for V2 private-pki orders, the primary hostname). Derived from the CSR CN if omitted. + * **SignerName** - OPTIONAL: V2 only. Per-template subscriber agreement signer name. Falls back to the requestor name if omitted. V1 uses the connector-level RequestorName. + * **SignerPlace** - OPTIONAL: V2 only. Per-template signer city/location. Falls back to the connector-level SignerPlace if omitted. V1 uses the connector-level SignerPlace. + * **SignerIp** - OPTIONAL: V2 only. Per-template signer IP address. Falls back to the connector-level SignerIp if omitted. V1 uses the connector-level SignerIp. + * **ProductFamily** - V2 ONLY: Product family for this template. Accepted values: 'ssl' (default) or 'private-pki'. 'private-pki' requires an explicit ProductCode and a Private PKI ProductVariant. 'signature' (Document Signer) is accepted by the parameter, but Document Signer enrollment is not supported. + * **ProductVariant** - V2 ONLY: Product variant sent in the V2 order body. ProductFamily 'ssl': 'dv', 'ov', or 'ev' — if omitted, derived from the selected product; an explicit value that contradicts the product fails enrollment. ProductFamily 'private-pki': 'intranet-ssl' or 'igtf-host' (required; no default). ## CERTInext API Setup @@ -223,7 +242,7 @@ Enter the copied value in the `ApiKey` field of the CA connector configuration. ### OAuth — alternative auth mode -If your CERTInext account has OAuth enabled, you can use OAuth client credentials as an alternative to AccessKey signing. +If your CERTInext account has OAuth enabled, you can use OAuth client credentials as an alternative to AccessKey signing on the V1 API. The V2 API always authenticates with OAuth client credentials (see [V2 OAuth2 Setup](#v2-oauth2-setup)). 1. Log in to the CERTInext portal. 2. Navigate to **Integrations → APIs**. @@ -238,32 +257,64 @@ 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 enforces `ApiUrl` (https, or http for a loopback host); for V1, `AccountNumber` and the credential fields for the selected `AuthMode`; for V2, `OAuthClientId`, `OAuthClientSecret`, and `SignerPlace`. 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 | |---|---|---|---|---| -| `ApiUrl` | Required | CERTInext API base URL for your environment. Must include the `/emSignHub-API/` path segment. No trailing slash is required but is accepted. | See the environments table above. | `https://api.certinext.io/emSignHub-API/` | -| `AccountNumber` | Required | Your CERTInext account number (numeric string). Included in the `meta` block of every API request. | Portal → click your name or avatar → **Account Settings** or **My Profile**. | `1234567890` | -| `AuthMode` | Required | Authentication mode. `AccessKey` uses HMAC signing (recommended). `OAuth` uses a bearer token. | N/A — choose based on the credential type you created. | `AccessKey` | -| `ApiKey` | Conditional | The REST API Access Key generated in the CERTInext portal. Used to compute `authKey = SHA256(accessKey + ts + txn)`. The raw key is never transmitted. Required when `AuthMode` is `AccessKey`. This field is masked in the UI. | Portal → **Integrations → APIs** → generate or view the credential row. | *(generated, masked in UI)* | -| `OAuthTokenUrl` | Conditional | OAuth token endpoint URL. Required when `AuthMode` is `OAuth`. | Provided by eMudhra for your account. | `https://auth.certinext.io/oauth/token` | -| `OAuthClientId` | Conditional | OAuth client ID. Required when `AuthMode` is `OAuth`. | Portal → **Integrations → APIs** → the OAuth credential row. | `keyfactor-gateway` | -| `OAuthClientSecret` | Conditional | OAuth client secret. Required when `AuthMode` is `OAuth`. This field is masked in the UI. | Generated at OAuth credential creation time. | *(generated, masked in UI)* | +| `ApiUrl` | Required | CERTInext API base URL. In V1 mode (default), must include the `/emSignHub-API/` path segment. When `UseV2Api` is `true` (see [V2 API](#v2-api) below), this is instead the bare V2 host with no trailing slash or path suffix — the two APIs are hosted differently, so this value changes when `UseV2Api` is toggled. Must use `https` — the OAuth client secret (V2) or API key (V1) is sent to this URL on every request, and `http` would transmit it in cleartext. `http` is rejected at connection-validation time except for a loopback host (`localhost`/`127.0.0.1`/`::1`), which is allowed for local test servers only. | See the environments table above. | `https://api.certinext.io/emSignHub-API/` | +| `AccountNumber` | Required (V1) | Your CERTInext account number (numeric string). Included in the `meta` block of every V1 API request. Not used when `UseV2Api` is `true`. | Portal → click your name or avatar → **Account Settings** or **My Profile**. | `1234567890` | +| `AuthMode` | Required (V1) | Authentication mode. `AccessKey` uses HMAC signing (recommended). `OAuth` uses a bearer token. Ignored when `UseV2Api` is `true`. | N/A — choose based on the credential type you created. | `AccessKey` | +| `ApiKey` | Conditional | The REST API Access Key generated in the CERTInext portal. Used to compute `authKey = SHA256(accessKey + ts + txn)`. The raw key is never transmitted. Required when `AuthMode` is `AccessKey` (V1). Not used when `UseV2Api` is `true`. This field is masked in the UI. | Portal → **Integrations → APIs** → generate or view the credential row. | *(generated, masked in UI)* | +| `OAuthTokenUrl` | Conditional | OAuth token endpoint URL. Required when `AuthMode` is `OAuth` (V1); must use `https`. Not used when `UseV2Api` is `true` — V2 requests its token from `{ApiUrl}/oauth/token`. | Provided by eMudhra for your account. | `https://auth.certinext.io/oauth/token` | +| `OAuthClientId` | Conditional | OAuth client ID. Required when `AuthMode` is `OAuth` (V1). Also required — and reused as-is — when `UseV2Api` is `true`; V2 does not have its own separate client ID field. | Portal → **Integrations → APIs** → the OAuth credential row. | `keyfactor-gateway` | +| `OAuthClientSecret` | Conditional | OAuth client secret. Required when `AuthMode` is `OAuth` (V1). Also required — and reused as-is — when `UseV2Api` is `true`. This field is masked in the UI. | Generated at OAuth credential creation time. | *(generated, masked in UI)* | | `RequestorName` | Required | Default name of the person or service submitting certificate orders. Sent in the `requestorInformation` block of every order request. | Use the name of the team or automation account responsible for these certificates. | `PKI Automation` | | `RequestorEmail` | Required | Default email address for the requestor. Must be a valid email address associated with your CERTInext account. Sent in the `requestorInformation` block of every order request. | Use a monitored team inbox or the account holder's email. | `pki-admin@example.com` | | `RequestorIsdCode` | Optional | International dialing code for the requestor phone number (digits only, no `+` prefix). Default: `1` (United States). | N/A — use the country code for your requestor. | `1` | | `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` | +| `RequestorDesignation` | Optional | Job title / role of the requestor (e.g. `IT Administrator`). Sent in the `requestorInformation` block (V1) and the `requestor.designation` field (V2). Free text with no CA-side enum. Left blank by default, in which case the field is omitted from the order entirely rather than sent with a default value. | N/A | `IT Administrator` | +| `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. When `UseV2Api` is `true` the connector can't be saved with this blank, because the V2 Subscriber Agreement sent with every SSL order requires it (a template's `SignerPlace` enrollment parameter still overrides it). | 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 V1 SSL order. On V2 it is sent as `groupNumber` on order create and as a query parameter on the catalog and orders-report calls. 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 (potentially tens of hours). | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | +| `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` on every SSL order (V1 and V2). 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` on V1 orders. `2` = credit-based (most accounts). `1` = cash model. Not used by V2. Default: `2`. | N/A | `2` | +| `EmailNotifications` | Optional | Whether CERTInext sends lifecycle-event emails to the requestor. `1` = full notification set (V1 sends it as-is; V2 maps it to `all`). `0` = silent on both V1 and V2. Blank/unset stays silent on V1 (sent as `0`) but is omitted on V2, so the CA's own default (`all`, not silent) applies instead. Any other value fails V2 enrollment before any CA call. Default: `0` — V2 orders are silent by default, matching V1. | 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 | V1 only. 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 submit DNS names only. Not consulted on V2 (see [Migrating from V1 to V2](#step-2--update-the-ca-connector)). Default: `true`. | N/A | `true` | +| `DefaultProductCode` | Optional, but effectively required if you use renewals (V1), or ProductId-only templates against an ambiguous V2 catalog | **V1 mode:** used for renewals only, and only when the template doesn't supply a product code (`ProductCode`/`ProfileId`) — CERTInext's `TrackOrder` doesn't return the prior order's product code. If neither is set, renewals go out with an empty product code. Has no effect on new V1 enrollments. **V2 mode:** also used to disambiguate a template that sets only `ProductId` (no explicit `ProductCode`) when the live V2 catalog has more than one product sharing the product's expected assurance level — if this value doesn't match one of the candidate codes, that enrollment (and template save-time validation) fails with an error listing them. | 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` | +| `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500` (V2 `/reports/orders` pages are capped at `100`). 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` | -| `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` | +| `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 (API keys, 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 entirely. Values above `30` are treated as `30`. 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. The total pickup budget is a fixed 5-second initial delay + (`PickupRetries` × `PickupDelay`), hard-capped at 180 seconds regardless of how the two values are set. Aim for well under ~90s total so the call doesn't run long enough to trip Command's own enrollment timeout. Values above `60` are treated as `60`; a non-positive value falls back to `10`. Default: `10` (a ~55s ceiling with default `PickupRetries`). | N/A | `10` | +| `DcvEnabled` | Optional | When `true`, the plugin performs DNS-based Domain Control Validation (DCV) during enrollment and synchronization for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway; without one, orders that need validation stay pending and the plugin logs why. Set to `false` if you validate domains another way. Default: `true`. | N/A | `true` | | `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` | -| `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` | +| `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. On V1, DCV driven during synchronization uses its own fixed 3-second delay; V2 uses this value everywhere. Default: `30`. | N/A | `30` | +| `DcvTimeoutMinutes` | Optional | Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) before giving up on the order, which stays pending. 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 | V1 only. 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 | V1 only. 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` | + +> **Pickup timing detail:** after a successful order placement, the plugin waits a fixed 5-second initial delay before the first poll attempt, then polls CERTInext every `PickupDelay` seconds up to `PickupRetries` times. Each poll calls `GetCertificate` to check whether the certificate has been issued. The total time budget is: **5s + (PickupRetries × PickupDelay) + API round-trip time per poll (~1s each)**. With defaults this is approximately 5 + (5 × 10) + 5 = **~60 seconds**. +> +> **Tuning for faster pickup:** if the CERTInext API typically issues certificates within a few seconds of order placement (as is typical for DV and auto-approved orders), you can reduce per-enrollment wait time by lowering `PickupDelay` and raising `PickupRetries` to compensate — this polls more frequently without changing the total budget. For example: +> +> | Configuration | PickupRetries | PickupDelay | Total budget | Poll cadence | +> |---------------|:---:|:---:|---|---| +> | Default | `5` | `10` | ~55s | Every 10s | +> | Faster polling | `10` | `5` | ~55s | Every 5s | +> | Aggressive | `30` | `2` | ~65s | Every 2s | +> | Minimal wait | `0` | — | 0s | No polling; defers to sync | +> +> The 5-second initial delay before the first poll is not configurable. The 180-second hard ceiling applies regardless of configuration. Pickup applies to V1 and V2 enrollments. When the DCV flow ran for the order inside the same `Enroll` call, the pickup poll is skipped, because the DCV flow already waited for issuance. > 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. @@ -271,7 +322,7 @@ The following fields are presented in the Keyfactor Command Management Portal wh ## Product Codes -CERTInext uses numeric product codes to identify certificate types. **Product codes are provisioned per account by eMudhra** — the codes available to your account are determined when your account is set up. The codes in the tables below are the values observed on specific sandbox and production accounts; your account may have different codes. +CERTInext uses numeric product codes to identify certificate types. **Product codes are provisioned per account by eMudhra** — the codes available to your account are determined when your account is set up. The codes in the tables below are example values for the sandbox and production environments; your account may have different codes. To retrieve the exact codes available to your account, call the `GetProductDetails` endpoint: - If you have a `GroupNumber` configured, include it in the request `productDetails` block — some accounts require this to return a non-empty list. @@ -283,9 +334,9 @@ To retrieve the exact codes available to your account, call the `GetProductDetai ### SSL/TLS -The product codes in this table were observed on: -- the US sandbox environment (`sandbox-us-api.certinext.io`) in April–May 2026 -- the Production India environment (`api.certinext.io`) via the live draft-order coverage matrix in [development.md](development.md) +The product codes in this table are for: +- the US sandbox environment (`sandbox-us-api.certinext.io`) +- the Production India environment (`api.certinext.io`) **Your account may still have different codes.** Always call `GetProductDetails` against your target environment before going live. @@ -302,7 +353,7 @@ The product codes in this table were observed on: | EV (Extended Validation) | `850` | `846` | All OV fields plus: `contractSignerInfo` object (`name`, `email`, `isdCode`, `mobileNumber`, `designation`, `employeeID`); `certificateApproverInfo` object (same fields); `certificateInformation.companyRegistrationNumber`; `streetAddress2` must be non-empty. | | EV UCC (Multi-domain EV) | `851` | `847` | Same as EV plus `certificateInformation.additionalDomains`. | -> Note: SSL/TLS codes appear to be offset by 4 between the US sandbox and Production India in the snapshots we've observed — but treat that as a coincidence, not a guarantee. eMudhra controls the per-account mapping and may use different numeric codes for any new account. Always confirm via `GetProductDetails`. +> Note: SSL/TLS codes are offset by 4 between the US sandbox and Production India in the tables above — treat that as a coincidence, not a guarantee. eMudhra controls the per-account mapping and may use different numeric codes for any new account. Always confirm via `GetProductDetails`. > Note: The CERTInext portal may display additional short-validity products (e.g. **DV SSL Certificate 1 Month**, **DV SSL Certificate Wildcard 1 Month**) that do not appear in the `GetProductDetails` API response and have no published product code. These products are not accessible via the API and are therefore **not supported by this plugin**. Contact eMudhra to determine whether API ordering is available for these products on your account. @@ -311,26 +362,28 @@ The product codes in this table were observed on: | Product | Sandbox Code | Production Code | Availability | |---|---|---|---| | emSign Intranet SSL 1 year | `149` | `100` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | -| IGTF Host 1 year | (not observed) | `104` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | +| IGTF Host 1 year | n/a | `104` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | -> Note: Private PKI products are not available for ordering on standard CERTInext accounts. Attempting to place an order will return EMS-1162 (product not provisioned). The sandbox Private PKI code (`149`) also returns EMS-1162 on standard sandbox accounts even though it appears in the `GetProductDetails` list. Contact eMudhra to have these products enabled on your account. +> Note: Private PKI products need a separate entitlement. On an account without it, placing an order returns EMS-1162 (product not provisioned). Contact eMudhra to have these products enabled. Whether the sandbox code `149` ("Sandbox emSign Intranet SSL 1 Year", `productTypeID` `39`) is available depends on the account. Check your own account's catalog. ### S/MIME and Document Signing -The same numeric product codes have been observed for S/MIME and document-signing products on both the US sandbox and Production India in the snapshots we have. **Treat that as an empirical observation, not a contract** — eMudhra is free to assign different codes per account. Always confirm via `GetProductDetails`. +The same numeric product codes are used for S/MIME and document-signing products on both the US sandbox and Production India. **Treat that as an example, not a contract** — eMudhra is free to assign different codes per account. Always confirm via `GetProductDetails`. | Product | Sandbox / Production Code | Availability | |---|---|---| | S/MIME | `894` | Requires a separate S/MIME entitlement on the account. Not available on standard SSL accounts. | -| Natural Person Doc Signer (tier 1) | `825` | Requires document signing entitlement. Not orderable on standard accounts. | -| Natural Person Doc Signer (tier 2) | `826` | Requires document signing entitlement. Not orderable on standard accounts. | -| Natural Person Doc Signer (tier 3) | `827` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 1) | `822` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 2) | `823` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 3) | `824` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 1) | `819` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 2) | `820` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 3) | `821` | Requires document signing entitlement. Not orderable on standard accounts. | +| Document Signer | `819`–`827` | Requires document signing entitlement. Not orderable on standard accounts. See the code-to-product table below. | + +CERTInext's published references don't agree on which Document Signer code maps to which product, so +confirm the product name for each code in your account's catalog before you use one. The V2 API spec +lists: + +| Code | Product (per V2 API spec) | +|---|---| +| `819` / `820` / `821` | Natural Person, 1 / 2 / 3 year | +| `822` / `823` / `824` | Legal Person, 1 / 2 / 3 year | +| `825` / `826` / `827` | Legal Entity, 1 / 2 / 3 year | > Note: S/MIME (894) and document signing products (819–827) require a separate entitlement that is not included in a standard SSL/TLS account. Contact eMudhra to request access. @@ -338,15 +391,371 @@ To retrieve the full list of product codes available to your account, call the ` > Note: SSL/TLS products are supported on standard accounts — see the SSL/TLS table above for the exact sandbox/production code pair for each product. Private PKI (Production `100`, `104` / Sandbox `149`), S/MIME (`894`), and document-signing products (`819`–`827`) require special provisioning by eMudhra and are not available on standard SSL/TLS accounts — ordering them returns EMS-1162. +## V2 API + +The plugin includes an opt-in CERTInext V2 REST API code path that uses OAuth2 `client_credentials` authentication and an order-centric resource model. V2 is disabled by default; V1 remains the active path unless `UseV2Api` is explicitly set to `true`. When enabled, V2 is fully self-contained: Ping, Enroll, GetSingleRecord, Revoke, and Synchronize all route through the V2 API, and V1 credentials (`ApiKey`, `AccountNumber`, `AuthMode`) are not required. Known limitations are listed under [Known Gaps](#known-gaps). + +### V2 CA Connector Fields + +V2 mode reuses the connector's `ApiUrl`, `OAuthClientId`, and `OAuthClientSecret` fields (documented above) rather than separate V2-only credentials — `ApiUrl` becomes the V2 host and `OAuthClientId`/`OAuthClientSecret` authenticate against it, regardless of `AuthMode`. Only the fields below are specific to V2 mode: + +| Field | Required / Optional | Description | Example | +|---|---|---|---| +| `UseV2Api` | Optional | Enable the V2 API code path for connection tests, enrollment, revocation, status checks, and synchronization. Default: `false`. | `false` | +| `V2SyncLookbackHours` | Optional | V2 mode only. During an incremental Synchronize, the plugin queries `from` = (last sync time minus this many hours) rather than the exact last-sync time, since the API's `from`/`to` filter may bracket either the order-placement date or the issuance date — a lookback window keeps an order created before last sync but issued afterward (e.g. a slow DCV order) from being missed. Default: `72`. | `72` | + +#### V2 OAuth2 Setup + +1. Log in to the CERTInext portal for your environment. +2. Navigate to **Integrations → APIs**. +3. Click **+ Create API Credentials**, set **API Type** to `REST`, and select the **OAuth** auth type (not `Access Key`). The V2 spec requires the key to be generated in OAuth mode. A key that wasn't gets HTTP 403 `unauthorized_client` at token time. +4. Note the client ID and client secret. Enter them in `OAuthClientId` and `OAuthClientSecret`. The V2 spec's token example uses the account number as `client_id`, but the plugin never substitutes `AccountNumber` for it, so set `OAuthClientId` explicitly. See [Step 1 of the migration guide](#step-1--create-a-v2-oauth2-credential) for notes on reusing V1 OAuth keys. +5. Set `UseV2Api` to `true` and set `ApiUrl` to the V2 base URL (no trailing path suffix), e.g. `https://sandbox-us-api.certinext.io`. +6. V1-only fields (`ApiKey`, `AccountNumber`, `AuthMode`) are not required in this mode and can be left blank. + +#### V2 Token Caching + +The plugin obtains a V2 bearer token via the standard OAuth2 `client_credentials` grant (`grant_type=client_credentials`, form-encoded) against `{ApiUrl}/oauth/token`. Tokens are cached in memory and reused until 60 seconds before expiry (minimum 30-second cache). Token refresh is thread-safe. + +### V2 Certificate Template Fields + +When `UseV2Api` is `true`, two additional enrollment parameters become relevant: + +| Parameter | Required / Optional | Type | Description | Example / Default | +|---|---|---|---|---| +| `ProductFamily` | Optional | String | CERTInext V2 product family. Supported for enrollment: `ssl` (SSL/TLS) and `private-pki` (Private PKI — see [V2 Private PKI Orders](#v2-private-pki-orders)). `signature` (Document Signer) is accepted by the parameter, but Document Signer enrollment is not supported: a `signature` enrollment fails before any order is placed. An unrecognized value is treated as `ssl`. Default: `ssl`. | `ssl` | +| `ProductVariant` | Optional | String | Product variant within the family. `ssl`: `dv`, `ov`, or `ev`. If omitted, the plugin derives it from the selected product (e.g. an OV product sends `ov`, an EV product sends `ev`) rather than always defaulting to `dv`; an explicit override that contradicts the product's derived variant fails enrollment with an actionable error instead of being sent as-is. `private-pki`: `intranet-ssl` or `igtf-host` — required, with no default. | `dv` | + +`ProductCode` continues to carry the numeric product code and is sent in the `X-Product-Code` header on V2 order placement. + +### V2 Product Code Resolution + +When `UseV2Api` is `true`, the numeric product code sent to CERTInext is resolved as follows: + +1. **Explicit `ProductCode` (or the deprecated `ProfileId` alias) on the template** — sent as-is in the `X-Product-Code` header, after template save-time validation confirms it exists in the live V2 catalog. +2. **No explicit code set** — the plugin maps the template's selected product to the catalog's expected `productTypeID` and looks for catalog entries sharing it: + - **Exactly one match** — used automatically. + - **No match** — enrollment (and template save-time validation) fails; the account may not be entitled to the product. + - **More than one match** — the live catalog can carry several entries at the same assurance level (e.g. two DV SSL entries with different billing terms). The connector's `DefaultProductCode` must name one of them, or enrollment fails with an error listing every candidate code and name. Set `ProductCode` explicitly on the template, or set `DefaultProductCode` on the connector, to disambiguate. + +This differs from V1, where `DefaultProductCode` only affects renewals (see the [`DefaultProductCode` field](#ca-configuration) above) — in V2 mode it also disambiguates new enrollments and template validation for a `ProductId`-only template. + +### V2 Private PKI Orders + +With `ProductFamily=private-pki`, the plugin places the order against CERTInext's Private PKI endpoint using the Private PKI request body, which differs from the SSL/TLS one: + +- **Product code is required.** Set `ProductCode` explicitly to your account's Private PKI catalog code. Private PKI codes vary per customer catalog, so the plugin can't look one up from the product selected on the template. Template validation checks that the code exists in the V2 catalog and is a Private PKI product (catalog `productTypeID` `39`). +- **Variant is required.** Set `ProductVariant` to `intranet-ssl` or `igtf-host`. +- **Hostname.** The order's primary `hostname` comes from `DomainName`, or from the CSR's CN when `DomainName` isn't set. +- **SANs, including IP addresses.** Additional SANs are sent in the order's `additionalHosts` field, which accepts DNS names and IPv4/IPv6 addresses. SANs come from the gateway's SAN list; the plugin falls back to the SANs in the CSR only when the gateway supplies none. Email and URI SANs can't be expressed in `additionalHosts`, so they're left off the order and a warning is written to the gateway log. `SubmitNonDnsSans` isn't consulted for Private PKI orders. +- **No DCV, organization, or subscriber agreement.** Private PKI orders have none of these steps, so DCV is never attempted for them, and `OrganizationNumber`, `AutoSecureWww`, `SignerName`, `SignerPlace`, and `SignerIp` aren't used. +- **Shared fields.** The requestor, technical contact, subscription, email-notification, and group settings are sent exactly as they are for SSL/TLS orders. + +### V2 Order Lifecycle + +A V2 enrollment places the order, submits the CSR, and then checks the order status; see [V2 enrollment flow](#v2-enrollment-flow) for the full sequence. V2 orders are identified by the `orderId` the V2 order placement endpoint returns, which the plugin stores unchanged as the `CARequestID` and uses for all later tracking, certificate download, and revocation calls. The V2 spec's examples show `ord_`-prefixed IDs, but V2 returns numeric order numbers in the same format as V1 (e.g. `6625262451`). Treat the ID as an opaque string. + +V2 status strings map to Keyfactor enrollment statuses as follows: + +| V2 Status | Keyfactor Status | Notes | +|---|---|---| +| `issued` | Issued | Certificate is immediately downloaded and returned to Command. | +| `pending-dcv` | Pending External Validation | Order is awaiting domain control validation. | +| `pending-csr` | Pending External Validation | Order is awaiting CSR submission or processing. | +| `pending-agreement` | Pending External Validation | Order requires subscriber agreement acceptance. | +| `pending-organization-verification` | Pending External Validation | OV/EV order is awaiting organization verification. | +| `pending-documents` | Pending External Validation | Order is awaiting supporting document submission. | +| `pending-approval` | Pending External Validation | Order is awaiting final CA/LRA approval before issuance. | +| `revoked` | Revoked | Order has been revoked. | +| `cancelled` | Failed | Order was cancelled; a new enrollment is required. | +| `rejected` | Failed | Order was rejected by the CA/LRA; a new enrollment is required. | +| `expired` | Issued | An expired-but-not-revoked order is reported as issued (GENERATED), matching V1's convention — it remains visible in Command's inventory rather than disappearing as a failure. | +| `unknown` | Pending External Validation | CERTInext can't currently report where the order is; the order is kept pending and re-checked on the next status poll or sync, and the plugin logs a warning. | + +Any V2 status not in this table (e.g. a value CERTInext adds in the future) maps to Failed, and the +plugin logs a warning distinguishing "unmapped status" from the statuses above that are deliberately +mapped to Failed — see the gateway trace log if certificates unexpectedly show as failed. + +**V2 synchronization.** Synchronize pages through `/reports/orders` (pages are capped at 100 rows; `PageSize` is clamped to that). The report carries display strings (for example `Order Fulfilled`, `Certificate Downloaded`) rather than the status enum above. The plugin maps the strings it recognizes and falls back to a live order-status call for any combination it doesn't, so a new CERTInext display value never silently misclassifies an order. Cancelled and rejected orders are skipped; issued orders have their certificate chain downloaded. CERTInext does not serve the certificate body of a revoked order, so a revoked order is propagated as revoked only when the gateway already holds that certificate (the revocation date and reason come from a live status call). Otherwise it is reported as Failed (the gateway has a record without a body) or skipped (the gateway has no record), so the gateway never stores a revoked record with no certificate. + +V2 has no *renew* endpoint. CERTInext does document a `/reissue` endpoint (`mode: rekey|update-sans`, with optional `revokePrevious`/`revokeReason`), but the plugin does not use it — every enrollment type (New, Reissue, Renew, RenewOrReissue) places a fresh V2 order, and the prior order/certificate is left issued rather than auto-revoked. + +### V2 Revocation Reason Handling + +CERTInext's V2 revoke endpoint accepts only a subset of its own documented reason enum. When Command's revoke reason maps to one CERTInext rejects, the plugin substitutes an accepted reason and retries once, rather than failing the revoke outright: CA-compromise and AA-compromise are retried as key-compromise; unspecified (Command's default when no reason is given) and certificate-hold are retried as cessation-of-operation. See [Revocation Reason Codes](#revocation-reason-codes) in the migration guide below for the full accepted/rejected matrix. + +## Migrating from V1 to V2 + +The CERTInext V2 REST API is an opt-in, order-centric API with OAuth2 authentication. It is +controlled entirely by the `UseV2Api` connector flag: `false` (default) keeps the connector on the +V1 API documented above; `true` switches **all** operations — Ping, Enroll, GetSingleRecord, Revoke, +and Synchronize — to V2. The two APIs cannot be mixed on a single connector. + +> Read [Known Gaps](#known-gaps) before migrating a production connector. The most important: +> Document Signer (`ProductFamily=signature`) enrollment isn't supported, per-SAN DCV on +> multi-domain (UCC) orders isn't validated end to end, and every renewal places a new order. + +### Before You Begin: Confirm V2 Will Work for Your Templates + +**V2 supports multi-domain (UCC) certificates**, including **DV UCC, DV Wildcard UCC, OV UCC, OV +Wildcard UCC, and EV UCC**. The plugin detects a UCC product from the live catalog's `productTypeID` +and sends the extra SAN domains in the order's `additionalDomains` field. Per the CERTInext V2 spec, +a UCC order's SANs are taken from the order, not the CSR. `additionalDomains` takes DNS names only, +so any non-DNS SAN (IP, email, URI) is left off a UCC order and a warning is written to the gateway +log. For a non-UCC SSL product, a CSR or SAN list that carries DNS names beyond the primary domain +and its `www.` variant is rejected before any order is placed; UCC products are exempt from that +check. + +**UCC DCV: the plugin runs DCV for each SAN, but per-SAN DCV on V2 isn't validated end to end.** For a +UCC order, the plugin's DNS-01 DCV flow publishes, verifies, and cleans up a TXT record for each domain +that CERTInext reports as not yet validated, not just the primary domain. Test each UCC template on +the sandbox before you rely on it in production, and keep it on a V1 connector if you need a +validated path today. + +**DCV needs a DNS provider plugin.** `DcvEnabled` defaults to `true`. If your gateway has no DNS +provider plugin for the order's domains, V2 DV orders stay pending after the order is placed. Deploy +a DNS provider plugin, or set `DcvEnabled` to `false` and validate domains another way. See +[DCV under V2](#dcv-under-v2). + +### Step 1 — Create a V2 OAuth2 Credential + +V2 authenticates with an OAuth2 `client_credentials` token, and the CERTInext V2 spec requires the +API key to be generated in **OAuth mode**. The credential goes in the connector's +`OAuthClientId`/`OAuthClientSecret` fields: + +1. Log in to the CERTInext portal for your environment. +2. Navigate to **Integrations → APIs**. +3. Click **+ Create API Credentials**. +4. Set **API Type** to `REST` and select the **OAuth** auth type, not `Access Key`. +5. Complete the form and click **Generate**. +6. Note the client ID and client secret right away. + +A V1 `Access Key` credential won't work against V2. If the key wasn't generated in OAuth mode, the +token request fails with HTTP 403 `unauthorized_client`, and the plugin reports that the key wasn't +generated in OAuth mode. A wrong client ID or secret fails with HTTP 401 `invalid_client` instead. +A key created for V1's `AuthMode: OAuth` isn't guaranteed to work on V2. If you reuse one and get the +403, create a new OAuth-mode key. + +The V2 spec's token example sends the account number as `client_id`. The plugin never substitutes +`AccountNumber` for the client ID, so always set `OAuthClientId` explicitly to the client ID shown in +the portal, even if the value matches your account number. + +### Step 2 — Update the CA Connector + +You can update the existing CA connector in place, or (recommended for a first migration) create a +second connector pointed at the same CERTInext account with `UseV2Api=true`, so you can validate V2 +behavior without disrupting V1 traffic. + +Set `UseV2Api` to `true`, change `ApiUrl` to the V2 host, set `OAuthClientId` and +`OAuthClientSecret`, and make sure `SignerPlace` is set (the connector can't be saved with it blank +in V2 mode). The remaining V1 fields behave as follows: + +| V1 field | What happens when you set `UseV2Api = true` | +|---|---| +| `ApiUrl` | **Must change format.** V1 requires the `/emSignHub-API/` path segment (e.g. `https://us-api.certinext.io/emSignHub-API/`); V2 is the bare host with no trailing slash or path suffix (e.g. `https://us-api.certinext.io`). Using the V1-style URL under V2 (or vice versa) will fail every call. In both modes, `ApiUrl` must use `https` — `http` is rejected at connection-validation time and at startup except for a loopback host, which stays allowed for local test servers. | +| `AccountNumber` | Not required, and not read by any V2 code path. V2 authenticates with `OAuthClientId`; the plugin doesn't reuse `AccountNumber` as the OAuth `client_id` (see Step 1). | +| `AuthMode` | Not required. V2 always authenticates via OAuth2 `client_credentials`, regardless of this setting. | +| `ApiKey` | Not required. V2 never computes an `authKey`. | +| `OAuthClientId` / `OAuthClientSecret` | **Reused, but repointed.** Set them to the OAuth-mode credential from Step 1. A V1 `AuthMode: OAuth` key isn't guaranteed to work on V2 (see Step 1). | +| `OAuthTokenUrl` | Not used. V2 always requests a token from `{ApiUrl}/oauth/token`; the token URL is derived, not configured. | +| `GroupNumber` | **Honored.** Sent as `groupNumber` on V2 order create (SSL/TLS and Private PKI) and as a `groupNumber` query parameter on the catalog and orders-report calls. Omitted when blank, so the account's default group applies. | +| `OrganizationNumber` | **Required for OV/EV, otherwise unused.** V2 OV/EV orders send `organization.organizationNumber` (with `preVetted=true`) from this setting — CERTInext hard-rejects an OV/EV order with no organization data (HTTP 422 `EMS-1180`), so `OrganizationNumber` must be set on the connector before enrolling OV/EV certificates via V2; the plugin fails the enrollment before any CA call if it is blank. DV orders never send an organization block, so this setting has no effect for DV. | +| `AccountingModel` | Not used by V2 order placement. | +| `EmailNotifications` | **Honored, with one default-value difference from V1.** `1` maps to `emailNotifications: "all"`; `0` maps to `"0"`. Blank/unset is omitted on V2 (the CA's own default of `"all"` applies) rather than sent as `"0"` the way V1's own fallback does — set `EmailNotifications=0` explicitly if you want V2 orders silent. Any other value fails the V2 enrollment before any CA call. | +| `SubscriptionAutoRenew` / `SubscriptionRenewCriteriaDays` | Honored. `SubscriptionAutoRenew=1` sets `subscription.autoRenew=true`; `SubscriptionRenewCriteriaDays` sets `subscription.renewBeforeDays` (blank omits the field, so the CA's documented default of 30 applies). An unparseable or negative `SubscriptionRenewCriteriaDays` fails the enrollment before any CA call. | +| `SubscriptionValidityYears` | Still used as the fallback validity when the template's `ValidityYears` parameter is not set. | +| `DefaultProductCode` | Used in V2 only to disambiguate a template that sets just `ProductId` when the live catalog has several products at the same assurance level (see [V2 Product Code Resolution](#v2-product-code-resolution)). Renewals don't need it: a V2 renewal is an ordinary new order that resolves its product code like any other. | +| `TechnicalContactName` / `Email` / `IsdCode` / `MobileNumber` | **Honored.** Sent as the order's `technicalPointOfContact` block on V2 SSL/TLS and Private PKI orders. Each blank field falls back to the matching `Requestor*` value, the same as V1. `designation` is always sent as `Technical Contact`. | +| `RequestorName` / `RequestorEmail` / `RequestorIsdCode` / `RequestorMobileNumber` / `RequestorDesignation` | Still used — carried into the V2 order's `requestor` block (the phone number is sent as `+`). `RequestorDesignation` is omitted from the order when blank (the default) rather than sent with any value. | +| `SignerPlace` / `SignerIp` | Still used — carried into the V2 order's `agreement` block. `SignerPlace` is required in V2 mode. | +| `AutoSecureWww` | Still used — controls whether V2 adds the `www.` variant. | +| `IgnoreExpired` | **Honored during V2 Synchronize.** When `true`, a report row whose `certificateExpiryDate` parses and is in the past is skipped. A row with a missing or unparseable expiry date is kept. | +| `SubmitNonDnsSans` | **SSL family (`ProductFamily=ssl`):** not consulted. A non-UCC order carries only the primary domain (plus `www.` when `AutoSecureWww` is set). A UCC order's `additionalDomains` takes DNS names only, so non-DNS SANs are left off the order with a warning in the gateway log (see [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates)). **Private PKI family (`ProductFamily=private-pki`):** not consulted — the order's `additionalHosts` field accepts DNS names and IPv4/IPv6 addresses natively, so IP-address SANs are always submitted; email and URI SANs cannot be expressed there and are left off the order with a warning in the gateway log. | +| `PageSize` | Still used, now against V2's `/reports/orders` paging (capped at 100 per page). | +| `PickupRetries` / `PickupDelay` | Still used — V2 enrollment polls for a quickly-issued certificate the same way V1 does. | +| `LogSensitiveRequestData` | Still used — governs the redaction of V2 request and response bodies in the gateway log. | +| `Enabled` | Still used. | + +New fields, `UseV2Api` and `V2SyncLookbackHours`, are documented in [V2 API](#v2-api) above. + +#### DCV under V2 + +`DcvEnabled` defaults to `true`, and the DCV settings behave as follows under V2: + +- `DcvEnabled`, `DcvTxtRecordTemplate`, `DcvPropagationDelaySeconds`, and `DcvTimeoutMinutes` are used. +- `DcvSyncMaxOrderAgeHours` and `DcvSyncMaxPerPass` bound DCV during synchronization, the same as V1. +- `DcvWaitForChallengeSeconds` and `DcvWaitForIssuanceSeconds` apply to V1 only. V2 publishes the challenge as soon as the order exists and polls the order until it leaves `pending-dcv` (bounded by `DcvTimeoutMinutes`), then runs the normal pickup poll. +- DCV applies to the SSL/TLS family. Private PKI orders have no DCV step. + +### Step 3 — Update Certificate Templates + +For each template you're migrating: + +1. **If the template sets only `ProductId` (no explicit `ProductCode`), check whether the live V2 + catalog has more than one product at that assurance level.** The plugin resolves the numeric code + automatically from the catalog when exactly one entry matches; when the catalog has several (e.g. + two DV SSL entries with different billing terms), you must either set `ProductCode` explicitly on + the template or set the connector's `DefaultProductCode` to one of the candidates — otherwise every + enrollment against that template fails with an error listing the candidate codes. See + [V2 Product Code Resolution](#v2-product-code-resolution) for the full resolution order. +2. Add `ProductFamily` (default `ssl`) if not already present — this is a V2-only parameter with no + V1 equivalent. `ProductVariant` (`dv`/`ov`/`ev`) is optional for `ssl`: if left unset, the plugin + derives it from the selected product (an OV product sends `ov`, an EV product sends `ev`) instead + of defaulting to `dv`; set it explicitly only to override. For a Private PKI template, set + `ProductFamily=private-pki`, `ProductVariant` to `intranet-ssl` or `igtf-host` (required, no + default), and an explicit `ProductCode` (see [V2 Private PKI Orders](#v2-private-pki-orders)). + `ProductFamily=signature` (Document Signer) enrollment is not supported. +3. Re-verify `ProductCode` (if set explicitly) against the V2 catalog. V1 and V2 product codes are not + guaranteed to be the same numeric values on your account — check the V2 catalog + rather than assuming the V1 code carries over. Template validation + (`ValidateProductInfo`) automatically checks `ProductCode` against the V2 catalog once + `UseV2Api=true`, so an incorrect code will be caught at template save time, not silently at + enrollment. +4. The `SignerName`, `SignerPlace`, `SignerIp`, and `DomainName` template parameters take effect in + V2. `RenewalWindowDays` and `ValidityDays` are V1-only and are ignored. +5. If the template enrolls for a UCC (multi-domain) product, test it on the sandbox first. The plugin + runs DCV for each SAN, but per-SAN DCV on V2 isn't validated end to end (see + [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates)). +6. If the product is OV or EV (whether `ProductVariant` is set explicitly or left to be derived), set + `OrganizationNumber` on the CA connector (a pre-vetted organization number from CERTInext's + Accounts → List Organizations). It is mandatory for OV/EV under V2 — enrollment fails fast with a + clear error if it's missing, rather than reaching the CA and getting back an opaque 422. + +### Step 4 — Test Before Cutting Over + +Run a full enroll → sync → revoke cycle against the sandbox environment with `UseV2Api=true` before +pointing a production template at the V2 connector. At minimum, confirm: + +- A new enrollment issues (or parks pending DCV/approval as expected) and is retrievable via + `GetSingleRecord`. +- A full and an incremental `Synchronize` both pick up the order. +- `Revoke` succeeds for the Command revoke reasons you actually use. + +### Verification Checklist + +- [ ] **Connection test.** Saving the connector succeeds. The plugin requested a token from + `{ApiUrl}/oauth/token` and called `GET /api/certinext/v2/auth/me`; a 401 means a wrong client ID + or secret, and a 403 means the key wasn't generated in OAuth mode. +- [ ] **Template save.** Each template saves. A "multiple catalog products match" error means the + template needs `ProductCode` or the connector needs `DefaultProductCode`. +- [ ] **DV enrollment.** A single-domain DV order returns the certificate, or returns pending and is + imported by the next sync. The order's TXT record is removed afterward. +- [ ] **UCC and wildcard.** Each template you migrated places one order carrying every DNS SAN, and + each domain reaches validated. +- [ ] **OV/EV.** `OrganizationNumber` is set and the order is accepted. These orders stay pending + until organization verification completes, so confirm the next sync imports them. +- [ ] **Synchronization.** A full sync followed by an incremental sync imports the new orders with + their certificates. Revoked orders appear only when the gateway already holds their certificate. +- [ ] **Revocation.** Revoke a test certificate with Command's default reason (unspecified) and with + key compromise. The gateway log shows the substituted reason for the first. +- [ ] **Logs.** The gateway log has no requestor names, email addresses, or request bodies + unless `LogSensitiveRequestData` is on. + +## Behavioral Differences After Migrating + +- **Order identifiers.** The V2 spec's examples show `ord_`-prefixed order IDs (e.g. + `ord_8K9mQ2vR8nP4bL`), but V2 returns numeric order numbers in the same format as V1 + (e.g. `6625262451`). V1-placed order numbers + also resolve through V2 Track Order and appear under the same number in the V2 orders report, so + existing `CARequestID` values carry over. The plugin stores whatever `orderId` CERTInext returns as + the `CARequestID`. Treat it as an opaque string in any external tooling rather than assuming either + format. +- **Status vocabulary.** V2 reports order status as strings (`issued`, `pending-dcv`, `pending-csr`, + `pending-agreement`, `pending-organization-verification`, `pending-documents`, `pending-approval`, + `revoked`, `cancelled`, `rejected`, `expired`, `unknown`) rather than V1's numeric CERTInext status + codes. The plugin maps both to the same Keyfactor status values, so this is transparent to + Command, but it changes what you'll see in gateway trace logs. +- **Synchronization source.** V2 sync reads CERTInext's `/reports/orders` endpoint instead of V1's + `GetOrderReport`. Incremental sync queries a window starting `V2SyncLookbackHours` (default 72) + before the last sync time rather than the exact last-sync timestamp, because the API's date filter + may bracket either the order-placement or the issuance date — this trades a small + amount of redundant re-processing for not missing a slow-issuing order. +- **Revoked orders in sync.** CERTInext doesn't serve the certificate body of a revoked order, so a + revoked order is imported as revoked only when the gateway already holds its certificate. Otherwise + it is reported as failed or skipped, so the gateway never stores a revoked record with no + certificate. +- **Orphaned orders.** If CERTInext rejects the CSR after the order was created, the plugin cancels + the order once and returns a failed result carrying the order ID. After a timeout, the plugin + checks the order before deciding: it cancels the order only if the CSR never arrived. + +### Renewals and Reissuance + +CERTInext V2 has no *renew* endpoint, but it does document a `/reissue` endpoint (`mode: +rekey|update-sans`, with optional `revokePrevious`/`revokeReason`). The plugin does not use it. +**Every** Command `New`, `Renew`, `Reissue`, and `RenewOrReissue` enrollment places a brand-new V2 +order — the same call path as a new enrollment — rather than reusing V1's renewal-window logic or the +`/reissue` endpoint. The prior order and certificate are left issued, not auto-revoked; Command links +the old and new certificates via history only. Because each renewal is a new order, CERTInext bills it +as one. Confirm with eMudhra how your account treats renewals before you rely on free renewals within +a subscription term. + +### Revocation Reason Codes + +V1 sends CERTInext a numeric `revokeReasonId`; V2 sends a kebab-case string reason. The plugin +handles this translation automatically. On SSL/TLS orders, only `key-compromise` (1), +`affiliation-changed` (3), `superseded` (4), `cessation-of-operation` (5), and `privilege-withdrawn` +(9) are accepted. `unspecified` (0, Command's default when no reason is given), `ca-compromise` (2), +`certificate-hold` (6), and `aa-compromise` (10) are all rejected with `"Invalid Revoke Reason ID"` — +`ca-compromise` and `certificate-hold` are listed in the spec for SSL/TLS, `aa-compromise` isn't listed +for SSL/TLS at all, but CERTInext rejects all four the same way. Rather than fail the revoke, the plugin +retries each once with a close accepted substitute: `ca-compromise` and `aa-compromise` retry as +`key-compromise`; `unspecified` and `certificate-hold` retry as `cessation-of-operation` (chosen over +`key-compromise` for those two because neither implies an actual key compromise). No customer action +is needed for any of these four cases. Any other revoke failure is surfaced as-is, without a retry. +Separately, a revoke note containing a semicolon (`;`) is rejected with `"Invalid Revoke Remarks"`. +The plugin's own generated notes avoid semicolons. + +V1 handles the same limit differently: it sends only 1, 3, 4, 5, and 9 as given, and sends every +other reason code, including unspecified, as key compromise. + +## Known Gaps + +The following V2 limitations are known. None of them is a show-stopper for a single-domain +deployment, but decide with these in mind rather than discovering them after cutting over: + +- **UCC per-SAN DCV isn't validated end to end.** The plugin runs DCV for each SAN on a UCC order, but + the path hasn't been validated against the CA. See + [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates) above. +- **Document Signer (`ProductFamily=signature`) enrollment isn't supported.** It fails before any + order is placed. +- **Every renewal/reissue places a new order.** See [Renewals and Reissuance](#renewals-and-reissuance) + above. +- **`OrganizationNumber` is required to enroll OV/EV via V2.** V2 OV/EV orders send + `organization.organizationNumber` with `preVetted=true` (mirroring V1's + `organizationDetails.preVetting`); omitting it fails the enrollment. If your account relies on + `OrganizationNumber` to fast-path DV issuance under V1, note that DV orders under V2 never send an + organization block, so that benefit does not carry over. +- **`AccountingModel` has no V2 effect.** V2 order create has no equivalent field. `GroupNumber`, the + technical-contact fields, `EmailNotifications`, `SubscriptionAutoRenew`/`RenewCriteriaDays`, and + `IgnoreExpired` are all honored on V2 (see the table above). +- **OV and OV UCC orders can exceed the plugin's fixed 120-second request timeout.** The order may + still be created on the CA and is imported by the next sync. EV orders under V2 are not validated + end to end. + +## Rolling Back to V1 + +Rolling back is just setting `UseV2Api` back to `false` on the connector — the V1 credential fields +(`ApiKey`/`AccountNumber`/`AuthMode` or V1 `OAuth`) are unaffected by having been unused while V2 was +active, as long as you didn't overwrite them in Step 2. Keep a copy of the V1 values, in particular +the V1-format `ApiUrl`, before you edit an existing connector. + +**Rollback caveat:** `Enroll`, `GetSingleRecord`, `Revoke`, and `Synchronize` choose V1 or V2 purely +from the connector's current `UseV2Api` flag, not from the stored `CARequestID`. V1-placed order +numbers resolve through V2 Track Order, and V2 order numbers use the same format as V1, but V1 Track +Order and `GetOrderReport` may not see an order that was placed through V2. Rolling back a connector +that has already issued V2 certificates isn't validated: check on the sandbox that sync, revoke, and +renewal still work for those certificates after switching back. Certificates enrolled before the +switch to V2 are V1 orders and are unaffected. + ## Architecture -This document describes how the CERTInext AnyCA Gateway REST plugin integrates with Keyfactor Command and the CERTInext certificate authority. It covers the three primary certificate lifecycle operations — synchronization, enrollment, and revocation — and how the plugin routes each through the CERTInext API. +This document describes how the CERTInext AnyCA Gateway REST plugin integrates with Keyfactor Command and the CERTInext certificate authority. It covers the primary certificate lifecycle operations — synchronization, enrollment, domain control validation, and revocation — for both CERTInext API generations (V1 and V2), and how the plugin routes each one. ## Component Overview ``` ┌─────────────────────────────────────────────────────────┐ -│ Keyfactor Command │ +│ Keyfactor Command │ │ │ │ Certificate Enrollment · Revocation · Sync Jobs │ └────────────────────────────┬────────────────────────────┘ @@ -355,49 +764,68 @@ This document describes how the CERTInext AnyCA Gateway REST plugin integrates w (plugin host process) │ ┌────────────────────────────▼────────────────────────────┐ -│ CERTInext AnyCA Gateway Plugin │ +│ CERTInext AnyCA Gateway Plugin │ │ │ │ Translates Keyfactor operations into CERTInext API │ │ calls, maps responses back to Command's data model, │ -│ and enforces audit logging on every operation. │ +│ publishes DNS-01 validation records through the │ +│ gateway's DNS provider plugin, and logs every │ +│ operation. │ └────────────────────────────┬────────────────────────────┘ - │ HTTPS · HMAC-signed requests + │ HTTPS · AccessKey (V1) or OAuth2 bearer │ ┌────────────────────────────▼────────────────────────────┐ -│ CERTInext REST API (eMudhra) │ +│ CERTInext REST API (eMudhra) │ │ │ -│ ValidateCredentials GenerateOrderSSL TrackOrder │ -│ GetCertificate RevokeOrder GetOrderReport │ -│ GetProductDetails SubmitCSR │ +│ V1 ValidateCredentials · GenerateOrderSSL │ +│ TrackOrder · GetCertificate · GetOrderReport │ +│ RevokeOrder · GetProductDetails │ +│ GetDcv · VerifyDcv │ +│ │ +│ V2 POST /oauth/token · GET /auth/me │ +│ GET /catalog/products · GET /reports/orders │ +│ POST /{family} create order │ +│ PUT /{family}/{id}/csr │ +│ GET /{family}/{id} track order │ +│ GET /{family}/{id}/certificate │ +│ GET /ssl-certificates/{id}/dcv │ +│ POST /ssl-certificates/{id}/dcv/verify │ +│ POST /{family}/{id}/revoke · POST .../cancel │ └─────────────────────────────────────────────────────────┘ ``` +`{family}` is `ssl-certificates`, `private-pki-certificates`, or `signature-certificates` (see the [API Endpoint Reference](#api-endpoint-reference)). + ## Request Authentication -Every API call is signed using HMAC-SHA256. The access key itself is never transmitted — only a derived hash is sent: +**V1, AccessKey mode.** Every call carries a `meta` block whose `authKey` is a SHA-256 digest of the access key, a timestamp, and a transaction ID. The access key itself is never transmitted — only the derived hash is sent: ``` authKey = SHA256(accessKey + requestTs + requestTxnId) ``` -A unique transaction ID (`requestTxnId`) is generated for each request. The timestamp (`requestTs`) and transaction ID travel alongside the `authKey` so the CERTInext server can reproduce and verify the hash. The plugin handles this automatically; no manual signing is required during normal operation. +A random numeric transaction ID (`requestTxnId`) is generated for each request. The timestamp (`requestTs`) and transaction ID travel alongside the `authKey` so the CERTInext server can reproduce and verify the hash. The plugin handles this automatically; no manual signing is required during normal operation. -An OAuth client-credentials mode is also available as an alternative. When OAuth is configured, the plugin exchanges a client ID and secret for a short-lived bearer token and automatically refreshes it before expiry. +**V1, OAuth mode.** When `AuthMode` is `OAuth`, the plugin exchanges a client ID and secret at `OAuthTokenUrl` for a bearer token, sends it in an `Authorization: Bearer` header, and refreshes it before expiry. The `meta` block is still sent, with an empty `authKey`. + +**V2.** When `UseV2Api` is enabled, the plugin uses an OAuth2 `client_credentials` flow, reusing the connector's `OAuthClientId`/`OAuthClientSecret` fields regardless of the V1 `AuthMode` setting. The plugin posts `client_id` and `client_secret` (form-encoded) to `{ApiUrl}/oauth/token` (the same `ApiUrl` field, which becomes the V2 host in this mode) and caches the resulting bearer token until 60 seconds before the expiry the token endpoint reports (minimum 30 seconds). Token refresh is thread-safe. ## Certificate Identifiers -CERTInext assigns two different reference numbers to each order. Understanding the difference matters when tracing certificates across systems: +V1 assigns two different reference numbers to each order. Understanding the difference matters when tracing certificates across systems: | Identifier | When it is assigned | What it is used for | |---|---|---| | **Request Number** | Immediately when an order is created | Tracking a draft order before it is formally submitted; attaching a CSR to a pending order | | **Order Number** | After the order is formally submitted and accepted | All post-issuance operations: checking status, downloading the certificate, revoking — **this is the identifier stored in Keyfactor Command** | +V2 returns a single `orderId` when the order is created; the plugin stores it unchanged as the `CARequestID`. + --- ## Gateway Startup -When the AnyCA Gateway process starts, it loads each configured CA connector. For CERTInext, this step reads the connector settings, establishes the API client, and confirms that the credentials are structurally valid. +When the AnyCA Gateway process starts, it loads each configured CA connector. For CERTInext, this step reads the connector settings, checks that the API URLs are acceptable, and builds the API client. Credentials are validated separately, when an administrator saves the connector (see [Connector Validation](#connector-validation)). ```mermaid sequenceDiagram @@ -405,61 +833,187 @@ sequenceDiagram participant Plugin as CERTInext Plugin participant API as CERTInext API - GW->>Plugin: Load CA connector configuration - Plugin->>Plugin: Validate required fields\n(API URL, account number, credentials) - Plugin->>Plugin: Initialize API client\nwith configured auth mode - Plugin->>Plugin: Record which credential fields are populated\n(values are never logged) - GW->>Plugin: Test connection - Plugin->>API: Verify credentials - API-->>Plugin: Credentials accepted - Plugin-->>GW: Connector ready + GW->>Plugin: Initialize with the CA connector configuration + Plugin->>Plugin: Require ApiUrl and reject http unless the host is loopback
(also OAuthTokenUrl for V1 OAuth) + Plugin->>Plugin: Build the API client
for the configured API generation and auth mode + Plugin->>Plugin: Log which credential fields are populated
(values are never logged) + opt DcvEnabled is true but no DNS provider factory was injected + Plugin->>Plugin: Log a warning: DCV will be skipped + end + opt LogSensitiveRequestData is true + Plugin->>Plugin: Log a warning that personal data
and request payloads will be logged + end + GW->>Plugin: Ping + alt Connector disabled + Plugin-->>GW: Skipped, connector is disabled + else V1 + Plugin->>API: ValidateCredentials + API-->>Plugin: Credentials accepted + Plugin-->>GW: Connector ready + else V2 + Plugin->>API: GET /auth/me (after obtaining a bearer token) + API-->>Plugin: Account details + Plugin-->>GW: Connector ready + end ``` --- ## Synchronization -Keyfactor Command periodically synchronizes its certificate inventory with CERTInext. The plugin retrieves all orders page by page and feeds them into Command's database. Synchronization can be a full refresh or incremental (only orders placed since the last successful sync). +Keyfactor Command periodically synchronizes its certificate inventory with CERTInext. The plugin retrieves orders page by page and feeds them into Command's database. Synchronization can be a full refresh or incremental. V1 and V2 differ in the report they read and in how they classify each order. + +### V1 synchronization + +A full sync reads every order in the account. An incremental sync requests only orders placed on or after the date of the previous sync. + +```mermaid +flowchart TD + A([Command starts a sync]) --> B["Request the next page of orders from GetOrderReport
(PageSize per page, date filter when incremental)"] + B --> C{More orders on this page?} + C -- No more pages --> Z["Log totals and signal completion to the gateway"] + C -- Next order --> D{"IgnoreExpired is on and
the certificate has expired?"} + D -- Yes --> C + D -- No --> E{"Order is pending, DCV is enabled, a DNS provider is
available, and it is inside the age window and
per-pass cap?"} + E -- Yes --> F["Run DNS-01 DCV for the order
then refetch it if DCV ran"] + E -- No --> G + F --> G{Order failed, rejected,
or cancelled?} + G -- Yes --> C + G -- No --> H{"Issued or revoked,
and no certificate body in the listing?"} + H -- Yes --> I["Fetch the certificate (TrackOrder + download)
keeping the listing's subject, product, and order date"] + H -- No --> J + I --> J["Emit the record to the gateway buffer
(issued with PEM, revoked, or pending)"] + J --> C +``` + +**Errors:** if processing an individual order throws, the error is logged and counted and the sync moves on to the next order. Once at least 50 records have been seen and more than 25 percent of them errored, the sync aborts with an error and is retried on the next cycle, rather than completing with mostly failed records. V2 synchronization applies the same rule. + +**Expired certificates:** the `IgnoreExpired` connector setting controls whether expired certificates are included. When enabled, expired certificates are skipped and never appear in the Command inventory. + +**DCV during sync:** pending orders are driven through DNS-01 validation while they are younger than `DcvSyncMaxOrderAgeHours` (older orders are reported as pending and left alone) and only up to `DcvSyncMaxPerPass` orders per pass, so a large backlog of stalled pending orders can't slow every sync. The V1 sync path uses a short fixed propagation delay and a single-shot challenge check rather than the longer `Enroll` waits. See [Domain Control Validation](#domain-control-validation). + +### V2 synchronization + +With `UseV2Api = true`, Synchronize pages through V2 `/reports/orders` (at most 100 rows per page, scoped to `GroupNumber` when set). An incremental sync asks for orders from `V2SyncLookbackHours` (default 72) before the last sync date, because the report's date filter may bracket either the order date or the issuance date and a slow order must not be missed. + +```mermaid +flowchart TD + A([Command starts a sync]) --> B["Compute the start date
(none for a full sync, last sync minus V2SyncLookbackHours otherwise)"] + B --> C["Request the next page of /reports/orders"] + C --> D{More rows on this page?} + D -- No more pages --> Z["Log totals and signal completion to the gateway"] + D -- Next row --> E{"IgnoreExpired is on and the certificate
expiry date is in the past?"} + E -- Yes --> D + E -- No --> F["Map the report's order and certificate status strings"] + F --> G{Status recognized?} + G -- No --> H["Look up the order live and use its status"] + G -- Yes --> I + H --> I{Failed, rejected, or cancelled?} + I -- Yes --> D + I -- No --> J{"Pending, DCV is enabled, a DNS provider is available,
and inside the age window and per-pass cap?"} + J -- Yes --> K["Run DNS-01 DCV for the order
and re-read its status"] + J -- No --> L + K --> L{Issued?} + L -- Yes --> M["Download the certificate chain
(leaf plus intermediates)"] + L -- No --> N{Revoked?} + N -- Yes --> O["Look up revocation date and reason live"] + N -- No --> P + M --> P + O --> Q{"Gateway already holds a certificate
body for this order?"} + Q -- Yes --> P["Emit the record to the gateway buffer"] + Q -- "Row but no body" --> R["Emit as failed"] + Q -- "No row" --> D + R --> D + P --> D +``` + +--- + +## Domain Control Validation + +DNS-01 domain control validation (DCV) proves control of each domain on an order by publishing a TXT record that CERTInext then checks. The plugin publishes and removes the record through whichever DNS provider plugin the gateway resolves for the domain (`DcvEnabled`, on by default). DCV runs in three places: inline during `Enroll`, during `Synchronize` for pending orders, and during `GetSingleRecord` for a pending order. It never runs for an order that is already issued, revoked, cancelled, or rejected, and V2 skips it for Private PKI and Document Signer orders, which have no DCV step. + +The TXT record is published at `DcvTxtRecordTemplate` (default `_emsign-validation.{0}`) with `{0}` replaced by the domain. A wildcard domain's `*.` label is stripped for the DNS side (the record is published at the base domain), and a wildcard and its apex on the same order share one record. On V1, only domains that CERTInext lists as pending, and that are unassigned or assigned to the DNS TXT method, are processed; on V2, every domain that is not yet verified is. A domain that can't be staged (no DNS provider resolves it, the provider fails, CERTInext returns no token) is logged and skipped so the other domains on the order can still be validated; the order stays pending until the skipped domain is resolved. The whole flow for one order is bounded by `DcvTimeoutMinutes`, and TXT records are always removed afterward, with a separate 60-second bound on each removal. + +### V1 DCV ```mermaid sequenceDiagram - participant CMD as Keyfactor Command participant Plugin as CERTInext Plugin participant API as CERTInext API + participant DNS as DNS Provider Plugin - CMD->>Plugin: Start synchronization\n(full refresh or incremental since last sync) - Plugin->>Plugin: Determine date filter\n(none for full sync, last sync date for incremental) - - loop Retrieve one page at a time - Plugin->>API: Request next page of orders\n(filtered by date if incremental) - API-->>Plugin: Page of order records - - loop For each order on the page - alt Certificate is expired and ignore-expired is enabled - Plugin->>Plugin: Skip — not imported - else Order failed or was cancelled - Plugin->>Plugin: Skip — no certificate to import - else Valid certificate - Plugin->>CMD: Add certificate record to inventory + loop Until the challenge is exposed or DcvWaitForChallengeSeconds elapses + Plugin->>API: TrackOrder + API-->>Plugin: Status and domainVerification (may be empty at first) + end + alt Order already issued, revoked, cancelled, or rejected + Plugin->>Plugin: Skip DCV + else Challenge not exposed in time + Plugin->>Plugin: Defer to the next sync + else Every domain already validated + Plugin->>Plugin: Skip staging and go straight to the issuance wait + else Pending DNS TXT domains + loop Each pending domain + Plugin->>API: GetDcv (order, domain, DNS TXT method) + API-->>Plugin: Validation token + Plugin->>DNS: Publish TXT record (token) + end + Plugin->>Plugin: Wait DcvPropagationDelaySeconds + loop Each staged domain + Plugin->>API: VerifyDcv + end + loop Every 3 s until each domain shows dcvStatus 1 + Plugin->>API: TrackOrder + API-->>Plugin: Per-domain dcvStatus + end + Plugin->>DNS: Remove TXT records + opt Enroll only + loop Every 3 s up to DcvWaitForIssuanceSeconds + Plugin->>API: GetCertificate + API-->>Plugin: Status and certificate PEM when issued end end end - - Plugin->>Plugin: Log totals: imported / skipped / errors - Plugin-->>CMD: Synchronization complete ``` -**Full vs. incremental sync:** A full sync imports every order in the account regardless of age. An incremental sync requests only orders placed after the previous sync timestamp, which is faster for accounts with large order histories. +If `GetDcv` returns `EMS-956` (CERTInext lists the challenge before it accepts calls for the order), the whole pass is deferred to the next sync. On the sync and single-record paths the plugin refetches the certificate after DCV instead of running the issuance wait. + +### V2 DCV + +```mermaid +sequenceDiagram + participant Plugin as CERTInext Plugin + participant API as CERTInext API (V2) + participant DNS as DNS Provider Plugin + + Note over Plugin: Runs when the SSL/TLS order is pending, DcvEnabled is true,
and a DNS provider is available + loop Each domain not yet VERIFIED (the primary domain only, if the order lists no per-domain state) + Plugin->>API: GET /ssl-certificates/{id}/dcv (per domain when the order lists several) + API-->>Plugin: Validation token (EMS-1080 means already verified) + Plugin->>DNS: Publish TXT record (token) + end + Plugin->>Plugin: Wait DcvPropagationDelaySeconds once for all domains + loop Each staged domain + Plugin->>API: POST /ssl-certificates/{id}/dcv/verify (domain, method dns-txt) + API-->>Plugin: overallStatus + end + loop Every 3 s until validated or DcvTimeoutMinutes elapses + Plugin->>API: GET /ssl-certificates/{id} + API-->>Plugin: Order status and per-domain dcvStatus + end + Plugin->>DNS: Remove TXT records +``` -**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. +For an order that lists per-domain verification state (a multi-domain order, for example), every domain that is not yet `VERIFIED` is staged, with one propagation wait for the whole batch, and the poll waits until each verified domain shows `VERIFIED` (or `REJECTED`). For a single-domain order the poll waits for the status to leave `pending-dcv`, and if `verify` doesn't report `VERIFIED` the plugin removes the record and leaves the order pending. Either way, the next sync or `GetSingleRecord` call retries only the domains that are still unverified. --- ## Certificate Enrollment -When a requester submits a certificate request through Keyfactor Command, the plugin translates the request into a CERTInext order and returns the result. The plugin handles three enrollment scenarios: new issuance, renewal (within a configured window before expiry), and reissuance (new keys, same profile). +When a requester submits a certificate request through Keyfactor Command, the plugin translates the request into a CERTInext order and returns the result. V1 and V2 follow different flows; see [Enrollment Decision Logic](#enrollment-decision-logic) for how the enrollment type is handled in each. -### New Certificate or Reissuance +### V1 enrollment (New or Reissue) ```mermaid sequenceDiagram @@ -467,48 +1021,131 @@ sequenceDiagram participant Plugin as CERTInext Plugin participant API as CERTInext API - CMD->>Plugin: Request new certificate\n(CSR, subject, SANs, product code, requester details) - Plugin->>Plugin: Validate product code is present - Plugin->>Plugin: Record enrollment intent in audit log\n(subject, SANs, product, requester — before any API call) + CMD->>Plugin: Enroll (CSR, subject, SANs, product code, requester details) + Plugin->>Plugin: Require a product code and log the enrollment intent
(requester personal data redacted by default) - Plugin->>API: Place certificate order\n(CSR, domain, organization details,\nsubscriber agreement, requestor info) - API-->>Plugin: Order accepted — order number assigned - - Plugin->>API: Check order status - API-->>Plugin: Order status and certificate details + Plugin->>API: GenerateOrderSSL (CSR inline, domain, organization,
subscriber agreement, requestor, technical contact) + API-->>Plugin: Order number assigned + Plugin->>API: TrackOrder + API-->>Plugin: Certificate status + opt Status is downloadable + Plugin->>API: GetCertificate + API-->>Plugin: Certificate PEM + end - 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 Order rejected by CERTInext - Plugin-->>CMD: Enrollment failed — see gateway logs + alt DCV enabled and a DNS provider plugin is available + Plugin->>Plugin: Run V1 DCV and the post-DCV issuance wait
(see Domain Control Validation) + Note over Plugin: The pickup poll below is skipped,
because the DCV flow owns the issuance wait + else DCV off or unavailable + loop Pickup poll while the order is pending
PickupRetries attempts, PickupDelay apart, after a 5 s initial delay + Plugin->>API: GetCertificate + API-->>Plugin: Status and certificate PEM + end end - Plugin->>Plugin: Record enrollment outcome in audit log\n(order number, serial number, status) + alt Certificate issued with PEM + Plugin-->>CMD: Issued, PEM returned + else Still pending + Plugin-->>CMD: Pending, Command picks it up during the next synchronization + else Order failed or rejected + Plugin-->>CMD: Failed, see gateway logs + end + Plugin->>Plugin: Log the outcome (order number, serial number, status) ``` -### Renewal +An order CERTInext reports as issued before the certificate is downloadable is returned as pending rather than as an issued result with no certificate. OV and EV orders are issued after organization verification (minutes to hours) and almost always exhaust the pickup window; the next synchronization imports them. A rate-limit rejection (`Inactive Account User.`) on order placement is retried with backoff before the failure is surfaced. -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. +**Synchronous certificate pickup:** after placing an order, the plugin polls CERTInext a bounded number of times — `PickupRetries` attempts (default 5) spaced `PickupDelay` seconds (default 10) apart after a fixed 5-second initial delay, with a hard ceiling of 180 seconds in total — before returning a pending result to Command. This lets fast-issuing DV certificates come back in the same enrollment call. Only a result with a certificate body counts as issued. -> **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. +### V1 renewal and RenewOrReissue + +When Command initiates a renewal, the plugin checks whether the prior certificate is within the configured renewal window. Inside the window it places a renewal order; outside the window, or when the prior certificate can't be found, it places a new order. + +> **Note:** CERTInext does not have a dedicated certificate renewal endpoint. Both paths submit a new `GenerateOrderSSL` order; the prior order is not revoked. The distinction determines how the request is built, not which endpoint is called. A renewal order does not run inline DCV; sync-driven DCV completes a pending DV renewal. + +> **Note:** If the prior-order lookup itself throws (rather than cleanly returning "not found" — for example a transient database error), the plugin falls back to a new order rather than failing the enrollment. + +```mermaid +flowchart TD + A([Renew or RenewOrReissue requested]) --> B{"Prior certificate serial number
(PriorCertSN) provided?"} + B -- No --> C["Place a new order
(same as a new enrollment)"] + B -- Yes --> D["Look up the prior order ID
in the Command database"] + D --> E{Prior order found?} + E -- "No, or lookup failed" --> C + E -- Yes --> F["Read the prior certificate's expiry date"] + F --> G{"Expiry is in the future and
within RenewalWindowDays?"} + G -- Yes --> H["Place a renewal order
(GenerateOrderSSL, prior order looked up first)"] + G -- "No: outside the window, already expired, or expiry unknown" --> C + H --> I["Pickup poll
PickupRetries x PickupDelay"] + C --> J(["Issued, pending, or failed result
returned to Command"]) + I --> J +``` + +### V2 enrollment flow + +With `UseV2Api = true`, every enrollment type (New, Reissue, Renew, RenewOrReissue) takes the same path and places a new order. The plugin checks the request locally first, so a template or request that can never succeed is rejected before any order is placed. ```mermaid flowchart TD - A([Renewal requested]) --> B{Prior certificate\nserial number\nprovided?} - B -- No --> C[Issue new certificate] - B -- Yes --> D[Look up prior order\nin Command database] - D --> E{Prior order\nfound?} - E -- No --> C - E -- Yes --> F[Check certificate\nexpiry date] - F --> G{Within renewal\nwindow?} - G -- Yes\nwithin window --> H[Submit new order\nlinked to prior record] - G -- No\noutside window --> C - H --> I([Certificate issued or pending]) - C --> I + A([Enroll requested]) --> B["Log the enrollment intent"] + B --> C{"Request passes local checks?
family is ssl or private-pki, SignerPlace set for SSL,
Private PKI variant and ProductCode set,
OrganizationNumber set for OV or EV,
valid subscription and notification settings"} + C -- No --> X1(["Enrollment rejected, no order placed"]) + C -- Yes --> D{Private PKI?} + D -- Yes --> G + D -- No --> E["Fetch the product catalog
and resolve the product code and product type"] + E --> F{"Code resolved?
Explicit ProductCode, or exactly one catalog match,
or DefaultProductCode among several"} + F -- No --> X2(["Failed result listing the candidates, no order placed"]) + F -- Yes --> F2{"Single-domain product with extra DNS SANs
in the CSR or SAN list?"} + F2 -- Yes --> X3(["Failed result, no order placed"]) + F2 -- No --> G["Create the order
(X-Product-Code and Idempotency-Key headers)"] + G --> H["Submit the CSR unchanged
PUT /{family}/{id}/csr"] + H --> I{CSR accepted?} + I -- "Rejected by the CA (HTTP 4xx)" --> J["Cancel the orphaned order once"] + J --> X4(["Failed result carrying the order ID"]) + I -- "Transport error or timeout" --> K["Track the order"] + K --> L{"Still pending-csr?"} + L -- Yes --> J + L -- "Tracking failed" --> P1(["Pending result with the order ID,
next sync resolves it"]) + L -- "Moved past pending-csr" --> M + I -- Yes --> M["Track the order"] + M --> M2{Tracking succeeded?} + M2 -- No --> P1 + M2 -- Yes --> N{"SSL order pending, DCV enabled,
and a DNS provider available?"} + N -- Yes --> O["Run V2 DCV, then track the order again"] + N -- No --> Q + O --> Q{Issued?} + Q -- Yes --> R["Download the certificate chain"] + R --> S{Download succeeded?} + S -- Yes --> T(["Issued result with the PEM chain"]) + S -- No --> U + Q -- No --> U{"Order still pending, and DCV did not
already wait inside this call?"} + U -- No --> W + U -- Yes --> V["Pickup poll
PickupRetries x PickupDelay
track, and download once issued"] + V --> W{"Order revoked at the CA
before a certificate was delivered?"} + W -- Yes --> X5(["Failed result"]) + W -- No --> P2(["Issued if the poll found the certificate, failed if the
order was rejected or cancelled, otherwise pending
and picked up by the next sync"]) ``` +Notes on the V2 flow: + +- **Product code.** For SSL/TLS, the plugin fetches the live catalog once per enrollment. With an explicit `ProductCode`, the catalog is used only to recognize multi-domain and wildcard products; if the catalog can't be fetched, the order proceeds as single-domain, so a request with extra SANs is rejected rather than silently accepted. Without an explicit code, the plugin selects the catalog entry whose product type matches the selected product and fails if none or several match (unless `DefaultProductCode` names one). Private PKI never fetches the catalog; its `ProductCode` is required and was validated when the template was saved. +- **Multi-domain products.** DNS SANs other than the primary domain are sent as `additionalDomains`; non-DNS SANs are left off the order with a warning. Private PKI sends DNS and IP SANs as `additionalHosts`. +- **Idempotency.** V2 order-create and revoke calls carry a fresh `Idempotency-Key` UUID on every call. CERTInext's V2 spec describes the header as parsed but not yet enforced, so the plugin does not rely on it to prevent duplicates: it never retries an order-create call, and the AnyCA Gateway doesn't retry a timed-out `Enroll`. If an operator resubmits after an order-create error, check the CERTInext portal for an order that was created anyway. +- **Revoked before delivery.** An order that CERTInext reports as revoked before any certificate was delivered is returned as failed, never as a revoked record with no certificate. +- **Full certificate chain.** The V2 `/certificate` endpoint returns the leaf in `certificatePem` and intermediates in `chainPem[]`. The plugin concatenates them, leaf first, into a single PEM before returning it to Command. +- **Order IDs.** The V2 spec's examples show `ord_`-prefixed IDs, but V2 returns numeric order numbers in the same format as V1 (for example `6625262451`), and V1 order numbers resolve through V2 Track Order unchanged. The plugin stores the ID unchanged as the `CARequestID`. + +### Status mapping + +CERTInext statuses map to the Keyfactor statuses Command understands as follows. The full V1 status-code list and the V2 status table are under [Order Lifecycle and Pending Approval](#order-lifecycle-and-pending-approval) and [V2 Order Lifecycle](#v2-order-lifecycle). + +| CERTInext | Command status | +|---|---| +| Issued, or expired but not revoked | Issued (an expired certificate stays in inventory) | +| Any pending state (approval, validation, CSR, agreement, documents, `unknown`) | Pending external validation | +| Revoked | Revoked | +| Rejected, cancelled, or an unrecognized status | Failed | + --- ## Revocation @@ -521,30 +1158,42 @@ sequenceDiagram participant Plugin as CERTInext Plugin participant API as CERTInext API - CMD->>Plugin: Revoke certificate\n(order number, serial number, reason code) - Plugin->>Plugin: Record revocation intent in audit log\n(order number, serial, reason — before any API call) + CMD->>Plugin: Revoke (order number, serial number, reason code) + Plugin->>Plugin: Log the revocation intent before any API call
(order number, serial, reason) - Plugin->>API: Retrieve current certificate status - API-->>Plugin: Current status and details + Plugin->>API: Retrieve the current order status
(V2: find which product family owns the order) + API-->>Plugin: Current status alt Certificate is already revoked - Plugin->>Plugin: Log warning — already revoked - Plugin-->>CMD: Confirmed revoked (no action needed) + Plugin->>Plugin: Log a warning, already revoked + Plugin-->>CMD: Confirmed revoked, no request sent else Certificate is not in an issued state - Plugin->>Plugin: Log error — cannot revoke - Plugin-->>CMD: Error — certificate is not revocable - else Certificate is issued and active - Plugin->>API: Submit revocation request\n(order number, reason, remarks) + Plugin->>Plugin: Log an error, cannot revoke + Plugin-->>CMD: Error, certificate is not revocable + else Certificate is issued + Plugin->>API: Revoke (order number, reason, remarks) + opt V2 only, reason in the rejected set and CERTInext replies 422 Invalid Revoke Reason ID + Plugin->>API: Revoke again, once, with the substitute reason + end API-->>Plugin: Revocation confirmed - - Plugin->>Plugin: Record revocation outcome in audit log\n(order number, serial, subject, reason) + Plugin->>Plugin: Log the outcome (order number, serial, reason) Plugin-->>CMD: Certificate revoked end ``` -**Idempotency:** If Command retries a revocation request (for example, after a timeout), the plugin detects that the certificate is already revoked and returns success without submitting a duplicate request to CERTInext. +**Idempotency:** if Command retries a revocation request (for example after a timeout), the plugin detects that the certificate is already revoked and returns success without submitting a duplicate request to CERTInext. + +**Audit trail:** the revocation intent is written to the gateway log *before* the API call is made, so it is recorded even if the API call subsequently fails. + +**V1 reason codes:** CERTInext's V1 revoke endpoint accepts only key compromise (1), affiliation changed (3), superseded (4), cessation of operation (5), and privilege withdrawn (9). Every other RFC 5280 reason code, including unspecified (0), is sent as key compromise (1). + +**V2 reason code substitution:** the V2 spec documents 8 RFC 5280 reason values for the SSL/TLS revoke endpoint (`aa-compromise` is listed only for the Document Signer and Private PKI endpoints, which document 9). On SSL/TLS orders CERTInext accepts only 5: `key-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, and `privilege-withdrawn`. Three documented values, `unspecified`, `ca-compromise`, and `certificate-hold`, return a 422 "Invalid Revoke Reason ID", as does `aa-compromise`. -**Audit trail:** The revocation intent is written to the gateway log *before* the API call is made. This ensures that the intent is captured even if the API call subsequently fails, satisfying SOX audit requirements. +Rather than surface any of these four rejections to the caller, the plugin retries each once with a close accepted substitute: `unspecified` (CRL reason 0, Command's default when no explicit reason is given — by far the most common revoke case) and `certificate-hold` (CRL reason 6) retry as `cessation-of-operation`; `ca-compromise` (CRL reason 2) and `aa-compromise` (CRL reason 10) retry as `key-compromise`. `cessation-of-operation` was chosen over `key-compromise` for the first pair because neither `unspecified` nor `certificate-hold` implies an actual key compromise, and `key-compromise` carries the V2 spec's Baseline Requirements §4.9.1.1 24-hour CRL-turnaround obligation, which would misrepresent the revoke. Only these four specific rejections trigger a retry; any other revoke failure is surfaced as-is. + +**V2 not revokable:** a 404 from the V2 revoke endpoint means the order was not found or is not in a revokable state. Because the plugin has already located the order's product family, it reports that plainly instead of probing the other families. + +**Note field:** CERTInext's revoke `note` (audit remarks) field rejects a semicolon (`;`) with a separate 422, "Invalid Revoke Remarks." Commas, periods, slashes, and parentheses are accepted. The plugin's own generated notes avoid semicolons for this reason. --- @@ -554,18 +1203,37 @@ When an administrator saves or edits a CERTInext CA connector in the Keyfactor C ```mermaid flowchart TD - A([Save connector configuration]) --> B{Connector\nmarked as disabled?} - B -- Yes --> C([Saved without validation\nConnector will not process requests]) - B -- No --> D{Required fields\npresent and valid?\nAPI URL · Account Number · Credentials} - D -- Missing or invalid --> E([Validation error shown to administrator]) - D -- Valid --> F[Build temporary API client\nfrom supplied settings] - F --> G[Send test request\nto CERTInext] - G --> H{API accepted\nthe credentials?} - H -- No --> I([Connection test failed\nCheck credentials and API URL]) - H -- Yes --> J([Connector saved and active]) + A([Save connector configuration]) --> B{Connector
marked as disabled?} + B -- Yes --> C(["Saved without validation
Connector will not process requests"]) + B -- No --> D{"ApiUrl present, and https
or a loopback host?"} + D -- No --> E(["Validation error shown to administrator"]) + D -- Yes --> F{UseV2Api?} + F -- "Yes (V2)" --> G{"OAuthClientId, OAuthClientSecret,
and SignerPlace present?"} + F -- "No (V1)" --> H{"AccountNumber present and the
credentials for AuthMode present?
AccessKey needs ApiKey
OAuth needs an https OAuthTokenUrl,
OAuthClientId, OAuthClientSecret"} + G -- No --> E + H -- No --> E + G -- Yes --> I["Build a temporary API client
from the supplied settings"] + H -- Yes --> I + I --> J["Send a test request
V1: ValidateCredentials, V2: GET /auth/me"] + J --> K{API accepted
the credentials?} + K -- No --> L(["Connection test failed
Check credentials and API URL"]) + K -- Yes --> M(["Connector saved and active"]) ``` -**Disabled connectors:** Setting `Enabled` to `false` allows the connector record to be created and saved before credentials are available. The live connectivity test is skipped, so no credentials are required at save time. +**Disabled connectors:** setting `Enabled` to `false` allows the connector record to be created and saved before credentials are available. The live connectivity test is skipped, so no credentials are required at save time. + +The same https-or-loopback check runs again at gateway startup, so a connector saved with an `http` URL before the check existed fails to start until its URL is corrected. + +### Template validation + +When an administrator saves a certificate template, the gateway asks the plugin to validate its product settings. The product code must be set (explicitly, or derived from the selected product on V1). On V1 it must appear in the CERTInext product list for the account. On V2: + +- an explicit `ProductCode` must exist in the live catalog and match the selected product's type; +- without one, exactly one catalog entry must match the selected product, or `DefaultProductCode` must name one of several; +- an SSL template's `ProductVariant` must agree with the selected product; +- a Private PKI template needs a Private PKI `ProductVariant` and an explicit `ProductCode` that is a Private PKI catalog product. + +Any failure is shown to the administrator with the candidate codes or the field to fix. --- @@ -573,16 +1241,38 @@ flowchart TD The table below maps each Keyfactor Command operation to the CERTInext API endpoint it calls. +**V1 endpoints (default)** + | Operation | CERTInext API endpoint | |---|---| | Test connection / verify credentials | `POST ValidateCredentials` | -| Issue new certificate | `POST GenerateOrderSSL` then `POST TrackOrder` | -| Renew certificate | `POST GenerateOrderSSL` then `POST TrackOrder` | +| Issue new certificate | `POST GenerateOrderSSL` (CSR inline), then `POST TrackOrder` and `POST GetCertificate` | +| Renew certificate | `POST TrackOrder` on the prior order, then `POST GenerateOrderSSL` as above | | Check certificate status | `POST TrackOrder` + `POST GetCertificate` | | Revoke certificate | `POST RevokeOrder` | | Synchronize inventory | `POST GetOrderReport` (paginated) | +| Domain control validation | `POST GetDcv`, `POST VerifyDcv` | | List available product codes | `POST GetProductDetails` | -| Attach CSR to draft order | `POST SubmitCSR` | +| Attach CSR to a draft order | `POST SubmitCSR` (developer tooling only; the plugin sends the CSR inline with `GenerateOrderSSL`) | + +**V2 endpoints (UseV2Api = true)** + +| Operation | V2 endpoint | +|---|---| +| Obtain Bearer token | `POST /oauth/token` | +| Test connection | `GET /api/certinext/v2/auth/me` | +| Create order | `POST /api/certinext/v2/{family}` | +| Submit CSR | `PUT /api/certinext/v2/{family}/{orderId}/csr` | +| Check order status | `GET /api/certinext/v2/{family}/{orderId}` | +| Get DCV challenge (SSL/TLS only) | `GET /api/certinext/v2/ssl-certificates/{orderId}/dcv` (with `?domain=` per domain) | +| Verify DCV (SSL/TLS only) | `POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify` | +| Download certificate | `GET /api/certinext/v2/{family}/{orderId}/certificate` | +| Revoke certificate | `POST /api/certinext/v2/{family}/{orderId}/revoke` | +| Cancel an orphaned order | `POST /api/certinext/v2/{family}/{orderId}/cancel` | +| List available products | `GET /api/certinext/v2/catalog/products` | +| Synchronize inventory | `GET /api/certinext/v2/reports/orders` (paginated) | + +`{family}` is `ssl-certificates`, `private-pki-certificates`, or `signature-certificates`. The plugin finds the family that owns an existing order by tracking it in each family in that order. ## License diff --git a/docsource/architecture.md b/docsource/architecture.md index 93ac459..dd846eb 100644 --- a/docsource/architecture.md +++ b/docsource/architecture.md @@ -1,12 +1,12 @@ ## Architecture -This document describes how the CERTInext AnyCA Gateway REST plugin integrates with Keyfactor Command and the CERTInext certificate authority. It covers the three primary certificate lifecycle operations — synchronization, enrollment, and revocation — and how the plugin routes each through the CERTInext API. +This document describes how the CERTInext AnyCA Gateway REST plugin integrates with Keyfactor Command and the CERTInext certificate authority. It covers the primary certificate lifecycle operations — synchronization, enrollment, domain control validation, and revocation — for both CERTInext API generations (V1 and V2), and how the plugin routes each one. ## Component Overview ``` ┌─────────────────────────────────────────────────────────┐ -│ Keyfactor Command │ +│ Keyfactor Command │ │ │ │ Certificate Enrollment · Revocation · Sync Jobs │ └────────────────────────────┬────────────────────────────┘ @@ -15,49 +15,68 @@ This document describes how the CERTInext AnyCA Gateway REST plugin integrates w (plugin host process) │ ┌────────────────────────────▼────────────────────────────┐ -│ CERTInext AnyCA Gateway Plugin │ +│ CERTInext AnyCA Gateway Plugin │ │ │ │ Translates Keyfactor operations into CERTInext API │ │ calls, maps responses back to Command's data model, │ -│ and enforces audit logging on every operation. │ +│ publishes DNS-01 validation records through the │ +│ gateway's DNS provider plugin, and logs every │ +│ operation. │ └────────────────────────────┬────────────────────────────┘ - │ HTTPS · HMAC-signed requests + │ HTTPS · AccessKey (V1) or OAuth2 bearer │ ┌────────────────────────────▼────────────────────────────┐ -│ CERTInext REST API (eMudhra) │ +│ CERTInext REST API (eMudhra) │ │ │ -│ ValidateCredentials GenerateOrderSSL TrackOrder │ -│ GetCertificate RevokeOrder GetOrderReport │ -│ GetProductDetails SubmitCSR │ +│ V1 ValidateCredentials · GenerateOrderSSL │ +│ TrackOrder · GetCertificate · GetOrderReport │ +│ RevokeOrder · GetProductDetails │ +│ GetDcv · VerifyDcv │ +│ │ +│ V2 POST /oauth/token · GET /auth/me │ +│ GET /catalog/products · GET /reports/orders │ +│ POST /{family} create order │ +│ PUT /{family}/{id}/csr │ +│ GET /{family}/{id} track order │ +│ GET /{family}/{id}/certificate │ +│ GET /ssl-certificates/{id}/dcv │ +│ POST /ssl-certificates/{id}/dcv/verify │ +│ POST /{family}/{id}/revoke · POST .../cancel │ └─────────────────────────────────────────────────────────┘ ``` +`{family}` is `ssl-certificates`, `private-pki-certificates`, or `signature-certificates` (see the [API Endpoint Reference](#api-endpoint-reference)). + ## Request Authentication -Every API call is signed using HMAC-SHA256. The access key itself is never transmitted — only a derived hash is sent: +**V1, AccessKey mode.** Every call carries a `meta` block whose `authKey` is a SHA-256 digest of the access key, a timestamp, and a transaction ID. The access key itself is never transmitted — only the derived hash is sent: ``` authKey = SHA256(accessKey + requestTs + requestTxnId) ``` -A unique transaction ID (`requestTxnId`) is generated for each request. The timestamp (`requestTs`) and transaction ID travel alongside the `authKey` so the CERTInext server can reproduce and verify the hash. The plugin handles this automatically; no manual signing is required during normal operation. +A random numeric transaction ID (`requestTxnId`) is generated for each request. The timestamp (`requestTs`) and transaction ID travel alongside the `authKey` so the CERTInext server can reproduce and verify the hash. The plugin handles this automatically; no manual signing is required during normal operation. + +**V1, OAuth mode.** When `AuthMode` is `OAuth`, the plugin exchanges a client ID and secret at `OAuthTokenUrl` for a bearer token, sends it in an `Authorization: Bearer` header, and refreshes it before expiry. The `meta` block is still sent, with an empty `authKey`. -An OAuth client-credentials mode is also available as an alternative. When OAuth is configured, the plugin exchanges a client ID and secret for a short-lived bearer token and automatically refreshes it before expiry. +**V2.** When `UseV2Api` is enabled, the plugin uses an OAuth2 `client_credentials` flow, reusing the connector's `OAuthClientId`/`OAuthClientSecret` fields regardless of the V1 `AuthMode` setting. The plugin posts `client_id` and `client_secret` (form-encoded) to `{ApiUrl}/oauth/token` (the same `ApiUrl` field, which becomes the V2 host in this mode) and caches the resulting bearer token until 60 seconds before the expiry the token endpoint reports (minimum 30 seconds). Token refresh is thread-safe. ## Certificate Identifiers -CERTInext assigns two different reference numbers to each order. Understanding the difference matters when tracing certificates across systems: +V1 assigns two different reference numbers to each order. Understanding the difference matters when tracing certificates across systems: | Identifier | When it is assigned | What it is used for | |---|---|---| | **Request Number** | Immediately when an order is created | Tracking a draft order before it is formally submitted; attaching a CSR to a pending order | | **Order Number** | After the order is formally submitted and accepted | All post-issuance operations: checking status, downloading the certificate, revoking — **this is the identifier stored in Keyfactor Command** | +V2 returns a single `orderId` when the order is created; the plugin stores it unchanged as the `CARequestID`. + --- ## Gateway Startup -When the AnyCA Gateway process starts, it loads each configured CA connector. For CERTInext, this step reads the connector settings, establishes the API client, and confirms that the credentials are structurally valid. +When the AnyCA Gateway process starts, it loads each configured CA connector. For CERTInext, this step reads the connector settings, checks that the API URLs are acceptable, and builds the API client. Credentials are validated separately, when an administrator saves the connector (see [Connector Validation](#connector-validation)). ```mermaid sequenceDiagram @@ -65,61 +84,187 @@ sequenceDiagram participant Plugin as CERTInext Plugin participant API as CERTInext API - GW->>Plugin: Load CA connector configuration - Plugin->>Plugin: Validate required fields\n(API URL, account number, credentials) - Plugin->>Plugin: Initialize API client\nwith configured auth mode - Plugin->>Plugin: Record which credential fields are populated\n(values are never logged) - GW->>Plugin: Test connection - Plugin->>API: Verify credentials - API-->>Plugin: Credentials accepted - Plugin-->>GW: Connector ready + GW->>Plugin: Initialize with the CA connector configuration + Plugin->>Plugin: Require ApiUrl and reject http unless the host is loopback
(also OAuthTokenUrl for V1 OAuth) + Plugin->>Plugin: Build the API client
for the configured API generation and auth mode + Plugin->>Plugin: Log which credential fields are populated
(values are never logged) + opt DcvEnabled is true but no DNS provider factory was injected + Plugin->>Plugin: Log a warning: DCV will be skipped + end + opt LogSensitiveRequestData is true + Plugin->>Plugin: Log a warning that personal data
and request payloads will be logged + end + GW->>Plugin: Ping + alt Connector disabled + Plugin-->>GW: Skipped, connector is disabled + else V1 + Plugin->>API: ValidateCredentials + API-->>Plugin: Credentials accepted + Plugin-->>GW: Connector ready + else V2 + Plugin->>API: GET /auth/me (after obtaining a bearer token) + API-->>Plugin: Account details + Plugin-->>GW: Connector ready + end ``` --- ## Synchronization -Keyfactor Command periodically synchronizes its certificate inventory with CERTInext. The plugin retrieves all orders page by page and feeds them into Command's database. Synchronization can be a full refresh or incremental (only orders placed since the last successful sync). +Keyfactor Command periodically synchronizes its certificate inventory with CERTInext. The plugin retrieves orders page by page and feeds them into Command's database. Synchronization can be a full refresh or incremental. V1 and V2 differ in the report they read and in how they classify each order. + +### V1 synchronization + +A full sync reads every order in the account. An incremental sync requests only orders placed on or after the date of the previous sync. + +```mermaid +flowchart TD + A([Command starts a sync]) --> B["Request the next page of orders from GetOrderReport
(PageSize per page, date filter when incremental)"] + B --> C{More orders on this page?} + C -- No more pages --> Z["Log totals and signal completion to the gateway"] + C -- Next order --> D{"IgnoreExpired is on and
the certificate has expired?"} + D -- Yes --> C + D -- No --> E{"Order is pending, DCV is enabled, a DNS provider is
available, and it is inside the age window and
per-pass cap?"} + E -- Yes --> F["Run DNS-01 DCV for the order
then refetch it if DCV ran"] + E -- No --> G + F --> G{Order failed, rejected,
or cancelled?} + G -- Yes --> C + G -- No --> H{"Issued or revoked,
and no certificate body in the listing?"} + H -- Yes --> I["Fetch the certificate (TrackOrder + download)
keeping the listing's subject, product, and order date"] + H -- No --> J + I --> J["Emit the record to the gateway buffer
(issued with PEM, revoked, or pending)"] + J --> C +``` + +**Errors:** if processing an individual order throws, the error is logged and counted and the sync moves on to the next order. Once at least 50 records have been seen and more than 25 percent of them errored, the sync aborts with an error and is retried on the next cycle, rather than completing with mostly failed records. V2 synchronization applies the same rule. + +**Expired certificates:** the `IgnoreExpired` connector setting controls whether expired certificates are included. When enabled, expired certificates are skipped and never appear in the Command inventory. + +**DCV during sync:** pending orders are driven through DNS-01 validation while they are younger than `DcvSyncMaxOrderAgeHours` (older orders are reported as pending and left alone) and only up to `DcvSyncMaxPerPass` orders per pass, so a large backlog of stalled pending orders can't slow every sync. The V1 sync path uses a short fixed propagation delay and a single-shot challenge check rather than the longer `Enroll` waits. See [Domain Control Validation](#domain-control-validation). + +### V2 synchronization + +With `UseV2Api = true`, Synchronize pages through V2 `/reports/orders` (at most 100 rows per page, scoped to `GroupNumber` when set). An incremental sync asks for orders from `V2SyncLookbackHours` (default 72) before the last sync date, because the report's date filter may bracket either the order date or the issuance date and a slow order must not be missed. + +```mermaid +flowchart TD + A([Command starts a sync]) --> B["Compute the start date
(none for a full sync, last sync minus V2SyncLookbackHours otherwise)"] + B --> C["Request the next page of /reports/orders"] + C --> D{More rows on this page?} + D -- No more pages --> Z["Log totals and signal completion to the gateway"] + D -- Next row --> E{"IgnoreExpired is on and the certificate
expiry date is in the past?"} + E -- Yes --> D + E -- No --> F["Map the report's order and certificate status strings"] + F --> G{Status recognized?} + G -- No --> H["Look up the order live and use its status"] + G -- Yes --> I + H --> I{Failed, rejected, or cancelled?} + I -- Yes --> D + I -- No --> J{"Pending, DCV is enabled, a DNS provider is available,
and inside the age window and per-pass cap?"} + J -- Yes --> K["Run DNS-01 DCV for the order
and re-read its status"] + J -- No --> L + K --> L{Issued?} + L -- Yes --> M["Download the certificate chain
(leaf plus intermediates)"] + L -- No --> N{Revoked?} + N -- Yes --> O["Look up revocation date and reason live"] + N -- No --> P + M --> P + O --> Q{"Gateway already holds a certificate
body for this order?"} + Q -- Yes --> P["Emit the record to the gateway buffer"] + Q -- "Row but no body" --> R["Emit as failed"] + Q -- "No row" --> D + R --> D + P --> D +``` + +--- + +## Domain Control Validation + +DNS-01 domain control validation (DCV) proves control of each domain on an order by publishing a TXT record that CERTInext then checks. The plugin publishes and removes the record through whichever DNS provider plugin the gateway resolves for the domain (`DcvEnabled`, on by default). DCV runs in three places: inline during `Enroll`, during `Synchronize` for pending orders, and during `GetSingleRecord` for a pending order. It never runs for an order that is already issued, revoked, cancelled, or rejected, and V2 skips it for Private PKI and Document Signer orders, which have no DCV step. + +The TXT record is published at `DcvTxtRecordTemplate` (default `_emsign-validation.{0}`) with `{0}` replaced by the domain. A wildcard domain's `*.` label is stripped for the DNS side (the record is published at the base domain), and a wildcard and its apex on the same order share one record. On V1, only domains that CERTInext lists as pending, and that are unassigned or assigned to the DNS TXT method, are processed; on V2, every domain that is not yet verified is. A domain that can't be staged (no DNS provider resolves it, the provider fails, CERTInext returns no token) is logged and skipped so the other domains on the order can still be validated; the order stays pending until the skipped domain is resolved. The whole flow for one order is bounded by `DcvTimeoutMinutes`, and TXT records are always removed afterward, with a separate 60-second bound on each removal. + +### V1 DCV ```mermaid sequenceDiagram - participant CMD as Keyfactor Command participant Plugin as CERTInext Plugin participant API as CERTInext API + participant DNS as DNS Provider Plugin - CMD->>Plugin: Start synchronization\n(full refresh or incremental since last sync) - Plugin->>Plugin: Determine date filter\n(none for full sync, last sync date for incremental) - - loop Retrieve one page at a time - Plugin->>API: Request next page of orders\n(filtered by date if incremental) - API-->>Plugin: Page of order records - - loop For each order on the page - alt Certificate is expired and ignore-expired is enabled - Plugin->>Plugin: Skip — not imported - else Order failed or was cancelled - Plugin->>Plugin: Skip — no certificate to import - else Valid certificate - Plugin->>CMD: Add certificate record to inventory + loop Until the challenge is exposed or DcvWaitForChallengeSeconds elapses + Plugin->>API: TrackOrder + API-->>Plugin: Status and domainVerification (may be empty at first) + end + alt Order already issued, revoked, cancelled, or rejected + Plugin->>Plugin: Skip DCV + else Challenge not exposed in time + Plugin->>Plugin: Defer to the next sync + else Every domain already validated + Plugin->>Plugin: Skip staging and go straight to the issuance wait + else Pending DNS TXT domains + loop Each pending domain + Plugin->>API: GetDcv (order, domain, DNS TXT method) + API-->>Plugin: Validation token + Plugin->>DNS: Publish TXT record (token) + end + Plugin->>Plugin: Wait DcvPropagationDelaySeconds + loop Each staged domain + Plugin->>API: VerifyDcv + end + loop Every 3 s until each domain shows dcvStatus 1 + Plugin->>API: TrackOrder + API-->>Plugin: Per-domain dcvStatus + end + Plugin->>DNS: Remove TXT records + opt Enroll only + loop Every 3 s up to DcvWaitForIssuanceSeconds + Plugin->>API: GetCertificate + API-->>Plugin: Status and certificate PEM when issued end end end - - Plugin->>Plugin: Log totals: imported / skipped / errors - Plugin-->>CMD: Synchronization complete ``` -**Full vs. incremental sync:** A full sync imports every order in the account regardless of age. An incremental sync requests only orders placed after the previous sync timestamp, which is faster for accounts with large order histories. +If `GetDcv` returns `EMS-956` (CERTInext lists the challenge before it accepts calls for the order), the whole pass is deferred to the next sync. On the sync and single-record paths the plugin refetches the certificate after DCV instead of running the issuance wait. + +### V2 DCV + +```mermaid +sequenceDiagram + participant Plugin as CERTInext Plugin + participant API as CERTInext API (V2) + participant DNS as DNS Provider Plugin + + Note over Plugin: Runs when the SSL/TLS order is pending, DcvEnabled is true,
and a DNS provider is available + loop Each domain not yet VERIFIED (the primary domain only, if the order lists no per-domain state) + Plugin->>API: GET /ssl-certificates/{id}/dcv (per domain when the order lists several) + API-->>Plugin: Validation token (EMS-1080 means already verified) + Plugin->>DNS: Publish TXT record (token) + end + Plugin->>Plugin: Wait DcvPropagationDelaySeconds once for all domains + loop Each staged domain + Plugin->>API: POST /ssl-certificates/{id}/dcv/verify (domain, method dns-txt) + API-->>Plugin: overallStatus + end + loop Every 3 s until validated or DcvTimeoutMinutes elapses + Plugin->>API: GET /ssl-certificates/{id} + API-->>Plugin: Order status and per-domain dcvStatus + end + Plugin->>DNS: Remove TXT records +``` -**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. +For an order that lists per-domain verification state (a multi-domain order, for example), every domain that is not yet `VERIFIED` is staged, with one propagation wait for the whole batch, and the poll waits until each verified domain shows `VERIFIED` (or `REJECTED`). For a single-domain order the poll waits for the status to leave `pending-dcv`, and if `verify` doesn't report `VERIFIED` the plugin removes the record and leaves the order pending. Either way, the next sync or `GetSingleRecord` call retries only the domains that are still unverified. --- ## Certificate Enrollment -When a requester submits a certificate request through Keyfactor Command, the plugin translates the request into a CERTInext order and returns the result. The plugin handles three enrollment scenarios: new issuance, renewal (within a configured window before expiry), and reissuance (new keys, same profile). +When a requester submits a certificate request through Keyfactor Command, the plugin translates the request into a CERTInext order and returns the result. V1 and V2 follow different flows; see [Enrollment Decision Logic](#enrollment-decision-logic) for how the enrollment type is handled in each. -### New Certificate or Reissuance +### V1 enrollment (New or Reissue) ```mermaid sequenceDiagram @@ -127,48 +272,131 @@ sequenceDiagram participant Plugin as CERTInext Plugin participant API as CERTInext API - CMD->>Plugin: Request new certificate\n(CSR, subject, SANs, product code, requester details) - Plugin->>Plugin: Validate product code is present - Plugin->>Plugin: Record enrollment intent in audit log\n(subject, SANs, product, requester — before any API call) + CMD->>Plugin: Enroll (CSR, subject, SANs, product code, requester details) + Plugin->>Plugin: Require a product code and log the enrollment intent
(requester personal data redacted by default) - Plugin->>API: Place certificate order\n(CSR, domain, organization details,\nsubscriber agreement, requestor info) - API-->>Plugin: Order accepted — order number assigned - - Plugin->>API: Check order status - API-->>Plugin: Order status and certificate details + Plugin->>API: GenerateOrderSSL (CSR inline, domain, organization,
subscriber agreement, requestor, technical contact) + API-->>Plugin: Order number assigned + Plugin->>API: TrackOrder + API-->>Plugin: Certificate status + opt Status is downloadable + Plugin->>API: GetCertificate + API-->>Plugin: Certificate PEM + end - 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 Order rejected by CERTInext - Plugin-->>CMD: Enrollment failed — see gateway logs + alt DCV enabled and a DNS provider plugin is available + Plugin->>Plugin: Run V1 DCV and the post-DCV issuance wait
(see Domain Control Validation) + Note over Plugin: The pickup poll below is skipped,
because the DCV flow owns the issuance wait + else DCV off or unavailable + loop Pickup poll while the order is pending
PickupRetries attempts, PickupDelay apart, after a 5 s initial delay + Plugin->>API: GetCertificate + API-->>Plugin: Status and certificate PEM + end end - Plugin->>Plugin: Record enrollment outcome in audit log\n(order number, serial number, status) + alt Certificate issued with PEM + Plugin-->>CMD: Issued, PEM returned + else Still pending + Plugin-->>CMD: Pending, Command picks it up during the next synchronization + else Order failed or rejected + Plugin-->>CMD: Failed, see gateway logs + end + Plugin->>Plugin: Log the outcome (order number, serial number, status) ``` -### Renewal +An order CERTInext reports as issued before the certificate is downloadable is returned as pending rather than as an issued result with no certificate. OV and EV orders are issued after organization verification (minutes to hours) and almost always exhaust the pickup window; the next synchronization imports them. A rate-limit rejection (`Inactive Account User.`) on order placement is retried with backoff before the failure is surfaced. + +**Synchronous certificate pickup:** after placing an order, the plugin polls CERTInext a bounded number of times — `PickupRetries` attempts (default 5) spaced `PickupDelay` seconds (default 10) apart after a fixed 5-second initial delay, with a hard ceiling of 180 seconds in total — before returning a pending result to Command. This lets fast-issuing DV certificates come back in the same enrollment call. Only a result with a certificate body counts as issued. -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. +### V1 renewal and RenewOrReissue -> **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. +When Command initiates a renewal, the plugin checks whether the prior certificate is within the configured renewal window. Inside the window it places a renewal order; outside the window, or when the prior certificate can't be found, it places a new order. + +> **Note:** CERTInext does not have a dedicated certificate renewal endpoint. Both paths submit a new `GenerateOrderSSL` order; the prior order is not revoked. The distinction determines how the request is built, not which endpoint is called. A renewal order does not run inline DCV; sync-driven DCV completes a pending DV renewal. + +> **Note:** If the prior-order lookup itself throws (rather than cleanly returning "not found" — for example a transient database error), the plugin falls back to a new order rather than failing the enrollment. + +```mermaid +flowchart TD + A([Renew or RenewOrReissue requested]) --> B{"Prior certificate serial number
(PriorCertSN) provided?"} + B -- No --> C["Place a new order
(same as a new enrollment)"] + B -- Yes --> D["Look up the prior order ID
in the Command database"] + D --> E{Prior order found?} + E -- "No, or lookup failed" --> C + E -- Yes --> F["Read the prior certificate's expiry date"] + F --> G{"Expiry is in the future and
within RenewalWindowDays?"} + G -- Yes --> H["Place a renewal order
(GenerateOrderSSL, prior order looked up first)"] + G -- "No: outside the window, already expired, or expiry unknown" --> C + H --> I["Pickup poll
PickupRetries x PickupDelay"] + C --> J(["Issued, pending, or failed result
returned to Command"]) + I --> J +``` + +### V2 enrollment flow + +With `UseV2Api = true`, every enrollment type (New, Reissue, Renew, RenewOrReissue) takes the same path and places a new order. The plugin checks the request locally first, so a template or request that can never succeed is rejected before any order is placed. ```mermaid flowchart TD - A([Renewal requested]) --> B{Prior certificate\nserial number\nprovided?} - B -- No --> C[Issue new certificate] - B -- Yes --> D[Look up prior order\nin Command database] - D --> E{Prior order\nfound?} - E -- No --> C - E -- Yes --> F[Check certificate\nexpiry date] - F --> G{Within renewal\nwindow?} - G -- Yes\nwithin window --> H[Submit new order\nlinked to prior record] - G -- No\noutside window --> C - H --> I([Certificate issued or pending]) - C --> I + A([Enroll requested]) --> B["Log the enrollment intent"] + B --> C{"Request passes local checks?
family is ssl or private-pki, SignerPlace set for SSL,
Private PKI variant and ProductCode set,
OrganizationNumber set for OV or EV,
valid subscription and notification settings"} + C -- No --> X1(["Enrollment rejected, no order placed"]) + C -- Yes --> D{Private PKI?} + D -- Yes --> G + D -- No --> E["Fetch the product catalog
and resolve the product code and product type"] + E --> F{"Code resolved?
Explicit ProductCode, or exactly one catalog match,
or DefaultProductCode among several"} + F -- No --> X2(["Failed result listing the candidates, no order placed"]) + F -- Yes --> F2{"Single-domain product with extra DNS SANs
in the CSR or SAN list?"} + F2 -- Yes --> X3(["Failed result, no order placed"]) + F2 -- No --> G["Create the order
(X-Product-Code and Idempotency-Key headers)"] + G --> H["Submit the CSR unchanged
PUT /{family}/{id}/csr"] + H --> I{CSR accepted?} + I -- "Rejected by the CA (HTTP 4xx)" --> J["Cancel the orphaned order once"] + J --> X4(["Failed result carrying the order ID"]) + I -- "Transport error or timeout" --> K["Track the order"] + K --> L{"Still pending-csr?"} + L -- Yes --> J + L -- "Tracking failed" --> P1(["Pending result with the order ID,
next sync resolves it"]) + L -- "Moved past pending-csr" --> M + I -- Yes --> M["Track the order"] + M --> M2{Tracking succeeded?} + M2 -- No --> P1 + M2 -- Yes --> N{"SSL order pending, DCV enabled,
and a DNS provider available?"} + N -- Yes --> O["Run V2 DCV, then track the order again"] + N -- No --> Q + O --> Q{Issued?} + Q -- Yes --> R["Download the certificate chain"] + R --> S{Download succeeded?} + S -- Yes --> T(["Issued result with the PEM chain"]) + S -- No --> U + Q -- No --> U{"Order still pending, and DCV did not
already wait inside this call?"} + U -- No --> W + U -- Yes --> V["Pickup poll
PickupRetries x PickupDelay
track, and download once issued"] + V --> W{"Order revoked at the CA
before a certificate was delivered?"} + W -- Yes --> X5(["Failed result"]) + W -- No --> P2(["Issued if the poll found the certificate, failed if the
order was rejected or cancelled, otherwise pending
and picked up by the next sync"]) ``` +Notes on the V2 flow: + +- **Product code.** For SSL/TLS, the plugin fetches the live catalog once per enrollment. With an explicit `ProductCode`, the catalog is used only to recognize multi-domain and wildcard products; if the catalog can't be fetched, the order proceeds as single-domain, so a request with extra SANs is rejected rather than silently accepted. Without an explicit code, the plugin selects the catalog entry whose product type matches the selected product and fails if none or several match (unless `DefaultProductCode` names one). Private PKI never fetches the catalog; its `ProductCode` is required and was validated when the template was saved. +- **Multi-domain products.** DNS SANs other than the primary domain are sent as `additionalDomains`; non-DNS SANs are left off the order with a warning. Private PKI sends DNS and IP SANs as `additionalHosts`. +- **Idempotency.** V2 order-create and revoke calls carry a fresh `Idempotency-Key` UUID on every call. CERTInext's V2 spec describes the header as parsed but not yet enforced, so the plugin does not rely on it to prevent duplicates: it never retries an order-create call, and the AnyCA Gateway doesn't retry a timed-out `Enroll`. If an operator resubmits after an order-create error, check the CERTInext portal for an order that was created anyway. +- **Revoked before delivery.** An order that CERTInext reports as revoked before any certificate was delivered is returned as failed, never as a revoked record with no certificate. +- **Full certificate chain.** The V2 `/certificate` endpoint returns the leaf in `certificatePem` and intermediates in `chainPem[]`. The plugin concatenates them, leaf first, into a single PEM before returning it to Command. +- **Order IDs.** The V2 spec's examples show `ord_`-prefixed IDs, but V2 returns numeric order numbers in the same format as V1 (for example `6625262451`), and V1 order numbers resolve through V2 Track Order unchanged. The plugin stores the ID unchanged as the `CARequestID`. + +### Status mapping + +CERTInext statuses map to the Keyfactor statuses Command understands as follows. The full V1 status-code list and the V2 status table are under [Order Lifecycle and Pending Approval](#order-lifecycle-and-pending-approval) and [V2 Order Lifecycle](#v2-order-lifecycle). + +| CERTInext | Command status | +|---|---| +| Issued, or expired but not revoked | Issued (an expired certificate stays in inventory) | +| Any pending state (approval, validation, CSR, agreement, documents, `unknown`) | Pending external validation | +| Revoked | Revoked | +| Rejected, cancelled, or an unrecognized status | Failed | + --- ## Revocation @@ -181,30 +409,42 @@ sequenceDiagram participant Plugin as CERTInext Plugin participant API as CERTInext API - CMD->>Plugin: Revoke certificate\n(order number, serial number, reason code) - Plugin->>Plugin: Record revocation intent in audit log\n(order number, serial, reason — before any API call) + CMD->>Plugin: Revoke (order number, serial number, reason code) + Plugin->>Plugin: Log the revocation intent before any API call
(order number, serial, reason) - Plugin->>API: Retrieve current certificate status - API-->>Plugin: Current status and details + Plugin->>API: Retrieve the current order status
(V2: find which product family owns the order) + API-->>Plugin: Current status alt Certificate is already revoked - Plugin->>Plugin: Log warning — already revoked - Plugin-->>CMD: Confirmed revoked (no action needed) + Plugin->>Plugin: Log a warning, already revoked + Plugin-->>CMD: Confirmed revoked, no request sent else Certificate is not in an issued state - Plugin->>Plugin: Log error — cannot revoke - Plugin-->>CMD: Error — certificate is not revocable - else Certificate is issued and active - Plugin->>API: Submit revocation request\n(order number, reason, remarks) + Plugin->>Plugin: Log an error, cannot revoke + Plugin-->>CMD: Error, certificate is not revocable + else Certificate is issued + Plugin->>API: Revoke (order number, reason, remarks) + opt V2 only, reason in the rejected set and CERTInext replies 422 Invalid Revoke Reason ID + Plugin->>API: Revoke again, once, with the substitute reason + end API-->>Plugin: Revocation confirmed - - Plugin->>Plugin: Record revocation outcome in audit log\n(order number, serial, subject, reason) + Plugin->>Plugin: Log the outcome (order number, serial, reason) Plugin-->>CMD: Certificate revoked end ``` -**Idempotency:** If Command retries a revocation request (for example, after a timeout), the plugin detects that the certificate is already revoked and returns success without submitting a duplicate request to CERTInext. +**Idempotency:** if Command retries a revocation request (for example after a timeout), the plugin detects that the certificate is already revoked and returns success without submitting a duplicate request to CERTInext. + +**Audit trail:** the revocation intent is written to the gateway log *before* the API call is made, so it is recorded even if the API call subsequently fails. + +**V1 reason codes:** CERTInext's V1 revoke endpoint accepts only key compromise (1), affiliation changed (3), superseded (4), cessation of operation (5), and privilege withdrawn (9). Every other RFC 5280 reason code, including unspecified (0), is sent as key compromise (1). + +**V2 reason code substitution:** the V2 spec documents 8 RFC 5280 reason values for the SSL/TLS revoke endpoint (`aa-compromise` is listed only for the Document Signer and Private PKI endpoints, which document 9). On SSL/TLS orders CERTInext accepts only 5: `key-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, and `privilege-withdrawn`. Three documented values, `unspecified`, `ca-compromise`, and `certificate-hold`, return a 422 "Invalid Revoke Reason ID", as does `aa-compromise`. + +Rather than surface any of these four rejections to the caller, the plugin retries each once with a close accepted substitute: `unspecified` (CRL reason 0, Command's default when no explicit reason is given — by far the most common revoke case) and `certificate-hold` (CRL reason 6) retry as `cessation-of-operation`; `ca-compromise` (CRL reason 2) and `aa-compromise` (CRL reason 10) retry as `key-compromise`. `cessation-of-operation` was chosen over `key-compromise` for the first pair because neither `unspecified` nor `certificate-hold` implies an actual key compromise, and `key-compromise` carries the V2 spec's Baseline Requirements §4.9.1.1 24-hour CRL-turnaround obligation, which would misrepresent the revoke. Only these four specific rejections trigger a retry; any other revoke failure is surfaced as-is. -**Audit trail:** The revocation intent is written to the gateway log *before* the API call is made. This ensures that the intent is captured even if the API call subsequently fails, satisfying SOX audit requirements. +**V2 not revokable:** a 404 from the V2 revoke endpoint means the order was not found or is not in a revokable state. Because the plugin has already located the order's product family, it reports that plainly instead of probing the other families. + +**Note field:** CERTInext's revoke `note` (audit remarks) field rejects a semicolon (`;`) with a separate 422, "Invalid Revoke Remarks." Commas, periods, slashes, and parentheses are accepted. The plugin's own generated notes avoid semicolons for this reason. --- @@ -214,18 +454,37 @@ When an administrator saves or edits a CERTInext CA connector in the Keyfactor C ```mermaid flowchart TD - A([Save connector configuration]) --> B{Connector\nmarked as disabled?} - B -- Yes --> C([Saved without validation\nConnector will not process requests]) - B -- No --> D{Required fields\npresent and valid?\nAPI URL · Account Number · Credentials} - D -- Missing or invalid --> E([Validation error shown to administrator]) - D -- Valid --> F[Build temporary API client\nfrom supplied settings] - F --> G[Send test request\nto CERTInext] - G --> H{API accepted\nthe credentials?} - H -- No --> I([Connection test failed\nCheck credentials and API URL]) - H -- Yes --> J([Connector saved and active]) + A([Save connector configuration]) --> B{Connector
marked as disabled?} + B -- Yes --> C(["Saved without validation
Connector will not process requests"]) + B -- No --> D{"ApiUrl present, and https
or a loopback host?"} + D -- No --> E(["Validation error shown to administrator"]) + D -- Yes --> F{UseV2Api?} + F -- "Yes (V2)" --> G{"OAuthClientId, OAuthClientSecret,
and SignerPlace present?"} + F -- "No (V1)" --> H{"AccountNumber present and the
credentials for AuthMode present?
AccessKey needs ApiKey
OAuth needs an https OAuthTokenUrl,
OAuthClientId, OAuthClientSecret"} + G -- No --> E + H -- No --> E + G -- Yes --> I["Build a temporary API client
from the supplied settings"] + H -- Yes --> I + I --> J["Send a test request
V1: ValidateCredentials, V2: GET /auth/me"] + J --> K{API accepted
the credentials?} + K -- No --> L(["Connection test failed
Check credentials and API URL"]) + K -- Yes --> M(["Connector saved and active"]) ``` -**Disabled connectors:** Setting `Enabled` to `false` allows the connector record to be created and saved before credentials are available. The live connectivity test is skipped, so no credentials are required at save time. +**Disabled connectors:** setting `Enabled` to `false` allows the connector record to be created and saved before credentials are available. The live connectivity test is skipped, so no credentials are required at save time. + +The same https-or-loopback check runs again at gateway startup, so a connector saved with an `http` URL before the check existed fails to start until its URL is corrected. + +### Template validation + +When an administrator saves a certificate template, the gateway asks the plugin to validate its product settings. The product code must be set (explicitly, or derived from the selected product on V1). On V1 it must appear in the CERTInext product list for the account. On V2: + +- an explicit `ProductCode` must exist in the live catalog and match the selected product's type; +- without one, exactly one catalog entry must match the selected product, or `DefaultProductCode` must name one of several; +- an SSL template's `ProductVariant` must agree with the selected product; +- a Private PKI template needs a Private PKI `ProductVariant` and an explicit `ProductCode` that is a Private PKI catalog product. + +Any failure is shown to the administrator with the candidate codes or the field to fix. --- @@ -233,13 +492,35 @@ flowchart TD The table below maps each Keyfactor Command operation to the CERTInext API endpoint it calls. +**V1 endpoints (default)** + | Operation | CERTInext API endpoint | |---|---| | Test connection / verify credentials | `POST ValidateCredentials` | -| Issue new certificate | `POST GenerateOrderSSL` then `POST TrackOrder` | -| Renew certificate | `POST GenerateOrderSSL` then `POST TrackOrder` | +| Issue new certificate | `POST GenerateOrderSSL` (CSR inline), then `POST TrackOrder` and `POST GetCertificate` | +| Renew certificate | `POST TrackOrder` on the prior order, then `POST GenerateOrderSSL` as above | | Check certificate status | `POST TrackOrder` + `POST GetCertificate` | | Revoke certificate | `POST RevokeOrder` | | Synchronize inventory | `POST GetOrderReport` (paginated) | +| Domain control validation | `POST GetDcv`, `POST VerifyDcv` | | List available product codes | `POST GetProductDetails` | -| Attach CSR to draft order | `POST SubmitCSR` | +| Attach CSR to a draft order | `POST SubmitCSR` (developer tooling only; the plugin sends the CSR inline with `GenerateOrderSSL`) | + +**V2 endpoints (UseV2Api = true)** + +| Operation | V2 endpoint | +|---|---| +| Obtain Bearer token | `POST /oauth/token` | +| Test connection | `GET /api/certinext/v2/auth/me` | +| Create order | `POST /api/certinext/v2/{family}` | +| Submit CSR | `PUT /api/certinext/v2/{family}/{orderId}/csr` | +| Check order status | `GET /api/certinext/v2/{family}/{orderId}` | +| Get DCV challenge (SSL/TLS only) | `GET /api/certinext/v2/ssl-certificates/{orderId}/dcv` (with `?domain=` per domain) | +| Verify DCV (SSL/TLS only) | `POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify` | +| Download certificate | `GET /api/certinext/v2/{family}/{orderId}/certificate` | +| Revoke certificate | `POST /api/certinext/v2/{family}/{orderId}/revoke` | +| Cancel an orphaned order | `POST /api/certinext/v2/{family}/{orderId}/cancel` | +| List available products | `GET /api/certinext/v2/catalog/products` | +| Synchronize inventory | `GET /api/certinext/v2/reports/orders` (paginated) | + +`{family}` is `ssl-certificates`, `private-pki-certificates`, or `signature-certificates`. The plugin finds the family that owns an existing order by tracking it in each family in that order. diff --git a/docsource/configuration.md b/docsource/configuration.md index 41c872e..46f8390 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -7,18 +7,22 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi * Expired certificates can optionally be excluded from synchronization using the `IgnoreExpired` configuration flag. * Certificate Enrollment for profiles configured in CERTInext: * 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 renewal — on the V1 API, 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). On the V2 API every renewal places a new order. * 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`. + * DNS-01 domain control validation (DCV) — the plugin publishes the validation TXT record through a DNS provider plugin deployed on the gateway and asks CERTInext to verify it, during enrollment and during synchronization (`DcvEnabled`, on by default). * 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: - * AccessKey (HMAC-based request signing) — the primary and recommended mode - * OAuth (bearer token via client credentials flow) + * AccessKey (HMAC-based request signing) — the primary and recommended mode for the V1 API + * OAuth (bearer token via client credentials flow) — optional for V1, and the only mode for the V2 API +* Two CERTInext API generations, selected per connector with `UseV2Api`: the V1 API (default) and the order-centric V2 REST API, which adds Private PKI products. See [V2 API](#v2-api) and [Migrating from V1 to V2](#migrating-from-v1-to-v2). ## Requirements -* Keyfactor Command 25.5.x or later -* AnyCA Gateway REST framework version 25.5.0 or later +* Keyfactor Command 26.2 or later +* AnyCA Gateway REST framework version 26.2.0 or later +* A DNS provider plugin deployed on the AnyCA Gateway if the connector validates domains with DNS-01 DCV (the default; see `DcvEnabled`) * A CERTInext account with API access enabled and at least one certificate product configured * Network connectivity from the AnyCA Gateway host to the CERTInext API endpoint for your region (see table below) * The AnyCA Gateway host must trust the TLS certificate presented by the CERTInext API endpoint @@ -33,6 +37,8 @@ CERTInext operates three separate environments. Use the sandbox environment for | Production — India (Global) | https://in.certinext.io/ | `https://api.certinext.io/emSignHub-API/` | | Production — US | https://us.certinext.io/ | `https://us-api.certinext.io/emSignHub-API/` | +The V2 API (`UseV2Api` = `true`) is served from the bare host with no path segment, for example `https://sandbox-us-api.certinext.io` or `https://us-api.certinext.io`. See [V2 API](#v2-api). + > Note: Product codes differ between sandbox and production. Always confirm product codes from the GetProductDetails API call against the environment you are targeting before going live. ## CERTInext API Setup @@ -67,7 +73,7 @@ Enter the copied value in the `ApiKey` field of the CA connector configuration. ### OAuth — alternative auth mode -If your CERTInext account has OAuth enabled, you can use OAuth client credentials as an alternative to AccessKey signing. +If your CERTInext account has OAuth enabled, you can use OAuth client credentials as an alternative to AccessKey signing on the V1 API. The V2 API always authenticates with OAuth client credentials (see [V2 OAuth2 Setup](#v2-oauth2-setup)). 1. Log in to the CERTInext portal. 2. Navigate to **Integrations → APIs**. @@ -87,36 +93,68 @@ Before enrolling certificates, the Keyfactor Command server must trust the CERTI 1. Log in to the CERTInext portal and download the root CA certificate and any intermediate CA certificates in the chain as PEM or DER files. 2. On the Keyfactor Command server, import those certificates into the appropriate Windows certificate store — **Trusted Root Certification Authorities** for the root CA and **Intermediate Certification Authorities** for any subordinate CAs. 3. In the Keyfactor Command Management Portal, navigate to **CA Connectors** and add a new CA using the **CERTInext AnyCA REST Gateway Plugin**. -4. Complete the CA connector configuration fields described in the next section, then save and test the connection. The gateway performs a live connectivity test against the CERTInext `ValidateCredentials` endpoint during validation. +4. Complete the CA connector configuration fields described in the next section, then save and test the connection. The gateway performs a live connectivity test during validation: the V1 `ValidateCredentials` endpoint, or `GET /api/certinext/v2/auth/me` when `UseV2Api` is `true`. ## 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 enforces `ApiUrl` (https, or http for a loopback host); for V1, `AccountNumber` and the credential fields for the selected `AuthMode`; for V2, `OAuthClientId`, `OAuthClientSecret`, and `SignerPlace`. 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 | |---|---|---|---|---| -| `ApiUrl` | Required | CERTInext API base URL for your environment. Must include the `/emSignHub-API/` path segment. No trailing slash is required but is accepted. | See the environments table above. | `https://api.certinext.io/emSignHub-API/` | -| `AccountNumber` | Required | Your CERTInext account number (numeric string). Included in the `meta` block of every API request. | Portal → click your name or avatar → **Account Settings** or **My Profile**. | `1234567890` | -| `AuthMode` | Required | Authentication mode. `AccessKey` uses HMAC signing (recommended). `OAuth` uses a bearer token. | N/A — choose based on the credential type you created. | `AccessKey` | -| `ApiKey` | Conditional | The REST API Access Key generated in the CERTInext portal. Used to compute `authKey = SHA256(accessKey + ts + txn)`. The raw key is never transmitted. Required when `AuthMode` is `AccessKey`. This field is masked in the UI. | Portal → **Integrations → APIs** → generate or view the credential row. | *(generated, masked in UI)* | -| `OAuthTokenUrl` | Conditional | OAuth token endpoint URL. Required when `AuthMode` is `OAuth`. | Provided by eMudhra for your account. | `https://auth.certinext.io/oauth/token` | -| `OAuthClientId` | Conditional | OAuth client ID. Required when `AuthMode` is `OAuth`. | Portal → **Integrations → APIs** → the OAuth credential row. | `keyfactor-gateway` | -| `OAuthClientSecret` | Conditional | OAuth client secret. Required when `AuthMode` is `OAuth`. This field is masked in the UI. | Generated at OAuth credential creation time. | *(generated, masked in UI)* | +| `ApiUrl` | Required | CERTInext API base URL. In V1 mode (default), must include the `/emSignHub-API/` path segment. When `UseV2Api` is `true` (see [V2 API](#v2-api) below), this is instead the bare V2 host with no trailing slash or path suffix — the two APIs are hosted differently, so this value changes when `UseV2Api` is toggled. Must use `https` — the OAuth client secret (V2) or API key (V1) is sent to this URL on every request, and `http` would transmit it in cleartext. `http` is rejected at connection-validation time except for a loopback host (`localhost`/`127.0.0.1`/`::1`), which is allowed for local test servers only. | See the environments table above. | `https://api.certinext.io/emSignHub-API/` | +| `AccountNumber` | Required (V1) | Your CERTInext account number (numeric string). Included in the `meta` block of every V1 API request. Not used when `UseV2Api` is `true`. | Portal → click your name or avatar → **Account Settings** or **My Profile**. | `1234567890` | +| `AuthMode` | Required (V1) | Authentication mode. `AccessKey` uses HMAC signing (recommended). `OAuth` uses a bearer token. Ignored when `UseV2Api` is `true`. | N/A — choose based on the credential type you created. | `AccessKey` | +| `ApiKey` | Conditional | The REST API Access Key generated in the CERTInext portal. Used to compute `authKey = SHA256(accessKey + ts + txn)`. The raw key is never transmitted. Required when `AuthMode` is `AccessKey` (V1). Not used when `UseV2Api` is `true`. This field is masked in the UI. | Portal → **Integrations → APIs** → generate or view the credential row. | *(generated, masked in UI)* | +| `OAuthTokenUrl` | Conditional | OAuth token endpoint URL. Required when `AuthMode` is `OAuth` (V1); must use `https`. Not used when `UseV2Api` is `true` — V2 requests its token from `{ApiUrl}/oauth/token`. | Provided by eMudhra for your account. | `https://auth.certinext.io/oauth/token` | +| `OAuthClientId` | Conditional | OAuth client ID. Required when `AuthMode` is `OAuth` (V1). Also required — and reused as-is — when `UseV2Api` is `true`; V2 does not have its own separate client ID field. | Portal → **Integrations → APIs** → the OAuth credential row. | `keyfactor-gateway` | +| `OAuthClientSecret` | Conditional | OAuth client secret. Required when `AuthMode` is `OAuth` (V1). Also required — and reused as-is — when `UseV2Api` is `true`. This field is masked in the UI. | Generated at OAuth credential creation time. | *(generated, masked in UI)* | | `RequestorName` | Required | Default name of the person or service submitting certificate orders. Sent in the `requestorInformation` block of every order request. | Use the name of the team or automation account responsible for these certificates. | `PKI Automation` | | `RequestorEmail` | Required | Default email address for the requestor. Must be a valid email address associated with your CERTInext account. Sent in the `requestorInformation` block of every order request. | Use a monitored team inbox or the account holder's email. | `pki-admin@example.com` | | `RequestorIsdCode` | Optional | International dialing code for the requestor phone number (digits only, no `+` prefix). Default: `1` (United States). | N/A — use the country code for your requestor. | `1` | | `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` | +| `RequestorDesignation` | Optional | Job title / role of the requestor (e.g. `IT Administrator`). Sent in the `requestorInformation` block (V1) and the `requestor.designation` field (V2). Free text with no CA-side enum. Left blank by default, in which case the field is omitted from the order entirely rather than sent with a default value. | N/A | `IT Administrator` | +| `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. When `UseV2Api` is `true` the connector can't be saved with this blank, because the V2 Subscriber Agreement sent with every SSL order requires it (a template's `SignerPlace` enrollment parameter still overrides it). | 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 V1 SSL order. On V2 it is sent as `groupNumber` on order create and as a query parameter on the catalog and orders-report calls. 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 (potentially tens of hours). | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | +| `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` on every SSL order (V1 and V2). 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` on V1 orders. `2` = credit-based (most accounts). `1` = cash model. Not used by V2. Default: `2`. | N/A | `2` | +| `EmailNotifications` | Optional | Whether CERTInext sends lifecycle-event emails to the requestor. `1` = full notification set (V1 sends it as-is; V2 maps it to `all`). `0` = silent on both V1 and V2. Blank/unset stays silent on V1 (sent as `0`) but is omitted on V2, so the CA's own default (`all`, not silent) applies instead. Any other value fails V2 enrollment before any CA call. Default: `0` — V2 orders are silent by default, matching V1. | 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 | V1 only. 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 submit DNS names only. Not consulted on V2 (see [Migrating from V1 to V2](#step-2--update-the-ca-connector)). Default: `true`. | N/A | `true` | +| `DefaultProductCode` | Optional, but effectively required if you use renewals (V1), or ProductId-only templates against an ambiguous V2 catalog | **V1 mode:** used for renewals only, and only when the template doesn't supply a product code (`ProductCode`/`ProfileId`) — CERTInext's `TrackOrder` doesn't return the prior order's product code. If neither is set, renewals go out with an empty product code. Has no effect on new V1 enrollments. **V2 mode:** also used to disambiguate a template that sets only `ProductId` (no explicit `ProductCode`) when the live V2 catalog has more than one product sharing the product's expected assurance level — if this value doesn't match one of the candidate codes, that enrollment (and template save-time validation) fails with an error listing them. | 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` | +| `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500` (V2 `/reports/orders` pages are capped at `100`). 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` | -| `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` | +| `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 (API keys, 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 entirely. Values above `30` are treated as `30`. 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. The total pickup budget is a fixed 5-second initial delay + (`PickupRetries` × `PickupDelay`), hard-capped at 180 seconds regardless of how the two values are set. Aim for well under ~90s total so the call doesn't run long enough to trip Command's own enrollment timeout. Values above `60` are treated as `60`; a non-positive value falls back to `10`. Default: `10` (a ~55s ceiling with default `PickupRetries`). | N/A | `10` | +| `DcvEnabled` | Optional | When `true`, the plugin performs DNS-based Domain Control Validation (DCV) during enrollment and synchronization for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway; without one, orders that need validation stay pending and the plugin logs why. Set to `false` if you validate domains another way. Default: `true`. | N/A | `true` | | `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` | -| `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` | +| `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. On V1, DCV driven during synchronization uses its own fixed 3-second delay; V2 uses this value everywhere. Default: `30`. | N/A | `30` | +| `DcvTimeoutMinutes` | Optional | Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) before giving up on the order, which stays pending. 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 | V1 only. 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 | V1 only. 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` | + +> **Pickup timing detail:** after a successful order placement, the plugin waits a fixed 5-second initial delay before the first poll attempt, then polls CERTInext every `PickupDelay` seconds up to `PickupRetries` times. Each poll calls `GetCertificate` to check whether the certificate has been issued. The total time budget is: **5s + (PickupRetries × PickupDelay) + API round-trip time per poll (~1s each)**. With defaults this is approximately 5 + (5 × 10) + 5 = **~60 seconds**. +> +> **Tuning for faster pickup:** if the CERTInext API typically issues certificates within a few seconds of order placement (as is typical for DV and auto-approved orders), you can reduce per-enrollment wait time by lowering `PickupDelay` and raising `PickupRetries` to compensate — this polls more frequently without changing the total budget. For example: +> +> | Configuration | PickupRetries | PickupDelay | Total budget | Poll cadence | +> |---------------|:---:|:---:|---|---| +> | Default | `5` | `10` | ~55s | Every 10s | +> | Faster polling | `10` | `5` | ~55s | Every 5s | +> | Aggressive | `30` | `2` | ~65s | Every 2s | +> | Minimal wait | `0` | — | 0s | No polling; defers to sync | +> +> The 5-second initial delay before the first poll is not configurable. The 180-second hard ceiling applies regardless of configuration. Pickup applies to V1 and V2 enrollments. When the DCV flow ran for the order inside the same `Enroll` call, the pickup poll is skipped, because the DCV flow already waited for issuance. > 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. @@ -126,27 +164,31 @@ The following fields are presented in the Keyfactor Command Management Portal wh A Keyfactor Command certificate template maps an enrollment request to a specific CERTInext product. Create one template per CERTInext product that you want to make available to requesters. +The AnyCA Gateway REST portal also needs one certificate profile per product before Command templates can be created against it. Create the profiles by hand in the gateway portal, or with the helper script this repository ships (`make register-profiles`; set `DRY_RUN=1` to preview). See [scripts/register/README.md](https://github.com/Keyfactor/certinext-caplugin/blob/main/scripts/register/README.md) for authentication and options. + In the Keyfactor Command Management Portal, navigate to **Certificate Templates** and create a new template associated with the CERTInext CA connector. The following enrollment parameters are available: | 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: on V1, the built-in default code for the selected product name is used (see [Product Codes](#product-codes)); on V2, the code is resolved from the live product catalog (see [V2 Product Code Resolution](#v2-product-code-resolution)). Set this explicitly when targeting the sandbox environment or a non-standard code. Required for `ProductFamily=private-pki`. | 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` | +| `ValidityDays` | Deprecated | Number | Legacy validity field, V1 only. 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 | **Currently has no effect** — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. | `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` | -| `KeyType` | Optional | String | Key algorithm to request at enrollment time. The key type is carried by the submitted CSR. CERTInext accepts **RSA 2048 / 3072 / 4096 and ECC P-256 / P-384** only — larger RSA, ECC P-521, and the Ed25519/Ed448 curves are rejected by the CA (`Invalid key size`). If omitted, the product default is used. | `RSA2048`, `RSA3072`, `RSA4096`, `EC256`, `EC384` | -| `DomainName` | Optional | String | Primary domain name for SSL/TLS orders. If omitted, the gateway derives the domain from the CSR `CN` field. | `example.com` | -| `SignerName` | Optional | String | Per-template override for the subscriber agreement signer name. When omitted, defaults to the connector-level `RequestorName`. | `Jane Smith` | -| `SignerPlace` | Optional | String | Per-template override for the subscriber agreement signer location. When omitted, defaults to the connector-level `SignerPlace`. | `Austin` | -| `SignerIp` | Optional | String | Per-template override for the subscriber agreement signer IP address. When omitted, defaults to the connector-level `SignerIp`. | `203.0.113.10` | +| `RenewalWindowDays` | Optional | Number | V1 only. Number of days before certificate expiration within which a renewal is attempted instead of a reissue. V2 places a new order for every renewal and reissue. Default: `90`. | `90` | +| `KeyType` | Optional | String | Informational. The key algorithm is determined by the submitted CSR, not by this parameter. CERTInext accepts **RSA 2048 / 3072 / 4096 and ECC P-256 / P-384** only — larger RSA, ECC P-521, and the Ed25519/Ed448 curves are rejected by the CA (`Invalid key size`). | `RSA2048`, `RSA3072`, `RSA4096`, `EC256`, `EC384` | +| `DomainName` | Optional | String | V2 only: primary domain name for SSL/TLS orders (for Private PKI, the primary hostname). If omitted, the plugin uses the CSR subject `CN`. V1 always uses the CSR subject `CN`. | `example.com` | +| `SignerName` | Optional | String | V2 only: per-template override for the subscriber agreement signer name. When omitted, defaults to the requestor name. V1 uses the connector-level `RequestorName`. | `Jane Smith` | +| `SignerPlace` | Optional | String | V2 only: per-template override for the subscriber agreement signer location. When omitted, defaults to the connector-level `SignerPlace`. V1 uses the connector-level value. | `Austin` | +| `SignerIp` | Optional | String | V2 only: per-template override for the subscriber agreement signer IP address. When omitted, defaults to the connector-level `SignerIp`. V1 uses the connector-level value. | `203.0.113.10` | + +The V2-only template parameters `ProductFamily` and `ProductVariant` are described under [V2 Certificate Template Fields](#v2-certificate-template-fields). ## Product Codes -CERTInext uses numeric product codes to identify certificate types. **Product codes are provisioned per account by eMudhra** — the codes available to your account are determined when your account is set up. The codes in the tables below are the values observed on specific sandbox and production accounts; your account may have different codes. +CERTInext uses numeric product codes to identify certificate types. **Product codes are provisioned per account by eMudhra** — the codes available to your account are determined when your account is set up. The codes in the tables below are example values for the sandbox and production environments; your account may have different codes. To retrieve the exact codes available to your account, call the `GetProductDetails` endpoint: - If you have a `GroupNumber` configured, include it in the request `productDetails` block — some accounts require this to return a non-empty list. @@ -158,9 +200,9 @@ To retrieve the exact codes available to your account, call the `GetProductDetai ### SSL/TLS -The product codes in this table were observed on: -- the US sandbox environment (`sandbox-us-api.certinext.io`) in April–May 2026 -- the Production India environment (`api.certinext.io`) via the live draft-order coverage matrix in [development.md](development.md) +The product codes in this table are for: +- the US sandbox environment (`sandbox-us-api.certinext.io`) +- the Production India environment (`api.certinext.io`) **Your account may still have different codes.** Always call `GetProductDetails` against your target environment before going live. @@ -177,7 +219,7 @@ The product codes in this table were observed on: | EV (Extended Validation) | `850` | `846` | All OV fields plus: `contractSignerInfo` object (`name`, `email`, `isdCode`, `mobileNumber`, `designation`, `employeeID`); `certificateApproverInfo` object (same fields); `certificateInformation.companyRegistrationNumber`; `streetAddress2` must be non-empty. | | EV UCC (Multi-domain EV) | `851` | `847` | Same as EV plus `certificateInformation.additionalDomains`. | -> Note: SSL/TLS codes appear to be offset by 4 between the US sandbox and Production India in the snapshots we've observed — but treat that as a coincidence, not a guarantee. eMudhra controls the per-account mapping and may use different numeric codes for any new account. Always confirm via `GetProductDetails`. +> Note: SSL/TLS codes are offset by 4 between the US sandbox and Production India in the tables above — treat that as a coincidence, not a guarantee. eMudhra controls the per-account mapping and may use different numeric codes for any new account. Always confirm via `GetProductDetails`. > Note: The CERTInext portal may display additional short-validity products (e.g. **DV SSL Certificate 1 Month**, **DV SSL Certificate Wildcard 1 Month**) that do not appear in the `GetProductDetails` API response and have no published product code. These products are not accessible via the API and are therefore **not supported by this plugin**. Contact eMudhra to determine whether API ordering is available for these products on your account. @@ -186,26 +228,28 @@ The product codes in this table were observed on: | Product | Sandbox Code | Production Code | Availability | |---|---|---|---| | emSign Intranet SSL 1 year | `149` | `100` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | -| IGTF Host 1 year | (not observed) | `104` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | +| IGTF Host 1 year | n/a | `104` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | -> Note: Private PKI products are not available for ordering on standard CERTInext accounts. Attempting to place an order will return EMS-1162 (product not provisioned). The sandbox Private PKI code (`149`) also returns EMS-1162 on standard sandbox accounts even though it appears in the `GetProductDetails` list. Contact eMudhra to have these products enabled on your account. +> Note: Private PKI products need a separate entitlement. On an account without it, placing an order returns EMS-1162 (product not provisioned). Contact eMudhra to have these products enabled. Whether the sandbox code `149` ("Sandbox emSign Intranet SSL 1 Year", `productTypeID` `39`) is available depends on the account. Check your own account's catalog. ### S/MIME and Document Signing -The same numeric product codes have been observed for S/MIME and document-signing products on both the US sandbox and Production India in the snapshots we have. **Treat that as an empirical observation, not a contract** — eMudhra is free to assign different codes per account. Always confirm via `GetProductDetails`. +The same numeric product codes are used for S/MIME and document-signing products on both the US sandbox and Production India. **Treat that as an example, not a contract** — eMudhra is free to assign different codes per account. Always confirm via `GetProductDetails`. | Product | Sandbox / Production Code | Availability | |---|---|---| | S/MIME | `894` | Requires a separate S/MIME entitlement on the account. Not available on standard SSL accounts. | -| Natural Person Doc Signer (tier 1) | `825` | Requires document signing entitlement. Not orderable on standard accounts. | -| Natural Person Doc Signer (tier 2) | `826` | Requires document signing entitlement. Not orderable on standard accounts. | -| Natural Person Doc Signer (tier 3) | `827` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 1) | `822` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 2) | `823` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 3) | `824` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 1) | `819` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 2) | `820` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 3) | `821` | Requires document signing entitlement. Not orderable on standard accounts. | +| Document Signer | `819`–`827` | Requires document signing entitlement. Not orderable on standard accounts. See the code-to-product table below. | + +CERTInext's published references don't agree on which Document Signer code maps to which product, so +confirm the product name for each code in your account's catalog before you use one. The V2 API spec +lists: + +| Code | Product (per V2 API spec) | +|---|---| +| `819` / `820` / `821` | Natural Person, 1 / 2 / 3 year | +| `822` / `823` / `824` | Legal Person, 1 / 2 / 3 year | +| `825` / `826` / `827` | Legal Entity, 1 / 2 / 3 year | > Note: S/MIME (894) and document signing products (819–827) require a separate entitlement that is not included in a standard SSL/TLS account. Contact eMudhra to request access. @@ -218,44 +262,77 @@ To retrieve the full list of product codes available to your account, call the ` ### Authentication -Every CERTInext API call is an HTTP POST with a JSON body. There is no Authorization header. Instead, the body carries a `meta` block with an `authKey` field computed as: +Every V1 API call is an HTTP POST with a JSON body. In `AccessKey` mode there is no Authorization header. Instead, the body carries a `meta` block with an `authKey` field computed as: ``` 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. +Where `requestTs` is the ISO 8601 timestamp and `requestTxnId` is a random numeric transaction ID 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 plugin obtains a bearer token via the configured client credentials flow and sends it in an `Authorization: Bearer` header; `authKey` is left empty in the `meta` block, which still carries the version, timestamp, transaction ID, and account number. + +V2 calls are plain REST requests that carry the bearer token in the `Authorization` header; see [V2 Token Caching](#v2-token-caching). + +### HTTP Timeout and Retries + +Every CERTInext API call (enroll, sync, revoke) uses a fixed 120-second request timeout, on both +V1 and V2. 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. + +- **Order placement and CSR submission are never auto-retried after a timeout or server error**, + because CERTInext may have already created the order. If the CA did create it, the next + [synchronization](#synchronization) imports it. Do not resubmit immediately; check the CERTInext + portal first. +- **Other V1 calls** (status checks, downloads, revoke, reports) are retried up to 3 attempts on a + 5xx response or network failure. 4xx responses are not retried. +- **V1 rate limit:** when order placement returns CERTInext's rate-limit response (the generic + `Inactive Account User.` error), the plugin retries it up to 5 attempts with exponential backoff + and jitter. See [Troubleshooting](https://github.com/Keyfactor/certinext-caplugin/blob/main/docsource/overview.md#troubleshooting). +- **V2 calls are never auto-retried.** The only repeated V2 call is the one-time revoke-reason + fallback described under [V2 Revocation Reason Handling](#v2-revocation-reason-handling). ### 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: +**V1.** When the gateway calls `Enroll`, the plugin selects a path from the enrollment type and the age of the prior certificate: + +1. **New or Reissue** (`EnrollmentType.New` / `Reissue`) — a new `GenerateOrderSSL` request is submitted. +2. **Renew or RenewOrReissue** — Command supplies the prior certificate's serial number (`PriorCertSN`). The plugin resolves the prior order from the Command database, then: + - no serial number, or no prior order found (including a failed lookup): a new order is submitted as for a new enrollment; + - the prior certificate's expiry is in the future and within `RenewalWindowDays` (default 90 days): a renewal order is submitted — CERTInext has no dedicated renewal endpoint, so this is also a new `GenerateOrderSSL` order; the prior order is looked up first and its details fill in anything the request doesn't supply; + - the prior certificate is outside the window, or already expired: a new order is submitted. -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). -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 prior order is never revoked automatically. The `RenewalWindowDays` template parameter sets the renewal/reissue boundary per certificate template. -The `RenewalWindowDays` template parameter controls the renewal/reissue boundary per certificate template. +**V2.** All enrollment types (New, Reissue, Renew, RenewOrReissue) place a new V2 order; there is no renewal-window logic. See [V2 Order Lifecycle](#v2-order-lifecycle). ### Required Order Fields -The `GenerateOrderSSL` API requires an `additionalInformation.remarks` field in every order request body. The gateway populates this field automatically with the text `"Issued via Keyfactor Command AnyCA REST Gateway."`. If you encounter error `EMS-918: Additional Information cannot be empty`, verify that the gateway version is current and that the field is being sent. +The V1 `GenerateOrderSSL` API requires an `additionalInformation.remarks` field in every order request body. The gateway populates this field automatically with the text `"Issued via Keyfactor Command AnyCA REST Gateway."`. If you encounter error `EMS-918: Additional Information cannot be empty`, verify that the gateway version is current and that the field is being sent. ### Order Lifecycle and Pending Approval 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 returns a failed result. -The gateway polls the `TrackOrder` endpoint during sync to pick up certificates that were approved after the initial enrollment call. +Orders that are still pending when `Enroll()` returns are picked up by a later synchronization. ### Synchronization -Synchronization uses the `GetOrderReport` endpoint with paginated results (controlled by `PageSize`, default 100, max 500). Each page is fetched sequentially until all orders are retrieved. The plugin maps each order's status to a Keyfactor certificate status and returns the result set to the gateway framework, which reconciles it against the Command inventory. +On V1, synchronization uses the `GetOrderReport` endpoint with paginated results (controlled by `PageSize`, default 100, max 500). Each page is fetched sequentially until all orders are retrieved; a full sync requests every order, and an incremental sync requests orders placed since the last sync date. The plugin maps each order's status to a Keyfactor certificate status and returns the result set to the gateway framework, which reconciles it against the Command inventory. + +- The order report carries no certificate body, so the plugin refetches the certificate for issued and revoked orders. +- Rejected and cancelled orders are skipped. +- Pending orders are emitted as pending. When DCV is enabled and a DNS provider plugin is available, recently placed pending orders are first driven through DNS-01 validation, bounded by `DcvSyncMaxOrderAgeHours` and `DcvSyncMaxPerPass`. +- If more than 25% of the records processed so far have failed (after at least 50 records), the sync aborts and is retried on the next cycle. Expired certificates are included by default. Set `IgnoreExpired: true` on the connector to skip them during sync. +On V2, synchronization reads `/reports/orders` instead — see [V2 Order Lifecycle](#v2-order-lifecycle). + ### Product Code Resolution When an enrollment request arrives, the numeric CERTInext product code is resolved in this order: @@ -266,4 +343,97 @@ When an enrollment request arrives, the numeric CERTInext product code is resolv If none of these yield a code, enrollment fails with a validation error. -{% include 'architecture.md' %} +## V2 API + +The plugin includes an opt-in CERTInext V2 REST API code path that uses OAuth2 `client_credentials` authentication and an order-centric resource model. V2 is disabled by default; V1 remains the active path unless `UseV2Api` is explicitly set to `true`. When enabled, V2 is fully self-contained: Ping, Enroll, GetSingleRecord, Revoke, and Synchronize all route through the V2 API, and V1 credentials (`ApiKey`, `AccountNumber`, `AuthMode`) are not required. Known limitations are listed under [Known Gaps](#known-gaps). + +### V2 CA Connector Fields + +V2 mode reuses the connector's `ApiUrl`, `OAuthClientId`, and `OAuthClientSecret` fields (documented above) rather than separate V2-only credentials — `ApiUrl` becomes the V2 host and `OAuthClientId`/`OAuthClientSecret` authenticate against it, regardless of `AuthMode`. Only the fields below are specific to V2 mode: + +| Field | Required / Optional | Description | Example | +|---|---|---|---| +| `UseV2Api` | Optional | Enable the V2 API code path for connection tests, enrollment, revocation, status checks, and synchronization. Default: `false`. | `false` | +| `V2SyncLookbackHours` | Optional | V2 mode only. During an incremental Synchronize, the plugin queries `from` = (last sync time minus this many hours) rather than the exact last-sync time, since the API's `from`/`to` filter may bracket either the order-placement date or the issuance date — a lookback window keeps an order created before last sync but issued afterward (e.g. a slow DCV order) from being missed. Default: `72`. | `72` | + +#### V2 OAuth2 Setup + +1. Log in to the CERTInext portal for your environment. +2. Navigate to **Integrations → APIs**. +3. Click **+ Create API Credentials**, set **API Type** to `REST`, and select the **OAuth** auth type (not `Access Key`). The V2 spec requires the key to be generated in OAuth mode. A key that wasn't gets HTTP 403 `unauthorized_client` at token time. +4. Note the client ID and client secret. Enter them in `OAuthClientId` and `OAuthClientSecret`. The V2 spec's token example uses the account number as `client_id`, but the plugin never substitutes `AccountNumber` for it, so set `OAuthClientId` explicitly. See [Step 1 of the migration guide](#step-1--create-a-v2-oauth2-credential) for notes on reusing V1 OAuth keys. +5. Set `UseV2Api` to `true` and set `ApiUrl` to the V2 base URL (no trailing path suffix), e.g. `https://sandbox-us-api.certinext.io`. +6. V1-only fields (`ApiKey`, `AccountNumber`, `AuthMode`) are not required in this mode and can be left blank. + +#### V2 Token Caching + +The plugin obtains a V2 bearer token via the standard OAuth2 `client_credentials` grant (`grant_type=client_credentials`, form-encoded) against `{ApiUrl}/oauth/token`. Tokens are cached in memory and reused until 60 seconds before expiry (minimum 30-second cache). Token refresh is thread-safe. + +### V2 Certificate Template Fields + +When `UseV2Api` is `true`, two additional enrollment parameters become relevant: + +| Parameter | Required / Optional | Type | Description | Example / Default | +|---|---|---|---|---| +| `ProductFamily` | Optional | String | CERTInext V2 product family. Supported for enrollment: `ssl` (SSL/TLS) and `private-pki` (Private PKI — see [V2 Private PKI Orders](#v2-private-pki-orders)). `signature` (Document Signer) is accepted by the parameter, but Document Signer enrollment is not supported: a `signature` enrollment fails before any order is placed. An unrecognized value is treated as `ssl`. Default: `ssl`. | `ssl` | +| `ProductVariant` | Optional | String | Product variant within the family. `ssl`: `dv`, `ov`, or `ev`. If omitted, the plugin derives it from the selected product (e.g. an OV product sends `ov`, an EV product sends `ev`) rather than always defaulting to `dv`; an explicit override that contradicts the product's derived variant fails enrollment with an actionable error instead of being sent as-is. `private-pki`: `intranet-ssl` or `igtf-host` — required, with no default. | `dv` | + +`ProductCode` continues to carry the numeric product code and is sent in the `X-Product-Code` header on V2 order placement. + +### V2 Product Code Resolution + +When `UseV2Api` is `true`, the numeric product code sent to CERTInext is resolved as follows: + +1. **Explicit `ProductCode` (or the deprecated `ProfileId` alias) on the template** — sent as-is in the `X-Product-Code` header, after template save-time validation confirms it exists in the live V2 catalog. +2. **No explicit code set** — the plugin maps the template's selected product to the catalog's expected `productTypeID` and looks for catalog entries sharing it: + - **Exactly one match** — used automatically. + - **No match** — enrollment (and template save-time validation) fails; the account may not be entitled to the product. + - **More than one match** — the live catalog can carry several entries at the same assurance level (e.g. two DV SSL entries with different billing terms). The connector's `DefaultProductCode` must name one of them, or enrollment fails with an error listing every candidate code and name. Set `ProductCode` explicitly on the template, or set `DefaultProductCode` on the connector, to disambiguate. + +This differs from V1, where `DefaultProductCode` only affects renewals (see the [`DefaultProductCode` field](#ca-configuration) above) — in V2 mode it also disambiguates new enrollments and template validation for a `ProductId`-only template. + +### V2 Private PKI Orders + +With `ProductFamily=private-pki`, the plugin places the order against CERTInext's Private PKI endpoint using the Private PKI request body, which differs from the SSL/TLS one: + +- **Product code is required.** Set `ProductCode` explicitly to your account's Private PKI catalog code. Private PKI codes vary per customer catalog, so the plugin can't look one up from the product selected on the template. Template validation checks that the code exists in the V2 catalog and is a Private PKI product (catalog `productTypeID` `39`). +- **Variant is required.** Set `ProductVariant` to `intranet-ssl` or `igtf-host`. +- **Hostname.** The order's primary `hostname` comes from `DomainName`, or from the CSR's CN when `DomainName` isn't set. +- **SANs, including IP addresses.** Additional SANs are sent in the order's `additionalHosts` field, which accepts DNS names and IPv4/IPv6 addresses. SANs come from the gateway's SAN list; the plugin falls back to the SANs in the CSR only when the gateway supplies none. Email and URI SANs can't be expressed in `additionalHosts`, so they're left off the order and a warning is written to the gateway log. `SubmitNonDnsSans` isn't consulted for Private PKI orders. +- **No DCV, organization, or subscriber agreement.** Private PKI orders have none of these steps, so DCV is never attempted for them, and `OrganizationNumber`, `AutoSecureWww`, `SignerName`, `SignerPlace`, and `SignerIp` aren't used. +- **Shared fields.** The requestor, technical contact, subscription, email-notification, and group settings are sent exactly as they are for SSL/TLS orders. + +### V2 Order Lifecycle + +A V2 enrollment places the order, submits the CSR, and then checks the order status; see [V2 enrollment flow](#v2-enrollment-flow) for the full sequence. V2 orders are identified by the `orderId` the V2 order placement endpoint returns, which the plugin stores unchanged as the `CARequestID` and uses for all later tracking, certificate download, and revocation calls. The V2 spec's examples show `ord_`-prefixed IDs, but V2 returns numeric order numbers in the same format as V1 (e.g. `6625262451`). Treat the ID as an opaque string. + +V2 status strings map to Keyfactor enrollment statuses as follows: + +| V2 Status | Keyfactor Status | Notes | +|---|---|---| +| `issued` | Issued | Certificate is immediately downloaded and returned to Command. | +| `pending-dcv` | Pending External Validation | Order is awaiting domain control validation. | +| `pending-csr` | Pending External Validation | Order is awaiting CSR submission or processing. | +| `pending-agreement` | Pending External Validation | Order requires subscriber agreement acceptance. | +| `pending-organization-verification` | Pending External Validation | OV/EV order is awaiting organization verification. | +| `pending-documents` | Pending External Validation | Order is awaiting supporting document submission. | +| `pending-approval` | Pending External Validation | Order is awaiting final CA/LRA approval before issuance. | +| `revoked` | Revoked | Order has been revoked. | +| `cancelled` | Failed | Order was cancelled; a new enrollment is required. | +| `rejected` | Failed | Order was rejected by the CA/LRA; a new enrollment is required. | +| `expired` | Issued | An expired-but-not-revoked order is reported as issued (GENERATED), matching V1's convention — it remains visible in Command's inventory rather than disappearing as a failure. | +| `unknown` | Pending External Validation | CERTInext can't currently report where the order is; the order is kept pending and re-checked on the next status poll or sync, and the plugin logs a warning. | + +Any V2 status not in this table (e.g. a value CERTInext adds in the future) maps to Failed, and the +plugin logs a warning distinguishing "unmapped status" from the statuses above that are deliberately +mapped to Failed — see the gateway trace log if certificates unexpectedly show as failed. + +**V2 synchronization.** Synchronize pages through `/reports/orders` (pages are capped at 100 rows; `PageSize` is clamped to that). The report carries display strings (for example `Order Fulfilled`, `Certificate Downloaded`) rather than the status enum above. The plugin maps the strings it recognizes and falls back to a live order-status call for any combination it doesn't, so a new CERTInext display value never silently misclassifies an order. Cancelled and rejected orders are skipped; issued orders have their certificate chain downloaded. CERTInext does not serve the certificate body of a revoked order, so a revoked order is propagated as revoked only when the gateway already holds that certificate (the revocation date and reason come from a live status call). Otherwise it is reported as Failed (the gateway has a record without a body) or skipped (the gateway has no record), so the gateway never stores a revoked record with no certificate. + +V2 has no *renew* endpoint. CERTInext does document a `/reissue` endpoint (`mode: rekey|update-sans`, with optional `revokePrevious`/`revokeReason`), but the plugin does not use it — every enrollment type (New, Reissue, Renew, RenewOrReissue) places a fresh V2 order, and the prior order/certificate is left issued rather than auto-revoked. + +### V2 Revocation Reason Handling + +CERTInext's V2 revoke endpoint accepts only a subset of its own documented reason enum. When Command's revoke reason maps to one CERTInext rejects, the plugin substitutes an accepted reason and retries once, rather than failing the revoke outright: CA-compromise and AA-compromise are retried as key-compromise; unspecified (Command's default when no reason is given) and certificate-hold are retried as cessation-of-operation. See [Revocation Reason Codes](#revocation-reason-codes) in the migration guide below for the full accepted/rejected matrix. + +{% include 'migration-v1-to-v2.md' %} diff --git a/docsource/development.md b/docsource/development.md index 14c6cff..e98c24d 100644 --- a/docsource/development.md +++ b/docsource/development.md @@ -4,14 +4,14 @@ This document covers local development, testing, and live API smoke-testing for ## Prerequisites -- .NET SDK 8.0 or later -- `python3` (used for HMAC computation in Makefile API targets) +- .NET 10 SDK (the plugin project multi-targets `net8.0` and `net10.0`; the unit and integration test projects target `net8.0`) +- `python3` (used for HMAC computation in the V1 Makefile API targets) - `jq` (used for JSON pretty-printing in Makefile API targets) -- `~/.env_certinext` populated with credentials (see below) +- `~/.env_certinext` populated with credentials (see below); for V2 targets and tests, `~/.env_certinext_v2` as well -## Credentials File +## Credentials Files -Create `~/.env_certinext` with the following variables. This file is **never committed** — add it to your global `.gitignore` or keep it only in `$HOME`. +Create `~/.env_certinext` with the following variables for the V1 API targets and tests. This file is **never committed** — add it to your global `.gitignore` or keep it only in `$HOME`. ```bash CERTINEXT_API_URL=https://api.certinext.io/emSignHub-API # or sandbox URL @@ -29,6 +29,17 @@ CERTINEXT_SIGNER_IP= > Note: `CERTINEXT_GROUP_NUMBER` and `CERTINEXT_ORG_NUMBER` are distinct. The group number is the delegation unit (cost center/department) used in every order request. The org number is the validated organization record used for OV and EV orders. +The V2 API uses a separate file, `~/.env_certinext_v2` (override the path with `CERTINEXT_V2_ENV_FILE` for the `v2-*` Makefile targets): + +```bash +CERTINEXT_API_URL=https://sandbox-us-api.certinext.io # V2 base URL: no /emSignHub-API suffix +CERTINEXT_CLIENT_ID= +CERTINEXT_CLIENT_SECRET= +CERTINEXT_USE_V2_API=1 # enables the V2 integration tests +``` + +The V2 file reuses the key names `CERTINEXT_API_URL` and `CERTINEXT_PRODUCT_CODE` with V2 values. Source only `~/.env_certinext` into your shell, never the V2 file — the V2 tests read it from disk themselves. See [scripts/v2/README.md](https://github.com/Keyfactor/certinext-caplugin/blob/main/scripts/v2/README.md) for the V2 helper scripts. + ## Build and Test Targets | Target | Command | Description | @@ -40,27 +51,13 @@ CERTINEXT_SIGNER_IP= | Coverage report (browser) | `make coverage-report` | Same as `coverage`, then opens HTML report in the default browser | | Clean | `make clean` | `dotnet clean` and wipe coverage output directories | -### Build variants — `DcvSupport` (DCV vs no-DCV) - -The plugin builds against two `Keyfactor.AnyGateway.IAnyCAPlugin` contracts from a single -codebase, selected by the `DcvSupport` MSBuild property. The plugin's `AnyCAPluginCertificate` -records must match the gateway host's IAnyCAPlugin version to persist, so the build must target -the host (see issue 0003). - -| Build | Command | IAnyCAPlugin | DCV | Target gateway host | -|---|---|---|---|---| -| **No-DCV (default)** | `make build` / `dotnet build` | `3.2.0` (stable) | fenced out (`#if SUPPORTS_DCV`) | AnyCA Gateway **25.5.x** (IAnyCAPlugin 3.2.0) | -| **DCV** | `dotnet build -p:DcvSupport=true` | `3.3.0-PRERELEASE` | enabled | AnyCA Gateway **26.x** (IAnyCAPlugin ≥ 3.3) | +### DCV build -The **default is the no-DCV / 3.2.0 build** — it is the GA artifact that loads and persists on the -current GA gateway (25.5.x) and depends only on a stable package, so it is what CI ships. Build the -DCV variant explicitly with `-p:DcvSupport=true` for 26.x hosts. The one property drives the package -version, the `SUPPORTS_DCV` compile constant, and DCV test-file inclusion across all three projects, -so the two host targets are a build flag rather than a maintained fork. +The plugin builds against `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3.0 with DNS-01 domain control validation (DCV) included, and targets AnyCA Gateway REST 26.2.0 and later. No build flag is needed: `dotnet build` and `make build` produce the DCV build, and the DCV unit and integration test files compile in with it. Release builds use the same default. See [DCV_BUILD_SUPPORT.md](https://github.com/Keyfactor/certinext-caplugin/blob/main/DCV_BUILD_SUPPORT.md) for how the DCV code is organized. ## API Smoke-Test Targets -All API targets source `~/.env_certinext`, compute the HMAC `authKey` (`SHA256(accessKey + ts + txn)`), and call the live CERTInext API via `curl`. All JSON responses are piped through `jq`. +All V1 API targets source `~/.env_certinext`, compute the HMAC `authKey` (`SHA256(accessKey + ts + txn)`), and call the live CERTInext V1 API via `curl`. All JSON responses are piped through `jq`. The V2 equivalents are the `v2-*` targets (for example `make v2-ping`, `make v2-list-products`, `make v2-orders-report`), which read `~/.env_certinext_v2`. **Start here when setting up a new environment:** @@ -83,7 +80,7 @@ make orders # lists recent orders — useful to find an ORDER_NUMBER to test | Discover product codes | `make probe-products` | Places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports which ones the account accepts | | Cancel one pending order | `scripts/reject-order.sh ORDER_NUMBER=NNNNN` | Shell script — cancels a single pending order (not a `make` target) | | Cancel all pending orders | `scripts/reject-all-pending.sh` | Shell script — dry-run by default; set `REJECT_ALL_PENDING=1` to fire (not a `make` target) | -| Show API target help | `make api-help` | Prints usage for all API targets | +| Show API target help | `make api-help` | Prints usage for the V1 API targets | > Note: `TrackOrder` and `GetCertificate` require a formal `orderNumber`, which is only assigned after a draft order is submitted and approved. Draft orders (created with `saveAndHold:"1"`) have a `requestNumber` but no `orderNumber` until that point. @@ -100,40 +97,28 @@ Draft orders behave as follows: The gateway does not use `saveAndHold` in normal enrollment flows. It is strictly a developer testing mechanism for validating order payloads against the live API. -## Integration Tests +## Tests -The `CERTInext.IntegrationTests/` project contains live API tests that run against the production India instance (`api.certinext.io`). All tests use `[SkippableFact]` and skip automatically when `~/.env_certinext` is absent or incomplete. +The solution has two test projects and a small runner: -Run them with: +| Project | Purpose | +|---|---| +| `CERTInext.Tests` | Unit and contract tests. No external services: HTTP is served in-process by WireMock.Net, and the client is replaced by Moq mocks. See `CERTInext.Tests/TESTING.md`. | +| `CERTInext.IntegrationTests` | Live-API tests. Every test skips automatically when credentials are absent, and destructive or order-placing tests are additionally gated behind opt-in environment flags. See `CERTInext.IntegrationTests/TESTING.md` and `CERTInext.IntegrationTests/INTEGRATION_TESTING.md`. | +| `CERTInext.IntegrationRunner` | A read-only console program that exercises the client against the live API: `Ping`, then a `GetOrderReport` listing, then `TrackOrder` for an order number passed as the first argument or in `CERTINEXT_TEST_ORDER_NUMBER`. | -```bash -make integration-test -``` +Run the unit tests with `make test`, and the live tests with `make integration-test`. -See `CERTInext.IntegrationTests/INTEGRATION_TESTING.md` for a full description of each test, what it validates, and the expected API state. +Drive live-API verification through the integration tests and the runner rather than ad-hoc scripts, so every check is repeatable. ## 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`. - -| 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. +Draft-order and track-order semantics are covered by `LifecycleTests`, which creates its own order and asserts on it without relying on account-specific identifiers. + +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 expected test results. diff --git a/docsource/migration-v1-to-v2.md b/docsource/migration-v1-to-v2.md new file mode 100644 index 0000000..44c3c60 --- /dev/null +++ b/docsource/migration-v1-to-v2.md @@ -0,0 +1,264 @@ +## Migrating from V1 to V2 + +The CERTInext V2 REST API is an opt-in, order-centric API with OAuth2 authentication. It is +controlled entirely by the `UseV2Api` connector flag: `false` (default) keeps the connector on the +V1 API documented above; `true` switches **all** operations — Ping, Enroll, GetSingleRecord, Revoke, +and Synchronize — to V2. The two APIs cannot be mixed on a single connector. + +> Read [Known Gaps](#known-gaps) before migrating a production connector. The most important: +> Document Signer (`ProductFamily=signature`) enrollment isn't supported, per-SAN DCV on +> multi-domain (UCC) orders isn't validated end to end, and every renewal places a new order. + +### Before You Begin: Confirm V2 Will Work for Your Templates + +**V2 supports multi-domain (UCC) certificates**, including **DV UCC, DV Wildcard UCC, OV UCC, OV +Wildcard UCC, and EV UCC**. The plugin detects a UCC product from the live catalog's `productTypeID` +and sends the extra SAN domains in the order's `additionalDomains` field. Per the CERTInext V2 spec, +a UCC order's SANs are taken from the order, not the CSR. `additionalDomains` takes DNS names only, +so any non-DNS SAN (IP, email, URI) is left off a UCC order and a warning is written to the gateway +log. For a non-UCC SSL product, a CSR or SAN list that carries DNS names beyond the primary domain +and its `www.` variant is rejected before any order is placed; UCC products are exempt from that +check. + +**UCC DCV: the plugin runs DCV for each SAN, but per-SAN DCV on V2 isn't validated end to end.** For a +UCC order, the plugin's DNS-01 DCV flow publishes, verifies, and cleans up a TXT record for each domain +that CERTInext reports as not yet validated, not just the primary domain. Test each UCC template on +the sandbox before you rely on it in production, and keep it on a V1 connector if you need a +validated path today. + +**DCV needs a DNS provider plugin.** `DcvEnabled` defaults to `true`. If your gateway has no DNS +provider plugin for the order's domains, V2 DV orders stay pending after the order is placed. Deploy +a DNS provider plugin, or set `DcvEnabled` to `false` and validate domains another way. See +[DCV under V2](#dcv-under-v2). + +### Step 1 — Create a V2 OAuth2 Credential + +V2 authenticates with an OAuth2 `client_credentials` token, and the CERTInext V2 spec requires the +API key to be generated in **OAuth mode**. The credential goes in the connector's +`OAuthClientId`/`OAuthClientSecret` fields: + +1. Log in to the CERTInext portal for your environment. +2. Navigate to **Integrations → APIs**. +3. Click **+ Create API Credentials**. +4. Set **API Type** to `REST` and select the **OAuth** auth type, not `Access Key`. +5. Complete the form and click **Generate**. +6. Note the client ID and client secret right away. + +A V1 `Access Key` credential won't work against V2. If the key wasn't generated in OAuth mode, the +token request fails with HTTP 403 `unauthorized_client`, and the plugin reports that the key wasn't +generated in OAuth mode. A wrong client ID or secret fails with HTTP 401 `invalid_client` instead. +A key created for V1's `AuthMode: OAuth` isn't guaranteed to work on V2. If you reuse one and get the +403, create a new OAuth-mode key. + +The V2 spec's token example sends the account number as `client_id`. The plugin never substitutes +`AccountNumber` for the client ID, so always set `OAuthClientId` explicitly to the client ID shown in +the portal, even if the value matches your account number. + +### Step 2 — Update the CA Connector + +You can update the existing CA connector in place, or (recommended for a first migration) create a +second connector pointed at the same CERTInext account with `UseV2Api=true`, so you can validate V2 +behavior without disrupting V1 traffic. + +Set `UseV2Api` to `true`, change `ApiUrl` to the V2 host, set `OAuthClientId` and +`OAuthClientSecret`, and make sure `SignerPlace` is set (the connector can't be saved with it blank +in V2 mode). The remaining V1 fields behave as follows: + +| V1 field | What happens when you set `UseV2Api = true` | +|---|---| +| `ApiUrl` | **Must change format.** V1 requires the `/emSignHub-API/` path segment (e.g. `https://us-api.certinext.io/emSignHub-API/`); V2 is the bare host with no trailing slash or path suffix (e.g. `https://us-api.certinext.io`). Using the V1-style URL under V2 (or vice versa) will fail every call. In both modes, `ApiUrl` must use `https` — `http` is rejected at connection-validation time and at startup except for a loopback host, which stays allowed for local test servers. | +| `AccountNumber` | Not required, and not read by any V2 code path. V2 authenticates with `OAuthClientId`; the plugin doesn't reuse `AccountNumber` as the OAuth `client_id` (see Step 1). | +| `AuthMode` | Not required. V2 always authenticates via OAuth2 `client_credentials`, regardless of this setting. | +| `ApiKey` | Not required. V2 never computes an `authKey`. | +| `OAuthClientId` / `OAuthClientSecret` | **Reused, but repointed.** Set them to the OAuth-mode credential from Step 1. A V1 `AuthMode: OAuth` key isn't guaranteed to work on V2 (see Step 1). | +| `OAuthTokenUrl` | Not used. V2 always requests a token from `{ApiUrl}/oauth/token`; the token URL is derived, not configured. | +| `GroupNumber` | **Honored.** Sent as `groupNumber` on V2 order create (SSL/TLS and Private PKI) and as a `groupNumber` query parameter on the catalog and orders-report calls. Omitted when blank, so the account's default group applies. | +| `OrganizationNumber` | **Required for OV/EV, otherwise unused.** V2 OV/EV orders send `organization.organizationNumber` (with `preVetted=true`) from this setting — CERTInext hard-rejects an OV/EV order with no organization data (HTTP 422 `EMS-1180`), so `OrganizationNumber` must be set on the connector before enrolling OV/EV certificates via V2; the plugin fails the enrollment before any CA call if it is blank. DV orders never send an organization block, so this setting has no effect for DV. | +| `AccountingModel` | Not used by V2 order placement. | +| `EmailNotifications` | **Honored, with one default-value difference from V1.** `1` maps to `emailNotifications: "all"`; `0` maps to `"0"`. Blank/unset is omitted on V2 (the CA's own default of `"all"` applies) rather than sent as `"0"` the way V1's own fallback does — set `EmailNotifications=0` explicitly if you want V2 orders silent. Any other value fails the V2 enrollment before any CA call. | +| `SubscriptionAutoRenew` / `SubscriptionRenewCriteriaDays` | Honored. `SubscriptionAutoRenew=1` sets `subscription.autoRenew=true`; `SubscriptionRenewCriteriaDays` sets `subscription.renewBeforeDays` (blank omits the field, so the CA's documented default of 30 applies). An unparseable or negative `SubscriptionRenewCriteriaDays` fails the enrollment before any CA call. | +| `SubscriptionValidityYears` | Still used as the fallback validity when the template's `ValidityYears` parameter is not set. | +| `DefaultProductCode` | Used in V2 only to disambiguate a template that sets just `ProductId` when the live catalog has several products at the same assurance level (see [V2 Product Code Resolution](#v2-product-code-resolution)). Renewals don't need it: a V2 renewal is an ordinary new order that resolves its product code like any other. | +| `TechnicalContactName` / `Email` / `IsdCode` / `MobileNumber` | **Honored.** Sent as the order's `technicalPointOfContact` block on V2 SSL/TLS and Private PKI orders. Each blank field falls back to the matching `Requestor*` value, the same as V1. `designation` is always sent as `Technical Contact`. | +| `RequestorName` / `RequestorEmail` / `RequestorIsdCode` / `RequestorMobileNumber` / `RequestorDesignation` | Still used — carried into the V2 order's `requestor` block (the phone number is sent as `+`). `RequestorDesignation` is omitted from the order when blank (the default) rather than sent with any value. | +| `SignerPlace` / `SignerIp` | Still used — carried into the V2 order's `agreement` block. `SignerPlace` is required in V2 mode. | +| `AutoSecureWww` | Still used — controls whether V2 adds the `www.` variant. | +| `IgnoreExpired` | **Honored during V2 Synchronize.** When `true`, a report row whose `certificateExpiryDate` parses and is in the past is skipped. A row with a missing or unparseable expiry date is kept. | +| `SubmitNonDnsSans` | **SSL family (`ProductFamily=ssl`):** not consulted. A non-UCC order carries only the primary domain (plus `www.` when `AutoSecureWww` is set). A UCC order's `additionalDomains` takes DNS names only, so non-DNS SANs are left off the order with a warning in the gateway log (see [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates)). **Private PKI family (`ProductFamily=private-pki`):** not consulted — the order's `additionalHosts` field accepts DNS names and IPv4/IPv6 addresses natively, so IP-address SANs are always submitted; email and URI SANs cannot be expressed there and are left off the order with a warning in the gateway log. | +| `PageSize` | Still used, now against V2's `/reports/orders` paging (capped at 100 per page). | +| `PickupRetries` / `PickupDelay` | Still used — V2 enrollment polls for a quickly-issued certificate the same way V1 does. | +| `LogSensitiveRequestData` | Still used — governs the redaction of V2 request and response bodies in the gateway log. | +| `Enabled` | Still used. | + +New fields, `UseV2Api` and `V2SyncLookbackHours`, are documented in [V2 API](#v2-api) above. + +#### DCV under V2 + +`DcvEnabled` defaults to `true`, and the DCV settings behave as follows under V2: + +- `DcvEnabled`, `DcvTxtRecordTemplate`, `DcvPropagationDelaySeconds`, and `DcvTimeoutMinutes` are used. +- `DcvSyncMaxOrderAgeHours` and `DcvSyncMaxPerPass` bound DCV during synchronization, the same as V1. +- `DcvWaitForChallengeSeconds` and `DcvWaitForIssuanceSeconds` apply to V1 only. V2 publishes the challenge as soon as the order exists and polls the order until it leaves `pending-dcv` (bounded by `DcvTimeoutMinutes`), then runs the normal pickup poll. +- DCV applies to the SSL/TLS family. Private PKI orders have no DCV step. + +### Step 3 — Update Certificate Templates + +For each template you're migrating: + +1. **If the template sets only `ProductId` (no explicit `ProductCode`), check whether the live V2 + catalog has more than one product at that assurance level.** The plugin resolves the numeric code + automatically from the catalog when exactly one entry matches; when the catalog has several (e.g. + two DV SSL entries with different billing terms), you must either set `ProductCode` explicitly on + the template or set the connector's `DefaultProductCode` to one of the candidates — otherwise every + enrollment against that template fails with an error listing the candidate codes. See + [V2 Product Code Resolution](#v2-product-code-resolution) for the full resolution order. +2. Add `ProductFamily` (default `ssl`) if not already present — this is a V2-only parameter with no + V1 equivalent. `ProductVariant` (`dv`/`ov`/`ev`) is optional for `ssl`: if left unset, the plugin + derives it from the selected product (an OV product sends `ov`, an EV product sends `ev`) instead + of defaulting to `dv`; set it explicitly only to override. For a Private PKI template, set + `ProductFamily=private-pki`, `ProductVariant` to `intranet-ssl` or `igtf-host` (required, no + default), and an explicit `ProductCode` (see [V2 Private PKI Orders](#v2-private-pki-orders)). + `ProductFamily=signature` (Document Signer) enrollment is not supported. +3. Re-verify `ProductCode` (if set explicitly) against the V2 catalog. V1 and V2 product codes are not + guaranteed to be the same numeric values on your account — check the V2 catalog + rather than assuming the V1 code carries over. Template validation + (`ValidateProductInfo`) automatically checks `ProductCode` against the V2 catalog once + `UseV2Api=true`, so an incorrect code will be caught at template save time, not silently at + enrollment. +4. The `SignerName`, `SignerPlace`, `SignerIp`, and `DomainName` template parameters take effect in + V2. `RenewalWindowDays` and `ValidityDays` are V1-only and are ignored. +5. If the template enrolls for a UCC (multi-domain) product, test it on the sandbox first. The plugin + runs DCV for each SAN, but per-SAN DCV on V2 isn't validated end to end (see + [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates)). +6. If the product is OV or EV (whether `ProductVariant` is set explicitly or left to be derived), set + `OrganizationNumber` on the CA connector (a pre-vetted organization number from CERTInext's + Accounts → List Organizations). It is mandatory for OV/EV under V2 — enrollment fails fast with a + clear error if it's missing, rather than reaching the CA and getting back an opaque 422. + +### Step 4 — Test Before Cutting Over + +Run a full enroll → sync → revoke cycle against the sandbox environment with `UseV2Api=true` before +pointing a production template at the V2 connector. At minimum, confirm: + +- A new enrollment issues (or parks pending DCV/approval as expected) and is retrievable via + `GetSingleRecord`. +- A full and an incremental `Synchronize` both pick up the order. +- `Revoke` succeeds for the Command revoke reasons you actually use. + +### Verification Checklist + +- [ ] **Connection test.** Saving the connector succeeds. The plugin requested a token from + `{ApiUrl}/oauth/token` and called `GET /api/certinext/v2/auth/me`; a 401 means a wrong client ID + or secret, and a 403 means the key wasn't generated in OAuth mode. +- [ ] **Template save.** Each template saves. A "multiple catalog products match" error means the + template needs `ProductCode` or the connector needs `DefaultProductCode`. +- [ ] **DV enrollment.** A single-domain DV order returns the certificate, or returns pending and is + imported by the next sync. The order's TXT record is removed afterward. +- [ ] **UCC and wildcard.** Each template you migrated places one order carrying every DNS SAN, and + each domain reaches validated. +- [ ] **OV/EV.** `OrganizationNumber` is set and the order is accepted. These orders stay pending + until organization verification completes, so confirm the next sync imports them. +- [ ] **Synchronization.** A full sync followed by an incremental sync imports the new orders with + their certificates. Revoked orders appear only when the gateway already holds their certificate. +- [ ] **Revocation.** Revoke a test certificate with Command's default reason (unspecified) and with + key compromise. The gateway log shows the substituted reason for the first. +- [ ] **Logs.** The gateway log has no requestor names, email addresses, or request bodies + unless `LogSensitiveRequestData` is on. + +## Behavioral Differences After Migrating + +- **Order identifiers.** The V2 spec's examples show `ord_`-prefixed order IDs (e.g. + `ord_8K9mQ2vR8nP4bL`), but V2 returns numeric order numbers in the same format as V1 + (e.g. `6625262451`). V1-placed order numbers + also resolve through V2 Track Order and appear under the same number in the V2 orders report, so + existing `CARequestID` values carry over. The plugin stores whatever `orderId` CERTInext returns as + the `CARequestID`. Treat it as an opaque string in any external tooling rather than assuming either + format. +- **Status vocabulary.** V2 reports order status as strings (`issued`, `pending-dcv`, `pending-csr`, + `pending-agreement`, `pending-organization-verification`, `pending-documents`, `pending-approval`, + `revoked`, `cancelled`, `rejected`, `expired`, `unknown`) rather than V1's numeric CERTInext status + codes. The plugin maps both to the same Keyfactor status values, so this is transparent to + Command, but it changes what you'll see in gateway trace logs. +- **Synchronization source.** V2 sync reads CERTInext's `/reports/orders` endpoint instead of V1's + `GetOrderReport`. Incremental sync queries a window starting `V2SyncLookbackHours` (default 72) + before the last sync time rather than the exact last-sync timestamp, because the API's date filter + may bracket either the order-placement or the issuance date — this trades a small + amount of redundant re-processing for not missing a slow-issuing order. +- **Revoked orders in sync.** CERTInext doesn't serve the certificate body of a revoked order, so a + revoked order is imported as revoked only when the gateway already holds its certificate. Otherwise + it is reported as failed or skipped, so the gateway never stores a revoked record with no + certificate. +- **Orphaned orders.** If CERTInext rejects the CSR after the order was created, the plugin cancels + the order once and returns a failed result carrying the order ID. After a timeout, the plugin + checks the order before deciding: it cancels the order only if the CSR never arrived. + +### Renewals and Reissuance + +CERTInext V2 has no *renew* endpoint, but it does document a `/reissue` endpoint (`mode: +rekey|update-sans`, with optional `revokePrevious`/`revokeReason`). The plugin does not use it. +**Every** Command `New`, `Renew`, `Reissue`, and `RenewOrReissue` enrollment places a brand-new V2 +order — the same call path as a new enrollment — rather than reusing V1's renewal-window logic or the +`/reissue` endpoint. The prior order and certificate are left issued, not auto-revoked; Command links +the old and new certificates via history only. Because each renewal is a new order, CERTInext bills it +as one. Confirm with eMudhra how your account treats renewals before you rely on free renewals within +a subscription term. + +### Revocation Reason Codes + +V1 sends CERTInext a numeric `revokeReasonId`; V2 sends a kebab-case string reason. The plugin +handles this translation automatically. On SSL/TLS orders, only `key-compromise` (1), +`affiliation-changed` (3), `superseded` (4), `cessation-of-operation` (5), and `privilege-withdrawn` +(9) are accepted. `unspecified` (0, Command's default when no reason is given), `ca-compromise` (2), +`certificate-hold` (6), and `aa-compromise` (10) are all rejected with `"Invalid Revoke Reason ID"` — +`ca-compromise` and `certificate-hold` are listed in the spec for SSL/TLS, `aa-compromise` isn't listed +for SSL/TLS at all, but CERTInext rejects all four the same way. Rather than fail the revoke, the plugin +retries each once with a close accepted substitute: `ca-compromise` and `aa-compromise` retry as +`key-compromise`; `unspecified` and `certificate-hold` retry as `cessation-of-operation` (chosen over +`key-compromise` for those two because neither implies an actual key compromise). No customer action +is needed for any of these four cases. Any other revoke failure is surfaced as-is, without a retry. +Separately, a revoke note containing a semicolon (`;`) is rejected with `"Invalid Revoke Remarks"`. +The plugin's own generated notes avoid semicolons. + +V1 handles the same limit differently: it sends only 1, 3, 4, 5, and 9 as given, and sends every +other reason code, including unspecified, as key compromise. + +## Known Gaps + +The following V2 limitations are known. None of them is a show-stopper for a single-domain +deployment, but decide with these in mind rather than discovering them after cutting over: + +- **UCC per-SAN DCV isn't validated end to end.** The plugin runs DCV for each SAN on a UCC order, but + the path hasn't been validated against the CA. See + [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates) above. +- **Document Signer (`ProductFamily=signature`) enrollment isn't supported.** It fails before any + order is placed. +- **Every renewal/reissue places a new order.** See [Renewals and Reissuance](#renewals-and-reissuance) + above. +- **`OrganizationNumber` is required to enroll OV/EV via V2.** V2 OV/EV orders send + `organization.organizationNumber` with `preVetted=true` (mirroring V1's + `organizationDetails.preVetting`); omitting it fails the enrollment. If your account relies on + `OrganizationNumber` to fast-path DV issuance under V1, note that DV orders under V2 never send an + organization block, so that benefit does not carry over. +- **`AccountingModel` has no V2 effect.** V2 order create has no equivalent field. `GroupNumber`, the + technical-contact fields, `EmailNotifications`, `SubscriptionAutoRenew`/`RenewCriteriaDays`, and + `IgnoreExpired` are all honored on V2 (see the table above). +- **OV and OV UCC orders can exceed the plugin's fixed 120-second request timeout.** The order may + still be created on the CA and is imported by the next sync. EV orders under V2 are not validated + end to end. + +## Rolling Back to V1 + +Rolling back is just setting `UseV2Api` back to `false` on the connector — the V1 credential fields +(`ApiKey`/`AccountNumber`/`AuthMode` or V1 `OAuth`) are unaffected by having been unused while V2 was +active, as long as you didn't overwrite them in Step 2. Keep a copy of the V1 values, in particular +the V1-format `ApiUrl`, before you edit an existing connector. + +**Rollback caveat:** `Enroll`, `GetSingleRecord`, `Revoke`, and `Synchronize` choose V1 or V2 purely +from the connector's current `UseV2Api` flag, not from the stored `CARequestID`. V1-placed order +numbers resolve through V2 Track Order, and V2 order numbers use the same format as V1, but V1 Track +Order and `GetOrderReport` may not see an order that was placed through V2. Rolling back a connector +that has already issued V2 certificates isn't validated: check on the sandbox that sync, revoke, and +renewal still work for those certificates after switching back. Certificates enrolled before the +switch to V2 are V1 orders and are unaffected. + +{% include 'architecture.md' %} diff --git a/docsource/overview.md b/docsource/overview.md index f11d3d3..6616ed4 100644 --- a/docsource/overview.md +++ b/docsource/overview.md @@ -1,6 +1,6 @@ ## Overview -The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabilities of the CERTInext platform (by eMudhra) to Keyfactor Command via the Keyfactor AnyCA Gateway REST. See [configuration.md](configuration.md) for full installation and configuration details, [architecture.md](architecture.md) for design notes, and [development.md](development.md) for local development. +The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabilities of the CERTInext platform (by eMudhra) to Keyfactor Command via the Keyfactor AnyCA Gateway REST. See [configuration.md](configuration.md) for full installation and configuration details, [architecture.md](architecture.md) for design notes, [migration-v1-to-v2.md](migration-v1-to-v2.md) for moving a connector to the V2 API, and [development.md](development.md) for local development. ## CERTInext CA Certificates @@ -16,77 +16,106 @@ After signing in, navigate to the certificate-authority / chain download page in ## Troubleshooting -### `"Inactive Account User."` returned from `GenerateOrderSSL` +### `"Inactive Account User."` returned from `GenerateOrderSSL` (V1) **Symptom** -Enrollments fail with the gateway exception: +V1 enrollments fail with the gateway exception: ``` CERTInext order failed: Inactive Account User.. See gateway logs for details. ``` -The same access key / account works perfectly fine before and after the failing window — a `Ping` (`ValidateCredentials`) call seconds earlier returns success, and the next individual enrollment after a brief pause also succeeds. +The same access key / account works before and after the failing window — a `Ping` (`ValidateCredentials`) call seconds earlier returns success, and the next individual enrollment after a brief pause also succeeds. **Root cause** -The CERTInext sandbox at `https://sandbox-us-api.certinext.io/emSignHub-API` applies a **burst rate limit** on order placement and surfaces rate‑limit rejection through the **generic** error string `"Inactive Account User."` — the same string the API uses for genuinely inactive accounts. There is currently no distinguishing `errorCode`, `Retry-After` header, or structured field to tell the two conditions apart from the meta block alone. +The CERTInext sandbox at `https://sandbox-us-api.certinext.io/emSignHub-API` applies a **burst rate limit** on order placement and reports the rejection with the **generic** error string `"Inactive Account User."` — the same string the API uses for genuinely inactive accounts. The response carries no distinguishing `errorCode`, `Retry-After` header, or structured field that tells the two conditions apart. -Empirically the limit kicks in at roughly **16+ enrollments submitted within 10 seconds** on the US sandbox. Sustained submission velocity well below that runs cleanly. +The limit applies at roughly **16 or more enrollments submitted within 10 seconds** on the US sandbox. Submission velocity well below that runs cleanly. **Confirmation steps** -1. Run a single `Ping` against the same `ApiUrl` / `AccessKey`. If it succeeds, the account is active; the prior failure was almost certainly a rate-limit hit. -2. Check the gateway warning log for the `LogApiFailure` line emitted just before the throw (see issue [#8](https://github.com/Keyfactor/certinext-caplugin/issues/8) and the `LogApiFailure` helper in `CERTInextClient.cs`). The full raw response body is included there — if CERTInext ever surfaces a distinguishing code or message for rate-limit (as opposed to account-state), it will appear in that line. -3. Wait 30–60 seconds, then retry the failed enrollment(s). A successful retry confirms it was rate-limit. +1. Run a single `Ping` against the same `ApiUrl` / `AccessKey`. If it succeeds, the account is active; the failure was almost certainly a rate-limit hit. +2. Check the gateway warning log for the API-failure line the plugin writes just before it throws. It includes the full raw response body, so any distinguishing code or message CERTInext returns appears there. +3. Wait 30–60 seconds, then retry the failed enrollment. A successful retry confirms it was a rate limit. **Mitigation** -- **Reduce submission velocity**: throttle order placements to roughly one per 1–2 seconds. The plugin does not yet have a built-in client-side throttle; pacing must come from the caller (e.g. Keyfactor Command's enrollment scheduling, or a workflow that places certs in batches). -- **For high-volume migration scenarios**: split the workload into batches of ~10 orders separated by a short pause, rather than firing everything at once. -- **No client-side automatic retry on this error**: a defensive retry inside `PlaceOrderAsync` would paper over the misleading error string and burn the operator's order quota on retries. We document the gotcha instead. +- **Automatic retry**: when order placement returns this error, the plugin retries it with exponential backoff and jitter (up to 5 attempts) before surfacing the failure, so brief bursts resolve without operator action. +- **Reduce submission velocity**: throttle order placements to roughly one per 1–2 seconds. The plugin has no built-in client-side throttle; pacing must come from the caller (for example Keyfactor Command's enrollment scheduling, or a workflow that places certificates in batches). +- **High-volume migrations**: split the workload into batches of about 10 orders separated by a short pause, rather than submitting everything at once. ### Enrollment returns immediately with `Status=90 (EXTERNALVALIDATION)` **Symptom** -Enrollment completes successfully but the cert is not yet issued — Command shows the request in pending status. A subsequent `Synchronize` picks it up. +Enrollment completes but the certificate is not yet issued — Command shows the request as pending. A later `Synchronize` picks it up. **Root cause** -This is the expected return shape on two paths: +This is the expected result when: -1. The plugin was loaded on an older gateway host (pre-IAnyCAPlugin v3.3) that does not inject `IDomainValidatorFactory`. DCV cannot run, so any product that requires DNS validation completes only after CERTInext-side validation finishes. -2. The plugin's bounded `Enroll()` budget (`DcvWaitForChallengeSeconds` + `DcvWaitForIssuanceSeconds`, defaults 60s each) elapsed before CERTInext finished asynchronous issuance. +1. The order is OV or EV. CERTInext issues these after organization verification (minutes to hours), longer than the enrollment call waits. +2. DCV is enabled but cannot run: no DNS provider plugin is deployed on the gateway, or none resolves the order's domain. The plugin logs why, and the order completes only after the domain is validated some other way. +3. The plugin's bounded `Enroll()` budget elapsed before CERTInext finished issuing: `PickupRetries` × `PickupDelay` for the certificate pickup poll, and on V1 `DcvWaitForChallengeSeconds` + `DcvWaitForIssuanceSeconds` (default 60s each) around DCV. **Mitigation** -The next gateway sync cycle will pick the cert up and transition it to `GENERATED`. The plugin's sync-driven DCV retry is single-shot per record, so even with hundreds of pending orders the sync completes in seconds, not minutes — see [configuration.md](configuration.md) for the `DcvWaitForChallengeSeconds`/`DcvWaitForIssuanceSeconds` knobs if you want to tune the Enroll-time budget. +The next gateway sync cycle picks the certificate up and moves it to issued. Sync-driven DCV is bounded per pass by `DcvSyncMaxOrderAgeHours` and `DcvSyncMaxPerPass`, so a large backlog of old pending orders doesn't slow each pass. See [configuration.md](configuration.md) for the knobs that tune the `Enroll()` wait. -### `EMS-956 "Invalid Request for this API"` from `GetDcv` +### `EMS-956 "Invalid Request for this API"` from `GetDcv` (V1) **Symptom** -The plugin's DCV machinery starts but the first `GetDcv` call returns this error. Plugin gracefully defers DCV to the next sync cycle (single warning log line, no exception thrown). +The plugin's DCV machinery starts but the first `GetDcv` call returns this error. The plugin defers DCV to the next sync cycle (a single log line, no exception). **Root cause** -CERTInext exposes the `domainVerification` slot in `TrackOrder` **before** the `GetDcv` endpoint will accept calls for that order — there's an internal gating window. The plugin's `IsDcvNotYetReady` predicate explicitly recognizes this and treats it as "DCV not ready yet, retry on the next sync". +CERTInext exposes the `domainVerification` slot in `TrackOrder` **before** the `GetDcv` endpoint accepts calls for that order. The plugin recognizes this condition and treats it as "DCV not ready yet, retry on the next sync". **Mitigation** -No action needed. Plugin's sync-driven DCV retry handles this transparently — the order will be picked up on a subsequent sync cycle once the CA-side gate clears (observed window: seconds to a few hours, environment-dependent). +No action needed. The order is picked up on a subsequent sync cycle once CERTInext's gate clears; the wait can range from seconds to several hours. ### Plugin fails to load with `Could not load type 'Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory'` **Symptom** -Gateway returns HTTP 500 on CA registration or first enrollment with the body `{"ErrorCode":"0x80131509"}`. Pod logs show `TypeLoadException` for `Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory`. +The gateway returns HTTP 500 on CA registration or first enrollment with the body `{"ErrorCode":"0x80131509"}`, and the gateway log shows a `TypeLoadException` for `Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory`. **Root cause** -Older gateway image whose bundled `Keyfactor.AnyGateway.IAnyCAPlugin` assembly is v3.2 or earlier (the `IDomainValidatorFactory` interface is v3.3+). This was fully addressed by the issue [#7](https://github.com/Keyfactor/certinext-caplugin/issues/7) fix in v1.0 — both the constructor-signature surface AND the field-type surface are now safe to load on v3.2 hosts. +The gateway's bundled `Keyfactor.AnyGateway.IAnyCAPlugin` assembly is older than 3.3, which does not define `IDomainValidatorFactory`. This plugin requires AnyCA Gateway REST 26.2.0 or later. **Mitigation** -Deploy the default (no-DCV) build for AnyCA Gateway 25.5.x; do not deploy the DCV build on a 25.5.x host. The **default build (no-DCV, IAnyCAPlugin 3.2.0)** is the one that loads *and* persists records on AnyCA Gateway 25.5.x, and it is what the released artifact ships. The DCV-capable build (IAnyCAPlugin 3.3.0-PRERELEASE, `dotnet build -p:DcvSupport=true`) is for AnyCA Gateway 26.x; loading it on a 25.5.x host triggers the type-load error above and, even when it loads, its records do not persist on a 3.2 host. See the `DcvSupport` build variants in the developer guide. +Upgrade the AnyCA Gateway REST to 26.2.0 or later. + +### Connector fails to start: `'ApiUrl' ... must use https` + +**Symptom** + +After upgrading, a connector fails initialization with an error naming `ApiUrl` (or `OAuthTokenUrl`) and `https`. + +**Root cause** + +The API key (V1) or OAuth client secret (V1 OAuth and V2) is sent to these URLs on every request, so the plugin rejects `http` URLs on save and at startup. A loopback host (`localhost`, `127.0.0.1`, `::1`) is the only exception, for local test servers. + +**Mitigation** + +Change the connector's `ApiUrl` (and, for V1 OAuth, `OAuthTokenUrl`) to an `https` URL. + +### V2: `Multiple CERTInext catalog products match ProductID ...` + +**Symptom** + +A V2 template that sets only `ProductId` fails to save, or an enrollment against it fails, listing several product codes. + +**Root cause** + +The live V2 catalog carries more than one product at the same assurance level (for example two DV SSL products with different billing terms), and the plugin won't guess between them. + +**Mitigation** + +Set `ProductCode` on the template to one of the listed codes, or set the connector's `DefaultProductCode` to one of them. See [V2 Product Code Resolution](configuration.md#v2-product-code-resolution). diff --git a/integration-manifest.json b/integration-manifest.json index 8a6d4ee..391ef3d 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -7,7 +7,7 @@ "link_github": true, "update_catalog": true, "description": "AnyCA REST Gateway plugin for CERTInext (eMudhra) certificate lifecycle management platform", - "gateway_framework": "25.5.0", + "gateway_framework": "26.2.0", "release_dir": "CERTInext/bin/Release", "release_project": "CERTInext/CERTInext.csproj", "about": { @@ -27,55 +27,55 @@ "ca_plugin_config": [ { "name": "ApiUrl", - "description": "REQUIRED: CERTInext API base URL. Sandbox (US): https://sandbox-us-api.certinext.io/emSignHub-API/ \u2014 Production (US): https://us-api.certinext.io/emSignHub-API/ \u2014 Production (Global/India): https://api.certinext.io/emSignHub-API/" + "description": "REQUIRED: CERTInext API base URL. Its meaning follows UseV2Api. V1 (default): Sandbox (US): https://sandbox-us-api.certinext.io/emSignHub-API/ \u2014 Production (US): https://us-api.certinext.io/emSignHub-API/ \u2014 Production (Global/India): https://api.certinext.io/emSignHub-API/. V2 (UseV2Api=true): the bare V2 host, e.g. https://sandbox-us-api.certinext.io, no trailing slash or path suffix \u2014 V1 and V2 are hosted differently, so this value changes when UseV2Api is toggled." }, { "name": "AccountNumber", - "description": "REQUIRED: Your CERTInext account number (numeric string). Available in the CERTInext portal." + "description": "REQUIRED when UseV2Api is false: your CERTInext account number (numeric string). Included in the `meta` block of every V1 request. Not used when UseV2Api is true. Available in the CERTInext portal." }, { "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 product-catalog requests and in the order (`delegationInformation.groupNumber` on V1, `groupNumber` on V2) so the order is routed to the correct account group, and V2 synchronization is scoped to the group. Some accounts will queue orders for additional review when this field is omitted. Available in the CERTInext portal under Delegation \u2192 Groups." }, { "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": "STRONGLY RECOMMENDED for OV/EV and faster DV issuance, and REQUIRED for OV/EV orders under V2: numeric CERTInext organization number for a pre-vetted organization (e.g. your company's pre-vetted entry). When set, V1 orders are submitted with `organizationDetails.preVetting=\"1\"` and the configured `organizationNumber`, telling CERTInext to skip the manual organization-vetting queue; V2 OV/EV orders send it as `organization.organizationNumber` with `preVetted=true` (V2 DV orders never send an organization block). Without this value, V1 orders are placed without any organizationDetails block and CERTInext may park them in `Pending System RA` for extended manual review (potentially tens of hours), and V2 OV/EV enrollment fails before any CA call. Available in the CERTInext portal under Organizations \u2192 Pre-vetted Organizations." }, { "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: Name sent as the order's technical point of contact (V1 `technicalPointOfContact.tpcName`, V2 `technicalPointOfContact.name`). 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." }, { "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 as the order's technical point of contact (V1 `tpcEmail`, V2 `email`). 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. 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). Defaults to the configured RequestorMobileNumber when blank." }, { "name": "AuthMode", - "description": "REQUIRED: Authentication mode. 'AccessKey' (default) \u2014 uses authKey = SHA256(accessKey + ts + txn) in every request body. 'OAuth' \u2014 uses an OAuth2 bearer token (requires OAuthTokenUrl, OAuthClientId, OAuthClientSecret)." + "description": "REQUIRED when UseV2Api is false: authentication mode. 'AccessKey' (default) \u2014 uses authKey = SHA256(accessKey + ts + txn) in every request body. 'OAuth' \u2014 uses an OAuth2 bearer token (requires OAuthTokenUrl, OAuthClientId, OAuthClientSecret). Ignored when UseV2Api is true; V2 always uses OAuth2 client credentials." }, { "name": "ApiKey", - "description": "REQUIRED when AuthMode is 'AccessKey': the REST API Access Key generated in the CERTInext portal under Integrations \u2192 APIs. This value is used to compute authKey = SHA256(accessKey + ts + txn); it is never transmitted directly." + "description": "REQUIRED when UseV2Api is false and AuthMode is 'AccessKey': the REST API Access Key generated in the CERTInext portal under Integrations \u2192 APIs. This value is used to compute authKey = SHA256(accessKey + ts + txn); it is never transmitted directly. Not used when UseV2Api is true." }, { "name": "OAuthTokenUrl", - "description": "OAuth token endpoint URL. Required when AuthMode is 'OAuth'." + "description": "OAuth token endpoint URL. Required when UseV2Api is false and AuthMode is 'OAuth'; must use https. Not used when UseV2Api is true \u2014 V2 requests its token from {ApiUrl}/oauth/token." }, { "name": "OAuthClientId", - "description": "OAuth client ID. Required when AuthMode is 'OAuth'." + "description": "OAuth client ID. Required when AuthMode is 'OAuth' (V1). Also required, and reused, when UseV2Api is true \u2014 V2 authenticates with these same OAuthClientId/OAuthClientSecret fields via client_credentials against {ApiUrl}/oauth/token, rather than separate V2-only credentials. The key must be generated in OAuth mode in the CERTInext portal." }, { "name": "OAuthClientSecret", - "description": "OAuth client secret. Required when AuthMode is 'OAuth'." + "description": "OAuth client secret. Required when AuthMode is 'OAuth' (V1). Also required, and reused, when UseV2Api is true (see OAuthClientId)." }, { "name": "RequestorName", @@ -93,25 +93,29 @@ "name": "RequestorMobileNumber", "description": "Requestor mobile number (digits only, no country code)." }, + { + "name": "RequestorDesignation", + "description": "OPTIONAL: Job title / role of the requestor (e.g. 'IT Administrator'). Sent in V2 orders' `requestor.designation` field. Free text with no CA-side enum. Left blank by default, in which case the field is omitted entirely from the order rather than sent with a default value." + }, { "name": "SignerPlace", - "description": "City or location of the subscriber agreement signer. Required by CERTInext for all orders." + "description": "City or location of the subscriber agreement signer (e.g. 'San Francisco, CA'). REQUIRED when UseV2Api is on: the V2 Subscriber Agreement sent with every SSL order requires it, so the connector cannot be saved with it blank. A per-template SignerPlace enrollment parameter overrides it." }, { "name": "SignerIp", - "description": "IP address of the subscriber agreement signer. Required by CERTInext for all orders." + "description": "IP address of the subscriber agreement signer. Sent in the order's agreement block; a template-level SignerIp enrollment parameter overrides it on V2." }, { "name": "DefaultProductCode", - "description": "OPTIONAL: Default numeric product code used when not specified at template level. Product codes are provided by eMudhra (e.g. the SSL DV 1-year code for your account). Retrieve available codes from Integrations \u2192 APIs \u2192 GetProductDetails." + "description": "OPTIONAL: Default numeric product code. V1: used for renewals when the template doesn't supply a product code (CERTInext's TrackOrder doesn't return the prior order's code). V2: disambiguates a ProductId-only template when the live catalog has more than one product at the same assurance level. Product codes are provided by eMudhra (e.g. the SSL DV 1-year code for your account). Retrieve available codes from Integrations \u2192 APIs \u2192 GetProductDetails." }, { "name": "AccountingModel", - "description": "OPTIONAL: CERTInext billing model sent in `orderDetails.accountingModel`. \"2\" = credit-based (most accounts, default). \"1\" = cash model." + "description": "OPTIONAL: CERTInext billing model sent in `orderDetails.accountingModel` on V1 orders. \"2\" = credit-based (most accounts, default). \"1\" = cash model. Not used by V2." }, { "name": "EmailNotifications", - "description": "OPTIONAL: Whether CERTInext sends lifecycle-event emails to the requestor. \"1\" = enabled, \"0\" = silent (recommended for gateway-driven orders so end users aren't surprised by CA emails). Default: \"0\"." + "description": "OPTIONAL: Whether CERTInext sends lifecycle-event emails to the requestor. \"1\" = full notification set (V1 sends it as-is; V2 maps it to \"all\"). \"0\" = silent on both V1 and V2. Blank/unset stays silent on V1 (sent as \"0\") but is omitted on V2, so the CA's own default (\"all\", not silent) applies instead. Any other value fails V2 enrollment before any CA call. Default: \"0\" \u2014 V2 orders are silent by default, matching V1." }, { "name": "SubscriptionValidityYears", @@ -133,17 +137,33 @@ "name": "IgnoreExpired", "description": "If true, expired certificates will be skipped during synchronization. Default: false." }, + { + "name": "SubmitNonDnsSans", + "description": "OPTIONAL: If true (default), V1 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 \u2014 but nothing the subscriber requested is dropped silently. Set to false to submit DNS names only: the order issues, but the certificate will not contain the non-DNS names. Not consulted on V2 (UCC `additionalDomains` takes DNS names only; Private PKI `additionalHosts` takes DNS names and IP addresses). Default: true." + }, { "name": "PageSize", - "description": "Number of orders to fetch per page during synchronization. Default: 100, max: 500." + "description": "Number of orders to fetch per page during synchronization. Default: 100, max: 500 (V2 `/reports/orders` pages are capped at 100)." }, { "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 \u2014 confirming exactly what was sent to the CA and that the order succeeded \u2014 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 (API keys, OAuth secrets, tokens) are always redacted regardless of this setting. Default: false." + }, + { + "name": "PickupRetries", + "description": "OPTIONAL: Number of times Enroll() polls CERTInext for 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. OV/EV orders are issued asynchronously (organization verification, minutes to hours), so they typically exhaust the wait and are returned pending regardless of this value." + }, + { + "name": "PickupDelay", + "description": "OPTIONAL: Number of seconds between certificate-pickup retries. A fixed 5-second initial delay plus PickupRetries times this value is the maximum time an enrollment call occupies a Command worker thread; the plugin caps the effective total at 180 seconds regardless of how PickupRetries/PickupDelay are set, reducing the retry count to fit. Target a total well under ~90s so the request does not time out. Default: 10 (a ~55s ceiling with the default retries)." + }, { "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." + "description": "OPTIONAL: When true, the plugin performs DNS-based Domain Control Validation (DCV) during enrollment and synchronization for orders that require it, using the DNS provider plugin deployed on the gateway (e.g. azure-azuredns-dnsplugin). Without a DNS provider plugin, orders that need validation stay pending and the plugin logs why. Set to false to skip DCV. Default: true." }, { "name": "DcvTxtRecordTemplate", @@ -155,15 +175,15 @@ }, { "name": "DcvTimeoutMinutes", - "description": "OPTIONAL: Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) before timing out the enrollment. Can also be set via the CERTINEXT_DCV_TIMEOUT_MINUTES environment variable; the env var takes precedence when both are set. Default: 10." + "description": "OPTIONAL: Maximum minutes for the entire DCV flow (DNS publish + propagation + verify) for one order before it is abandoned and left pending. Can also be set via the CERTINEXT_DCV_TIMEOUT_MINUTES environment variable; the env var takes precedence when both are set. Default: 10." }, { "name": "DcvWaitForChallengeSeconds", - "description": "OPTIONAL: How long (seconds) the plugin will wait inside Enroll() for CERTInext to expose the DCV challenge (i.e. populate `domainVerification` in TrackOrder). Under concurrent load CERTInext sometimes takes a few seconds after GenerateOrderSSL before the slot appears. Without this wait, the plugin's initial TrackOrder check sees null and skips DCV \u2014 the order then has to wait for the next gateway sync cycle to be picked up. Setting to 0 disables the wait (single-check behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60." + "description": "OPTIONAL (V1 only): How long (seconds) the plugin will wait inside Enroll() for CERTInext to expose the DCV challenge (i.e. populate `domainVerification` in TrackOrder). Under concurrent load CERTInext sometimes takes a few seconds after GenerateOrderSSL before the slot appears. Without this wait, the plugin's initial TrackOrder check sees null and skips DCV \u2014 the order then has to wait for the next gateway sync cycle to be picked up. Setting to 0 disables the wait (single-check behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60." }, { "name": "DcvWaitForIssuanceSeconds", - "description": "OPTIONAL: How long (seconds) the plugin will wait inside Enroll() after DCV verifies for CERTInext to finish generating the certificate. CERTInext issuance is async \u2014 DCV may be verified but the cert PEM isn't yet available for download. Without this wait, Enroll() returns a pending result and the issued cert is picked up by the next sync cycle. Setting to 0 disables the wait (single-fetch behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60." + "description": "OPTIONAL (V1 only): How long (seconds) the plugin will wait inside Enroll() after DCV verifies for CERTInext to finish generating the certificate. CERTInext issuance is async \u2014 DCV may be verified but the cert PEM isn't yet available for download. Without this wait, Enroll() returns a pending result and the issued cert is picked up by the next sync cycle. Setting to 0 disables the wait (single-fetch behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60." }, { "name": "DcvSyncMaxOrderAgeHours", @@ -172,12 +192,20 @@ { "name": "DcvSyncMaxPerPass", "description": "OPTIONAL: Maximum number of pending DV orders the plugin will attempt to drive through DCV in a single synchronization pass. Bounds the per-pass cost regardless of backlog size; remaining pending orders are reported as-is and picked up on a later pass (the per-minute incremental scan keeps recent orders moving). Set to 0 to disable the cap. Default: 50." + }, + { + "name": "UseV2Api", + "description": "OPTIONAL: When true, the plugin routes Ping / Enroll / GetSingleRecord / Revoke / Synchronize through the CERTInext V2 REST API (/api/certinext/v2/), including V2 /reports/orders for Synchronize. Requires ApiUrl (the V2 base URL in this mode), OAuthClientId, OAuthClientSecret, and SignerPlace. V1 credentials (ApiKey/AccountNumber/AuthMode) are not required when this is true. Default: false (V1 API)." + }, + { + "name": "V2SyncLookbackHours", + "description": "OPTIONAL (V2 mode only): during an incremental Synchronize, the plugin queries V2 /reports/orders with a 'from' date of (lastSync minus this many hours) rather than exactly lastSync, since the API's from/to filter may bracket either the order-placement date or the issuance date. A lookback window ensures an order created before lastSync but issued afterward (e.g. a slow DCV order) still surfaces on the next incremental pass. Ignored when UseV2Api is false. Default: 72." } ], "enrollment_config": [ { "name": "ProductCode", - "description": "OPTIONAL: Override the numeric CERTInext product code for this template. When omitted, the default production code for the selected product is used automatically (e.g. DV SSL \u2192 838). Set this explicitly when targeting sandbox or a non-standard code." + "description": "OPTIONAL: Override the numeric CERTInext product code for this template. When omitted: on V1, the default production code for the selected product is used (e.g. DV SSL \u2192 838); on V2, the code is resolved from the live CERTInext product catalog by matching the selected product, so it stays correct even though V2 catalog numbering varies by account. Required for ProductFamily 'private-pki'. Set this explicitly when targeting sandbox or a non-standard code." }, { "name": "ProfileId", @@ -189,11 +217,11 @@ }, { "name": "ValidityDays", - "description": "DEPRECATED: Use ValidityYears instead. If set, value is divided by 365 and rounded up to get the subscription year count." + "description": "DEPRECATED: Use ValidityYears instead. V1 only: if set, value is divided by 365 and rounded up to get the subscription year count." }, { "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 \u2014 reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. Default: false." }, { "name": "RequesterName", @@ -205,27 +233,35 @@ }, { "name": "RenewalWindowDays", - "description": "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." + "description": "OPTIONAL: V1 only. 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. V2 places a new order for every renewal and reissue. Default: 90." }, { "name": "KeyType", - "description": "OPTIONAL: Key algorithm to request (e.g. 'RSA2048', 'RSA4096', 'EC256', 'EC384'). If omitted, the profile default is used." + "description": "OPTIONAL: Informational. The key algorithm is determined by the submitted CSR; CERTInext accepts RSA 2048/3072/4096 and ECC P-256/P-384 and rejects larger RSA, ECC P-521, and Ed25519/Ed448." }, { "name": "DomainName", - "description": "OPTIONAL: Primary domain for SSL/TLS orders. Derived from the CSR CN if omitted." + "description": "OPTIONAL: Primary domain for SSL/TLS orders (for V2 private-pki orders, the primary hostname). Derived from the CSR CN if omitted." }, { "name": "SignerName", - "description": "OPTIONAL: Per-template subscriber agreement signer name. Falls back to the connector-level RequestorName if omitted." + "description": "OPTIONAL: V2 only. Per-template subscriber agreement signer name. Falls back to the requestor name if omitted. V1 uses the connector-level RequestorName." }, { "name": "SignerPlace", - "description": "OPTIONAL: Per-template signer city/location. Falls back to the connector-level SignerPlace if omitted." + "description": "OPTIONAL: V2 only. Per-template signer city/location. Falls back to the connector-level SignerPlace if omitted. V1 uses the connector-level SignerPlace." }, { "name": "SignerIp", - "description": "OPTIONAL: Per-template signer IP address. Falls back to the connector-level SignerIp if omitted." + "description": "OPTIONAL: V2 only. Per-template signer IP address. Falls back to the connector-level SignerIp if omitted. V1 uses the connector-level SignerIp." + }, + { + "name": "ProductFamily", + "description": "V2 ONLY: Product family for this template. Accepted values: 'ssl' (default) or 'private-pki'. 'private-pki' requires an explicit ProductCode and a Private PKI ProductVariant. 'signature' (Document Signer) is accepted by the parameter, but Document Signer enrollment is not supported." + }, + { + "name": "ProductVariant", + "description": "V2 ONLY: Product variant sent in the V2 order body. ProductFamily 'ssl': 'dv', 'ov', or 'ev' \u2014 if omitted, derived from the selected product; an explicit value that contradicts the product fails enrollment. ProductFamily 'private-pki': 'intranet-ssl' or 'igtf-host' (required; no default)." } ] } diff --git a/scripts/lib/certinext-v2-auth.sh b/scripts/lib/certinext-v2-auth.sh index 0ed2bc2..38a33ce 100755 --- a/scripts/lib/certinext-v2-auth.sh +++ b/scripts/lib/certinext-v2-auth.sh @@ -1,43 +1,294 @@ #!/usr/bin/env bash -# Shared Bearer-token helper for CERTInext V2 API scripts. +# Shared helpers for the CERTInext V2 dev scripts in scripts/v2/. # -# Usage (from a script in scripts/v2/): -# source "$(dirname "$0")/../lib/certinext-v2-auth.sh" -# # $CERTINEXT_V2_TOKEN is now set +# Source this from a scripts/v2/*.sh script; do not execute it directly: +# . "$(dirname "$0")/../lib/certinext-v2-auth.sh" # -# Requires CERTINEXT_ACCESS_KEY, CERTINEXT_ACCOUNT_NUMBER, and -# CERTINEXT_V2_API_URL to be set in the calling environment -# (sourced from ~/.env_certinext before this file is sourced). +# Auth model (mirrors CERTInextClient.GetOrRefreshV2TokenAsync): +# POST {CERTINEXT_API_URL}/oauth/token +# Content-Type: application/x-www-form-urlencoded +# grant_type=client_credentials&client_id=...&client_secret=... +# -> {"access_token": "...", "token_type": "...", "expires_in": N} +# CERTINEXT_API_URL is the single V2 base URL (e.g. https://sandbox-us.certinext.io, +# no /emSignHub-API suffix). There is no V1 SHA256 authKey step any more. # -# Internally reuses certinext_meta from certinext-auth.sh to compute -# the SHA256 authKey, then exchanges it for a short-lived Bearer JWT -# at POST {v2BaseURL}/oauth/token. - -# shellcheck source=./certinext-auth.sh -# $0 is the calling script (in scripts/v2/), so ../lib/ reaches scripts/lib/. -. "$(dirname "$0")/../lib/certinext-auth.sh" - -read -r _v2_ts _v2_txn _v2_authKey <<< "$(certinext_meta)" - -_v2_token_response=$(curl -s -X POST "$CERTINEXT_V2_API_URL/oauth/token" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg grant_type "client_credentials" \ - --arg accountNumber "$CERTINEXT_ACCOUNT_NUMBER" \ - --arg authKey "$_v2_authKey" \ - --arg ver "1.0" \ - --arg ts "$_v2_ts" \ - --arg txn "$_v2_txn" \ - '{grant_type:$grant_type,accountNumber:$accountNumber,authKey:$authKey,ver:$ver,ts:$ts,txn:$txn}')") - -CERTINEXT_V2_TOKEN=$(echo "$_v2_token_response" | jq -r '.tokenDetails.accessToken // empty') - -if [ -z "$CERTINEXT_V2_TOKEN" ]; then - echo "ERROR: failed to acquire V2 Bearer token. Response:" >&2 - echo "$_v2_token_response" | jq . >&2 +# Credentials come from the V2 env file, default ~/.env_certinext_v2, overridable with +# CERTINEXT_V2_ENV_FILE. The file is PARSED (KEY=VALUE, '#' comments, optional +# surrounding quotes), never sourced, so it cannot run code or leak variables into the +# caller's shell. Like V2EnvHelper.LoadEnvFile in the integration tests, a value in the +# file wins over a same-named process env var (a shell that sourced the V1 +# ~/.env_certinext has a V1 CERTINEXT_API_URL exported). Process env is the fallback for +# keys the file doesn't define. +# +# Keys used: CERTINEXT_API_URL, CERTINEXT_CLIENT_ID, CERTINEXT_CLIENT_SECRET (required); +# CERTINEXT_REQUESTOR_NAME, CERTINEXT_REQUESTOR_EMAIL, CERTINEXT_REQUESTOR_MOBILE, +# CERTINEXT_SIGNER_IP (optional, order-body scripts only). +# +# Secret handling: the client secret and bearer token live only in unexported shell +# variables of the running script. They are handed to curl through process substitution +# (/dev/fd pipes), so they never appear in argv (ps), on disk, or on stdout/stderr. +# Do not add `set -x` to any script that sources this file. + +if [ -z "${BASH_VERSION:-}" ]; then + echo "ERROR: certinext-v2-auth.sh must be sourced from bash." >&2 exit 1 fi -export CERTINEXT_V2_TOKEN +for _v2_tool in curl jq; do + if ! command -v "$_v2_tool" >/dev/null 2>&1; then + echo "ERROR: '$_v2_tool' is required but not installed." >&2 + exit 1 + fi +done +unset _v2_tool + +V2_ENV_FILE="${CERTINEXT_V2_ENV_FILE:-$HOME/.env_certinext_v2}" +V2_MUTATE=0 +V2_TOKEN="" + +# _v2_trim — strip leading/trailing whitespace (bash 3.2 compatible). +_v2_trim() { + local s=$1 + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +# _v2_file_value — print KEY's value from $V2_ENV_FILE; return 1 if not defined. +# Last definition wins. Lines without '=' and '#' comments are ignored. +_v2_file_value() { + local want=$1 line key val found=1 out="" + [ -r "$V2_ENV_FILE" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + line=$(_v2_trim "$line") + case "$line" in ''|'#'*) continue ;; esac + case "$line" in *=*) ;; *) continue ;; esac + key=$(_v2_trim "${line%%=*}") + key="${key#export }" + key=$(_v2_trim "$key") + [ "$key" = "$want" ] || continue + val=$(_v2_trim "${line#*=}") + if [ "${#val}" -ge 2 ]; then + case "$val" in + \"*\") val="${val#\"}"; val="${val%\"}" ;; + \'*\') val="${val#\'}"; val="${val%\'}" ;; + esac + fi + out=$val + found=0 + done < "$V2_ENV_FILE" + [ "$found" -eq 0 ] && printf '%s' "$out" + return "$found" +} + +# v2_cfg [default] — value from the env file, else process env, else default. +v2_cfg() { + local key=$1 def=${2:-} val + if val=$(_v2_file_value "$key") && [ -n "$val" ]; then + printf '%s' "$val" + return 0 + fi + val="${!key:-}" + if [ -n "$val" ]; then printf '%s' "$val"; else printf '%s' "$def"; fi +} + +# v2_require — like v2_cfg but exits with a clear message when the key is missing. +v2_require() { + local key=$1 val + val=$(v2_cfg "$key") + if [ -z "$val" ]; then + echo "ERROR: required key $key is not set in $V2_ENV_FILE (or the environment)." >&2 + echo " Set CERTINEXT_V2_ENV_FILE to use a different env file." >&2 + exit 1 + fi + printf '%s' "$val" +} + +# v2_base_url — validated CERTINEXT_API_URL without a trailing slash. +# Refuses plain http except for localhost stubs, so the client secret is never sent in clear. +v2_base_url() { + local url + url=$(v2_require CERTINEXT_API_URL) || exit 1 + url="${url%/}" + case "$url" in + https://*) ;; + http://localhost|http://localhost:*|http://localhost/*|http://127.0.0.1|http://127.0.0.1:*|http://127.0.0.1/*) ;; + *) + echo "ERROR: CERTINEXT_API_URL must be an https:// URL (plain http is only allowed for localhost stubs)." >&2 + exit 1 ;; + esac + case "$url" in + *emSignHub-API*) + echo "ERROR: CERTINEXT_API_URL looks like a V1 URL (contains /emSignHub-API)." >&2 + echo " V2 needs the base URL only, e.g. https://sandbox-us.certinext.io" >&2 + exit 1 ;; + esac + printf '%s' "$url" +} + +# v2_parse_args "$@" — read-only scripts: accepts only -h/--help. +v2_parse_args() { + local a + for a in "$@"; do + case "$a" in + -h|--help) v2_usage; exit 0 ;; + *) echo "ERROR: unknown argument '$a' (this script is read-only)" >&2; v2_usage; exit 1 ;; + esac + done +} + +# v2_parse_mutating_args "$@" — state-changing scripts: --yes-mutate sets V2_MUTATE=1. +v2_parse_mutating_args() { + local a + for a in "$@"; do + case "$a" in + --yes-mutate) V2_MUTATE=1 ;; + -h|--help) v2_usage; exit 0 ;; + *) echo "ERROR: unknown argument '$a'" >&2; v2_usage; exit 1 ;; + esac + done +} + +# v2_require_var — exit with usage if the named script input env var is empty. +v2_require_var() { + local name=$1 + if [ -z "${!name:-}" ]; then + echo "ERROR: $name is required." >&2 + v2_usage + exit 1 + fi +} + +# v2_require_id — like v2_require_var, and the value must be a safe URL path segment. +v2_require_id() { + local name=$1 + v2_require_var "$name" + case "${!name}" in + *[!A-Za-z0-9_.-]*) + echo "ERROR: $name='${!name}' contains characters not allowed in a URL path segment." >&2 + exit 1 ;; + esac +} + +# v2_signer_ip [--offline] — CERTINEXT_SIGNER_IP, else auto-detect via api.ipify.org. +# --offline returns a placeholder instead of calling out (used for the dry-run preview). +v2_signer_ip() { + local ip + ip=$(v2_cfg CERTINEXT_SIGNER_IP) + if [ -n "$ip" ]; then printf '%s' "$ip"; return 0; fi + if [ "${1:-}" = "--offline" ]; then printf '%s' ""; return 0; fi + ip=$(curl -sS --max-time 10 https://api.ipify.org) || { + echo "ERROR: could not auto-detect signer IP; set CERTINEXT_SIGNER_IP." >&2 + exit 1 + } + printf '%s' "$ip" +} + +# Default usage; scripts redefine v2_usage after sourcing this file. +v2_usage() { echo "Usage: $(basename "$0")" >&2; } + +# v2_mutation_gate [body-json] — for state-changing scripts. +# Without --yes-mutate: print the planned request (no network calls, no token) and exit 3. +v2_mutation_gate() { + local method=$1 path=$2 body=${3:-} base + if [ "$V2_MUTATE" -eq 1 ]; then + return 0 + fi + base=$(v2_base_url) || exit 1 + { + echo "REFUSING TO RUN: this script changes state on the CERTInext account." + echo "It would send:" + echo " $method $base$path" + if [ -n "$body" ]; then + echo " body:" + printf '%s\n' "$body" | jq . 2>/dev/null | sed 's/^/ /' || printf ' %s\n' "$body" + fi + echo "Re-run with --yes-mutate to actually send it." + } >&2 + exit 3 +} + +# v2_uuid — random UUID for Idempotency-Key headers. +v2_uuid() { + if command -v uuidgen >/dev/null 2>&1; then + uuidgen | tr '[:upper:]' '[:lower:]' + else + python3 -c 'import uuid; print(uuid.uuid4())' + fi +} + +# v2_get_token — fetch an OAuth2 client_credentials token into V2_TOKEN (unexported). +# Never prints the token, the secret, or the raw token-endpoint response. +v2_get_token() { + local base client_id client_secret resp status body err + base=$(v2_base_url) || exit 1 + client_id=$(v2_require CERTINEXT_CLIENT_ID) || exit 1 + client_secret=$(v2_require CERTINEXT_CLIENT_SECRET) || exit 1 + + resp=$(curl -sS -X POST "$base/oauth/token" \ + -H "Accept: application/json" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "client_id=$client_id" \ + --data-urlencode "client_secret@"<(printf '%s' "$client_secret") \ + -w $'\n%{http_code}') || { + echo "ERROR: V2 token request to $base/oauth/token failed (network/TLS error)." >&2 + exit 1 + } + client_secret="" + status="${resp##*$'\n'}" + body="${resp%$'\n'*}" + resp="" + + if [ "$status" != "200" ]; then + # Never echo the body: an error response could reflect submitted form fields. + err=$(printf '%s' "$body" | jq -r '.error // empty' 2>/dev/null || true) + case "$err" in + ''|*[!A-Za-z0-9_.-]*) err="" ;; # only print short OAuth2 error codes + esac + echo "ERROR: V2 token request failed: HTTP $status${err:+ ($err)} from $base/oauth/token (client_id=$client_id)." >&2 + case "$status" in + 401) echo " Hint: CERTINEXT_CLIENT_ID / CERTINEXT_CLIENT_SECRET is wrong, or the key was revoked." >&2 ;; + 403) echo " Hint: the access key exists but was not generated in OAuth mode in the portal." >&2 ;; + esac + exit 1 + fi + + V2_TOKEN=$(printf '%s' "$body" | jq -r '.access_token // empty' 2>/dev/null || true) + body="" + if [ -z "$V2_TOKEN" ]; then + echo "ERROR: V2 token response from $base/oauth/token did not contain access_token." >&2 + exit 1 + fi +} + +# v2_request [extra curl args...] — authenticated request to the V2 API. +# Fetches a token on first use. Prints "HTTP " to stderr and the response body +# (pretty-printed by jq when it is JSON) to stdout. Returns 1 on HTTP >= 400. +v2_request() { + local method=$1 path=$2 base resp status body + shift 2 + base=$(v2_base_url) || exit 1 + [ -n "$V2_TOKEN" ] || v2_get_token + + resp=$(curl -sS -X "$method" "$base$path" \ + -H @<(printf 'Authorization: Bearer %s\n' "$V2_TOKEN") \ + -H "Accept: application/json" \ + "$@" \ + -w $'\n%{http_code}') || { + echo "ERROR: $method $base$path failed (network/TLS error)." >&2 + return 1 + } + status="${resp##*$'\n'}" + body="${resp%$'\n'*}" -unset _v2_ts _v2_txn _v2_authKey _v2_token_response + echo "HTTP $status" >&2 + if [ -n "$body" ]; then + if printf '%s' "$body" | jq -e . >/dev/null 2>&1; then + printf '%s' "$body" | jq . + else + printf '%s\n' "$body" + fi + fi + [ "$status" -lt 400 ] 2>/dev/null +} diff --git a/scripts/v2/README.md b/scripts/v2/README.md new file mode 100644 index 0000000..ca3cdcf --- /dev/null +++ b/scripts/v2/README.md @@ -0,0 +1,110 @@ +# scripts/v2 — CERTInext V2 REST API dev helpers + +Small curl + jq scripts for poking the CERTInext V2 API (`/api/certinext/v2/...`) by hand. +They are dev tooling only and are not shipped with the plugin. For repeatable live-API +verification, use `CERTInext.IntegrationTests` / `CERTInext.IntegrationRunner` instead +(see `CLAUDE.md`). + +## Auth and credentials + +The scripts use the same auth model as the plugin's V2 mode +(`CERTInextClient.GetOrRefreshV2TokenAsync`): + +``` +POST {CERTINEXT_API_URL}/oauth/token (application/x-www-form-urlencoded) +grant_type=client_credentials&client_id=...&client_secret=... +``` + +`CERTINEXT_API_URL` is the single V2 base URL, e.g. `https://sandbox-us.certinext.io` +(no `/emSignHub-API` suffix). The old `CERTINEXT_V2_API_URL` variable and the V1 SHA256 +`authKey` token exchange are gone. + +Credentials are read from `~/.env_certinext_v2`, the same file the V2 integration tests +use. Set `CERTINEXT_V2_ENV_FILE=/path/to/file` to use a different file. + +| Key | Required | Used by | +|-----|----------|---------| +| `CERTINEXT_API_URL` | yes | all | +| `CERTINEXT_CLIENT_ID` | yes | all | +| `CERTINEXT_CLIENT_SECRET` | yes | all | +| `CERTINEXT_REQUESTOR_EMAIL` | order-create only | `create-ssl-order`, `create-private-pki-order` | +| `CERTINEXT_REQUESTOR_NAME`, `CERTINEXT_REQUESTOR_MOBILE` | no (defaults) | order-create, `accept-agreement` | +| `CERTINEXT_SIGNER_IP` | no (auto-detected via api.ipify.org) | `create-ssl-order`, `accept-agreement` | + +How the file is read (see `scripts/lib/certinext-v2-auth.sh`): + +- The file is parsed as `KEY=VALUE` lines, never `source`d. It can't run code, and nothing + from it leaks into your shell. Blank lines and `#` comments are skipped, and one pair of + surrounding `"` or `'` quotes is stripped from each value. +- A value in the file wins over a same-named exported env var, the same way + `V2EnvHelper.LoadEnvFile` works. A shell that sourced the V1 `~/.env_certinext` exports + a V1 `CERTINEXT_API_URL`, and that must not leak in. Exported env vars only fill in keys + the file doesn't define. +- A missing required key fails fast and names the key and file. +- `CERTINEXT_API_URL` must be `https://`. Plain `http://` is accepted only for + `localhost` / `127.0.0.1` stubs. A URL containing `/emSignHub-API` is rejected as a V1 URL. + +Secret handling: the client secret and the bearer token are held only in unexported shell +variables. They reach curl through process substitution (`/dev/fd` pipes), so they never +show up in `ps` output, on disk, or on stdout/stderr. On a failed token request, the scripts +print only the HTTP status and the short OAuth2 `error` code, never the response body. +Don't run these scripts with `set -x` / `bash -x`. + +Requires `bash`, `curl` (7.55+ for `-H @file`), and `jq`. + +## Read-only scripts + +These run immediately and send only GET requests (plus the token request). + +| Script | Inputs | Endpoint | +|--------|--------|----------| +| `ping.sh` | — | `GET /auth/me` | +| `list-products.sh` | — | `GET /catalog/products` | +| `get-custom-fields.sh` | `PRODUCT_CODE` | `GET /catalog/products/{code}/custom-fields` | +| `list-groups.sh` | — | `GET /groups` | +| `list-organizations.sh` | — | `GET /organizations` | +| `list-domains.sh` | — | `GET /domains` | +| `orders-report.sh` | `[PAGE=1] [SIZE=100]` | `GET /reports/orders` (1-based paging) | +| `track-order.sh` | `ORDER_ID` | `GET /ssl-certificates/{id}` | +| `get-dcv.sh` | `ORDER_ID`, `DOMAIN` | `GET /ssl-certificates/{id}/dcv?domain=` | +| `download-certificate.sh` | `ORDER_ID` | `GET /ssl-certificates/{id}/certificate` | +| `track-private-pki.sh` | `ORDER_ID` | `GET /private-pki-certificates/{id}` | +| `download-certificate-private-pki.sh` | `ORDER_ID` | `GET /private-pki-certificates/{id}/certificate` | + +## Mutating scripts (`--yes-mutate` required) + +These scripts change state on the CERTInext account, and some of them are irreversible or +cost money. Without `--yes-mutate`, a script prints the request it would send (method, URL, +JSON body) and exits with status 3. The preview makes no network calls: it doesn't fetch a +token or look up the signer IP. + +| Script | Effect | Inputs | +|--------|--------|--------| +| `create-ssl-order.sh` | places an SSL order | `PRODUCT_CODE`, `DOMAIN`, `[VARIANT=dv]` | +| `create-private-pki-order.sh` | places a Private PKI order | `PRODUCT_CODE`, `CERT_HOSTNAME`, `CA_PROFILE_ID`, `MASTER_PRODUCT_ID` | +| `verify-dcv.sh` | asks the CA to check DCV, which can advance the order | `ORDER_ID`, `DOMAIN`, `[METHOD=http-url]` | +| `submit-csr.sh` | attaches a CSR to an SSL order, which advances it | `ORDER_ID`, `CSR_FILE` | +| `submit-csr-private-pki.sh` | attaches a CSR, and the customer CA signs immediately | `ORDER_ID`, `CSR_FILE` | +| `accept-agreement.sh` | accepts the Subscriber Agreement, and the CA issues | `ORDER_ID` | +| `cancel-ssl-order.sh` | cancels an unissued SSL order | `ORDER_ID` | +| `revoke-ssl.sh` | revokes an issued SSL certificate (irreversible) | `ORDER_ID`, `[REASON=superseded]` | +| `revoke-private-pki.sh` | revokes a Private PKI certificate (irreversible) | `ORDER_ID`, `[REASON=superseded]` | + +`create-private-pki-order.sh` takes `CERT_HOSTNAME`, not `HOSTNAME`. Bash always sets +`HOSTNAME` to the local machine name, so the old input silently fell back to this +workstation's hostname. + +## Examples + +```bash +scripts/v2/ping.sh +ORDER_ID=1234567890 scripts/v2/track-order.sh + +# Preview only (prints the request and exits 3): +ORDER_ID=1234567890 scripts/v2/revoke-ssl.sh +# Actually revoke: +ORDER_ID=1234567890 scripts/v2/revoke-ssl.sh --yes-mutate + +# Via make: mutating targets forward V2_ARGS to the script. +make v2-revoke-ssl ORDER_ID=1234567890 V2_ARGS=--yes-mutate +``` diff --git a/scripts/v2/accept-agreement.sh b/scripts/v2/accept-agreement.sh index ebf7320..753f8be 100755 --- a/scripts/v2/accept-agreement.sh +++ b/scripts/v2/accept-agreement.sh @@ -1,30 +1,32 @@ #!/usr/bin/env bash # V2 ssl-certificates/{orderId}/agreement — record Subscriber Agreement acceptance. +# MUTATING: the CA proceeds to issue the certificate. Refuses to run and prints the +# planned request unless --yes-mutate is passed. # Required env var: ORDER_ID +# Env-file keys: CERTINEXT_REQUESTOR_NAME, CERTINEXT_SIGNER_IP (else api.ipify.org lookup) # -# 204 No Content = recorded; the CA proceeds to issue the certificate. -# After this step poll v2-track-order until status=issued, then v2-download-certificate. +# 204 No Content = recorded. Then poll track-order.sh until status=issued, and run +# download-certificate.sh. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/accept-agreement.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID -if [ -z "$ORDER_ID" ]; then - echo "Usage: ORDER_ID= scripts/v2/accept-agreement.sh" >&2 - exit 1 -fi +name=$(v2_cfg CERTINEXT_REQUESTOR_NAME "Keyfactor Gateway Test") -name="${CERTINEXT_REQUESTOR_NAME:-Keyfactor Gateway Test}" -signerIp="${CERTINEXT_SIGNER_IP:-}" -if [ -z "$signerIp" ]; then signerIp=$(curl -s https://api.ipify.org); fi +build_body() { + jq -n --arg name "$name" --arg ip "$1" \ + '{agreement:{signerName:$name,signerIp:$ip,signerPlace:"Gateway",accepted:true}}' +} -echo "V2 POST /api/certinext/v2/ssl-certificates/$ORDER_ID/agreement signerName=$name signerIp=$signerIp" -curl -s -X POST "$CERTINEXT_V2_API_URL/api/certinext/v2/ssl-certificates/$ORDER_ID/agreement" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg name "$name" \ - --arg ip "$signerIp" \ - '{agreement:{signerName:$name,signerIp:$ip,signerPlace:"Gateway",accepted:true}}')" \ -| jq . +path="/api/certinext/v2/ssl-certificates/$ORDER_ID/agreement" +v2_mutation_gate POST "$path" "$(build_body "$(v2_signer_ip --offline)")" + +body=$(build_body "$(v2_signer_ip)") +echo "V2 POST $path signerName=$name" >&2 +v2_request POST "$path" -H "Content-Type: application/json" --data-binary "$body" diff --git a/scripts/v2/cancel-ssl-order.sh b/scripts/v2/cancel-ssl-order.sh index 09d3a49..bf358a3 100755 --- a/scripts/v2/cancel-ssl-order.sh +++ b/scripts/v2/cancel-ssl-order.sh @@ -1,24 +1,23 @@ #!/usr/bin/env bash -# V2 ssl-certificates/{orderId}/cancel — withdraw an SSL order before issuance. +# V2 ssl-certificates/{orderId}/cancel — CANCEL an SSL order before issuance. MUTATING. +# Refuses to run and prints the planned request unless --yes-mutate is passed. # Required env var: ORDER_ID # -# Use this before the certificate is issued. -# Once issued, use v2-revoke-ssl instead. -# 204 No Content = cancelled; order remains visible via v2-track-order with status=cancelled. +# Once issued, use revoke-ssl.sh instead. +# 204 No Content = cancelled; order remains visible via track-order with status=cancelled. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/cancel-ssl-order.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID -if [ -z "$ORDER_ID" ]; then - echo "Usage: ORDER_ID= scripts/v2/cancel-ssl-order.sh" >&2 - exit 1 -fi +path="/api/certinext/v2/ssl-certificates/$ORDER_ID/cancel" +body='{"reason":"No longer required"}' +v2_mutation_gate POST "$path" "$body" -echo "V2 POST /api/certinext/v2/ssl-certificates/$ORDER_ID/cancel" -curl -s -X POST "$CERTINEXT_V2_API_URL/api/certinext/v2/ssl-certificates/$ORDER_ID/cancel" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"reason":"No longer required"}' \ -| jq . +echo "V2 POST $path" >&2 +v2_request POST "$path" -H "Content-Type: application/json" --data-binary "$body" diff --git a/scripts/v2/create-private-pki-order.sh b/scripts/v2/create-private-pki-order.sh index e4fe86a..521fb1e 100755 --- a/scripts/v2/create-private-pki-order.sh +++ b/scripts/v2/create-private-pki-order.sh @@ -1,36 +1,38 @@ #!/usr/bin/env bash -# V2 private-pki-certificates — create a Private PKI certificate order. -# Required env vars: PRODUCT_CODE, HOSTNAME, CA_PROFILE_ID, MASTER_PRODUCT_ID +# V2 private-pki-certificates — PLACE a Private PKI certificate order. MUTATING. +# Refuses to run and prints the planned request unless --yes-mutate is passed. +# Required env vars: PRODUCT_CODE, CERT_HOSTNAME, CA_PROFILE_ID, MASTER_PRODUCT_ID +# (CERT_HOSTNAME, not HOSTNAME: bash always sets HOSTNAME to the local machine name, +# so the old HOSTNAME input silently ordered a certificate for this workstation.) +# Env-file keys: CERTINEXT_REQUESTOR_EMAIL (required), CERTINEXT_REQUESTOR_NAME, +# CERTINEXT_REQUESTOR_MOBILE # -# On success prints the orderId prominently. -# Use orderId with v2-track-private-pki, v2-submit-csr-private-pki, -# v2-download-certificate-private-pki, and v2-revoke-private-pki. +# On success prints the orderId. Use it with track-private-pki, submit-csr-private-pki, +# download-certificate-private-pki, and revoke-private-pki. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: PRODUCT_CODE= CERT_HOSTNAME= CA_PROFILE_ID= MASTER_PRODUCT_ID= scripts/v2/create-private-pki-order.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" PRODUCT_CODE="${PRODUCT_CODE:-}" -HOSTNAME="${HOSTNAME:-}" +CERT_HOSTNAME="${CERT_HOSTNAME:-}" CA_PROFILE_ID="${CA_PROFILE_ID:-}" MASTER_PRODUCT_ID="${MASTER_PRODUCT_ID:-}" +v2_require_var PRODUCT_CODE +v2_require_var CERT_HOSTNAME +v2_require_var CA_PROFILE_ID +v2_require_var MASTER_PRODUCT_ID -if [ -z "$PRODUCT_CODE" ] || [ -z "$HOSTNAME" ] || [ -z "$CA_PROFILE_ID" ] || [ -z "$MASTER_PRODUCT_ID" ]; then - echo "Usage: PRODUCT_CODE= HOSTNAME= CA_PROFILE_ID= MASTER_PRODUCT_ID= scripts/v2/create-private-pki-order.sh" >&2 - exit 1 -fi +name=$(v2_cfg CERTINEXT_REQUESTOR_NAME "Keyfactor Gateway Test") +email=$(v2_require CERTINEXT_REQUESTOR_EMAIL) +phone=$(v2_cfg CERTINEXT_REQUESTOR_MOBILE "$(v2_cfg CERTINEXT_REQUESTOR_PHONE "+10000000000")") -idempotency_key=$(python3 -c "import uuid; print(uuid.uuid4())") - -name="${CERTINEXT_REQUESTOR_NAME:-Keyfactor Gateway Test}" -email="${CERTINEXT_REQUESTOR_EMAIL}" -phone="${CERTINEXT_REQUESTOR_PHONE:-+10000000000}" - -echo "V2 POST /api/certinext/v2/private-pki-certificates productCode=$PRODUCT_CODE hostname=$HOSTNAME caProfileId=$CA_PROFILE_ID masterProductId=$MASTER_PRODUCT_ID idempotencyKey=$idempotency_key" - -result=$(jq -n \ +body=$(jq -n \ --arg caProfileId "$CA_PROFILE_ID" \ --arg masterProductId "$MASTER_PRODUCT_ID" \ - --arg hostname "$HOSTNAME" \ + --arg hostname "$CERT_HOSTNAME" \ --arg name "$name" \ --arg email "$email" \ --arg phone "$phone" \ @@ -41,17 +43,22 @@ result=$(jq -n \ additionalHosts:[], emailNotifications:"all", subscription:{validityYears:1}, - requestor:{name:$name,email:$email,phone:$phone,designation:"IT Administrator"}}' \ - | curl -s -X POST "$CERTINEXT_V2_API_URL/api/certinext/v2/private-pki-certificates" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Content-Type: application/json" \ - -H "X-Product-Code: $PRODUCT_CODE" \ - -H "Idempotency-Key: $idempotency_key" \ - -d @-) + requestor:{name:$name,email:$email,phone:$phone,designation:"IT Administrator"}}') + +path="/api/certinext/v2/private-pki-certificates" +v2_mutation_gate POST "$path (X-Product-Code: $PRODUCT_CODE)" "$body" + +idempotency_key=$(v2_uuid) +echo "V2 POST $path productCode=$PRODUCT_CODE hostname=$CERT_HOSTNAME caProfileId=$CA_PROFILE_ID masterProductId=$MASTER_PRODUCT_ID idempotencyKey=$idempotency_key" >&2 +rc=0 +result=$(v2_request POST "$path" \ + -H "Content-Type: application/json" \ + -H "X-Product-Code: $PRODUCT_CODE" \ + -H "Idempotency-Key: $idempotency_key" \ + --data-binary "$body") || rc=$? -echo "" echo "==> Full response:" -echo "$result" | jq . -echo "" -echo "==> orderId (use with v2-track-private-pki, v2-submit-csr-private-pki, etc.):" -echo "$result" | jq -r '.orderId // .detail // .title // "none"' +printf '%s\n' "$result" +echo "==> orderId (use with track-private-pki, submit-csr-private-pki, etc.):" +printf '%s' "$result" | jq -r '.orderId // .detail // .title // "none"' 2>/dev/null || echo "none" +exit "$rc" diff --git a/scripts/v2/create-ssl-order.sh b/scripts/v2/create-ssl-order.sh index 785c3c0..1be9f9b 100755 --- a/scripts/v2/create-ssl-order.sh +++ b/scripts/v2/create-ssl-order.sh @@ -1,59 +1,63 @@ #!/usr/bin/env bash -# V2 ssl-certificates — create a new SSL/TLS certificate order. +# V2 ssl-certificates — PLACE a new SSL/TLS certificate order. MUTATING (may incur cost). +# Refuses to run and prints the planned request unless --yes-mutate is passed. # Required env vars: PRODUCT_CODE, DOMAIN # Optional env vars: VARIANT (default dv) +# Env-file keys: CERTINEXT_REQUESTOR_EMAIL (required), CERTINEXT_REQUESTOR_NAME, +# CERTINEXT_REQUESTOR_MOBILE, CERTINEXT_SIGNER_IP (else api.ipify.org lookup) # -# On success prints the orderId prominently. -# Use orderId with v2-get-dcv, v2-verify-dcv, v2-submit-csr, v2-accept-agreement, -# v2-download-certificate, v2-revoke-ssl, and v2-cancel-ssl-order. +# On success prints the orderId. Use it with track-order, get-dcv, verify-dcv, +# submit-csr, accept-agreement, download-certificate, revoke-ssl, cancel-ssl-order. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: PRODUCT_CODE= DOMAIN= [VARIANT=dv] scripts/v2/create-ssl-order.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" PRODUCT_CODE="${PRODUCT_CODE:-}" DOMAIN="${DOMAIN:-}" VARIANT="${VARIANT:-dv}" +v2_require_var PRODUCT_CODE +v2_require_var DOMAIN + +name=$(v2_cfg CERTINEXT_REQUESTOR_NAME "Keyfactor Gateway Test") +email=$(v2_require CERTINEXT_REQUESTOR_EMAIL) +phone=$(v2_cfg CERTINEXT_REQUESTOR_MOBILE "$(v2_cfg CERTINEXT_REQUESTOR_PHONE "+10000000000")") + +build_body() { + jq -n \ + --arg variant "$VARIANT" \ + --arg domain "$DOMAIN" \ + --arg name "$name" \ + --arg email "$email" \ + --arg phone "$phone" \ + --arg signerIp "$1" \ + '{productVariant:$variant, + emailNotifications:"all", + requestor:{name:$name,email:$email,phone:$phone,designation:"IT Administrator"}, + certificate:{domain:$domain,autoSecureWww:true}, + subscription:{validityYears:1,autoRenew:false,renewBeforeDays:30}, + agreement:{signerName:$name,signerIp:$signerIp,signerPlace:"Gateway",accepted:true}, + remarks:"Issued via Keyfactor Command AnyCA REST Gateway."}' +} + +path="/api/certinext/v2/ssl-certificates" +v2_mutation_gate POST "$path (X-Product-Code: $PRODUCT_CODE)" "$(build_body "$(v2_signer_ip --offline)")" + +body=$(build_body "$(v2_signer_ip)") +idempotency_key=$(v2_uuid) + +echo "V2 POST $path productCode=$PRODUCT_CODE domain=$DOMAIN variant=$VARIANT idempotencyKey=$idempotency_key" >&2 +rc=0 +result=$(v2_request POST "$path" \ + -H "Content-Type: application/json" \ + -H "X-Product-Code: $PRODUCT_CODE" \ + -H "Idempotency-Key: $idempotency_key" \ + --data-binary "$body") || rc=$? -if [ -z "$PRODUCT_CODE" ] || [ -z "$DOMAIN" ]; then - echo "Usage: PRODUCT_CODE= DOMAIN= [VARIANT=dv] scripts/v2/create-ssl-order.sh" >&2 - exit 1 -fi - -idempotency_key=$(python3 -c "import uuid; print(uuid.uuid4())") - -name="${CERTINEXT_REQUESTOR_NAME:-Keyfactor Gateway Test}" -email="${CERTINEXT_REQUESTOR_EMAIL}" -phone="${CERTINEXT_REQUESTOR_PHONE:-+10000000000}" - -signerIp="${CERTINEXT_SIGNER_IP:-}" -if [ -z "$signerIp" ]; then signerIp=$(curl -s https://api.ipify.org); fi - -echo "V2 POST /api/certinext/v2/ssl-certificates productCode=$PRODUCT_CODE domain=$DOMAIN variant=$VARIANT idempotencyKey=$idempotency_key" - -result=$(jq -n \ - --arg variant "$VARIANT" \ - --arg domain "$DOMAIN" \ - --arg name "$name" \ - --arg email "$email" \ - --arg phone "$phone" \ - --arg signerIp "$signerIp" \ - '{productVariant:$variant, - emailNotifications:"all", - requestor:{name:$name,email:$email,phone:$phone,designation:"IT Administrator"}, - certificate:{domain:$domain,autoSecureWww:true}, - subscription:{validityYears:1,autoRenew:false,renewBeforeDays:30}, - agreement:{signerName:$name,signerIp:$signerIp,signerPlace:"Gateway",accepted:true}, - remarks:"Issued via Keyfactor Command AnyCA REST Gateway."}' \ - | curl -s -X POST "$CERTINEXT_V2_API_URL/api/certinext/v2/ssl-certificates" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Content-Type: application/json" \ - -H "X-Product-Code: $PRODUCT_CODE" \ - -H "Idempotency-Key: $idempotency_key" \ - -d @-) - -echo "" echo "==> Full response:" -echo "$result" | jq . -echo "" -echo "==> orderId (use with v2-track-order, v2-get-dcv, etc.):" -echo "$result" | jq -r '.orderId // .detail // .title // "none"' +printf '%s\n' "$result" +echo "==> orderId (use with track-order, get-dcv, etc.):" +printf '%s' "$result" | jq -r '.orderId // .detail // .title // "none"' 2>/dev/null || echo "none" +exit "$rc" diff --git a/scripts/v2/download-certificate-private-pki.sh b/scripts/v2/download-certificate-private-pki.sh index ad1945f..39b7a24 100755 --- a/scripts/v2/download-certificate-private-pki.sh +++ b/scripts/v2/download-certificate-private-pki.sh @@ -1,22 +1,19 @@ #!/usr/bin/env bash -# V2 private-pki-certificates/{orderId}/certificate — download issued Private PKI certificate. +# V2 private-pki-certificates/{orderId}/certificate — download issued Private PKI +# certificate. Read-only. # Required env var: ORDER_ID # # Returns JSON with certificatePem, serialNumber, subject, issuer, notBefore, notAfter. # Returns 422 if the order is not yet in issued state. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/download-certificate-private-pki.sh" >&2; } +v2_parse_args "$@" ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID -if [ -z "$ORDER_ID" ]; then - echo "Usage: ORDER_ID= scripts/v2/download-certificate-private-pki.sh" >&2 - exit 1 -fi - -echo "V2 GET /api/certinext/v2/private-pki-certificates/$ORDER_ID/certificate" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/private-pki-certificates/$ORDER_ID/certificate" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/private-pki-certificates/$ORDER_ID/certificate" >&2 +v2_request GET "/api/certinext/v2/private-pki-certificates/$ORDER_ID/certificate" diff --git a/scripts/v2/download-certificate.sh b/scripts/v2/download-certificate.sh index 3da275e..4426b72 100755 --- a/scripts/v2/download-certificate.sh +++ b/scripts/v2/download-certificate.sh @@ -1,22 +1,18 @@ #!/usr/bin/env bash -# V2 ssl-certificates/{orderId}/certificate — download issued SSL certificate. +# V2 ssl-certificates/{orderId}/certificate — download issued SSL certificate. Read-only. # Required env var: ORDER_ID # # Returns JSON with certificatePem, serialNumber, subject, issuer, notBefore, notAfter. # Returns 422 if the order is not yet in issued state. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/download-certificate.sh" >&2; } +v2_parse_args "$@" ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID -if [ -z "$ORDER_ID" ]; then - echo "Usage: ORDER_ID= scripts/v2/download-certificate.sh" >&2 - exit 1 -fi - -echo "V2 GET /api/certinext/v2/ssl-certificates/$ORDER_ID/certificate" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/ssl-certificates/$ORDER_ID/certificate" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/ssl-certificates/$ORDER_ID/certificate" >&2 +v2_request GET "/api/certinext/v2/ssl-certificates/$ORDER_ID/certificate" diff --git a/scripts/v2/get-custom-fields.sh b/scripts/v2/get-custom-fields.sh index b266293..729c79d 100755 --- a/scripts/v2/get-custom-fields.sh +++ b/scripts/v2/get-custom-fields.sh @@ -1,19 +1,16 @@ #!/usr/bin/env bash -# V2 catalog/products/{code}/custom-fields — mandatory + optional custom fields for a product. +# V2 catalog/products/{code}/custom-fields — mandatory + optional custom fields for a +# product. Read-only. # Required env var: PRODUCT_CODE +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: PRODUCT_CODE= scripts/v2/get-custom-fields.sh" >&2; } +v2_parse_args "$@" PRODUCT_CODE="${PRODUCT_CODE:-}" +v2_require_id PRODUCT_CODE -if [ -z "$PRODUCT_CODE" ]; then - echo "Usage: PRODUCT_CODE= scripts/v2/get-custom-fields.sh" >&2 - exit 1 -fi - -echo "V2 GET /api/certinext/v2/catalog/products/$PRODUCT_CODE/custom-fields" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/catalog/products/$PRODUCT_CODE/custom-fields" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/catalog/products/$PRODUCT_CODE/custom-fields" >&2 +v2_request GET "/api/certinext/v2/catalog/products/$PRODUCT_CODE/custom-fields" diff --git a/scripts/v2/get-dcv.sh b/scripts/v2/get-dcv.sh index a91d057..37d85f9 100755 --- a/scripts/v2/get-dcv.sh +++ b/scripts/v2/get-dcv.sh @@ -1,23 +1,21 @@ #!/usr/bin/env bash -# V2 ssl-certificates/{orderId}/dcv — get DCV challenge artifacts for a domain. +# V2 ssl-certificates/{orderId}/dcv — get DCV challenge artifacts for a domain. Read-only. # Required env vars: ORDER_ID, DOMAIN # # Returns http-url, dns-txt, and email challenge methods. -# Publish the artifact for your chosen method, then call v2-verify-dcv. +# Publish the artifact for your chosen method, then run verify-dcv.sh --yes-mutate. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= DOMAIN= scripts/v2/get-dcv.sh" >&2; } +v2_parse_args "$@" ORDER_ID="${ORDER_ID:-}" DOMAIN="${DOMAIN:-}" +v2_require_id ORDER_ID +v2_require_var DOMAIN -if [ -z "$ORDER_ID" ] || [ -z "$DOMAIN" ]; then - echo "Usage: ORDER_ID= DOMAIN= scripts/v2/get-dcv.sh" >&2 - exit 1 -fi - -echo "V2 GET /api/certinext/v2/ssl-certificates/$ORDER_ID/dcv?domain=$DOMAIN" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/ssl-certificates/$ORDER_ID/dcv?domain=$DOMAIN" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/ssl-certificates/$ORDER_ID/dcv?domain=$DOMAIN" >&2 +v2_request GET "/api/certinext/v2/ssl-certificates/$ORDER_ID/dcv" \ + -G --data-urlencode "domain=$DOMAIN" diff --git a/scripts/v2/list-domains.sh b/scripts/v2/list-domains.sh index 0909d32..b53e612 100755 --- a/scripts/v2/list-domains.sh +++ b/scripts/v2/list-domains.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# V2 domains — list domains already pre-validated under this account. +# V2 domains — list domains already pre-validated under this account. Read-only. # DCV does not need to be repeated for domains in this list. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: scripts/v2/list-domains.sh" >&2; } +v2_parse_args "$@" -echo "V2 GET /api/certinext/v2/domains" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/domains" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/domains" >&2 +v2_request GET "/api/certinext/v2/domains" diff --git a/scripts/v2/list-groups.sh b/scripts/v2/list-groups.sh index 5708490..8436e32 100755 --- a/scripts/v2/list-groups.sh +++ b/scripts/v2/list-groups.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# V2 groups — list billing groups accessible to this account. +# V2 groups — list billing groups accessible to this account. Read-only. # Use a groupNumber from here in order bodies to charge a specific cost centre. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: scripts/v2/list-groups.sh" >&2; } +v2_parse_args "$@" -echo "V2 GET /api/certinext/v2/groups" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/groups" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/groups" >&2 +v2_request GET "/api/certinext/v2/groups" diff --git a/scripts/v2/list-organizations.sh b/scripts/v2/list-organizations.sh index 7fc559a..0c56630 100755 --- a/scripts/v2/list-organizations.sh +++ b/scripts/v2/list-organizations.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# V2 organizations — list pre-vetted organizations available for OV/EV SSL. +# V2 organizations — list pre-vetted organizations available for OV/EV SSL. Read-only. # Reference an organizationNumber in order bodies to skip re-vetting. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: scripts/v2/list-organizations.sh" >&2; } +v2_parse_args "$@" -echo "V2 GET /api/certinext/v2/organizations" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/organizations" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/organizations" >&2 +v2_request GET "/api/certinext/v2/organizations" diff --git a/scripts/v2/list-products.sh b/scripts/v2/list-products.sh index ef0aba0..7cc82fe 100755 --- a/scripts/v2/list-products.sh +++ b/scripts/v2/list-products.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# V2 catalog/products — list all products the account can order. +# V2 catalog/products — list all products the account can order. Read-only. # Each entry has a stable productCode used in the X-Product-Code header. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: scripts/v2/list-products.sh" >&2; } +v2_parse_args "$@" -echo "V2 GET /api/certinext/v2/catalog/products" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/catalog/products" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/catalog/products" >&2 +v2_request GET "/api/certinext/v2/catalog/products" diff --git a/scripts/v2/orders-report.sh b/scripts/v2/orders-report.sh index 178b263..3fb8ea0 100755 --- a/scripts/v2/orders-report.sh +++ b/scripts/v2/orders-report.sh @@ -1,14 +1,20 @@ #!/usr/bin/env bash -# V2 reports/orders — paginated order history. -# NOTE: currently returns 501 Not Implemented. -# Use v1 make get-order-report (POST /emSignHub-API/GetOrderReport) meanwhile. +# V2 reports/orders — one page of order history. Read-only. +# This is the endpoint the plugin's V2 Synchronize pages through. +# Optional env vars: PAGE (default 1; paging is 1-based, page=0 is treated as 1), +# SIZE (default 100; the server clamps to 100) +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: [PAGE=1] [SIZE=100] scripts/v2/orders-report.sh" >&2; } +v2_parse_args "$@" -echo "V2 GET /api/certinext/v2/reports/orders?page=0&size=50" -echo "NOTE: this endpoint currently returns 501 Not Implemented — use v1 make get-order-report as a fallback." -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/reports/orders?page=0&size=50" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +PAGE="${PAGE:-1}" +SIZE="${SIZE:-100}" +case "$PAGE$SIZE" in + *[!0-9]*) echo "ERROR: PAGE and SIZE must be non-negative integers." >&2; exit 1 ;; +esac + +echo "V2 GET /api/certinext/v2/reports/orders?page=$PAGE&size=$SIZE" >&2 +v2_request GET "/api/certinext/v2/reports/orders?page=$PAGE&size=$SIZE" diff --git a/scripts/v2/ping.sh b/scripts/v2/ping.sh index 3a1886f..578bc05 100755 --- a/scripts/v2/ping.sh +++ b/scripts/v2/ping.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # V2 auth/me — returns the account context the Bearer token resolves to. -# Mirrors ICERTInextClient.PingAsync via the V2 API. +# Mirrors ICERTInextClient.PingAsync via the V2 API. Read-only. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: scripts/v2/ping.sh" >&2; } +v2_parse_args "$@" -echo "V2 GET /api/certinext/v2/auth/me" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/auth/me" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/auth/me" >&2 +v2_request GET "/api/certinext/v2/auth/me" diff --git a/scripts/v2/revoke-private-pki.sh b/scripts/v2/revoke-private-pki.sh index c3e15f9..71d4b8c 100755 --- a/scripts/v2/revoke-private-pki.sh +++ b/scripts/v2/revoke-private-pki.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# V2 private-pki-certificates/{orderId}/revoke — permanently revoke an issued Private PKI certificate. +# V2 private-pki-certificates/{orderId}/revoke — permanently REVOKE an issued Private PKI +# certificate. MUTATING and irreversible. Refuses to run and prints the planned request +# unless --yes-mutate is passed. # Required env var: ORDER_ID # Optional env var: REASON (default superseded) # @@ -8,24 +10,24 @@ # # 204 No Content = revocation recorded on the customer CA. # 422 = order not yet issued, or already revoked. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= [REASON=superseded] scripts/v2/revoke-private-pki.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" ORDER_ID="${ORDER_ID:-}" REASON="${REASON:-superseded}" +v2_require_id ORDER_ID -if [ -z "$ORDER_ID" ]; then - echo "Usage: ORDER_ID= [REASON=superseded] scripts/v2/revoke-private-pki.sh" >&2 - exit 1 -fi +path="/api/certinext/v2/private-pki-certificates/$ORDER_ID/revoke" +body=$(jq -n --arg reason "$REASON" '{reason:$reason,note:"Revoked via scripts/v2 dev helper."}') +v2_mutation_gate POST "$path" "$body" -idempotency_key=$(python3 -c "import uuid; print(uuid.uuid4())") - -echo "V2 POST /api/certinext/v2/private-pki-certificates/$ORDER_ID/revoke reason=$REASON idempotencyKey=$idempotency_key" -curl -s -X POST "$CERTINEXT_V2_API_URL/api/certinext/v2/private-pki-certificates/$ORDER_ID/revoke" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: $idempotency_key" \ - -d "$(jq -n --arg reason "$REASON" '{reason:$reason,note:"Revoked via Makefile smoke test."}')" \ -| jq . +idempotency_key=$(v2_uuid) +echo "V2 POST $path reason=$REASON idempotencyKey=$idempotency_key" >&2 +v2_request POST "$path" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: $idempotency_key" \ + --data-binary "$body" diff --git a/scripts/v2/revoke-ssl.sh b/scripts/v2/revoke-ssl.sh index 0cc6d28..fb76912 100755 --- a/scripts/v2/revoke-ssl.sh +++ b/scripts/v2/revoke-ssl.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# V2 ssl-certificates/{orderId}/revoke — permanently revoke an issued SSL certificate. +# V2 ssl-certificates/{orderId}/revoke — permanently REVOKE an issued SSL certificate. +# MUTATING and irreversible. Refuses to run and prints the planned request unless +# --yes-mutate is passed. # Required env var: ORDER_ID # Optional env var: REASON (default superseded) # @@ -8,24 +10,24 @@ # # 204 No Content = revocation queued; CRL/OCSP reflect this on next publish. # 422 = order not yet issued, or already revoked. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= [REASON=superseded] scripts/v2/revoke-ssl.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" ORDER_ID="${ORDER_ID:-}" REASON="${REASON:-superseded}" +v2_require_id ORDER_ID -if [ -z "$ORDER_ID" ]; then - echo "Usage: ORDER_ID= [REASON=superseded] scripts/v2/revoke-ssl.sh" >&2 - exit 1 -fi +path="/api/certinext/v2/ssl-certificates/$ORDER_ID/revoke" +body=$(jq -n --arg reason "$REASON" '{reason:$reason,note:"Revoked via scripts/v2 dev helper."}') +v2_mutation_gate POST "$path" "$body" -idempotency_key=$(python3 -c "import uuid; print(uuid.uuid4())") - -echo "V2 POST /api/certinext/v2/ssl-certificates/$ORDER_ID/revoke reason=$REASON idempotencyKey=$idempotency_key" -curl -s -X POST "$CERTINEXT_V2_API_URL/api/certinext/v2/ssl-certificates/$ORDER_ID/revoke" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: $idempotency_key" \ - -d "$(jq -n --arg reason "$REASON" '{reason:$reason,note:"Revoked via Makefile smoke test."}')" \ -| jq . +idempotency_key=$(v2_uuid) +echo "V2 POST $path reason=$REASON idempotencyKey=$idempotency_key" >&2 +v2_request POST "$path" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: $idempotency_key" \ + --data-binary "$body" diff --git a/scripts/v2/submit-csr-private-pki.sh b/scripts/v2/submit-csr-private-pki.sh index 09abf20..da056f3 100755 --- a/scripts/v2/submit-csr-private-pki.sh +++ b/scripts/v2/submit-csr-private-pki.sh @@ -1,29 +1,29 @@ #!/usr/bin/env bash # V2 private-pki-certificates/{orderId}/csr — attach a PEM CSR to a Private PKI order. +# MUTATING: the customer CA signs immediately after CSR submission. Refuses to run and +# prints the planned request unless --yes-mutate is passed. # Required env vars: ORDER_ID, CSR_FILE (path to PEM file) # -# The customer CA signs immediately after CSR submission. # 204 No Content = CSR accepted. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= CSR_FILE= scripts/v2/submit-csr-private-pki.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" ORDER_ID="${ORDER_ID:-}" CSR_FILE="${CSR_FILE:-}" - -if [ -z "$ORDER_ID" ] || [ -z "$CSR_FILE" ]; then - echo "Usage: ORDER_ID= CSR_FILE= scripts/v2/submit-csr-private-pki.sh" >&2 - exit 1 -fi - +v2_require_id ORDER_ID +v2_require_var CSR_FILE if [ ! -f "$CSR_FILE" ]; then - echo "CSR_FILE '$CSR_FILE' not found" >&2 + echo "ERROR: CSR_FILE '$CSR_FILE' not found" >&2 exit 1 fi -echo "V2 PUT /api/certinext/v2/private-pki-certificates/$ORDER_ID/csr csrFile=$CSR_FILE" -curl -s -X PUT "$CERTINEXT_V2_API_URL/api/certinext/v2/private-pki-certificates/$ORDER_ID/csr" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --rawfile csr "$CSR_FILE" '{csr:$csr,attested:false}')" \ -| jq . +path="/api/certinext/v2/private-pki-certificates/$ORDER_ID/csr" +body=$(jq -n --rawfile csr "$CSR_FILE" '{csr:$csr,attested:false}') +v2_mutation_gate PUT "$path" "$body" + +echo "V2 PUT $path csrFile=$CSR_FILE" >&2 +v2_request PUT "$path" -H "Content-Type: application/json" --data-binary "$body" diff --git a/scripts/v2/submit-csr.sh b/scripts/v2/submit-csr.sh index 9ba2725..66bc84f 100755 --- a/scripts/v2/submit-csr.sh +++ b/scripts/v2/submit-csr.sh @@ -1,28 +1,29 @@ #!/usr/bin/env bash -# V2 ssl-certificates/{orderId}/csr — attach a PEM CSR to an SSL order. +# V2 ssl-certificates/{orderId}/csr — attach a PEM CSR to an SSL order. MUTATING +# (advances the order). Refuses to run and prints the planned request unless +# --yes-mutate is passed. # Required env vars: ORDER_ID, CSR_FILE (path to PEM file) # # 204 No Content = CSR accepted; order advances to pending-agreement. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= CSR_FILE= scripts/v2/submit-csr.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" ORDER_ID="${ORDER_ID:-}" CSR_FILE="${CSR_FILE:-}" - -if [ -z "$ORDER_ID" ] || [ -z "$CSR_FILE" ]; then - echo "Usage: ORDER_ID= CSR_FILE= scripts/v2/submit-csr.sh" >&2 - exit 1 -fi - +v2_require_id ORDER_ID +v2_require_var CSR_FILE if [ ! -f "$CSR_FILE" ]; then - echo "CSR_FILE '$CSR_FILE' not found" >&2 + echo "ERROR: CSR_FILE '$CSR_FILE' not found" >&2 exit 1 fi -echo "V2 PUT /api/certinext/v2/ssl-certificates/$ORDER_ID/csr csrFile=$CSR_FILE" -curl -s -X PUT "$CERTINEXT_V2_API_URL/api/certinext/v2/ssl-certificates/$ORDER_ID/csr" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --rawfile csr "$CSR_FILE" '{csr:$csr,attested:false}')" \ -| jq . +path="/api/certinext/v2/ssl-certificates/$ORDER_ID/csr" +body=$(jq -n --rawfile csr "$CSR_FILE" '{csr:$csr,attested:false}') +v2_mutation_gate PUT "$path" "$body" + +echo "V2 PUT $path csrFile=$CSR_FILE" >&2 +v2_request PUT "$path" -H "Content-Type: application/json" --data-binary "$body" diff --git a/scripts/v2/track-order.sh b/scripts/v2/track-order.sh index 18e1628..9b99514 100755 --- a/scripts/v2/track-order.sh +++ b/scripts/v2/track-order.sh @@ -1,22 +1,18 @@ #!/usr/bin/env bash -# V2 ssl-certificates/{orderId} — fetch current state of an SSL order. +# V2 ssl-certificates/{orderId} — fetch current state of an SSL order. Read-only. # Required env var: ORDER_ID # # Status values: pending-dcv -> pending-csr -> pending-agreement -> issued # (or cancelled / revoked) +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/track-order.sh" >&2; } +v2_parse_args "$@" ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID -if [ -z "$ORDER_ID" ]; then - echo "Usage: ORDER_ID= scripts/v2/track-order.sh" >&2 - exit 1 -fi - -echo "V2 GET /api/certinext/v2/ssl-certificates/$ORDER_ID" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/ssl-certificates/$ORDER_ID" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/ssl-certificates/$ORDER_ID" >&2 +v2_request GET "/api/certinext/v2/ssl-certificates/$ORDER_ID" diff --git a/scripts/v2/track-private-pki.sh b/scripts/v2/track-private-pki.sh index 9fb2d38..9c65351 100755 --- a/scripts/v2/track-private-pki.sh +++ b/scripts/v2/track-private-pki.sh @@ -1,22 +1,19 @@ #!/usr/bin/env bash # V2 private-pki-certificates/{orderId} — fetch current state of a Private PKI order. +# Read-only. # Required env var: ORDER_ID # # Status values: pending-csr -> issued (or cancelled / revoked). # Private PKI orders skip vetting because the CA is customer-owned. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/track-private-pki.sh" >&2; } +v2_parse_args "$@" ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID -if [ -z "$ORDER_ID" ]; then - echo "Usage: ORDER_ID= scripts/v2/track-private-pki.sh" >&2 - exit 1 -fi - -echo "V2 GET /api/certinext/v2/private-pki-certificates/$ORDER_ID" -curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/private-pki-certificates/$ORDER_ID" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Accept: application/json" \ -| jq . +echo "V2 GET /api/certinext/v2/private-pki-certificates/$ORDER_ID" >&2 +v2_request GET "/api/certinext/v2/private-pki-certificates/$ORDER_ID" diff --git a/scripts/v2/verify-dcv.sh b/scripts/v2/verify-dcv.sh index 9b769e1..e41c7e6 100755 --- a/scripts/v2/verify-dcv.sh +++ b/scripts/v2/verify-dcv.sh @@ -1,26 +1,28 @@ #!/usr/bin/env bash # V2 ssl-certificates/{orderId}/dcv/verify — ask the CA to re-check a DCV artifact. +# MUTATING (can advance the order). Refuses to run and prints the planned request unless +# --yes-mutate is passed. # Required env vars: ORDER_ID, DOMAIN # Optional env var: METHOD (default http-url; also: dns-txt, email) # # 204 No Content = DCV passed; order advances to pending-csr. # 422 = CA could not find the artifact; check file path or DNS propagation. +# Credentials: see scripts/v2/README.md. set -euo pipefail -. ~/.env_certinext +# shellcheck source=scripts/lib/certinext-v2-auth.sh . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= DOMAIN= [METHOD=http-url] scripts/v2/verify-dcv.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" ORDER_ID="${ORDER_ID:-}" DOMAIN="${DOMAIN:-}" METHOD="${METHOD:-http-url}" +v2_require_id ORDER_ID +v2_require_var DOMAIN -if [ -z "$ORDER_ID" ] || [ -z "$DOMAIN" ]; then - echo "Usage: ORDER_ID= DOMAIN= [METHOD=http-url] scripts/v2/verify-dcv.sh" >&2 - exit 1 -fi +path="/api/certinext/v2/ssl-certificates/$ORDER_ID/dcv/verify" +body=$(jq -n --arg domain "$DOMAIN" --arg method "$METHOD" '{domain:$domain,method:$method}') +v2_mutation_gate POST "$path" "$body" -echo "V2 POST /api/certinext/v2/ssl-certificates/$ORDER_ID/dcv/verify domain=$DOMAIN method=$METHOD" -curl -s -X POST "$CERTINEXT_V2_API_URL/api/certinext/v2/ssl-certificates/$ORDER_ID/dcv/verify" \ - -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg domain "$DOMAIN" --arg method "$METHOD" '{domain:$domain,method:$method}')" \ -| jq . +echo "V2 POST $path domain=$DOMAIN method=$METHOD" >&2 +v2_request POST "$path" -H "Content-Type: application/json" --data-binary "$body"